Compare commits

..
Author SHA1 Message Date
Peter Steinberger 6b3f9a5934 docs: note android sms capability 2026-01-04 13:57:41 +01:00
Peter Steinberger 0372457ba8 fix(android): refresh hello on sms permission grant 2026-01-04 13:56:20 +01:00
Peter Steinberger f28a03c407 fix(android): add sms permission flow and tests 2026-01-04 13:27:30 +01:00
Vasanth Rao Naik Sabavat f5abc8e9c9 feat(android): add SMS sending capability to Android node
Add sms.send command to allow sending text messages via the paired Android device.

Changes:
- Add SmsManager class to handle SMS sending via Android SmsManager API
- Add ClawdisSmsCommand enum and Sms capability to protocol constants
- Wire sms.send command into NodeRuntime invoke handler
- Add SEND_SMS permission to AndroidManifest.xml
- Advertise sms capability when SEND_SMS permission is granted

The SMS capability is only advertised when the user has granted SEND_SMS
permission. Messages longer than 160 chars are automatically split into
multipart messages.
2026-01-03 22:16:46 -08:00
Peter Steinberger 4b3ca29404 build: add homebrew to sandbox image 2026-01-04 06:12:06 +00:00
Peter Steinberger 259b14d66a chore: refresh protocol models 2026-01-04 07:07:21 +01:00
Peter Steinberger c9504a6f20 refactor: split config module 2026-01-04 07:05:17 +01:00
Peter Steinberger 5e36e2c3f3 fix: allow elevated via discord username 2026-01-04 05:47:28 +00:00
Peter Steinberger d2da305190 feat: fallback elevated allowlist to discord dms 2026-01-04 05:31:00 +00:00
Peter Steinberger be9fa124df build: add pkg-config + libasound2-dev to sandbox image 2026-01-04 05:28:08 +00:00
Peter Steinberger ff88f3c075 style: fix lint ordering 2026-01-04 06:27:54 +01:00
Peter Steinberger 1315fc4caf docs: split elevated directives 2026-01-04 05:21:12 +00:00
Peter Steinberger a03895dfa9 fix: default elevated mode to on 2026-01-04 05:19:28 +00:00
Peter Steinberger 40c3898ca1 docs: update changelog for #166 2026-01-04 06:17:07 +01:00
Peter Steinberger 6ea0eb438c style: fix lint formatting 2026-01-04 06:17:07 +01:00
Peter Steinberger 04cd1bd11a fix(macos): bridge wizard option values 2026-01-04 06:17:07 +01:00
Peter Steinberger fe0b3500cc feat: add elevated bash mode 2026-01-04 05:15:59 +00:00
Tu Nombre RealandClaude Opus 4.5 b978cc4e91 feat(macos): add Swift 6 strict concurrency compatibility
Prepares the macOS app for Swift 6 strict concurrency mode by:

1. Adding Sendable conformance to WizardNextResult, WizardStartResult,
   and WizardStatusResult in GatewayModels.swift

2. Adding AnyCodable bridging helpers in OnboardingWizard.swift to
   handle type conflicts between ClawdisProtocol and local module

3. Making CLLocationManagerDelegate methods nonisolated in:
   - MacNodeLocationService.swift
   - PermissionManager.swift (LocationPermissionRequester)

   Using Task { @MainActor in } pattern to safely access MainActor
   state from nonisolated protocol requirements.

These changes are forward-compatible and don't affect behavior on
current Swift versions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 06:09:52 +01:00
Peter Steinberger 72a9e58777 refactor(auto-reply): split reply flow 2026-01-04 05:47:37 +01:00
Peter Steinberger fd91da2b7f fix: log dynamic config reloads 2026-01-04 04:24:50 +00:00
Peter Steinberger 5673f4299a build: add sandbox common image builder 2026-01-04 04:17:13 +00:00
Peter Steinberger 770daadaf7 chore: bump Peekaboo submodule 2026-01-04 05:15:57 +01:00
Peter Steinberger 13c2f22240 refactor: split agent tools 2026-01-04 05:07:44 +01:00
Peter Steinberger f2ce455c8c fix: set writable home for sandbox browser 2026-01-04 03:49:39 +00:00
Peter Steinberger 640ec465d7 chore: bump Peekaboo submodule 2026-01-04 04:46:07 +01:00
Peter Steinberger 70f79bd926 fix: stabilize sandbox browser startup 2026-01-04 03:45:14 +00:00
Peter Steinberger 7d95f43a75 style: fix lint 2026-01-04 03:37:08 +00:00
Peter Steinberger c2f3b653c2 docs: thank scald for Notion skill 2026-01-04 04:36:28 +01:00
Peter SteinbergerandJames Groat 12ba32c724 feat(browser): add remote-capable profiles
Co-authored-by: James Groat <james@groat.com>
2026-01-04 03:33:07 +00:00
Peter Steinberger 0e75aa2716 test: add sessions_send loopback test 2026-01-04 04:30:57 +01:00
Steve Caldwell 44990d837f feat: add Notion API skill
Create and manage Notion pages, databases, and blocks via API.
2026-01-04 04:29:44 +01:00
Shadow 3a28e3562c Discord: tools for uploading emojis and stickers! 2026-01-03 21:20:01 -06:00
Peter Steinberger 24aa3e3311 test: stabilize gateway tests 2026-01-04 04:16:38 +01:00
Peter Steinberger 3c4c2aa98c refactor: split gateway server methods 2026-01-04 04:05:18 +01:00
Peter Steinberger 3ebee63cb3 feat: add clawdhub skill 2026-01-04 04:05:10 +01:00
Peter Steinberger 6d6038b855 docs: tighten wacli skill guidance 2026-01-04 03:45:49 +01:00
Peter Steinberger 55876f7be0 test(agents): cover ping-pong announce flow 2026-01-04 03:41:58 +01:00
Peter Steinberger cd3c42d0c0 feat(sessions): add agent-to-agent ping-pong 2026-01-04 03:37:44 +01:00
Peter Steinberger add1301a51 feat(sessions): add agent-to-agent post step 2026-01-04 03:04:55 +01:00
Peter Steinberger 052cec70ae fix: render thinking text in italics 2026-01-04 02:44:11 +01:00
Peter Steinberger 534de59f7c docs: clarify menu bar sessionKey usage 2026-01-04 02:10:22 +01:00
Peter Steinberger 1d06164e18 refactor: use per-send run ids for gateway agent 2026-01-04 02:08:52 +01:00
Peter Steinberger fe67073b74 fix: avoid sessions_send timeouts 2026-01-04 01:52:01 +01:00
Peter Steinberger cbf41859aa test: relax cron default scheduler timeout 2026-01-04 01:45:50 +01:00
Cash Williams 12186e14a9 fix(android): handle unreachable gateway gracefully
Previously, if the gateway was unreachable (wrong IP, offline, etc.),
the Android app would crash with an unhandled socket exception.

Changes:
- Wrap socket.connect() in try/catch to handle connection failures
- Return PairResult with error message instead of crashing
- Display actual error message in status text instead of generic 'pairing required'

This gives users useful feedback like 'Connection refused' or
'Network is unreachable' instead of a crash.
2026-01-04 01:44:43 +01:00
Peter Steinberger fbaa109a3a fix: stabilize lint and test timeouts 2026-01-04 01:42:08 +01:00
Peter Steinberger 70d68d29d0 fix: warm agent.wait cache 2026-01-04 01:35:02 +01:00
Peter Steinberger e7615c464a docs: update apple-reminders skill for remindctl 2026-01-04 01:33:47 +01:00
Peter Steinberger a1780efb9f fix: adjust typing TTL 2026-01-04 00:26:31 +00:00
Peter Steinberger 53d954695e style: format agent.wait imports 2026-01-04 01:22:22 +01:00
Peter Steinberger 44bdd4ca0c chore: regen Swift protocol models 2026-01-04 01:20:20 +01:00
Peter Steinberger 8724c2aea8 fix: satisfy gate checks 2026-01-04 01:16:53 +01:00
Peter Steinberger e3c543ec06 fix: wait on agent.wait for sessions_send 2026-01-04 01:15:23 +01:00
Peter Steinberger 412e8b3aee test: cover gif playback send params 2026-01-03 23:57:43 +00:00
Peter Steinberger 5862f95bd2 fix: lock main session deletion 2026-01-03 23:57:17 +00:00
Peter Steinberger e17c038d18 fix: add gif playback for WhatsApp sends 2026-01-03 23:56:40 +00:00
Peter Steinberger e1dd764504 feat: add node location support 2026-01-04 00:54:44 +01:00
Peter Steinberger 52f59e6dc1 fix: drop stale ClawdisCLI build flag 2026-01-04 00:42:22 +01:00
Peter Steinberger 3bc24bf179 fix: wait for final agent response in sessions_send 2026-01-04 00:40:40 +01:00
Peter Steinberger e07fdd117d docs: migrate Mintlify config 2026-01-04 00:36:55 +01:00
Peter Steinberger 7c062e0ef2 fix: clarify provider requirements in onboarding 2026-01-03 23:29:38 +00:00
Peter Steinberger 0f1781fc2c docs: add Mintlify config 2026-01-04 00:25:42 +01:00
Peter Steinberger 0f6e566a20 fix: make sessions_send wait via agent events 2026-01-04 00:12:14 +01:00
Peter Steinberger 03ee77b0e1 docs: add mac config sync note 2026-01-04 00:09:18 +01:00
Peter Steinberger 86038ec165 chore: apply lint fixes 2026-01-04 00:06:02 +01:00
Peter Steinberger e7c9b9a749 feat: add sessions tools and send policy 2026-01-03 23:44:42 +01:00
Peter Steinberger 919d5d1dbb fix: restore sandbox PATH default 2026-01-03 22:36:16 +00:00
Peter Steinberger 3f7c69fa7f docs: note mac app config sync 2026-01-03 23:34:25 +01:00
Shadow cc07ea82a4 CI: split macOS/android checks 2026-01-03 23:25:51 +01:00
Peter Steinberger 30e22769bb docs: update changelog for #144 2026-01-03 22:25:30 +00:00
Peter Steinberger 6c406b488d ci: consolidate check jobs 2026-01-03 22:25:29 +00:00
Peter Steinberger f13f89e8b9 docs: update changelog for PR 156 2026-01-03 22:59:11 +01:00
Peter Steinberger 8b069e62fc fix: appease lint after merge 2026-01-03 22:59:11 +01:00
Shadow e2709a3ebd CI: split macOS/android checks 2026-01-03 21:55:39 +00:00
Azade 18a89a31af fix(browser): avoid esbuild __name helper in evaluateViaPlaywright
When tsx/esbuild compiles arrow functions, it adds a __name helper
for debugging. This helper doesn't exist in the browser context,
causing 'ReferenceError: __name is not defined' when using
page.evaluate() with inline functions.

The fix uses new Function() constructed at runtime, which esbuild
doesn't transform, avoiding the __name injection.
2026-01-03 22:37:21 +01:00
Peter Steinberger 934f891932 fix: include embedded agent error cause in reply 2026-01-03 21:30:43 +00:00
Peter Steinberger 5493772910 fix: tolerate missing sandbox config in embedded runner 2026-01-03 21:30:40 +00:00
Peter Steinberger c533593d8e fix: add ~/.local/bin to PATH bootstrap for uv-installed tools (fixes #78) (#150) 2026-01-03 22:25:52 +01:00
Mariano Belinky fe1b894676 docs: clarify personal vs private in README (#125) 2026-01-03 22:21:55 +01:00
Mariano Belinky d88581eb7c fix: add ~/.local/bin to PATH for uv tool binaries (#78) 2026-01-03 22:21:16 +01:00
Peter Steinberger 3d39e2ad75 feat(macos): sync gateway config 2026-01-03 22:17:04 +01:00
Peter Steinberger 2dc10ce337 docs: expand peekaboo skill docs 2026-01-03 22:14:21 +01:00
Peter Steinberger d8a417f7ff feat: add sandbox browser support 2026-01-03 22:14:18 +01:00
Peter Steinberger 107dc1aa42 style(logging): organize embedded log imports 2026-01-03 21:09:44 +00:00
Peter Steinberger 9d2d0c64c2 test(gateway): cover config reload 2026-01-03 21:01:26 +00:00
Peter Steinberger 3872f32419 fix(logging): quiet embedded run console logs 2026-01-03 20:57:39 +00:00
Peter Steinberger 3b075dff8a feat: add per-session agent sandbox 2026-01-03 21:41:58 +01:00
Peter Steinberger 7bad9f3fbd fix: drop embedded sandbox wiring 2026-01-03 20:16:53 +00:00
Peter Steinberger 16e3535ac0 refactor: remove bash pty mode 2026-01-03 20:15:10 +00:00
Peter Steinberger a15cffb7de fix: stream tool summaries early and tool output 2026-01-03 21:04:40 +01:00
Peter Steinberger 03c1599544 docs(templates): add platform formatting tips (Discord embeds, tables) 2026-01-03 20:01:17 +00:00
Shadow 6464d93bbb Discord: add forwarded message handling 2026-01-03 13:56:09 -06:00
Peter Steinberger 424d31af1f docs(templates): add voice storytelling tip for sag users 2026-01-03 19:55:32 +00:00
Peter Steinberger e9d7ac8e84 feat(gateway): add config hot reload 2026-01-03 19:52:24 +00:00
Peter Steinberger fac694fc03 docs(skills): add parallel Codex orchestration learnings
- coding-agent: document --yolo flag, git worktree + tmux pattern
- tmux: add section on orchestrating coding agents in parallel

Learnings from running 5 parallel Codex sessions to analyze GitHub issues
2026-01-03 19:45:18 +00:00
Shadow 3e84b9632d Discord: handle system message types 2026-01-03 13:15:19 -06:00
Peter Steinberger ce3fd09e14 docs(faq): add alternative providers section (OpenRouter, Z.AI)
- Added OpenRouter and Z.AI setup examples
- Emphasized using latest Claude 4.5 models, not deprecated 3.x

🦞
2026-01-03 19:14:05 +00:00
Peter Steinberger 641080a0b6 fix: document macOS permission requirements 2026-01-03 20:05:22 +01:00
Jake 99c3fc1128 Scripts: Make ad-hoc fallback opt-in with stronger TCC warnings 2026-01-03 20:05:22 +01:00
Jake 8c7b2aa2d3 Scripts: Fallback to ad-hoc signing in codesign-mac-app.sh 2026-01-03 20:05:22 +01:00
255 changed files with 23075 additions and 10337 deletions

No files matched your search

+67 -60
View File
@@ -5,12 +5,33 @@ on:
pull_request: pull_request:
jobs: jobs:
build: checks:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
runtime: [node, bun] include:
- runtime: node
task: lint
command: pnpm lint
- runtime: node
task: test
command: pnpm test
- runtime: node
task: build
command: pnpm build
- runtime: node
task: protocol
command: pnpm protocol:check
- runtime: bun
task: lint
command: bunx biome check src
- runtime: bun
task: test
command: bunx vitest run
- runtime: bun
task: build
command: bunx tsc -p tsconfig.json
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -77,37 +98,42 @@ jobs:
pnpm -v pnpm -v
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Lint (node) - name: Run ${{ matrix.task }} (${{ matrix.runtime }})
if: matrix.runtime == 'node' run: ${{ matrix.command }}
run: pnpm lint
- name: Test (node)
if: matrix.runtime == 'node'
run: pnpm test
- name: Build (node)
if: matrix.runtime == 'node'
run: pnpm build
- name: Protocol check (node)
if: matrix.runtime == 'node'
run: pnpm protocol:check
- name: Lint (bun)
if: matrix.runtime == 'bun'
run: bunx biome check src
- name: Test (bun)
if: matrix.runtime == 'bun'
run: bunx vitest run
- name: Build (bun)
if: matrix.runtime == 'bun'
run: bunx tsc -p tsconfig.json
macos-app: macos-app:
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
runs-on: macos-latest runs-on: macos-latest
strategy:
fail-fast: false
matrix:
include:
- task: lint
command: |
swiftlint --config .swiftlint.yml
swiftformat --lint apps/macos/Sources --config .swiftformat
- task: build
command: |
set -euo pipefail
for attempt in 1 2 3; do
if swift build --package-path apps/macos --configuration release; then
exit 0
fi
echo "swift build failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
- task: test
command: |
set -euo pipefail
for attempt in 1 2 3; do
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
exit 0
fi
echo "swift test failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -142,35 +168,8 @@ jobs:
xcodebuild -version xcodebuild -version
swift --version swift --version
- name: SwiftLint - name: Run ${{ matrix.task }}
run: swiftlint --config .swiftlint.yml run: ${{ matrix.command }}
- name: SwiftFormat (lint mode)
run: swiftformat --lint apps/macos/Sources --config .swiftformat
- name: Swift build (release)
run: |
set -euo pipefail
for attempt in 1 2 3; do
if swift build --package-path apps/macos --configuration release; then
exit 0
fi
echo "swift build failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
- name: Swift tests (coverage)
run: |
set -euo pipefail
for attempt in 1 2 3; do
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
exit 0
fi
echo "swift test failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
ios: ios:
if: false # ignore iOS in CI for now if: false # ignore iOS in CI for now
runs-on: macos-latest runs-on: macos-latest
@@ -346,6 +345,14 @@ jobs:
android: android:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- task: test
command: ./gradlew --no-daemon :app:testDebugUnitTest
- task: build
command: ./gradlew --no-daemon :app:assembleDebug
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -385,6 +392,6 @@ jobs:
"platforms;android-36" \ "platforms;android-36" \
"build-tools;36.0.0" "build-tools;36.0.0"
- name: Android unit tests + debug build - name: Run Android ${{ matrix.task }}
working-directory: apps/android working-directory: apps/android
run: ./gradlew --no-daemon :app:testDebugUnitTest :app:assembleDebug run: ${{ matrix.command }}
+30
View File
@@ -5,18 +5,37 @@
### Breaking ### Breaking
- Identifiers: rename bundle IDs and internal domains to `com.clawdis.*` (macOS: `com.clawdis.mac`, iOS: `com.clawdis.ios`, Android: `com.clawdis.android`) and update the gateway LaunchAgent label to `com.clawdis.gateway`. - Identifiers: rename bundle IDs and internal domains to `com.clawdis.*` (macOS: `com.clawdis.mac`, iOS: `com.clawdis.ios`, Android: `com.clawdis.android`) and update the gateway LaunchAgent label to `com.clawdis.gateway`.
- Agent tools: drop the `clawdis_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`). - Agent tools: drop the `clawdis_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`).
- Bash tool: remove `stdinMode: "pty"`/node-pty support; use the tmux skill for real TTYs.
- Sessions: primary session key is fixed to `main` (or `global` for global scope); `session.mainKey` is ignored.
### Features ### Features
- Highlight: agent-to-agent ping-pong (reply-back loop) with `REPLY_SKIP` plus target announce step with `ANNOUNCE_SKIP` (max turns configurable, 0–5).
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app. - Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
- Gateway: add config hot reload with hybrid restart strategy (`gateway.reload`) and per-section reload handling.
- UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI. - UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI.
- Control UI: support configurable base paths (`gateway.controlUi.basePath`, default unchanged) for hosting under URL prefixes. - Control UI: support configurable base paths (`gateway.controlUi.basePath`, default unchanged) for hosting under URL prefixes.
- Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC. - Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC.
- Config: expose schema + UI hints for generic config forms (Web UI + future clients). - Config: expose schema + UI hints for generic config forms (Web UI + future clients).
- Browser: add multi-profile browser control with per-profile remote CDP URLs — thanks @jamesgroat.
- Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia. - Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia.
- Skills: add Notion API skill — thanks @scald.
- Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) (#140) — thanks @thewilloftheshadow. - Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) (#140) — thanks @thewilloftheshadow.
- Agent: add optional per-session Docker sandbox for tool execution (`agent.sandbox`) with allow/deny policy and auto-pruning.
- Agent: add sandboxed Chromium browser (CDP + optional noVNC observer) for sandboxed sessions.
- Nodes: add `location.get` with Always/Precise settings on macOS/iOS/Android plus CLI/tool support.
- Android nodes: add `sms.send` with permission-gated capability refresh (#172) — thanks @vsabavat.
### Fixes ### Fixes
- macOS: improve Swift 6 strict concurrency compatibility (#166) — thanks @Nachx639.
- CI: fix lint ordering after merge cleanup (#156) — thanks @steipete.
- CI: consolidate checks to avoid redundant installs (#144) — thanks @thewilloftheshadow.
- WhatsApp: support `gifPlayback` for MP4 GIF sends via CLI/gateway.
- Gateway: log config hot reloads for dynamic-read changes without restarts.
- Sessions: prevent `sessions_send` timeouts by running nested agent turns on a separate lane.
- Sessions: use per-send run IDs for gateway agent calls to avoid wait collisions.
- Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends. - Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends.
- Auto-reply: fix typing TTL to 2 minutes and log TTL with s/m units.
- Bash tool: default auto-background delay to 10s.
- Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm. - Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm.
- Block streaming: default to text_end and suppress duplicate block sends while in-flight. - Block streaming: default to text_end and suppress duplicate block sends while in-flight.
- Block streaming: avoid duplicate block chunks when providers repeat full content on text_end. - Block streaming: avoid duplicate block chunks when providers repeat full content on text_end.
@@ -24,19 +43,24 @@
- Gmail hooks: resolve gcloud Python to a real executable when PATH uses mise shims — thanks @joargp. - Gmail hooks: resolve gcloud Python to a real executable when PATH uses mise shims — thanks @joargp.
- Control UI: generate UUIDs when `crypto.randomUUID()` is unavailable over HTTP — thanks @ratulsarna. - Control UI: generate UUIDs when `crypto.randomUUID()` is unavailable over HTTP — thanks @ratulsarna.
- Control UI: stream live tool output cards in Chat (agent events include sessionKey). - Control UI: stream live tool output cards in Chat (agent events include sessionKey).
- Chat UI: render assistant `<think>`/`<final>` markup as italic thinking text in history + streaming instead of showing raw tags.
- Agent: add soft block-stream chunking (800–1200 chars default) with paragraph/newline preference. - Agent: add soft block-stream chunking (800–1200 chars default) with paragraph/newline preference.
- Agent: route embedded run lifecycle logs through subsystem console formatting and reduce log noise.
- Agent tools: scope the Discord tool to Discord surface runs. - Agent tools: scope the Discord tool to Discord surface runs.
- Agent tools: format verbose tool summaries without brackets, with unique emojis and `tool: detail` style. - Agent tools: format verbose tool summaries without brackets, with unique emojis and `tool: detail` style.
- Agent tools: emit verbose tool summaries at tool start (no debounce). - Agent tools: emit verbose tool summaries at tool start (no debounce).
- Gateway: split server helpers/tests into hooks/session-utils/ws-log/net modules for better isolation; add unit coverage for hooks/session utils/ws log. - Gateway: split server helpers/tests into hooks/session-utils/ws-log/net modules for better isolation; add unit coverage for hooks/session utils/ws log.
- Gateway: extract WS method handling + HTTP/provider/constant helpers to shrink server wiring and improve testability. - Gateway: extract WS method handling + HTTP/provider/constant helpers to shrink server wiring and improve testability.
- Gateway: prevent deleting the main session and abort active runs before deleting other sessions.
- Onboarding: fix Control UI basePath usage when showing/opening gateway URLs. - Onboarding: fix Control UI basePath usage when showing/opening gateway URLs.
- Onboarding: clarify provider requirements (WhatsApp/Signal phone numbers, iMessage Apple ID guidance) in the provider picker.
- macOS Connections: move to sidebar + detail layout with structured sections and header actions. - macOS Connections: move to sidebar + detail layout with structured sections and header actions.
- macOS onboarding: increase window height so the permissions page fits without scrolling. - macOS onboarding: increase window height so the permissions page fits without scrolling.
- Thinking: default to low for reasoning-capable models when no /think or config default is set. - Thinking: default to low for reasoning-capable models when no /think or config default is set.
- Logging: decouple file log levels from console verbosity; verbose-only details are captured when `logging.level` is debug/trace. - Logging: decouple file log levels from console verbosity; verbose-only details are captured when `logging.level` is debug/trace.
- Build: fix regex literal in tool-meta path detection (watch build error). - Build: fix regex literal in tool-meta path detection (watch build error).
- Build: require AVX2 Bun for x86_64 relay packaging (reject baseline builds). - Build: require AVX2 Bun for x86_64 relay packaging (reject baseline builds).
- Build: drop stale ClawdisCLI product from macOS build-and-run script.
- Auto-reply: add run-level telemetry + typing TTL guardrails to diagnose stuck replies. - Auto-reply: add run-level telemetry + typing TTL guardrails to diagnose stuck replies.
- WhatsApp: honor per-group mention gating overrides when group ids are stored as session keys. - WhatsApp: honor per-group mention gating overrides when group ids are stored as session keys.
- Dependencies: bump pi-mono packages to 0.32.3. - Dependencies: bump pi-mono packages to 0.32.3.
@@ -45,10 +69,16 @@
- Skills: add Sheets/Docs examples to gog skill (#128) — thanks @mbelinky. - Skills: add Sheets/Docs examples to gog skill (#128) — thanks @mbelinky.
- Skills: clarify bear-notes token + callback usage (#120) — thanks @tylerwince. - Skills: clarify bear-notes token + callback usage (#120) — thanks @tylerwince.
- Skills: document Discord `sendMessage` media attachments and `to` format clarification. - Skills: document Discord `sendMessage` media attachments and `to` format clarification.
- Skills: expand peekaboo skill examples + common parameters.
- Skills: add tmux skill + interactive coding guidance in coding-agent. - Skills: add tmux skill + interactive coding guidance in coding-agent.
- Gateway: document port configuration + multi-instance isolation. - Gateway: document port configuration + multi-instance isolation.
- Gateway: document config hot reload + reload matrix.
- Onboarding/Config: add protocol notes for wizard + schema RPC. - Onboarding/Config: add protocol notes for wizard + schema RPC.
- Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces. - Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces.
- Sandbox: document per-session agent sandbox setup, browser image, and Docker build.
- macOS: clarify menu bar uses sessionKey from agent events.
- Sessions: document agent-to-agent reply loop (`REPLY_SKIP`) and announce step (`ANNOUNCE_SKIP`).
- Skills: clarify wacli third-party messaging scope and JID format examples.
## 2.0.0-beta5 — 2026-01-03 ## 2.0.0-beta5 — 2026-01-03
+16
View File
@@ -0,0 +1,16 @@
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
jq \
python3 \
ripgrep \
&& rm -rf /var/lib/apt/lists/*
CMD ["sleep", "infinity"]
+27
View File
@@ -0,0 +1,27 @@
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
chromium \
curl \
fonts-liberation \
fonts-noto-color-emoji \
git \
jq \
novnc \
python3 \
websockify \
x11vnc \
xvfb \
&& rm -rf /var/lib/apt/lists/*
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/clawdis-sandbox-browser
RUN chmod +x /usr/local/bin/clawdis-sandbox-browser
EXPOSE 9222 5900 6080
CMD ["clawdis-sandbox-browser"]
Submodule Peekaboo updated: 9db365b73c...b69e4e8dc0.
+1 -1
View File
@@ -18,7 +18,7 @@
**Clawdis** is a *personal AI assistant* you run on your own devices. **Clawdis** is a *personal AI assistant* you run on your own devices.
It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, iMessage, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant. It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, iMessage, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
If you want a private, single-user assistant that feels local, fast, and always-on, this is it. If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
Website: https://clawd.me · Docs: [`docs/index.md`](docs/index.md) · FAQ: [`docs/faq.md`](docs/faq.md) · Wizard: [`docs/wizard.md`](docs/wizard.md) · Docker (optional): [`docs/docker.md`](docs/docker.md) · Discord: https://discord.gg/clawd Website: https://clawd.me · Docs: [`docs/index.md`](docs/index.md) · FAQ: [`docs/faq.md`](docs/faq.md) · Wizard: [`docs/wizard.md`](docs/wizard.md) · Docker (optional): [`docs/docker.md`](docs/docker.md) · Discord: https://discord.gg/clawd
@@ -9,17 +9,18 @@
<uses-permission <uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES" android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" /> android:usesPermissionFlags="neverForLocation" />
<uses-permission <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
android:name="android.permission.ACCESS_FINE_LOCATION" <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
android:maxSdkVersion="32" /> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-feature <uses-feature
android:name="android.hardware.camera" android:name="android.hardware.camera"
android:required="false" /> android:required="false" />
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<application <application
android:name=".NodeApp" android:name=".NodeApp"
@@ -0,0 +1,15 @@
package com.clawdis.android
enum class LocationMode(val rawValue: String) {
Off("off"),
WhileUsing("whileUsing"),
Always("always"),
;
companion object {
fun fromRawValue(raw: String?): LocationMode {
val normalized = raw?.trim()?.lowercase()
return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off
}
}
}
@@ -39,6 +39,7 @@ class MainActivity : ComponentActivity() {
screenCaptureRequester = ScreenCaptureRequester(this) screenCaptureRequester = ScreenCaptureRequester(this)
viewModel.camera.attachLifecycleOwner(this) viewModel.camera.attachLifecycleOwner(this)
viewModel.camera.attachPermissionRequester(permissionRequester) viewModel.camera.attachPermissionRequester(permissionRequester)
viewModel.sms.attachPermissionRequester(permissionRequester)
viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester) viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester)
viewModel.screenRecorder.attachPermissionRequester(permissionRequester) viewModel.screenRecorder.attachPermissionRequester(permissionRequester)
@@ -7,6 +7,7 @@ import com.clawdis.android.chat.OutgoingAttachment
import com.clawdis.android.node.CameraCaptureManager import com.clawdis.android.node.CameraCaptureManager
import com.clawdis.android.node.CanvasController import com.clawdis.android.node.CanvasController
import com.clawdis.android.node.ScreenRecordManager import com.clawdis.android.node.ScreenRecordManager
import com.clawdis.android.node.SmsManager
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
class MainViewModel(app: Application) : AndroidViewModel(app) { class MainViewModel(app: Application) : AndroidViewModel(app) {
@@ -15,6 +16,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val canvas: CanvasController = runtime.canvas val canvas: CanvasController = runtime.canvas
val camera: CameraCaptureManager = runtime.camera val camera: CameraCaptureManager = runtime.camera
val screenRecorder: ScreenRecordManager = runtime.screenRecorder val screenRecorder: ScreenRecordManager = runtime.screenRecorder
val sms: SmsManager = runtime.sms
val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
@@ -33,6 +35,8 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val instanceId: StateFlow<String> = runtime.instanceId val instanceId: StateFlow<String> = runtime.instanceId
val displayName: StateFlow<String> = runtime.displayName val displayName: StateFlow<String> = runtime.displayName
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
val locationMode: StateFlow<LocationMode> = runtime.locationMode
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
val preventSleep: StateFlow<Boolean> = runtime.preventSleep val preventSleep: StateFlow<Boolean> = runtime.preventSleep
val wakeWords: StateFlow<List<String>> = runtime.wakeWords val wakeWords: StateFlow<List<String>> = runtime.wakeWords
val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode
@@ -70,6 +74,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setCameraEnabled(value) runtime.setCameraEnabled(value)
} }
fun setLocationMode(mode: LocationMode) {
runtime.setLocationMode(mode)
}
fun setLocationPreciseEnabled(value: Boolean) {
runtime.setLocationPreciseEnabled(value)
}
fun setPreventSleep(value: Boolean) { fun setPreventSleep(value: Boolean) {
runtime.setPreventSleep(value) runtime.setPreventSleep(value)
} }
@@ -106,6 +118,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setTalkEnabled(enabled) runtime.setTalkEnabled(enabled)
} }
fun refreshBridgeHello() {
runtime.refreshBridgeHello()
}
fun connect(endpoint: BridgeEndpoint) { fun connect(endpoint: BridgeEndpoint) {
runtime.connect(endpoint) runtime.connect(endpoint)
} }
@@ -3,6 +3,7 @@ package com.clawdis.android
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.location.LocationManager
import android.os.Build import android.os.Build
import android.os.SystemClock import android.os.SystemClock
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -16,21 +17,26 @@ import com.clawdis.android.bridge.BridgeEndpoint
import com.clawdis.android.bridge.BridgePairingClient import com.clawdis.android.bridge.BridgePairingClient
import com.clawdis.android.bridge.BridgeSession import com.clawdis.android.bridge.BridgeSession
import com.clawdis.android.node.CameraCaptureManager import com.clawdis.android.node.CameraCaptureManager
import com.clawdis.android.node.LocationCaptureManager
import com.clawdis.android.BuildConfig import com.clawdis.android.BuildConfig
import com.clawdis.android.node.CanvasController import com.clawdis.android.node.CanvasController
import com.clawdis.android.node.ScreenRecordManager import com.clawdis.android.node.ScreenRecordManager
import com.clawdis.android.node.SmsManager
import com.clawdis.android.protocol.ClawdisCapability import com.clawdis.android.protocol.ClawdisCapability
import com.clawdis.android.protocol.ClawdisCameraCommand import com.clawdis.android.protocol.ClawdisCameraCommand
import com.clawdis.android.protocol.ClawdisCanvasA2UIAction import com.clawdis.android.protocol.ClawdisCanvasA2UIAction
import com.clawdis.android.protocol.ClawdisCanvasA2UICommand import com.clawdis.android.protocol.ClawdisCanvasA2UICommand
import com.clawdis.android.protocol.ClawdisCanvasCommand import com.clawdis.android.protocol.ClawdisCanvasCommand
import com.clawdis.android.protocol.ClawdisScreenCommand import com.clawdis.android.protocol.ClawdisScreenCommand
import com.clawdis.android.protocol.ClawdisLocationCommand
import com.clawdis.android.protocol.ClawdisSmsCommand
import com.clawdis.android.voice.TalkModeManager import com.clawdis.android.voice.TalkModeManager
import com.clawdis.android.voice.VoiceWakeManager import com.clawdis.android.voice.VoiceWakeManager
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -55,7 +61,9 @@ class NodeRuntime(context: Context) {
val prefs = SecurePrefs(appContext) val prefs = SecurePrefs(appContext)
val canvas = CanvasController() val canvas = CanvasController()
val camera = CameraCaptureManager(appContext) val camera = CameraCaptureManager(appContext)
val location = LocationCaptureManager(appContext)
val screenRecorder = ScreenRecordManager(appContext) val screenRecorder = ScreenRecordManager(appContext)
val sms = SmsManager(appContext)
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
private val externalAudioCaptureActive = MutableStateFlow(false) private val externalAudioCaptureActive = MutableStateFlow(false)
@@ -186,6 +194,8 @@ class NodeRuntime(context: Context) {
val instanceId: StateFlow<String> = prefs.instanceId val instanceId: StateFlow<String> = prefs.instanceId
val displayName: StateFlow<String> = prefs.displayName val displayName: StateFlow<String> = prefs.displayName
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
val locationMode: StateFlow<LocationMode> = prefs.locationMode
val locationPreciseEnabled: StateFlow<Boolean> = prefs.locationPreciseEnabled
val preventSleep: StateFlow<Boolean> = prefs.preventSleep val preventSleep: StateFlow<Boolean> = prefs.preventSleep
val wakeWords: StateFlow<List<String>> = prefs.wakeWords val wakeWords: StateFlow<List<String>> = prefs.wakeWords
val voiceWakeMode: StateFlow<VoiceWakeMode> = prefs.voiceWakeMode val voiceWakeMode: StateFlow<VoiceWakeMode> = prefs.voiceWakeMode
@@ -312,6 +322,14 @@ class NodeRuntime(context: Context) {
prefs.setCameraEnabled(value) prefs.setCameraEnabled(value)
} }
fun setLocationMode(mode: LocationMode) {
prefs.setLocationMode(mode)
}
fun setLocationPreciseEnabled(value: Boolean) {
prefs.setLocationPreciseEnabled(value)
}
fun setPreventSleep(value: Boolean) { fun setPreventSleep(value: Boolean) {
prefs.setPreventSleep(value) prefs.setPreventSleep(value)
} }
@@ -349,104 +367,128 @@ class NodeRuntime(context: Context) {
prefs.setTalkEnabled(value) prefs.setTalkEnabled(value)
} }
private fun buildInvokeCommands(): List<String> =
buildList {
add(ClawdisCanvasCommand.Present.rawValue)
add(ClawdisCanvasCommand.Hide.rawValue)
add(ClawdisCanvasCommand.Navigate.rawValue)
add(ClawdisCanvasCommand.Eval.rawValue)
add(ClawdisCanvasCommand.Snapshot.rawValue)
add(ClawdisCanvasA2UICommand.Push.rawValue)
add(ClawdisCanvasA2UICommand.PushJSONL.rawValue)
add(ClawdisCanvasA2UICommand.Reset.rawValue)
add(ClawdisScreenCommand.Record.rawValue)
if (cameraEnabled.value) {
add(ClawdisCameraCommand.Snap.rawValue)
add(ClawdisCameraCommand.Clip.rawValue)
}
if (locationMode.value != LocationMode.Off) {
add(ClawdisLocationCommand.Get.rawValue)
}
if (sms.canSendSms()) {
add(ClawdisSmsCommand.Send.rawValue)
}
}
private fun buildCapabilities(): List<String> =
buildList {
add(ClawdisCapability.Canvas.rawValue)
add(ClawdisCapability.Screen.rawValue)
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
if (sms.canSendSms()) add(ClawdisCapability.Sms.rawValue)
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
add(ClawdisCapability.VoiceWake.rawValue)
}
if (locationMode.value != LocationMode.Off) {
add(ClawdisCapability.Location.rawValue)
}
}
private fun buildPairingHello(token: String?): BridgePairingClient.Hello {
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
.joinToString(" ")
.trim()
.ifEmpty { null }
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
val advertisedVersion =
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
"$versionName-dev"
} else {
versionName
}
return BridgePairingClient.Hello(
nodeId = instanceId.value,
displayName = displayName.value,
token = token,
platform = "Android",
version = advertisedVersion,
deviceFamily = "Android",
modelIdentifier = modelIdentifier,
caps = buildCapabilities(),
commands = buildInvokeCommands(),
)
}
private fun buildSessionHello(token: String?): BridgeSession.Hello {
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
.joinToString(" ")
.trim()
.ifEmpty { null }
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
val advertisedVersion =
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
"$versionName-dev"
} else {
versionName
}
return BridgeSession.Hello(
nodeId = instanceId.value,
displayName = displayName.value,
token = token,
platform = "Android",
version = advertisedVersion,
deviceFamily = "Android",
modelIdentifier = modelIdentifier,
caps = buildCapabilities(),
commands = buildInvokeCommands(),
)
}
fun refreshBridgeHello() {
scope.launch {
if (!_isConnected.value) return@launch
val token = prefs.loadBridgeToken()
if (token.isNullOrBlank()) return@launch
session.updateHello(buildSessionHello(token))
}
}
fun connect(endpoint: BridgeEndpoint) { fun connect(endpoint: BridgeEndpoint) {
scope.launch { scope.launch {
_statusText.value = "Connecting…" _statusText.value = "Connecting…"
val storedToken = prefs.loadBridgeToken() val storedToken = prefs.loadBridgeToken()
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
.joinToString(" ")
.trim()
.ifEmpty { null }
val invokeCommands =
buildList {
add(ClawdisCanvasCommand.Present.rawValue)
add(ClawdisCanvasCommand.Hide.rawValue)
add(ClawdisCanvasCommand.Navigate.rawValue)
add(ClawdisCanvasCommand.Eval.rawValue)
add(ClawdisCanvasCommand.Snapshot.rawValue)
add(ClawdisCanvasA2UICommand.Push.rawValue)
add(ClawdisCanvasA2UICommand.PushJSONL.rawValue)
add(ClawdisCanvasA2UICommand.Reset.rawValue)
add(ClawdisScreenCommand.Record.rawValue)
if (cameraEnabled.value) {
add(ClawdisCameraCommand.Snap.rawValue)
add(ClawdisCameraCommand.Clip.rawValue)
}
}
val resolved = val resolved =
if (storedToken.isNullOrBlank()) { if (storedToken.isNullOrBlank()) {
_statusText.value = "Pairing…" _statusText.value = "Pairing…"
val caps = buildList {
add(ClawdisCapability.Canvas.rawValue)
add(ClawdisCapability.Screen.rawValue)
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
add(ClawdisCapability.VoiceWake.rawValue)
}
}
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
val advertisedVersion =
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
"$versionName-dev"
} else {
versionName
}
BridgePairingClient().pairAndHello( BridgePairingClient().pairAndHello(
endpoint = endpoint, endpoint = endpoint,
hello = hello = buildPairingHello(token = null),
BridgePairingClient.Hello(
nodeId = instanceId.value,
displayName = displayName.value,
token = null,
platform = "Android",
version = advertisedVersion,
deviceFamily = "Android",
modelIdentifier = modelIdentifier,
caps = caps,
commands = invokeCommands,
),
) )
} else { } else {
BridgePairingClient.PairResult(ok = true, token = storedToken.trim()) BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
} }
if (!resolved.ok || resolved.token.isNullOrBlank()) { if (!resolved.ok || resolved.token.isNullOrBlank()) {
_statusText.value = "Failed: pairing required" val errorMessage = resolved.error?.trim().orEmpty().ifEmpty { "pairing required" }
_statusText.value = "Failed: $errorMessage"
return@launch return@launch
} }
val authToken = requireNotNull(resolved.token).trim() val authToken = requireNotNull(resolved.token).trim()
prefs.saveBridgeToken(authToken) prefs.saveBridgeToken(authToken)
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
val advertisedVersion =
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
"$versionName-dev"
} else {
versionName
}
session.connect( session.connect(
endpoint = endpoint, endpoint = endpoint,
hello = hello = buildSessionHello(token = authToken),
BridgeSession.Hello(
nodeId = instanceId.value,
displayName = displayName.value,
token = authToken,
platform = "Android",
version = advertisedVersion,
deviceFamily = "Android",
modelIdentifier = modelIdentifier,
caps =
buildList {
add(ClawdisCapability.Canvas.rawValue)
add(ClawdisCapability.Screen.rawValue)
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
add(ClawdisCapability.VoiceWake.rawValue)
}
},
commands = invokeCommands,
),
) )
} }
} }
@@ -458,6 +500,28 @@ class NodeRuntime(context: Context) {
) )
} }
private fun hasFineLocationPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
)
}
private fun hasCoarseLocationPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
)
}
private fun hasBackgroundLocationPermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return true
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
)
}
fun connectManual() { fun connectManual() {
val host = manualHost.value.trim() val host = manualHost.value.trim()
val port = manualPort.value val port = manualPort.value
@@ -667,6 +731,14 @@ class NodeRuntime(context: Context) {
message = "CAMERA_DISABLED: enable Camera in Settings", message = "CAMERA_DISABLED: enable Camera in Settings",
) )
} }
if (command.startsWith(ClawdisLocationCommand.NamespacePrefix) &&
locationMode.value == LocationMode.Off
) {
return BridgeSession.InvokeResult.error(
code = "LOCATION_DISABLED",
message = "LOCATION_DISABLED: enable Location in Settings",
)
}
return when (command) { return when (command) {
ClawdisCanvasCommand.Present.rawValue -> { ClawdisCanvasCommand.Present.rawValue -> {
@@ -787,6 +859,59 @@ class NodeRuntime(context: Context) {
if (includeAudio) externalAudioCaptureActive.value = false if (includeAudio) externalAudioCaptureActive.value = false
} }
} }
ClawdisLocationCommand.Get.rawValue -> {
val mode = locationMode.value
if (!isForeground.value && mode != LocationMode.Always) {
return BridgeSession.InvokeResult.error(
code = "LOCATION_BACKGROUND_UNAVAILABLE",
message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always",
)
}
if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) {
return BridgeSession.InvokeResult.error(
code = "LOCATION_PERMISSION_REQUIRED",
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
)
}
if (!isForeground.value && mode == LocationMode.Always && !hasBackgroundLocationPermission()) {
return BridgeSession.InvokeResult.error(
code = "LOCATION_PERMISSION_REQUIRED",
message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings",
)
}
val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson)
val preciseEnabled = locationPreciseEnabled.value
val accuracy =
when (desiredAccuracy) {
"precise" -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
"coarse" -> "coarse"
else -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
}
val providers =
when (accuracy) {
"precise" -> listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
"coarse" -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
else -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
}
try {
val payload =
location.getLocation(
desiredProviders = providers,
maxAgeMs = maxAgeMs,
timeoutMs = timeoutMs,
isPrecise = accuracy == "precise",
)
BridgeSession.InvokeResult.ok(payload.payloadJson)
} catch (err: TimeoutCancellationException) {
BridgeSession.InvokeResult.error(
code = "LOCATION_TIMEOUT",
message = "LOCATION_TIMEOUT: no fix in time",
)
} catch (err: Throwable) {
val message = err.message ?: "LOCATION_UNAVAILABLE: no fix"
BridgeSession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message)
}
}
ClawdisScreenCommand.Record.rawValue -> { ClawdisScreenCommand.Record.rawValue -> {
// Status pill mirrors screen recording state so it stays visible without overlay stacking. // Status pill mirrors screen recording state so it stays visible without overlay stacking.
_screenRecordActive.value = true _screenRecordActive.value = true
@@ -803,6 +928,17 @@ class NodeRuntime(context: Context) {
_screenRecordActive.value = false _screenRecordActive.value = false
} }
} }
ClawdisSmsCommand.Send.rawValue -> {
val res = sms.send(paramsJson)
if (res.ok) {
BridgeSession.InvokeResult.ok(res.payloadJson)
} else {
val error = res.error ?: "SMS_SEND_FAILED"
val idx = error.indexOf(':')
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEND_FAILED"
BridgeSession.InvokeResult.error(code = code, message = error)
}
}
else -> else ->
BridgeSession.InvokeResult.error( BridgeSession.InvokeResult.error(
code = "INVALID_REQUEST", code = "INVALID_REQUEST",
@@ -840,6 +976,25 @@ class NodeRuntime(context: Context) {
return code to "$code: $message" return code to "$code: $message"
} }
private fun parseLocationParams(paramsJson: String?): Triple<Long?, Long, String?> {
if (paramsJson.isNullOrBlank()) {
return Triple(null, 10_000L, null)
}
val root =
try {
json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
}
val maxAgeMs = (root?.get("maxAgeMs") as? JsonPrimitive)?.content?.toLongOrNull()
val timeoutMs =
(root?.get("timeoutMs") as? JsonPrimitive)?.content?.toLongOrNull()?.coerceIn(1_000L, 60_000L)
?: 10_000L
val desiredAccuracy =
(root?.get("desiredAccuracy") as? JsonPrimitive)?.content?.trim()?.lowercase()
return Triple(maxAgeMs, timeoutMs, desiredAccuracy)
}
private fun resolveA2uiHostUrl(): String? { private fun resolveA2uiHostUrl(): String? {
val raw = session.currentCanvasHostUrl()?.trim().orEmpty() val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
if (raw.isBlank()) return null if (raw.isBlank()) return null
@@ -115,7 +115,7 @@ class PermissionRequester(private val activity: ComponentActivity) {
private fun buildRationaleMessage(permissions: List<String>): String { private fun buildRationaleMessage(permissions: List<String>): String {
val labels = permissions.map { permissionLabel(it) } val labels = permissions.map { permissionLabel(it) }
return "Clawdis needs ${labels.joinToString(", ")} to capture camera media." return "Clawdis needs ${labels.joinToString(", ")} permissions to continue."
} }
private fun buildSettingsMessage(permissions: List<String>): String { private fun buildSettingsMessage(permissions: List<String>): String {
@@ -127,6 +127,7 @@ class PermissionRequester(private val activity: ComponentActivity) {
when (permission) { when (permission) {
Manifest.permission.CAMERA -> "Camera" Manifest.permission.CAMERA -> "Camera"
Manifest.permission.RECORD_AUDIO -> "Microphone" Manifest.permission.RECORD_AUDIO -> "Microphone"
Manifest.permission.SEND_SMS -> "SMS"
else -> permission else -> permission
} }
} }
@@ -47,6 +47,14 @@ class SecurePrefs(context: Context) {
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true)) private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
private val _locationMode =
MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off")))
val locationMode: StateFlow<LocationMode> = _locationMode
private val _locationPreciseEnabled =
MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true))
val locationPreciseEnabled: StateFlow<Boolean> = _locationPreciseEnabled
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true)) private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
val preventSleep: StateFlow<Boolean> = _preventSleep val preventSleep: StateFlow<Boolean> = _preventSleep
@@ -93,6 +101,16 @@ class SecurePrefs(context: Context) {
_cameraEnabled.value = value _cameraEnabled.value = value
} }
fun setLocationMode(mode: LocationMode) {
prefs.edit { putString("location.enabledMode", mode.rawValue) }
_locationMode.value = mode
}
fun setLocationPreciseEnabled(value: Boolean) {
prefs.edit { putBoolean("location.preciseEnabled", value) }
_locationPreciseEnabled.value = value
}
fun setPreventSleep(value: Boolean) { fun setPreventSleep(value: Boolean) {
prefs.edit { putBoolean("screen.preventSleep", value) } prefs.edit { putBoolean("screen.preventSleep", value) }
_preventSleep.value = value _preventSleep.value = value
@@ -37,21 +37,21 @@ class BridgePairingClient {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val socket = Socket() val socket = Socket()
socket.tcpNoDelay = true socket.tcpNoDelay = true
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 60_000
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
fun send(line: String) {
writer.write(line)
writer.write("\n")
writer.flush()
}
fun sendJson(obj: JsonObject) = send(obj.toString())
try { try {
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 60_000
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
fun send(line: String) {
writer.write(line)
writer.write("\n")
writer.flush()
}
fun sendJson(obj: JsonObject) = send(obj.toString())
sendJson( sendJson(
buildJsonObject { buildJsonObject {
put("type", JsonPrimitive("hello")) put("type", JsonPrimitive("hello"))
@@ -111,6 +111,9 @@ class BridgePairingClient {
} }
else -> PairResult(ok = false, token = null, error = "unexpected bridge response") else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
} }
} catch (e: Exception) {
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
PairResult(ok = false, token = null, error = message)
} finally { } finally {
try { try {
socket.close() socket.close()
@@ -75,6 +75,13 @@ class BridgeSession(
} }
} }
suspend fun updateHello(hello: Hello) {
val target = desired ?: return
desired = target.first to hello
val conn = currentConnection ?: return
conn.sendJson(buildHelloJson(hello))
}
fun disconnect() { fun disconnect() {
desired = null desired = null
// Unblock connectOnce() read loop. Coroutine cancellation alone won't interrupt BufferedReader.readLine(). // Unblock connectOnce() read loop. Coroutine cancellation alone won't interrupt BufferedReader.readLine().
@@ -196,20 +203,7 @@ class BridgeSession(
currentConnection = conn currentConnection = conn
try { try {
conn.sendJson( conn.sendJson(buildHelloJson(hello))
buildJsonObject {
put("type", JsonPrimitive("hello"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.token?.let { put("token", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
},
)
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection") val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
val first = json.parseToJsonElement(firstLine).asObjectOrNull() val first = json.parseToJsonElement(firstLine).asObjectOrNull()
@@ -307,6 +301,20 @@ class BridgeSession(
} }
} }
private fun buildHelloJson(hello: Hello): JsonObject =
buildJsonObject {
put("type", JsonPrimitive("hello"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.token?.let { put("token", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
}
private fun normalizeCanvasHostUrl(raw: String?, endpoint: BridgeEndpoint): String? { private fun normalizeCanvasHostUrl(raw: String?, endpoint: BridgeEndpoint): String? {
val trimmed = raw?.trim().orEmpty() val trimmed = raw?.trim().orEmpty()
val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { URI(it) }.getOrNull() } val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { URI(it) }.getOrNull() }
@@ -0,0 +1,98 @@
package com.clawdis.android.node
import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import android.location.LocationManager
import android.os.CancellationSignal
import java.time.Instant
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
class LocationCaptureManager(private val context: Context) {
data class Payload(val payloadJson: String)
suspend fun getLocation(
desiredProviders: List<String>,
maxAgeMs: Long?,
timeoutMs: Long,
isPrecise: Boolean,
): Payload =
withContext(Dispatchers.Main) {
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
if (!manager.isProviderEnabled(LocationManager.GPS_PROVIDER) &&
!manager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
) {
throw IllegalStateException("LOCATION_UNAVAILABLE: no location providers enabled")
}
val cached = bestLastKnown(manager, desiredProviders, maxAgeMs)
val location =
cached ?: requestCurrent(manager, desiredProviders, timeoutMs)
val timestamp = DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(location.time))
val source = location.provider
val altitudeMeters = if (location.hasAltitude()) location.altitude else null
val speedMps = if (location.hasSpeed()) location.speed.toDouble() else null
val headingDeg = if (location.hasBearing()) location.bearing.toDouble() else null
Payload(
buildString {
append("{\"lat\":")
append(location.latitude)
append(",\"lon\":")
append(location.longitude)
append(",\"accuracyMeters\":")
append(location.accuracy.toDouble())
if (altitudeMeters != null) append(",\"altitudeMeters\":").append(altitudeMeters)
if (speedMps != null) append(",\"speedMps\":").append(speedMps)
if (headingDeg != null) append(",\"headingDeg\":").append(headingDeg)
append(",\"timestamp\":\"").append(timestamp).append('"')
append(",\"isPrecise\":").append(isPrecise)
append(",\"source\":\"").append(source).append('"')
append('}')
},
)
}
private fun bestLastKnown(
manager: LocationManager,
providers: List<String>,
maxAgeMs: Long?,
): Location? {
val now = System.currentTimeMillis()
val candidates =
providers.mapNotNull { provider -> manager.getLastKnownLocation(provider) }
val freshest = candidates.maxByOrNull { it.time } ?: return null
if (maxAgeMs != null && now - freshest.time > maxAgeMs) return null
return freshest
}
@SuppressLint("MissingPermission")
private suspend fun requestCurrent(
manager: LocationManager,
providers: List<String>,
timeoutMs: Long,
): Location {
val resolved =
providers.firstOrNull { manager.isProviderEnabled(it) }
?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available")
return withTimeout(timeoutMs.coerceAtLeast(1)) {
suspendCancellableCoroutine { cont ->
val signal = CancellationSignal()
cont.invokeOnCancellation { signal.cancel() }
manager.getCurrentLocation(resolved, signal, context.mainExecutor) { location ->
if (location != null) {
cont.resume(location)
} else {
cont.resumeWithException(IllegalStateException("LOCATION_UNAVAILABLE: no fix"))
}
}
}
}
}
}
@@ -0,0 +1,230 @@
package com.clawdis.android.node
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.telephony.SmsManager as AndroidSmsManager
import androidx.core.content.ContextCompat
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.encodeToString
import com.clawdis.android.PermissionRequester
/**
* Sends SMS messages via the Android SMS API.
* Requires SEND_SMS permission to be granted.
*/
class SmsManager(private val context: Context) {
private val json = JsonConfig
@Volatile private var permissionRequester: PermissionRequester? = null
data class SendResult(
val ok: Boolean,
val to: String,
val message: String?,
val error: String? = null,
val payloadJson: String,
)
internal data class ParsedParams(
val to: String,
val message: String,
)
internal sealed class ParseResult {
data class Ok(val params: ParsedParams) : ParseResult()
data class Error(
val error: String,
val to: String = "",
val message: String? = null,
) : ParseResult()
}
internal data class SendPlan(
val parts: List<String>,
val useMultipart: Boolean,
)
companion object {
internal val JsonConfig = Json { ignoreUnknownKeys = true }
internal fun parseParams(paramsJson: String?, json: Json = JsonConfig): ParseResult {
val params = paramsJson?.trim().orEmpty()
if (params.isEmpty()) {
return ParseResult.Error(error = "INVALID_REQUEST: paramsJSON required")
}
val obj = try {
json.parseToJsonElement(params).jsonObject
} catch (_: Throwable) {
null
}
if (obj == null) {
return ParseResult.Error(error = "INVALID_REQUEST: expected JSON object")
}
val to = (obj["to"] as? JsonPrimitive)?.content?.trim().orEmpty()
val message = (obj["message"] as? JsonPrimitive)?.content.orEmpty()
if (to.isEmpty()) {
return ParseResult.Error(
error = "INVALID_REQUEST: 'to' phone number required",
message = message,
)
}
if (message.isEmpty()) {
return ParseResult.Error(
error = "INVALID_REQUEST: 'message' text required",
to = to,
)
}
return ParseResult.Ok(ParsedParams(to = to, message = message))
}
internal fun buildSendPlan(
message: String,
divider: (String) -> List<String>,
): SendPlan {
val parts = divider(message).ifEmpty { listOf(message) }
return SendPlan(parts = parts, useMultipart = parts.size > 1)
}
internal fun buildPayloadJson(
json: Json = JsonConfig,
ok: Boolean,
to: String,
error: String?,
): String {
val payload =
mutableMapOf<String, JsonElement>(
"ok" to JsonPrimitive(ok),
"to" to JsonPrimitive(to),
)
if (!ok) {
payload["error"] = JsonPrimitive(error ?: "SMS_SEND_FAILED")
}
return json.encodeToString(JsonObject.serializer(), JsonObject(payload))
}
}
fun hasSmsPermission(): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.SEND_SMS
) == PackageManager.PERMISSION_GRANTED
}
fun canSendSms(): Boolean {
return hasSmsPermission() && hasTelephonyFeature()
}
fun hasTelephonyFeature(): Boolean {
return context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
}
fun attachPermissionRequester(requester: PermissionRequester) {
permissionRequester = requester
}
/**
* Send an SMS message.
*
* @param paramsJson JSON with "to" (phone number) and "message" (text) fields
* @return SendResult indicating success or failure
*/
suspend fun send(paramsJson: String?): SendResult {
if (!hasTelephonyFeature()) {
return errorResult(
error = "SMS_UNAVAILABLE: telephony not available",
)
}
if (!ensureSmsPermission()) {
return errorResult(
error = "SMS_PERMISSION_REQUIRED: grant SMS permission",
)
}
val parseResult = parseParams(paramsJson, json)
if (parseResult is ParseResult.Error) {
return errorResult(
error = parseResult.error,
to = parseResult.to,
message = parseResult.message,
)
}
val params = (parseResult as ParseResult.Ok).params
return try {
val smsManager = context.getSystemService(AndroidSmsManager::class.java)
?: throw IllegalStateException("SMS_UNAVAILABLE: SmsManager not available")
val plan = buildSendPlan(params.message) { smsManager.divideMessage(it) }
if (plan.useMultipart) {
smsManager.sendMultipartTextMessage(
params.to, // destination
null, // service center (null = default)
ArrayList(plan.parts), // message parts
null, // sent intents
null, // delivery intents
)
} else {
smsManager.sendTextMessage(
params.to, // destination
null, // service center (null = default)
params.message,// message
null, // sent intent
null, // delivery intent
)
}
okResult(to = params.to, message = params.message)
} catch (e: SecurityException) {
errorResult(
error = "SMS_PERMISSION_REQUIRED: ${e.message}",
to = params.to,
message = params.message,
)
} catch (e: Throwable) {
errorResult(
error = "SMS_SEND_FAILED: ${e.message ?: "unknown error"}",
to = params.to,
message = params.message,
)
}
}
private suspend fun ensureSmsPermission(): Boolean {
if (hasSmsPermission()) return true
val requester = permissionRequester ?: return false
val results = requester.requestIfMissing(listOf(Manifest.permission.SEND_SMS))
return results[Manifest.permission.SEND_SMS] == true
}
private fun okResult(to: String, message: String): SendResult {
return SendResult(
ok = true,
to = to,
message = message,
error = null,
payloadJson = buildPayloadJson(json = json, ok = true, to = to, error = null),
)
}
private fun errorResult(error: String, to: String = "", message: String? = null): SendResult {
return SendResult(
ok = false,
to = to,
message = message,
error = error,
payloadJson = buildPayloadJson(json = json, ok = false, to = to, error = error),
)
}
}
@@ -4,7 +4,9 @@ enum class ClawdisCapability(val rawValue: String) {
Canvas("canvas"), Canvas("canvas"),
Camera("camera"), Camera("camera"),
Screen("screen"), Screen("screen"),
Sms("sms"),
VoiceWake("voiceWake"), VoiceWake("voiceWake"),
Location("location"),
} }
enum class ClawdisCanvasCommand(val rawValue: String) { enum class ClawdisCanvasCommand(val rawValue: String) {
@@ -49,3 +51,21 @@ enum class ClawdisScreenCommand(val rawValue: String) {
const val NamespacePrefix: String = "screen." const val NamespacePrefix: String = "screen."
} }
} }
enum class ClawdisSmsCommand(val rawValue: String) {
Send("sms.send"),
;
companion object {
const val NamespacePrefix: String = "sms."
}
}
enum class ClawdisLocationCommand(val rawValue: String) {
Get("location.get"),
;
companion object {
const val NamespacePrefix: String = "location."
}
}
@@ -1,8 +1,12 @@
package com.clawdis.android.ui package com.clawdis.android.ui
import android.Manifest import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
@@ -42,12 +46,14 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.clawdis.android.BuildConfig import com.clawdis.android.BuildConfig
import com.clawdis.android.LocationMode
import com.clawdis.android.MainViewModel import com.clawdis.android.MainViewModel
import com.clawdis.android.NodeForegroundService import com.clawdis.android.NodeForegroundService
import com.clawdis.android.VoiceWakeMode import com.clawdis.android.VoiceWakeMode
@@ -58,6 +64,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
val instanceId by viewModel.instanceId.collectAsState() val instanceId by viewModel.instanceId.collectAsState()
val displayName by viewModel.displayName.collectAsState() val displayName by viewModel.displayName.collectAsState()
val cameraEnabled by viewModel.cameraEnabled.collectAsState() val cameraEnabled by viewModel.cameraEnabled.collectAsState()
val locationMode by viewModel.locationMode.collectAsState()
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
val preventSleep by viewModel.preventSleep.collectAsState() val preventSleep by viewModel.preventSleep.collectAsState()
val wakeWords by viewModel.wakeWords.collectAsState() val wakeWords by viewModel.wakeWords.collectAsState()
val voiceWakeMode by viewModel.voiceWakeMode.collectAsState() val voiceWakeMode by viewModel.voiceWakeMode.collectAsState()
@@ -101,11 +109,63 @@ fun SettingsSheet(viewModel: MainViewModel) {
viewModel.setCameraEnabled(cameraOk) viewModel.setCameraEnabled(cameraOk)
} }
var pendingLocationMode by remember { mutableStateOf<LocationMode?>(null) }
var pendingPreciseToggle by remember { mutableStateOf(false) }
val locationPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
val fineOk = perms[Manifest.permission.ACCESS_FINE_LOCATION] == true
val coarseOk = perms[Manifest.permission.ACCESS_COARSE_LOCATION] == true
val granted = fineOk || coarseOk
val requestedMode = pendingLocationMode
pendingLocationMode = null
if (pendingPreciseToggle) {
pendingPreciseToggle = false
viewModel.setLocationPreciseEnabled(fineOk)
return@rememberLauncherForActivityResult
}
if (!granted) {
viewModel.setLocationMode(LocationMode.Off)
return@rememberLauncherForActivityResult
}
if (requestedMode != null) {
viewModel.setLocationMode(requestedMode)
if (requestedMode == LocationMode.Always && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val backgroundOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (!backgroundOk) {
openAppSettings(context)
}
}
}
}
val audioPermissionLauncher = val audioPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ -> rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ ->
// Status text is handled by NodeRuntime. // Status text is handled by NodeRuntime.
} }
val smsPermissionAvailable =
remember {
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
}
var smsPermissionGranted by
remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
PackageManager.PERMISSION_GRANTED,
)
}
val smsPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
smsPermissionGranted = granted
viewModel.refreshBridgeHello()
}
fun setCameraEnabledChecked(checked: Boolean) { fun setCameraEnabledChecked(checked: Boolean) {
if (!checked) { if (!checked) {
viewModel.setCameraEnabled(false) viewModel.setCameraEnabled(false)
@@ -122,6 +182,47 @@ fun SettingsSheet(viewModel: MainViewModel) {
} }
} }
fun requestLocationPermissions(targetMode: LocationMode) {
val fineOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val coarseOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (fineOk || coarseOk) {
viewModel.setLocationMode(targetMode)
if (targetMode == LocationMode.Always && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val backgroundOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (!backgroundOk) {
openAppSettings(context)
}
}
} else {
pendingLocationMode = targetMode
locationPermissionLauncher.launch(
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
)
}
}
fun setPreciseLocationChecked(checked: Boolean) {
if (!checked) {
viewModel.setLocationPreciseEnabled(false)
return
}
val fineOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (fineOk) {
viewModel.setLocationPreciseEnabled(true)
} else {
pendingPreciseToggle = true
locationPermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION))
}
}
val visibleBridges = val visibleBridges =
if (isConnected && remoteAddress != null) { if (isConnected && remoteAddress != null) {
bridges.filterNot { "${it.host}:${it.port}" == remoteAddress } bridges.filterNot { "${it.host}:${it.port}" == remoteAddress }
@@ -149,7 +250,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
// Order parity: Node → Bridge → Voice → Camera → Screen. // Order parity: Node → Bridge → Voice → Camera → Messaging → Location → Screen.
item { Text("Node", style = MaterialTheme.typography.titleSmall) } item { Text("Node", style = MaterialTheme.typography.titleSmall) }
item { item {
OutlinedTextField( OutlinedTextField(
@@ -423,6 +524,104 @@ fun SettingsSheet(viewModel: MainViewModel) {
item { HorizontalDivider() } item { HorizontalDivider() }
// Messaging
item { Text("Messaging", style = MaterialTheme.typography.titleSmall) }
item {
val buttonLabel =
when {
!smsPermissionAvailable -> "Unavailable"
smsPermissionGranted -> "Manage"
else -> "Grant"
}
ListItem(
headlineContent = { Text("SMS Permission") },
supportingContent = {
Text(
if (smsPermissionAvailable) {
"Allow the bridge to send SMS from this device."
} else {
"SMS requires a device with telephony hardware."
},
)
},
trailingContent = {
Button(
onClick = {
if (!smsPermissionAvailable) return@Button
if (smsPermissionGranted) {
openAppSettings(context)
} else {
smsPermissionLauncher.launch(Manifest.permission.SEND_SMS)
}
},
enabled = smsPermissionAvailable,
) {
Text(buttonLabel)
}
},
)
}
item { HorizontalDivider() }
// Location
item { Text("Location", style = MaterialTheme.typography.titleSmall) }
item {
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
ListItem(
headlineContent = { Text("Off") },
supportingContent = { Text("Disable location sharing.") },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.Off,
onClick = { viewModel.setLocationMode(LocationMode.Off) },
)
},
)
ListItem(
headlineContent = { Text("While Using") },
supportingContent = { Text("Only while Clawdis is open.") },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.WhileUsing,
onClick = { requestLocationPermissions(LocationMode.WhileUsing) },
)
},
)
ListItem(
headlineContent = { Text("Always") },
supportingContent = { Text("Allow background location (requires system permission).") },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.Always,
onClick = { requestLocationPermissions(LocationMode.Always) },
)
},
)
}
}
item {
ListItem(
headlineContent = { Text("Precise Location") },
supportingContent = { Text("Use precise GPS when available.") },
trailingContent = {
Switch(
checked = locationPreciseEnabled,
onCheckedChange = ::setPreciseLocationChecked,
enabled = locationMode != LocationMode.Off,
)
},
)
}
item {
Text(
"Always may require Android Settings to allow background location.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
item { HorizontalDivider() }
// Screen // Screen
item { Text("Screen", style = MaterialTheme.typography.titleSmall) } item { Text("Screen", style = MaterialTheme.typography.titleSmall) }
item { item {
@@ -453,3 +652,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
item { Spacer(modifier = Modifier.height(20.dp)) } item { Spacer(modifier = Modifier.height(20.dp)) }
} }
} }
private fun openAppSettings(context: Context) {
val intent =
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
)
context.startActivity(intent)
}
@@ -0,0 +1,91 @@
package com.clawdis.android.node
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SmsManagerTest {
private val json = SmsManager.JsonConfig
@Test
fun parseParamsRejectsEmptyPayload() {
val result = SmsManager.parseParams("", json)
assertTrue(result is SmsManager.ParseResult.Error)
val error = result as SmsManager.ParseResult.Error
assertEquals("INVALID_REQUEST: paramsJSON required", error.error)
}
@Test
fun parseParamsRejectsInvalidJson() {
val result = SmsManager.parseParams("not-json", json)
assertTrue(result is SmsManager.ParseResult.Error)
val error = result as SmsManager.ParseResult.Error
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
}
@Test
fun parseParamsRejectsNonObjectJson() {
val result = SmsManager.parseParams("[]", json)
assertTrue(result is SmsManager.ParseResult.Error)
val error = result as SmsManager.ParseResult.Error
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
}
@Test
fun parseParamsRejectsMissingTo() {
val result = SmsManager.parseParams("{\"message\":\"Hi\"}", json)
assertTrue(result is SmsManager.ParseResult.Error)
val error = result as SmsManager.ParseResult.Error
assertEquals("INVALID_REQUEST: 'to' phone number required", error.error)
assertEquals("Hi", error.message)
}
@Test
fun parseParamsRejectsMissingMessage() {
val result = SmsManager.parseParams("{\"to\":\"+1234\"}", json)
assertTrue(result is SmsManager.ParseResult.Error)
val error = result as SmsManager.ParseResult.Error
assertEquals("INVALID_REQUEST: 'message' text required", error.error)
assertEquals("+1234", error.to)
}
@Test
fun parseParamsTrimsToField() {
val result = SmsManager.parseParams("{\"to\":\" +1555 \",\"message\":\"Hello\"}", json)
assertTrue(result is SmsManager.ParseResult.Ok)
val ok = result as SmsManager.ParseResult.Ok
assertEquals("+1555", ok.params.to)
assertEquals("Hello", ok.params.message)
}
@Test
fun buildPayloadJsonEscapesFields() {
val payload = SmsManager.buildPayloadJson(
json = json,
ok = false,
to = "+1\"23",
error = "SMS_SEND_FAILED: \"nope\"",
)
val parsed = json.parseToJsonElement(payload).jsonObject
assertEquals("false", parsed["ok"]?.jsonPrimitive?.content)
assertEquals("+1\"23", parsed["to"]?.jsonPrimitive?.content)
assertEquals("SMS_SEND_FAILED: \"nope\"", parsed["error"]?.jsonPrimitive?.content)
}
@Test
fun buildSendPlanUsesMultipartWhenMultipleParts() {
val plan = SmsManager.buildSendPlan("hello") { listOf("a", "b") }
assertTrue(plan.useMultipart)
assertEquals(listOf("a", "b"), plan.parts)
}
@Test
fun buildSendPlanFallsBackToSinglePartWhenDividerEmpty() {
val plan = SmsManager.buildSendPlan("hello") { emptyList() }
assertFalse(plan.useMultipart)
assertEquals(listOf("hello"), plan.parts)
}
}
@@ -229,6 +229,10 @@ final class BridgeConnectionController {
let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey) let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey)
if voiceWakeEnabled { caps.append(ClawdisCapability.voiceWake.rawValue) } if voiceWakeEnabled { caps.append(ClawdisCapability.voiceWake.rawValue) }
let locationModeRaw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
let locationMode = ClawdisLocationMode(rawValue: locationModeRaw) ?? .off
if locationMode != .off { caps.append(ClawdisCapability.location.rawValue) }
return caps return caps
} }
@@ -251,6 +255,9 @@ final class BridgeConnectionController {
commands.append(ClawdisCameraCommand.snap.rawValue) commands.append(ClawdisCameraCommand.snap.rawValue)
commands.append(ClawdisCameraCommand.clip.rawValue) commands.append(ClawdisCameraCommand.clip.rawValue)
} }
if caps.contains(ClawdisCapability.location.rawValue) {
commands.append(ClawdisLocationCommand.get.rawValue)
}
return commands return commands
} }
+4
View File
@@ -35,6 +35,10 @@
<string>Clawdis can capture photos or short video clips when requested via the bridge.</string> <string>Clawdis can capture photos or short video clips when requested via the bridge.</string>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>Clawdis discovers and connects to your Clawdis bridge on the local network.</string> <string>Clawdis discovers and connects to your Clawdis bridge on the local network.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Clawdis uses your location when you allow location sharing.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Clawdis can share your location in the background when you enable Always.</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>Clawdis needs microphone access for voice wake.</string> <string>Clawdis needs microphone access for voice wake.</string>
<key>NSSpeechRecognitionUsageDescription</key> <key>NSSpeechRecognitionUsageDescription</key>
@@ -0,0 +1,142 @@
import ClawdisKit
import CoreLocation
import Foundation
@MainActor
final class LocationService: NSObject, CLLocationManagerDelegate {
enum Error: Swift.Error {
case timeout
case unavailable
}
private let manager = CLLocationManager()
private var authContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
override init() {
super.init()
self.manager.delegate = self
self.manager.desiredAccuracy = kCLLocationAccuracyBest
}
func authorizationStatus() -> CLAuthorizationStatus {
self.manager.authorizationStatus
}
func accuracyAuthorization() -> CLAccuracyAuthorization {
if #available(iOS 14.0, *) {
return self.manager.accuracyAuthorization
}
return .fullAccuracy
}
func ensureAuthorization(mode: ClawdisLocationMode) async -> CLAuthorizationStatus {
guard CLLocationManager.locationServicesEnabled() else { return .denied }
let status = self.manager.authorizationStatus
if status == .notDetermined {
self.manager.requestWhenInUseAuthorization()
let updated = await self.awaitAuthorizationChange()
if mode != .always { return updated }
}
if mode == .always {
let current = self.manager.authorizationStatus
if current == .authorizedWhenInUse {
self.manager.requestAlwaysAuthorization()
return await self.awaitAuthorizationChange()
}
return current
}
return self.manager.authorizationStatus
}
func currentLocation(
params: ClawdisLocationGetParams,
desiredAccuracy: ClawdisLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
{
let now = Date()
if let maxAgeMs,
let cached = self.manager.location,
now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs)
{
return cached
}
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
let timeout = max(0, timeoutMs ?? 10_000)
return try await self.withTimeout(timeoutMs: timeout) {
try await self.requestLocation()
}
}
private func requestLocation() async throws -> CLLocation {
try await withCheckedThrowingContinuation { cont in
self.locationContinuation = cont
self.manager.requestLocation()
}
}
private func awaitAuthorizationChange() async -> CLAuthorizationStatus {
await withCheckedContinuation { cont in
self.authContinuation = cont
}
}
private func withTimeout<T>(
timeoutMs: Int,
operation: @escaping () async throws -> T) async throws -> T
{
if timeoutMs == 0 {
return try await operation()
}
return try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
throw Error.timeout
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
private static func accuracyValue(_ accuracy: ClawdisLocationAccuracy) -> CLLocationAccuracy {
switch accuracy {
case .coarse:
return kCLLocationAccuracyKilometer
case .balanced:
return kCLLocationAccuracyHundredMeters
case .precise:
return kCLLocationAccuracyBest
}
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
if let cont = self.authContinuation {
self.authContinuation = nil
cont.resume(returning: manager.authorizationStatus)
}
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
if let latest = locations.last {
cont.resume(returning: latest)
} else {
cont.resume(throwing: Error.unavailable)
}
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
cont.resume(throwing: error)
}
}
+82
View File
@@ -31,6 +31,7 @@ final class NodeAppModel {
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>? @ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
let voiceWake = VoiceWakeManager() let voiceWake = VoiceWakeManager()
let talkMode = TalkModeManager() let talkMode = TalkModeManager()
private let locationService = LocationService()
private var lastAutoA2uiURL: String? private var lastAutoA2uiURL: String?
var bridgeSession: BridgeSession { self.bridge } var bridgeSession: BridgeSession { self.bridge }
@@ -188,6 +189,19 @@ final class NodeAppModel {
self.talkMode.setEnabled(enabled) self.talkMode.setEnabled(enabled)
} }
func requestLocationPermissions(mode: ClawdisLocationMode) async -> Bool {
guard mode != .off else { return true }
let status = await self.locationService.ensureAuthorization(mode: mode)
switch status {
case .authorizedAlways:
return true
case .authorizedWhenInUse:
return mode != .always
default:
return false
}
}
func connectToBridge( func connectToBridge(
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello) hello: BridgeHello)
@@ -466,6 +480,64 @@ final class NodeAppModel {
do { do {
switch command { switch command {
case ClawdisLocationCommand.get.rawValue:
let mode = self.locationMode()
guard mode != .off else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "LOCATION_DISABLED: enable Location in Settings"))
}
if self.isBackgrounded, mode != .always {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .backgroundUnavailable,
message: "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always"))
}
let params = (try? Self.decodeParams(ClawdisLocationGetParams.self, from: req.paramsJSON)) ??
ClawdisLocationGetParams()
let desired = params.desiredAccuracy ??
(self.isLocationPreciseEnabled() ? .precise : .balanced)
let status = self.locationService.authorizationStatus()
if status != .authorizedAlways && status != .authorizedWhenInUse {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
}
if self.isBackgrounded && status != .authorizedAlways {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "LOCATION_PERMISSION_REQUIRED: enable Always for background access"))
}
let location = try await self.locationService.currentLocation(
params: params,
desiredAccuracy: desired,
maxAgeMs: params.maxAgeMs,
timeoutMs: params.timeoutMs)
let isPrecise = self.locationService.accuracyAuthorization() == .fullAccuracy
let payload = ClawdisLocationPayload(
lat: location.coordinate.latitude,
lon: location.coordinate.longitude,
accuracyMeters: location.horizontalAccuracy,
altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil,
speedMps: location.speed >= 0 ? location.speed : nil,
headingDeg: location.course >= 0 ? location.course : nil,
timestamp: ISO8601DateFormatter().string(from: location.timestamp),
isPrecise: isPrecise,
source: nil)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
case ClawdisCanvasCommand.present.rawValue: case ClawdisCanvasCommand.present.rawValue:
let params = (try? Self.decodeParams(ClawdisCanvasPresentParams.self, from: req.paramsJSON)) ?? let params = (try? Self.decodeParams(ClawdisCanvasPresentParams.self, from: req.paramsJSON)) ??
ClawdisCanvasPresentParams() ClawdisCanvasPresentParams()
@@ -696,6 +768,16 @@ final class NodeAppModel {
} }
} }
private func locationMode() -> ClawdisLocationMode {
let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
return ClawdisLocationMode(rawValue: raw) ?? .off
}
private func isLocationPreciseEnabled() -> Bool {
if UserDefaults.standard.object(forKey: "location.preciseEnabled") == nil { return true }
return UserDefaults.standard.bool(forKey: "location.preciseEnabled")
}
private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T { private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
guard let json, let data = json.data(using: .utf8) else { guard let json, let data = json.data(using: .utf8) else {
throw NSError(domain: "Bridge", code: 20, userInfo: [ throw NSError(domain: "Bridge", code: 20, userInfo: [
@@ -23,6 +23,8 @@ struct SettingsTab: View {
@AppStorage("talk.enabled") private var talkEnabled: Bool = false @AppStorage("talk.enabled") private var talkEnabled: Bool = false
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true @AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true @AppStorage("camera.enabled") private var cameraEnabled: Bool = true
@AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = ClawdisLocationMode.off.rawValue
@AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true @AppStorage("screen.preventSleep") private var preventSleep: Bool = true
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = "" @AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
@AppStorage("bridge.lastDiscoveredStableID") private var lastDiscoveredBridgeStableID: String = "" @AppStorage("bridge.lastDiscoveredStableID") private var lastDiscoveredBridgeStableID: String = ""
@@ -34,6 +36,7 @@ struct SettingsTab: View {
@State private var connectStatus = ConnectStatusStore() @State private var connectStatus = ConnectStatusStore()
@State private var connectingBridgeID: String? @State private var connectingBridgeID: String?
@State private var localIPAddress: String? @State private var localIPAddress: String?
@State private var lastLocationModeRaw: String = ClawdisLocationMode.off.rawValue
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -181,6 +184,22 @@ struct SettingsTab: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Section("Location") {
Picker("Location Access", selection: self.$locationEnabledModeRaw) {
Text("Off").tag(ClawdisLocationMode.off.rawValue)
Text("While Using").tag(ClawdisLocationMode.whileUsing.rawValue)
Text("Always").tag(ClawdisLocationMode.always.rawValue)
}
.pickerStyle(.segmented)
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
.disabled(self.locationMode == .off)
Text("Always requires system permission and may prompt to open Settings.")
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("Screen") { Section("Screen") {
Toggle("Prevent Sleep", isOn: self.$preventSleep) Toggle("Prevent Sleep", isOn: self.$preventSleep)
Text("Keeps the screen awake while Clawdis is open.") Text("Keeps the screen awake while Clawdis is open.")
@@ -201,6 +220,7 @@ struct SettingsTab: View {
} }
.onAppear { .onAppear {
self.localIPAddress = Self.primaryIPv4Address() self.localIPAddress = Self.primaryIPv4Address()
self.lastLocationModeRaw = self.locationEnabledModeRaw
} }
.onChange(of: self.preferredBridgeStableID) { _, newValue in .onChange(of: self.preferredBridgeStableID) { _, newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -210,6 +230,20 @@ struct SettingsTab: View {
.onChange(of: self.appModel.bridgeServerName) { _, _ in .onChange(of: self.appModel.bridgeServerName) { _, _ in
self.connectStatus.text = nil self.connectStatus.text = nil
} }
.onChange(of: self.locationEnabledModeRaw) { _, newValue in
let previous = self.lastLocationModeRaw
self.lastLocationModeRaw = newValue
guard let mode = ClawdisLocationMode(rawValue: newValue) else { return }
Task {
let granted = await self.appModel.requestLocationPermissions(mode: mode)
if !granted {
await MainActor.run {
self.locationEnabledModeRaw = previous
self.lastLocationModeRaw = previous
}
}
}
}
} }
} }
@@ -278,6 +312,10 @@ struct SettingsTab: View {
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
} }
private var locationMode: ClawdisLocationMode {
ClawdisLocationMode(rawValue: self.locationEnabledModeRaw) ?? .off
}
private func appVersion() -> String { private func appVersion() -> String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
} }
+172 -3
View File
@@ -8,6 +8,8 @@ import SwiftUI
@Observable @Observable
final class AppState { final class AppState {
private let isPreview: Bool private let isPreview: Bool
private var isInitializing = true
private var configWatcher: ConfigFileWatcher?
private var suppressVoiceWakeGlobalSync = false private var suppressVoiceWakeGlobalSync = false
private var voiceWakeGlobalSyncTask: Task<Void, Never>? private var voiceWakeGlobalSyncTask: Task<Void, Never>?
@@ -153,6 +155,7 @@ final class AppState {
var connectionMode: ConnectionMode { var connectionMode: ConnectionMode {
didSet { didSet {
self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) } self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
self.syncGatewayConfigIfNeeded()
} }
} }
@@ -181,7 +184,10 @@ final class AppState {
} }
var remoteTarget: String { var remoteTarget: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } } didSet {
self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) }
self.syncGatewayConfigIfNeeded()
}
} }
var remoteIdentity: String { var remoteIdentity: String {
@@ -245,13 +251,44 @@ final class AppState {
UserDefaults.standard.set(IconOverrideSelection.system.rawValue, forKey: iconOverrideKey) UserDefaults.standard.set(IconOverrideSelection.system.rawValue, forKey: iconOverrideKey)
} }
let configRoot = ClawdisConfigFile.loadDict()
let configGateway = configRoot["gateway"] as? [String: Any]
let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let configMode: ConnectionMode? = {
switch configModeRaw {
case "local":
return .local
case "remote":
return .remote
default:
return nil
}
}()
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
let configHasRemoteUrl = !(configRemoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey) let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
if let storedMode { if let configMode {
self.connectionMode = configMode
} else if configHasRemoteUrl {
self.connectionMode = .remote
} else if let storedMode {
self.connectionMode = ConnectionMode(rawValue: storedMode) ?? .local self.connectionMode = ConnectionMode(rawValue: storedMode) ?? .local
} else { } else {
self.connectionMode = onboardingSeen ? .local : .unconfigured self.connectionMode = onboardingSeen ? .local : .unconfigured
} }
self.remoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
if self.connectionMode == .remote,
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let host = AppState.remoteHost(from: configRemoteUrl)
{
self.remoteTarget = "\(NSUserName())@\(host)"
} else {
self.remoteTarget = storedRemoteTarget
}
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
@@ -278,6 +315,138 @@ final class AppState {
Task { await VoiceWakeRuntime.shared.refresh(state: self) } Task { await VoiceWakeRuntime.shared.refresh(state: self) }
Task { await TalkModeController.shared.setEnabled(self.talkEnabled) } Task { await TalkModeController.shared.setEnabled(self.talkEnabled) }
} }
self.isInitializing = false
if !self.isPreview {
self.startConfigWatcher()
}
}
deinit {
self.configWatcher?.stop()
}
private static func remoteHost(from urlString: String?) -> String? {
guard let raw = urlString?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty,
let url = URL(string: raw),
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
!host.isEmpty
else {
return nil
}
return host
}
private func startConfigWatcher() {
let configUrl = ClawdisConfigFile.url()
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
Task { @MainActor in
self?.applyConfigFromDisk()
}
}
self.configWatcher?.start()
}
private func applyConfigFromDisk() {
let root = ClawdisConfigFile.loadDict()
self.applyConfigOverrides(root)
}
private func applyConfigOverrides(_ root: [String: Any]) {
let gateway = root["gateway"] as? [String: Any]
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String
let hasRemoteUrl = !(remoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
let desiredMode: ConnectionMode? = {
switch modeRaw {
case "local":
return .local
case "remote":
return .remote
case "unconfigured":
return .unconfigured
default:
return nil
}
}()
if let desiredMode {
if desiredMode != self.connectionMode {
self.connectionMode = desiredMode
}
} else if hasRemoteUrl, self.connectionMode != .remote {
self.connectionMode = .remote
}
let targetMode = desiredMode ?? self.connectionMode
if targetMode == .remote,
let host = AppState.remoteHost(from: remoteUrl)
{
self.updateRemoteTarget(host: host)
}
}
private func updateRemoteTarget(host: String) {
let parsed = CommandResolver.parseSSHTarget(self.remoteTarget)
let user = parsed?.user ?? NSUserName()
let port = parsed?.port ?? 22
let assembled = port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)"
if assembled != self.remoteTarget {
self.remoteTarget = assembled
}
}
private func syncGatewayConfigIfNeeded() {
guard !self.isPreview, !self.isInitializing else { return }
var root = ClawdisConfigFile.loadDict()
var gateway = root["gateway"] as? [String: Any] ?? [:]
var changed = false
let desiredMode: String?
switch self.connectionMode {
case .local:
desiredMode = "local"
case .remote:
desiredMode = "remote"
case .unconfigured:
desiredMode = nil
}
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let desiredMode {
if currentMode != desiredMode {
gateway["mode"] = desiredMode
changed = true
}
} else if currentMode != nil {
gateway.removeValue(forKey: "mode")
changed = true
}
if self.connectionMode == .remote,
let host = CommandResolver.parseSSHTarget(self.remoteTarget)?.host
{
var remote = gateway["remote"] as? [String: Any] ?? [:]
let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
gateway["remote"] = remote
changed = true
}
}
guard changed else { return }
root["gateway"] = gateway
ClawdisConfigFile.saveDict(root)
} }
func triggerVoiceEars(ttl: TimeInterval? = 5) { func triggerVoiceEars(ttl: TimeInterval? = 5) {
@@ -0,0 +1,118 @@
import CoreServices
import Foundation
final class ConfigFileWatcher: @unchecked Sendable {
private let url: URL
private let queue: DispatchQueue
private var stream: FSEventStreamRef?
private var pending = false
private let onChange: () -> Void
private let watchedDir: URL
private let targetPath: String
private let targetName: String
init(url: URL, onChange: @escaping () -> Void) {
self.url = url
self.queue = DispatchQueue(label: "com.clawdis.configwatcher")
self.onChange = onChange
self.watchedDir = url.deletingLastPathComponent()
self.targetPath = url.path
self.targetName = url.lastPathComponent
}
deinit {
self.stop()
}
func start() {
guard self.stream == nil else { return }
let retainedSelf = Unmanaged.passRetained(self)
var context = FSEventStreamContext(
version: 0,
info: retainedSelf.toOpaque(),
retain: nil,
release: { pointer in
guard let pointer else { return }
Unmanaged<ConfigFileWatcher>.fromOpaque(pointer).release()
},
copyDescription: nil)
let paths = [self.watchedDir.path] as CFArray
let flags = FSEventStreamCreateFlags(
kFSEventStreamCreateFlagFileEvents |
kFSEventStreamCreateFlagUseCFTypes |
kFSEventStreamCreateFlagNoDefer)
guard let stream = FSEventStreamCreate(
kCFAllocatorDefault,
Self.callback,
&context,
paths,
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
0.05,
flags)
else {
retainedSelf.release()
return
}
self.stream = stream
FSEventStreamSetDispatchQueue(stream, self.queue)
if FSEventStreamStart(stream) == false {
self.stream = nil
FSEventStreamSetDispatchQueue(stream, nil)
FSEventStreamInvalidate(stream)
FSEventStreamRelease(stream)
}
}
func stop() {
guard let stream = self.stream else { return }
self.stream = nil
FSEventStreamStop(stream)
FSEventStreamSetDispatchQueue(stream, nil)
FSEventStreamInvalidate(stream)
FSEventStreamRelease(stream)
}
}
extension ConfigFileWatcher {
private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in
guard let info else { return }
let watcher = Unmanaged<ConfigFileWatcher>.fromOpaque(info).takeUnretainedValue()
watcher.handleEvents(
numEvents: numEvents,
eventPaths: eventPaths,
eventFlags: eventFlags)
}
private func handleEvents(
numEvents: Int,
eventPaths: UnsafeMutableRawPointer?,
eventFlags: UnsafePointer<FSEventStreamEventFlags>?
) {
guard numEvents > 0 else { return }
guard eventFlags != nil else { return }
guard self.matchesTarget(eventPaths: eventPaths) else { return }
if self.pending { return }
self.pending = true
self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in
guard let self else { return }
self.pending = false
self.onChange()
}
}
private func matchesTarget(eventPaths: UnsafeMutableRawPointer?) -> Bool {
guard let eventPaths else { return true }
let paths = unsafeBitCast(eventPaths, to: NSArray.self)
for case let path as String in paths {
if path == self.targetPath { return true }
if path.hasSuffix("/\(self.targetName)") { return true }
if path == self.watchedDir.path { return true }
}
return false
}
}
@@ -25,6 +25,8 @@ let remoteProjectRootKey = "clawdis.remoteProjectRoot"
let remoteCliPathKey = "clawdis.remoteCliPath" let remoteCliPathKey = "clawdis.remoteCliPath"
let canvasEnabledKey = "clawdis.canvasEnabled" let canvasEnabledKey = "clawdis.canvasEnabled"
let cameraEnabledKey = "clawdis.cameraEnabled" let cameraEnabledKey = "clawdis.cameraEnabled"
let locationModeKey = "clawdis.locationMode"
let locationPreciseKey = "clawdis.locationPreciseEnabled"
let peekabooBridgeEnabledKey = "clawdis.peekabooBridgeEnabled" let peekabooBridgeEnabledKey = "clawdis.peekabooBridgeEnabled"
let deepLinkKeyKey = "clawdis.deepLinkKey" let deepLinkKeyKey = "clawdis.deepLinkKey"
let modelCatalogPathKey = "clawdis.modelCatalogPath" let modelCatalogPathKey = "clawdis.modelCatalogPath"
@@ -306,6 +306,7 @@ extension GatewayConnection {
struct SnapshotConfig: Decodable, Sendable { struct SnapshotConfig: Decodable, Sendable {
struct Session: Decodable, Sendable { struct Session: Decodable, Sendable {
let mainKey: String? let mainKey: String?
let scope: String?
} }
let session: Session? let session: Session?
@@ -316,9 +317,11 @@ extension GatewayConnection {
static func mainSessionKey(fromConfigGetData data: Data) throws -> String { static func mainSessionKey(fromConfigGetData data: Data) throws -> String {
let snapshot = try JSONDecoder().decode(ConfigGetSnapshot.self, from: data) let snapshot = try JSONDecoder().decode(ConfigGetSnapshot.self, from: data)
let raw = snapshot.config?.session?.mainKey let scope = snapshot.config?.session?.scope?.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if scope == "global" {
return trimmed.isEmpty ? "main" : trimmed return "global"
}
return "main"
} }
func mainSessionKey(timeoutMs: Double = 15000) async -> String { func mainSessionKey(timeoutMs: Double = 15000) async -> String {
@@ -1,11 +1,15 @@
import AppKit import AppKit
import ClawdisIPC import ClawdisIPC
import ClawdisKit
import CoreLocation
import Observation import Observation
import SwiftUI import SwiftUI
struct GeneralSettings: View { struct GeneralSettings: View {
@Bindable var state: AppState @Bindable var state: AppState
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false @AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdisLocationMode.off.rawValue
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
private let healthStore = HealthStore.shared private let healthStore = HealthStore.shared
private let gatewayManager = GatewayProcessManager.shared private let gatewayManager = GatewayProcessManager.shared
@State private var gatewayDiscovery = GatewayDiscoveryModel() @State private var gatewayDiscovery = GatewayDiscoveryModel()
@@ -18,6 +22,7 @@ struct GeneralSettings: View {
@State private var showRemoteAdvanced = false @State private var showRemoteAdvanced = false
private let isPreview = ProcessInfo.processInfo.isPreview private let isPreview = ProcessInfo.processInfo.isPreview
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode } private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
@State private var lastLocationModeRaw: String = ClawdisLocationMode.off.rawValue
var body: some View { var body: some View {
ScrollView(.vertical) { ScrollView(.vertical) {
@@ -64,6 +69,26 @@ struct GeneralSettings: View {
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.", subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
binding: self.$cameraEnabled) binding: self.$cameraEnabled)
VStack(alignment: .leading, spacing: 6) {
Text("Location Access")
.font(.body)
Picker("", selection: self.$locationModeRaw) {
Text("Off").tag(ClawdisLocationMode.off.rawValue)
Text("While Using").tag(ClawdisLocationMode.whileUsing.rawValue)
Text("Always").tag(ClawdisLocationMode.always.rawValue)
}
.pickerStyle(.segmented)
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
.disabled(self.locationMode == .off)
Text("Always may require System Settings to approve background location.")
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
SettingsToggleRow( SettingsToggleRow(
title: "Enable Peekaboo Bridge", title: "Enable Peekaboo Bridge",
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.", subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
@@ -90,12 +115,27 @@ struct GeneralSettings: View {
guard !self.isPreview else { return } guard !self.isPreview else { return }
self.refreshCLIStatus() self.refreshCLIStatus()
self.refreshGatewayStatus() self.refreshGatewayStatus()
self.lastLocationModeRaw = self.locationModeRaw
} }
.onChange(of: self.state.canvasEnabled) { _, enabled in .onChange(of: self.state.canvasEnabled) { _, enabled in
if !enabled { if !enabled {
CanvasManager.shared.hideAll() CanvasManager.shared.hideAll()
} }
} }
.onChange(of: self.locationModeRaw) { _, newValue in
let previous = self.lastLocationModeRaw
self.lastLocationModeRaw = newValue
guard let mode = ClawdisLocationMode(rawValue: newValue) else { return }
Task {
let granted = await self.requestLocationAuthorization(mode: mode)
if !granted {
await MainActor.run {
self.locationModeRaw = previous
self.lastLocationModeRaw = previous
}
}
}
}
} }
private var activeBinding: Binding<Bool> { private var activeBinding: Binding<Bool> {
@@ -104,6 +144,29 @@ struct GeneralSettings: View {
set: { self.state.isPaused = !$0 }) set: { self.state.isPaused = !$0 })
} }
private var locationMode: ClawdisLocationMode {
ClawdisLocationMode(rawValue: self.locationModeRaw) ?? .off
}
private func requestLocationAuthorization(mode: ClawdisLocationMode) async -> Bool {
guard mode != .off else { return true }
let status = CLLocationManager.authorizationStatus()
if status == .authorizedAlways || status == .authorizedWhenInUse {
if mode == .always && status != .authorizedAlways {
let updated = await LocationPermissionRequester.shared.request(always: true)
return updated == .authorizedAlways || updated == .authorizedWhenInUse
}
return true
}
let updated = await LocationPermissionRequester.shared.request(always: mode == .always)
switch updated {
case .authorizedAlways, .authorizedWhenInUse:
return true
default:
return false
}
}
private var connectionSection: some View { private var connectionSection: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text("Clawdis runs") Text("Clawdis runs")
@@ -435,7 +435,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
compact.representedObject = row.key compact.representedObject = row.key
menu.addItem(compact) menu.addItem(compact)
if row.key != "main" { if row.key != "main" && row.key != "global" {
let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "") let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "")
del.target = self del.target = self
del.representedObject = row.key del.representedObject = row.key
@@ -0,0 +1,116 @@
import ClawdisKit
import CoreLocation
import Foundation
@MainActor
final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
enum Error: Swift.Error {
case timeout
case unavailable
}
private let manager = CLLocationManager()
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
override init() {
super.init()
self.manager.delegate = self
self.manager.desiredAccuracy = kCLLocationAccuracyBest
}
func authorizationStatus() -> CLAuthorizationStatus {
self.manager.authorizationStatus
}
func accuracyAuthorization() -> CLAccuracyAuthorization {
if #available(macOS 11.0, *) {
return self.manager.accuracyAuthorization
}
return .fullAccuracy
}
func currentLocation(
desiredAccuracy: ClawdisLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
{
guard CLLocationManager.locationServicesEnabled() else {
throw Error.unavailable
}
let now = Date()
if let maxAgeMs,
let cached = self.manager.location,
now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs)
{
return cached
}
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
let timeout = max(0, timeoutMs ?? 10_000)
return try await self.withTimeout(timeoutMs: timeout) {
try await self.requestLocation()
}
}
private func requestLocation() async throws -> CLLocation {
try await withCheckedThrowingContinuation { cont in
self.locationContinuation = cont
self.manager.requestLocation()
}
}
private func withTimeout<T>(
timeoutMs: Int,
operation: @escaping () async throws -> T) async throws -> T
{
if timeoutMs == 0 {
return try await operation()
}
return try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
throw Error.timeout
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
private static func accuracyValue(_ accuracy: ClawdisLocationAccuracy) -> CLLocationAccuracy {
switch accuracy {
case .coarse:
return kCLLocationAccuracyKilometer
case .balanced:
return kCLLocationAccuracyHundredMeters
case .precise:
return kCLLocationAccuracyBest
}
}
// MARK: - CLLocationManagerDelegate (nonisolated for Swift 6 compatibility)
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
Task { @MainActor in
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
if let latest = locations.last {
cont.resume(returning: latest)
} else {
cont.resume(throwing: Error.unavailable)
}
}
}
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
let errorCopy = error // Capture error for Sendable compliance
Task { @MainActor in
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
cont.resume(throwing: errorCopy)
}
}
}
@@ -110,6 +110,10 @@ final class MacNodeModeCoordinator {
if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false { if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false {
caps.append(ClawdisCapability.camera.rawValue) caps.append(ClawdisCapability.camera.rawValue)
} }
let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
if ClawdisLocationMode(rawValue: rawLocationMode) != .off {
caps.append(ClawdisCapability.location.rawValue)
}
return caps return caps
} }
@@ -139,6 +143,9 @@ final class MacNodeModeCoordinator {
commands.append(ClawdisCameraCommand.snap.rawValue) commands.append(ClawdisCameraCommand.snap.rawValue)
commands.append(ClawdisCameraCommand.clip.rawValue) commands.append(ClawdisCameraCommand.clip.rawValue)
} }
if capsSet.contains(ClawdisCapability.location.rawValue) {
commands.append(ClawdisLocationCommand.get.rawValue)
}
return commands return commands
} }
@@ -6,6 +6,7 @@ import Foundation
actor MacNodeRuntime { actor MacNodeRuntime {
private let cameraCapture = CameraCaptureService() private let cameraCapture = CameraCaptureService()
@MainActor private let screenRecorder = ScreenRecordService() @MainActor private let screenRecorder = ScreenRecordService()
@MainActor private let locationService = MacNodeLocationService()
// swiftlint:disable:next function_body_length cyclomatic_complexity // swiftlint:disable:next function_body_length cyclomatic_complexity
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
@@ -168,6 +169,63 @@ actor MacNodeRuntime {
let payload = try Self.encodePayload(["devices": devices]) let payload = try Self.encodePayload(["devices": devices])
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdisLocationCommand.get.rawValue:
let mode = Self.locationMode()
guard mode != .off else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "LOCATION_DISABLED: enable Location in Settings"))
}
let params = (try? Self.decodeParams(ClawdisLocationGetParams.self, from: req.paramsJSON)) ??
ClawdisLocationGetParams()
let desired = params.desiredAccuracy ??
(Self.locationPreciseEnabled() ? .precise : .balanced)
let status = await self.locationService.authorizationStatus()
if status != .authorizedAlways && status != .authorizedWhenInUse {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
}
do {
let location = try await self.locationService.currentLocation(
desiredAccuracy: desired,
maxAgeMs: params.maxAgeMs,
timeoutMs: params.timeoutMs)
let isPrecise = await self.locationService.accuracyAuthorization() == .fullAccuracy
let payload = ClawdisLocationPayload(
lat: location.coordinate.latitude,
lon: location.coordinate.longitude,
accuracyMeters: location.horizontalAccuracy,
altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil,
speedMps: location.speed >= 0 ? location.speed : nil,
headingDeg: location.course >= 0 ? location.course : nil,
timestamp: ISO8601DateFormatter().string(from: location.timestamp),
isPrecise: isPrecise,
source: nil)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
} catch MacNodeLocationService.Error.timeout {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "LOCATION_TIMEOUT: no fix in time"))
} catch {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "LOCATION_UNAVAILABLE: \(error.localizedDescription)"))
}
case MacNodeScreenCommand.record.rawValue: case MacNodeScreenCommand.record.rawValue:
let params = (try? Self.decodeParams(MacNodeScreenRecordParams.self, from: req.paramsJSON)) ?? let params = (try? Self.decodeParams(MacNodeScreenRecordParams.self, from: req.paramsJSON)) ??
MacNodeScreenRecordParams() MacNodeScreenRecordParams()
@@ -413,6 +471,16 @@ actor MacNodeRuntime {
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
} }
private nonisolated static func locationMode() -> ClawdisLocationMode {
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
return ClawdisLocationMode(rawValue: raw) ?? .off
}
private nonisolated static func locationPreciseEnabled() -> Bool {
if UserDefaults.standard.object(forKey: locationPreciseKey) == nil { return true }
return UserDefaults.standard.bool(forKey: locationPreciseKey)
}
private static func errorResponse( private static func errorResponse(
_ req: BridgeInvokeRequest, _ req: BridgeInvokeRequest,
code: ClawdisNodeErrorCode, code: ClawdisNodeErrorCode,
@@ -6,6 +6,25 @@ import SwiftUI
private let onboardingWizardLogger = Logger(subsystem: "com.clawdis", category: "onboarding.wizard") private let onboardingWizardLogger = Logger(subsystem: "com.clawdis", category: "onboarding.wizard")
// MARK: - Swift 6 AnyCodable Bridging Helpers
// Bridge between ClawdisProtocol.AnyCodable and the local module to avoid
// Swift 6 strict concurrency type conflicts.
private typealias ProtocolAnyCodable = ClawdisProtocol.AnyCodable
private func bridgeToLocal(_ value: ProtocolAnyCodable) -> AnyCodable {
if let data = try? JSONEncoder().encode(value),
let decoded = try? JSONDecoder().decode(AnyCodable.self, from: data)
{
return decoded
}
return AnyCodable(value.value)
}
private func bridgeToLocal(_ value: ProtocolAnyCodable?) -> AnyCodable? {
value.map(bridgeToLocal)
}
@MainActor @MainActor
@Observable @Observable
final class OnboardingWizardModel { final class OnboardingWizardModel {
@@ -285,11 +304,11 @@ struct OnboardingWizardStepView: View {
return return
} }
let option = optionItems[selectedIndex].option let option = optionItems[selectedIndex].option
onSubmit(option.value ?? AnyCodable(option.label)) onSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
case "multiselect": case "multiselect":
let values = optionItems let values = optionItems
.filter { selectedIndices.contains($0.index) } .filter { selectedIndices.contains($0.index) }
.map { $0.option.value ?? AnyCodable($0.option.label) } .map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) }
onSubmit(AnyCodable(values)) onSubmit(AnyCodable(values))
case "action": case "action":
onSubmit(AnyCodable(true)) onSubmit(AnyCodable(true))
@@ -307,12 +326,12 @@ private struct WizardOptionItem: Identifiable {
} }
private struct WizardOption { private struct WizardOption {
let value: AnyCodable? let value: ProtocolAnyCodable?
let label: String let label: String
let hint: String? let hint: String?
} }
private func decodeWizardStep(_ raw: [String: AnyCodable]?) -> WizardStep? { private func decodeWizardStep(_ raw: [String: ProtocolAnyCodable]?) -> WizardStep? {
guard let raw else { return nil } guard let raw else { return nil }
do { do {
let data = try JSONEncoder().encode(raw) let data = try JSONEncoder().encode(raw)
@@ -323,7 +342,7 @@ private func decodeWizardStep(_ raw: [String: AnyCodable]?) -> WizardStep? {
} }
} }
private func parseWizardOptions(_ raw: [[String: AnyCodable]]?) -> [WizardOption] { private func parseWizardOptions(_ raw: [[String: ProtocolAnyCodable]]?) -> [WizardOption] {
guard let raw else { return [] } guard let raw else { return [] }
return raw.map { entry in return raw.map { entry in
let value = entry["value"] let value = entry["value"]
@@ -337,7 +356,7 @@ private func wizardStepType(_ step: WizardStep) -> String {
(step.type.value as? String) ?? "" (step.type.value as? String) ?? ""
} }
private func anyCodableString(_ value: AnyCodable?) -> String { private func anyCodableString(_ value: ProtocolAnyCodable?) -> String {
switch value?.value { switch value?.value {
case let string as String: case let string as String:
return string return string
@@ -352,11 +371,11 @@ private func anyCodableString(_ value: AnyCodable?) -> String {
} }
} }
private func anyCodableStringValue(_ value: AnyCodable?) -> String? { private func anyCodableStringValue(_ value: ProtocolAnyCodable?) -> String? {
value?.value as? String value?.value as? String
} }
private func anyCodableBool(_ value: AnyCodable?) -> Bool { private func anyCodableBool(_ value: ProtocolAnyCodable?) -> Bool {
switch value?.value { switch value?.value {
case let bool as Bool: case let bool as Bool:
return bool return bool
@@ -367,18 +386,18 @@ private func anyCodableBool(_ value: AnyCodable?) -> Bool {
} }
} }
private func anyCodableArray(_ value: AnyCodable?) -> [AnyCodable] { private func anyCodableArray(_ value: ProtocolAnyCodable?) -> [ProtocolAnyCodable] {
switch value?.value { switch value?.value {
case let arr as [AnyCodable]: case let arr as [ProtocolAnyCodable]:
return arr return arr
case let arr as [Any]: case let arr as [Any]:
return arr.map { AnyCodable($0) } return arr.map { ProtocolAnyCodable($0) }
default: default:
return [] return []
} }
} }
private func anyCodableEqual(_ lhs: AnyCodable?, _ rhs: AnyCodable?) -> Bool { private func anyCodableEqual(_ lhs: ProtocolAnyCodable?, _ rhs: ProtocolAnyCodable?) -> Bool {
switch (lhs?.value, rhs?.value) { switch (lhs?.value, rhs?.value) {
case let (l as String, r as String): case let (l as String, r as String):
return l == r return l == r
@@ -3,6 +3,7 @@ import ApplicationServices
import AVFoundation import AVFoundation
import ClawdisIPC import ClawdisIPC
import CoreGraphics import CoreGraphics
import CoreLocation
import Foundation import Foundation
import Observation import Observation
import Speech import Speech
@@ -33,6 +34,8 @@ enum PermissionManager {
await self.ensureSpeechRecognition(interactive: interactive) await self.ensureSpeechRecognition(interactive: interactive)
case .camera: case .camera:
await self.ensureCamera(interactive: interactive) await self.ensureCamera(interactive: interactive)
case .location:
await self.ensureLocation(interactive: interactive)
} }
} }
@@ -134,6 +137,25 @@ enum PermissionManager {
} }
} }
private static func ensureLocation(interactive: Bool) async -> Bool {
let status = CLLocationManager.authorizationStatus()
switch status {
case .authorizedAlways, .authorizedWhenInUse:
return true
case .notDetermined:
guard interactive else { return false }
let updated = await LocationPermissionRequester.shared.request(always: false)
return updated == .authorizedAlways || updated == .authorizedWhenInUse
case .denied, .restricted:
if interactive {
LocationPermissionHelper.openSettings()
}
return false
@unknown default:
return false
}
}
static func voiceWakePermissionsGranted() -> Bool { static func voiceWakePermissionsGranted() -> Bool {
let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
let speech = SFSpeechRecognizer.authorizationStatus() == .authorized let speech = SFSpeechRecognizer.authorizationStatus() == .authorized
@@ -176,6 +198,9 @@ enum PermissionManager {
case .camera: case .camera:
results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
case .location:
let status = CLLocationManager.authorizationStatus()
results[cap] = status == .authorizedAlways || status == .authorizedWhenInUse
} }
} }
return results return results
@@ -227,6 +252,54 @@ enum CameraPermissionHelper {
} }
} }
enum LocationPermissionHelper {
static func openSettings() {
let candidates = [
"x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices",
"x-apple.systempreferences:com.apple.preference.security",
]
for candidate in candidates {
if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
return
}
}
}
}
@MainActor
final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
static let shared = LocationPermissionRequester()
private let manager = CLLocationManager()
private var continuation: CheckedContinuation<CLAuthorizationStatus, Never>?
override init() {
super.init()
self.manager.delegate = self
}
func request(always: Bool) async -> CLAuthorizationStatus {
if always {
self.manager.requestAlwaysAuthorization()
} else {
self.manager.requestWhenInUseAuthorization()
}
return await withCheckedContinuation { cont in
self.continuation = cont
}
}
// nonisolated for Swift 6 strict concurrency compatibility
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
Task { @MainActor in
guard let cont = self.continuation else { return }
self.continuation = nil
cont.resume(returning: status)
}
}
}
enum AppleScriptPermission { enum AppleScriptPermission {
private static let logger = Logger(subsystem: "com.clawdis", category: "AppleScriptPermission") private static let logger = Logger(subsystem: "com.clawdis", category: "AppleScriptPermission")
@@ -121,6 +121,7 @@ struct PermissionRow: View {
case .microphone: "Microphone" case .microphone: "Microphone"
case .speechRecognition: "Speech Recognition" case .speechRecognition: "Speech Recognition"
case .camera: "Camera" case .camera: "Camera"
case .location: "Location"
} }
} }
@@ -134,6 +135,7 @@ struct PermissionRow: View {
case .microphone: "Allow Voice Wake and audio capture" case .microphone: "Allow Voice Wake and audio capture"
case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device" case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device"
case .camera: "Capture photos and video from the camera" case .camera: "Capture photos and video from the camera"
case .location: "Share location when requested by the agent"
} }
} }
@@ -146,6 +148,7 @@ struct PermissionRow: View {
case .microphone: "mic" case .microphone: "mic"
case .speechRecognition: "waveform" case .speechRecognition: "waveform"
case .camera: "camera" case .camera: "camera"
case .location: "location"
} }
} }
} }
@@ -47,6 +47,8 @@
<string>Clawdis captures the screen when the agent needs screenshots for context.</string> <string>Clawdis captures the screen when the agent needs screenshots for context.</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>Clawdis can capture photos or short video clips when requested by the agent.</string> <string>Clawdis can capture photos or short video clips when requested by the agent.</string>
<key>NSLocationUsageDescription</key>
<string>Clawdis can share your location when requested by the agent.</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>Clawdis needs the mic for Voice Wake tests and agent audio capture.</string> <string>Clawdis needs the mic for Voice Wake tests and agent audio capture.</string>
<key>NSSpeechRecognitionUsageDescription</key> <key>NSSpeechRecognitionUsageDescription</key>
+1
View File
@@ -12,6 +12,7 @@ public enum Capability: String, Codable, CaseIterable, Sendable {
case microphone case microphone
case speechRecognition case speechRecognition
case camera case camera
case location
} }
public enum CameraFacing: String, Codable, Sendable { public enum CameraFacing: String, Codable, Sendable {
@@ -340,6 +340,7 @@ public struct SendParams: Codable {
public let to: String public let to: String
public let message: String public let message: String
public let mediaurl: String? public let mediaurl: String?
public let gifplayback: Bool?
public let provider: String? public let provider: String?
public let idempotencykey: String public let idempotencykey: String
@@ -347,12 +348,14 @@ public struct SendParams: Codable {
to: String, to: String,
message: String, message: String,
mediaurl: String?, mediaurl: String?,
gifplayback: Bool?,
provider: String?, provider: String?,
idempotencykey: String idempotencykey: String
) { ) {
self.to = to self.to = to
self.message = message self.message = message
self.mediaurl = mediaurl self.mediaurl = mediaurl
self.gifplayback = gifplayback
self.provider = provider self.provider = provider
self.idempotencykey = idempotencykey self.idempotencykey = idempotencykey
} }
@@ -360,6 +363,7 @@ public struct SendParams: Codable {
case to case to
case message case message
case mediaurl = "mediaUrl" case mediaurl = "mediaUrl"
case gifplayback = "gifPlayback"
case provider case provider
case idempotencykey = "idempotencyKey" case idempotencykey = "idempotencyKey"
} }
@@ -374,6 +378,8 @@ public struct AgentParams: Codable {
public let deliver: Bool? public let deliver: Bool?
public let channel: String? public let channel: String?
public let timeout: Int? public let timeout: Int?
public let lane: String?
public let extrasystemprompt: String?
public let idempotencykey: String public let idempotencykey: String
public init( public init(
@@ -385,6 +391,8 @@ public struct AgentParams: Codable {
deliver: Bool?, deliver: Bool?,
channel: String?, channel: String?,
timeout: Int?, timeout: Int?,
lane: String?,
extrasystemprompt: String?,
idempotencykey: String idempotencykey: String
) { ) {
self.message = message self.message = message
@@ -395,6 +403,8 @@ public struct AgentParams: Codable {
self.deliver = deliver self.deliver = deliver
self.channel = channel self.channel = channel
self.timeout = timeout self.timeout = timeout
self.lane = lane
self.extrasystemprompt = extrasystemprompt
self.idempotencykey = idempotencykey self.idempotencykey = idempotencykey
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
@@ -406,10 +416,33 @@ public struct AgentParams: Codable {
case deliver case deliver
case channel case channel
case timeout case timeout
case lane
case extrasystemprompt = "extraSystemPrompt"
case idempotencykey = "idempotencyKey" case idempotencykey = "idempotencyKey"
} }
} }
public struct AgentWaitParams: Codable {
public let runid: String
public let afterms: Int?
public let timeoutms: Int?
public init(
runid: String,
afterms: Int?,
timeoutms: Int?
) {
self.runid = runid
self.afterms = afterms
self.timeoutms = timeoutms
}
private enum CodingKeys: String, CodingKey {
case runid = "runId"
case afterms = "afterMs"
case timeoutms = "timeoutMs"
}
}
public struct WakeParams: Codable { public struct WakeParams: Codable {
public let mode: AnyCodable public let mode: AnyCodable
public let text: String public let text: String
@@ -613,27 +646,35 @@ public struct SessionsPatchParams: Codable {
public let key: String public let key: String
public let thinkinglevel: AnyCodable? public let thinkinglevel: AnyCodable?
public let verboselevel: AnyCodable? public let verboselevel: AnyCodable?
public let elevatedlevel: AnyCodable?
public let model: AnyCodable? public let model: AnyCodable?
public let sendpolicy: AnyCodable?
public let groupactivation: AnyCodable? public let groupactivation: AnyCodable?
public init( public init(
key: String, key: String,
thinkinglevel: AnyCodable?, thinkinglevel: AnyCodable?,
verboselevel: AnyCodable?, verboselevel: AnyCodable?,
elevatedlevel: AnyCodable?,
model: AnyCodable?, model: AnyCodable?,
sendpolicy: AnyCodable?,
groupactivation: AnyCodable? groupactivation: AnyCodable?
) { ) {
self.key = key self.key = key
self.thinkinglevel = thinkinglevel self.thinkinglevel = thinkinglevel
self.verboselevel = verboselevel self.verboselevel = verboselevel
self.elevatedlevel = elevatedlevel
self.model = model self.model = model
self.sendpolicy = sendpolicy
self.groupactivation = groupactivation self.groupactivation = groupactivation
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case key case key
case thinkinglevel = "thinkingLevel" case thinkinglevel = "thinkingLevel"
case verboselevel = "verboseLevel" case verboselevel = "verboseLevel"
case elevatedlevel = "elevatedLevel"
case model case model
case sendpolicy = "sendPolicy"
case groupactivation = "groupActivation" case groupactivation = "groupActivation"
} }
} }
@@ -32,7 +32,7 @@ import Testing
} }
""" """
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8)) let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
#expect(key == "primary") #expect(key == "main")
} }
@Test func configGetSnapshotMainKeyFallsBackWhenEmptyOrWhitespace() throws { @Test func configGetSnapshotMainKeyFallsBackWhenEmptyOrWhitespace() throws {
@@ -54,4 +54,14 @@ import Testing
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8)) let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
#expect(key == "main") #expect(key == "main")
} }
@Test func configGetSnapshotUsesGlobalScope() throws {
let json = """
{
"config": { "session": { "scope": "global" } }
}
"""
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
#expect(key == "global")
}
} }
@@ -0,0 +1,139 @@
import Foundation
struct AssistantTextSegment: Identifiable {
enum Kind {
case thinking
case response
}
let id = UUID()
let kind: Kind
let text: String
}
enum AssistantTextParser {
static func segments(from raw: String) -> [AssistantTextSegment] {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
guard raw.contains("<") else {
return [AssistantTextSegment(kind: .response, text: trimmed)]
}
var segments: [AssistantTextSegment] = []
var cursor = raw.startIndex
var currentKind: AssistantTextSegment.Kind = .response
var matchedTag = false
while let match = self.nextTag(in: raw, from: cursor) {
matchedTag = true
if match.range.lowerBound > cursor {
self.appendSegment(kind: currentKind, text: raw[cursor..<match.range.lowerBound], to: &segments)
}
guard let tagEnd = raw.range(of: ">", range: match.range.upperBound..<raw.endIndex) else {
cursor = raw.endIndex
break
}
let isSelfClosing = self.isSelfClosingTag(in: raw, tagEnd: tagEnd)
cursor = tagEnd.upperBound
if isSelfClosing { continue }
if match.closing {
currentKind = .response
} else {
currentKind = match.kind == .think ? .thinking : .response
}
}
if cursor < raw.endIndex {
self.appendSegment(kind: currentKind, text: raw[cursor..<raw.endIndex], to: &segments)
}
guard matchedTag else {
return [AssistantTextSegment(kind: .response, text: trimmed)]
}
return segments
}
static func hasVisibleContent(in raw: String) -> Bool {
!self.segments(from: raw).isEmpty
}
private enum TagKind {
case think
case final
}
private struct TagMatch {
let kind: TagKind
let closing: Bool
let range: Range<String.Index>
}
private static func nextTag(in text: String, from start: String.Index) -> TagMatch? {
let candidates: [TagMatch] = [
self.findTagStart(tag: "think", closing: false, in: text, from: start).map {
TagMatch(kind: .think, closing: false, range: $0)
},
self.findTagStart(tag: "think", closing: true, in: text, from: start).map {
TagMatch(kind: .think, closing: true, range: $0)
},
self.findTagStart(tag: "final", closing: false, in: text, from: start).map {
TagMatch(kind: .final, closing: false, range: $0)
},
self.findTagStart(tag: "final", closing: true, in: text, from: start).map {
TagMatch(kind: .final, closing: true, range: $0)
},
].compactMap { $0 }
return candidates.min { $0.range.lowerBound < $1.range.lowerBound }
}
private static func findTagStart(
tag: String,
closing: Bool,
in text: String,
from start: String.Index) -> Range<String.Index>?
{
let token = closing ? "</\(tag)" : "<\(tag)"
var searchRange = start..<text.endIndex
while let range = text.range(
of: token,
options: [.caseInsensitive, .diacriticInsensitive],
range: searchRange)
{
let boundaryIndex = range.upperBound
guard boundaryIndex < text.endIndex else { return range }
let boundary = text[boundaryIndex]
let isBoundary = boundary == ">" || boundary.isWhitespace || (!closing && boundary == "/")
if isBoundary {
return range
}
searchRange = boundaryIndex..<text.endIndex
}
return nil
}
private static func isSelfClosingTag(in text: String, tagEnd: Range<String.Index>) -> Bool {
var cursor = tagEnd.lowerBound
while cursor > text.startIndex {
cursor = text.index(before: cursor)
let char = text[cursor]
if char.isWhitespace { continue }
return char == "/"
}
return false
}
private static func appendSegment(
kind: AssistantTextSegment.Kind,
text: Substring,
to segments: inout [AssistantTextSegment])
{
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
segments.append(AssistantTextSegment(kind: kind, text: trimmed))
}
}
@@ -158,7 +158,6 @@ private struct ChatMessageBody: View {
var body: some View { var body: some View {
let text = self.primaryText let text = self.primaryText
let split = ChatMarkdownSplitter.split(markdown: text)
let textColor = self.isUser ? ClawdisChatTheme.userText : ClawdisChatTheme.assistantText let textColor = self.isUser ? ClawdisChatTheme.userText : ClawdisChatTheme.assistantText
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
@@ -169,37 +168,40 @@ private struct ChatMessageBody: View {
text: text, text: text,
isUser: self.isUser) isUser: self.isUser)
} }
} else { } else if self.isUser {
let split = ChatMarkdownSplitter.split(markdown: text)
ForEach(split.blocks) { block in ForEach(split.blocks) { block in
switch block.kind { switch block.kind {
case .text: case .text:
MarkdownTextView(text: block.text, textColor: textColor) MarkdownTextView(text: block.text, textColor: textColor, font: .system(size: 14))
case let .code(language): case let .code(language):
CodeBlockView(code: block.text, language: language, isUser: self.isUser) CodeBlockView(code: block.text, language: language, isUser: self.isUser)
} }
} }
}
if !split.images.isEmpty { if !split.images.isEmpty {
ForEach( ForEach(
split.images, split.images,
id: \ChatMarkdownSplitter.InlineImage.id) id: \ChatMarkdownSplitter.InlineImage.id)
{ (item: ChatMarkdownSplitter.InlineImage) in { (item: ChatMarkdownSplitter.InlineImage) in
if let img = item.image { if let img = item.image {
ClawdisPlatformImageFactory.image(img) ClawdisPlatformImageFactory.image(img)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(maxHeight: 260) .frame(maxHeight: 260)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous) RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)) .strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
} else { } else {
Text(item.label.isEmpty ? "Image" : item.label) Text(item.label.isEmpty ? "Image" : item.label)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
}
} }
} }
} else {
ChatAssistantTextBody(text: text)
} }
if !self.inlineAttachments.isEmpty { if !self.inlineAttachments.isEmpty {
@@ -506,7 +508,7 @@ struct ChatStreamingAssistantBubble: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
ChatMarkdownBody(text: self.text, textColor: ClawdisChatTheme.assistantText) ChatAssistantTextBody(text: self.text)
} }
.padding(12) .padding(12)
.background( .background(
@@ -614,6 +616,7 @@ private struct TypingDots: View {
private struct MarkdownTextView: View { private struct MarkdownTextView: View {
let text: String let text: String
let textColor: Color let textColor: Color
let font: Font
var body: some View { var body: some View {
let normalized = self.text.replacingOccurrences( let normalized = self.text.replacingOccurrences(
@@ -624,20 +627,36 @@ private struct MarkdownTextView: View {
interpretedSyntax: .inlineOnlyPreservingWhitespace) interpretedSyntax: .inlineOnlyPreservingWhitespace)
if let attributed = try? AttributedString(markdown: normalized, options: options) { if let attributed = try? AttributedString(markdown: normalized, options: options) {
Text(attributed) Text(attributed)
.font(.system(size: 14)) .font(self.font)
.foregroundStyle(self.textColor) .foregroundStyle(self.textColor)
} else { } else {
Text(normalized) Text(normalized)
.font(.system(size: 14)) .font(self.font)
.foregroundStyle(self.textColor) .foregroundStyle(self.textColor)
} }
} }
} }
@MainActor
private struct ChatAssistantTextBody: View {
let text: String
var body: some View {
let segments = AssistantTextParser.segments(from: self.text)
VStack(alignment: .leading, spacing: 10) {
ForEach(segments) { segment in
let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14)
ChatMarkdownBody(text: segment.text, textColor: ClawdisChatTheme.assistantText, font: font)
}
}
}
}
@MainActor @MainActor
private struct ChatMarkdownBody: View { private struct ChatMarkdownBody: View {
let text: String let text: String
let textColor: Color let textColor: Color
let font: Font
var body: some View { var body: some View {
let split = ChatMarkdownSplitter.split(markdown: self.text) let split = ChatMarkdownSplitter.split(markdown: self.text)
@@ -645,7 +664,7 @@ private struct ChatMarkdownBody: View {
ForEach(split.blocks) { block in ForEach(split.blocks) { block in
switch block.kind { switch block.kind {
case .text: case .text:
MarkdownTextView(text: block.text, textColor: self.textColor) MarkdownTextView(text: block.text, textColor: self.textColor, font: self.font)
case let .code(language): case let .code(language):
CodeBlockView(code: block.text, language: language, isUser: false) CodeBlockView(code: block.text, language: language, isUser: false)
} }
@@ -169,7 +169,7 @@ public struct ClawdisChatView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
if let text = self.viewModel.streamingAssistantText, !text.isEmpty { if let text = self.viewModel.streamingAssistantText, AssistantTextParser.hasVisibleContent(in: text) {
ChatStreamingAssistantBubble(text: text) ChatStreamingAssistantBubble(text: text)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
@@ -246,7 +246,7 @@ public struct ClawdisChatView: View {
return true return true
} }
if let text = self.viewModel.streamingAssistantText, if let text = self.viewModel.streamingAssistantText,
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty AssistantTextParser.hasVisibleContent(in: text)
{ {
return true return true
} }
@@ -261,7 +261,7 @@ public struct ClawdisChatView: View {
private var showsEmptyState: Bool { private var showsEmptyState: Bool {
self.viewModel.messages.isEmpty && self.viewModel.messages.isEmpty &&
(self.viewModel.streamingAssistantText?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) && !(self.viewModel.streamingAssistantText.map { AssistantTextParser.hasVisibleContent(in: $0) } ?? false) &&
self.viewModel.pendingRunCount == 0 && self.viewModel.pendingRunCount == 0 &&
self.viewModel.pendingToolCalls.isEmpty self.viewModel.pendingToolCalls.isEmpty
} }
@@ -5,4 +5,5 @@ public enum ClawdisCapability: String, Codable, Sendable {
case camera case camera
case screen case screen
case voiceWake case voiceWake
case location
} }
@@ -0,0 +1,57 @@
import Foundation
public enum ClawdisLocationCommand: String, Codable, Sendable {
case get = "location.get"
}
public enum ClawdisLocationAccuracy: String, Codable, Sendable {
case coarse
case balanced
case precise
}
public struct ClawdisLocationGetParams: Codable, Sendable, Equatable {
public var timeoutMs: Int?
public var maxAgeMs: Int?
public var desiredAccuracy: ClawdisLocationAccuracy?
public init(timeoutMs: Int? = nil, maxAgeMs: Int? = nil, desiredAccuracy: ClawdisLocationAccuracy? = nil) {
self.timeoutMs = timeoutMs
self.maxAgeMs = maxAgeMs
self.desiredAccuracy = desiredAccuracy
}
}
public struct ClawdisLocationPayload: Codable, Sendable, Equatable {
public var lat: Double
public var lon: Double
public var accuracyMeters: Double
public var altitudeMeters: Double?
public var speedMps: Double?
public var headingDeg: Double?
public var timestamp: String
public var isPrecise: Bool
public var source: String?
public init(
lat: Double,
lon: Double,
accuracyMeters: Double,
altitudeMeters: Double? = nil,
speedMps: Double? = nil,
headingDeg: Double? = nil,
timestamp: String,
isPrecise: Bool,
source: String? = nil)
{
self.lat = lat
self.lon = lon
self.accuracyMeters = accuracyMeters
self.altitudeMeters = altitudeMeters
self.speedMps = speedMps
self.headingDeg = headingDeg
self.timestamp = timestamp
self.isPrecise = isPrecise
self.source = source
}
}
@@ -0,0 +1,7 @@
import Foundation
public enum ClawdisLocationMode: String, Codable, Sendable, CaseIterable {
case off
case whileUsing
case always
}
@@ -0,0 +1,37 @@
@testable import ClawdisChatUI
import Testing
@Suite struct AssistantTextParserTests {
@Test func splitsThinkAndFinalSegments() {
let segments = AssistantTextParser.segments(
from: "<think>internal</think>\n\n<final>Hello there</final>")
#expect(segments.count == 2)
#expect(segments[0].kind == .thinking)
#expect(segments[0].text == "internal")
#expect(segments[1].kind == .response)
#expect(segments[1].text == "Hello there")
}
@Test func keepsTextWithoutTags() {
let segments = AssistantTextParser.segments(from: "Just text.")
#expect(segments.count == 1)
#expect(segments[0].kind == .response)
#expect(segments[0].text == "Just text.")
}
@Test func ignoresThinkingLikeTags() {
let raw = "<thinking>example</thinking>\nKeep this."
let segments = AssistantTextParser.segments(from: raw)
#expect(segments.count == 1)
#expect(segments[0].kind == .response)
#expect(segments[0].text == raw.trimmingCharacters(in: .whitespacesAndNewlines))
}
@Test func dropsEmptyTaggedContent() {
let segments = AssistantTextParser.segments(from: "<think></think>")
#expect(segments.isEmpty)
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ read_when:
- Required: `--message <text>` - Required: `--message <text>`
- Session selection: - Session selection:
- If `--session-id` is given, reuse it. - If `--session-id` is given, reuse it.
- Else if `--to <e164>` is given, derive the session key from `session.scope` (direct chats collapse to `session.mainKey`). - Else if `--to <e164>` is given, derive the session key from `session.scope` (direct chats collapse to `main`, or `global` when scope is global).
- Runs the embedded Pi agent (configured via `agent`). - Runs the embedded Pi agent (configured via `agent`).
- Thinking/verbose: - Thinking/verbose:
- Flags `--thinking <off|minimal|low|medium|high>` and `--verbose <on|off>` persist into the session store. - Flags `--thinking <off|minimal|low|medium|high>` and `--verbose <on|off>` persist into the session store.
+4 -3
View File
@@ -13,10 +13,11 @@ Clawdis runs shell commands through the `bash` tool and keeps long‑running tas
Key parameters: Key parameters:
- `command` (required) - `command` (required)
- `yieldMs` (default 20000): auto‑background after this delay - `yieldMs` (default 10000): auto‑background after this delay
- `background` (bool): background immediately - `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill the process after this timeout - `timeout` (seconds, default 1800): kill the process after this timeout
- `stdinMode` (`pipe` | `pty`): use a real TTY when `pty` is requested and node-pty loads (otherwise warns + falls back) - `elevated` (bool): run on host if elevated mode is enabled/allowed
- Need a real TTY? Use the tmux skill.
- `workdir`, `env` - `workdir`, `env`
Behavior: Behavior:
@@ -30,7 +31,7 @@ Environment overrides:
- `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m–3h) - `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m–3h)
Config (preferred): Config (preferred):
- `agent.bash.backgroundMs` (default 20000) - `agent.bash.backgroundMs` (default 10000)
- `agent.bash.timeoutSec` (default 1800) - `agent.bash.timeoutSec` (default 1800)
- `agent.bash.cleanupMs` (default 1800000) - `agent.bash.cleanupMs` (default 1800000)
+3 -15
View File
@@ -12,18 +12,11 @@ Run shell commands in the workspace. Supports foreground + background execution
## Parameters ## Parameters
- `command` (required) - `command` (required)
- `yieldMs` (default 20000): auto-background after delay - `yieldMs` (default 10000): auto-background after delay
- `background` (bool): background immediately - `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill on expiry - `timeout` (seconds, default 1800): kill on expiry
- `stdinMode` (`pipe` | `pty`): - `elevated` (bool): run on host if elevated mode is enabled/allowed
- `pipe` (default): classic stdin/stdout/stderr pipes - Need a real TTY? Use the tmux skill.
- `pty`: real TTY via node-pty (merged stdout/stderr)
## TTY mode (`stdinMode: "pty"`)
- Uses node-pty if available. If node-pty fails to load/start, the tool warns and falls back to `pipe`.
- Output streams are merged (no separate stderr).
- `process write` sends raw input; `eof: true` sends Ctrl-D (`\x04`).
## Examples ## Examples
@@ -37,8 +30,3 @@ Background + poll:
{"tool":"bash","command":"npm run build","yieldMs":1000} {"tool":"bash","command":"npm run build","yieldMs":1000}
{"tool":"process","action":"poll","sessionId":"<id>"} {"tool":"process","action":"poll","sessionId":"<id>"}
``` ```
TTY command:
```json
{"tool":"bash","command":"htop","stdinMode":"pty","background":true}
```
+60
View File
@@ -153,6 +153,59 @@ Hooks (arming):
Clawdis should treat "open/closed" as a health check (fast path), not by scanning Clawdis should treat "open/closed" as a health check (fast path), not by scanning
global Chrome processes (avoid false positives). global Chrome processes (avoid false positives).
## Multi-profile support
Clawdis supports multiple named browser profiles, each with:
- Dedicated CDP port (auto-allocated from 18800-18899) **or** a per-profile CDP URL
- Persistent user data directory (`~/.clawdis/browser/<name>/user-data/`)
- Unique color for visual distinction
### Configuration
```json
{
"browser": {
"enabled": true,
"defaultProfile": "clawd",
"profiles": {
"clawd": { "cdpPort": 18800, "color": "#FF4500" },
"work": { "cdpPort": 18801, "color": "#0066CC" },
"remote": { "cdpUrl": "http://10.0.0.42:9222", "color": "#00AA00" }
}
}
}
```
### Profile actions
- `GET /profiles` — list all profiles with status
- `POST /profiles/create` `{ name, color?, cdpUrl? }` — create new profile (auto-allocates port if no `cdpUrl`)
- `DELETE /profiles/:name` — delete profile (stops browser + removes user data for local profiles)
- `POST /reset-profile?profile=<name>` — kill orphan process on profile's port (local profiles only)
### Profile parameter
All existing endpoints accept optional `?profile=<name>` query parameter:
- `GET /?profile=work` — status for work profile
- `POST /start?profile=work` — start work profile browser
- `GET /tabs?profile=work` — list tabs for work profile
- etc.
When `profile` is omitted, uses `browser.defaultProfile` (defaults to "clawd").
### Profile naming rules
- Lowercase alphanumeric characters and hyphens only
- Must start with a letter or number (not a hyphen)
- Maximum 64 characters
- Examples: `clawd`, `work`, `my-project-1`
### Port allocation
Ports are allocated from range 18800-18899 (~100 profiles max). This is far more
than practical use — memory and CPU exhaustion occur well before port exhaustion.
Ports are allocated once at profile creation and persisted permanently.
Remote profiles are attach-only and do **not** use the local port range.
## Interaction with the agent (clawd) ## Interaction with the agent (clawd)
The agent should use browser tools only when: The agent should use browser tools only when:
@@ -168,6 +221,13 @@ The agent should not assume tabs are ephemeral. It should:
## CLI quick reference (one example each) ## CLI quick reference (one example each)
All commands accept `--profile <name>` to target a specific profile (default: `clawd`).
Profile management:
- `clawdis browser profiles`
- `clawdis browser create-profile --name work`
- `clawdis browser create-profile --name remote --cdp-url http://10.0.0.42:9222`
- `clawdis browser delete-profile --name work`
Basics: Basics:
- `clawdis browser status` - `clawdis browser status`
- `clawdis browser start` - `clawdis browser start`
+1 -2
View File
@@ -138,8 +138,7 @@ Example:
session: { session: {
scope: "per-sender", scope: "per-sender",
resetTriggers: ["/new", "/reset"], resetTriggers: ["/new", "/reset"],
idleMinutes: 10080, idleMinutes: 10080
mainKey: "main"
} }
} }
``` ```
+3
View File
@@ -31,6 +31,9 @@ Author: steipete · Status: draft spec · Date: 2025-12-20
- The gateway port is configurable via `gateway.port` or `CLAWDIS_GATEWAY_PORT` (default 18789). The mac app reads that value for launchd, probes, and remote SSH tunnels. - The gateway port is configurable via `gateway.port` or `CLAWDIS_GATEWAY_PORT` (default 18789). The mac app reads that value for launchd, probes, and remote SSH tunnels.
- The mac app connects to the bridge as a **node** and advertises capabilities/commands. - The mac app connects to the bridge as a **node** and advertises capabilities/commands.
- Agent‑facing actions are exposed via `node.invoke` (no local control socket). - Agent‑facing actions are exposed via `node.invoke` (no local control socket).
- The mac app watches `~/.clawdis/clawdis.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
- If `gateway.mode` is unset but `gateway.remote.url` is set, the mac app treats it as remote mode.
- Changing connection mode in the mac app writes `gateway.mode` (and `gateway.remote.url` in remote mode) back to the config file.
### Node commands (mac) ### Node commands (mac)
- Canvas: `canvas.present|navigate|eval|snapshot|a2ui.*` - Canvas: `canvas.present|navigate|eval|snapshot|a2ui.*`
+183 -5
View File
@@ -33,6 +33,11 @@ better forms without hard-coding config knowledge.
} }
``` ```
Build the default image once with:
```bash
scripts/sandbox-setup.sh
```
## Self-chat mode (recommended for group control) ## Self-chat mode (recommended for group control)
To prevent the bot from responding to WhatsApp @-mentions in groups (only respond to specific text triggers): To prevent the bot from responding to WhatsApp @-mentions in groups (only respond to specific text triggers):
@@ -323,6 +328,9 @@ Default: `~/clawd`.
} }
``` ```
If `agent.sandbox` is enabled, non-main sessions can override this with their
own per-session workspaces under `agent.sandbox.workspaceRoot`.
### `messages` ### `messages`
Controls inbound/outbound prefixes and timestamps. Controls inbound/outbound prefixes and timestamps.
@@ -380,6 +388,7 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
}, },
thinkingDefault: "low", thinkingDefault: "low",
verboseDefault: "off", verboseDefault: "off",
elevatedDefault: "on",
timeoutSeconds: 600, timeoutSeconds: 600,
mediaMaxMb: 5, mediaMaxMb: 5,
heartbeat: { heartbeat: {
@@ -388,7 +397,7 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
}, },
maxConcurrent: 3, maxConcurrent: 3,
bash: { bash: {
backgroundMs: 20000, backgroundMs: 10000,
timeoutSec: 1800, timeoutSec: 1800,
cleanupMs: 1800000 cleanupMs: 1800000
}, },
@@ -427,14 +436,110 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
- `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`). - `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`).
`agent.bash` configures background bash defaults: `agent.bash` configures background bash defaults:
- `backgroundMs`: time before auto-background (ms, default 20000) - `backgroundMs`: time before auto-background (ms, default 10000)
- `timeoutSec`: auto-kill after this runtime (seconds, default 1800) - `timeoutSec`: auto-kill after this runtime (seconds, default 1800)
- `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000) - `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000)
`agent.elevated` controls elevated (host) bash access:
- `enabled`: allow elevated mode (default true)
- `allowFrom`: per-surface allowlists (empty = disabled)
- `whatsapp`: E.164 numbers
- `telegram`: chat ids or usernames
- `discord`: user ids or usernames (falls back to `discord.dm.allowFrom` if omitted)
- `signal`: E.164 numbers
- `imessage`: handles/chat ids
- `webchat`: session ids or usernames
Example:
```json5
{
agent: {
elevated: {
enabled: true,
allowFrom: {
whatsapp: ["+15555550123"],
discord: ["steipete", "1234567890123"]
}
}
}
}
```
`agent.maxConcurrent` sets the maximum number of embedded agent runs that can `agent.maxConcurrent` sets the maximum number of embedded agent runs that can
execute in parallel across sessions. Each session is still serialized (one run execute in parallel across sessions. Each session is still serialized (one run
per session key at a time). Default: 1. per session key at a time). Default: 1.
### `agent.sandbox`
Optional per-session **Docker sandboxing** for the embedded agent. Intended for
non-main sessions so they cannot access your host system.
Defaults (if enabled):
- one container per session
- Debian bookworm-slim based image
- workspace per session under `~/.clawdis/sandboxes`
- auto-prune: idle > 24h OR age > 7d
- tools: allow only `bash`, `process`, `read`, `write`, `edit` (deny wins)
- optional sandboxed browser (Chromium + CDP, noVNC observer)
```json5
{
agent: {
sandbox: {
mode: "non-main", // off | non-main | all
perSession: true,
workspaceRoot: "~/.clawdis/sandboxes",
docker: {
image: "clawdis-sandbox:bookworm-slim",
containerPrefix: "clawdis-sbx-",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: ["/tmp", "/var/tmp", "/run"],
network: "bridge",
user: "1000:1000",
capDrop: ["ALL"],
env: { LANG: "C.UTF-8" },
setupCommand: "apt-get update && apt-get install -y git curl jq"
},
browser: {
enabled: false,
image: "clawdis-sandbox-browser:bookworm-slim",
containerPrefix: "clawdis-sbx-browser-",
cdpPort: 9222,
vncPort: 5900,
noVncPort: 6080,
headless: false,
enableNoVnc: true
},
tools: {
allow: ["bash", "process", "read", "write", "edit"],
deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"]
},
prune: {
idleHours: 24, // 0 disables idle pruning
maxAgeDays: 7 // 0 disables max-age pruning
}
}
}
}
```
Build the default sandbox image once with:
```bash
scripts/sandbox-setup.sh
```
Build the optional browser image with:
```bash
scripts/sandbox-browser-setup.sh
```
When `agent.sandbox.browser.enabled=true`, the browser tool uses a sandboxed
Chromium instance (CDP). If noVNC is enabled (default when headless=false),
the noVNC URL is injected into the system prompt so the agent can reference it.
This does not require `browser.enabled` in the main config; the sandbox control
URL is injected per session.
### `models` (custom providers + base URLs) ### `models` (custom providers + base URLs)
Clawdis uses the **pi-coding-agent** model catalog. You can add custom providers Clawdis uses the **pi-coding-agent** model catalog. You can add custom providers
@@ -541,11 +646,26 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
idleMinutes: 60, idleMinutes: 60,
resetTriggers: ["/new", "/reset"], resetTriggers: ["/new", "/reset"],
store: "~/.clawdis/sessions/sessions.json", store: "~/.clawdis/sessions/sessions.json",
mainKey: "main" // mainKey is ignored; primary key is fixed to "main"
agentToAgent: {
// Max ping-pong reply turns between requester/target (0–5).
maxPingPongTurns: 5
},
sendPolicy: {
rules: [
{ action: "deny", match: { surface: "discord", chatType: "group" } }
],
default: "allow"
}
} }
} }
``` ```
Fields:
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
- `sendPolicy.rules[]`: match by `surface` (provider), `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
### `skills` (skills config) ### `skills` (skills config)
Controls bundled allowlist, install preferences, extra skill folders, and per-skill Controls bundled allowlist, install preferences, extra skill folders, and per-skill
@@ -598,11 +718,16 @@ Example:
### `browser` (clawd-managed Chrome) ### `browser` (clawd-managed Chrome)
Clawdis can start a **dedicated, isolated** Chrome/Chromium instance for clawd and expose a small loopback control server. Clawdis can start a **dedicated, isolated** Chrome/Chromium instance for clawd and expose a small loopback control server.
Profiles can point at a **remote** Chrome via `profiles.<name>.cdpUrl`. Remote
profiles are attach-only (start/stop/reset are disabled).
`browser.cdpUrl` remains for legacy single-profile configs and as the base
scheme/host for profiles that only set `cdpPort`.
Defaults: Defaults:
- enabled: `true` - enabled: `true`
- control URL: `http://127.0.0.1:18791` (CDP uses `18792`) - control URL: `http://127.0.0.1:18791` (CDP uses `18792`)
- CDP URL: `http://127.0.0.1:18792` (control URL + 1) - CDP URL: `http://127.0.0.1:18792` (control URL + 1, legacy single-profile)
- profile color: `#FF4500` (lobster-orange) - profile color: `#FF4500` (lobster-orange)
- Note: the control server is started by the running gateway (Clawdis.app menubar, or `clawdis gateway`). - Note: the control server is started by the running gateway (Clawdis.app menubar, or `clawdis gateway`).
@@ -611,7 +736,13 @@ Defaults:
browser: { browser: {
enabled: true, enabled: true,
controlUrl: "http://127.0.0.1:18791", controlUrl: "http://127.0.0.1:18791",
// cdpUrl: "http://127.0.0.1:18792", // override for remote CDP // cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
defaultProfile: "clawd",
profiles: {
clawd: { cdpPort: 18800, color: "#FF4500" },
work: { cdpPort: 18801, color: "#0066CC" },
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }
},
color: "#FF4500", color: "#FF4500",
// Advanced: // Advanced:
// headless: false, // headless: false,
@@ -683,6 +814,11 @@ Remote client defaults (CLI):
- `gateway.remote.token` supplies the token for remote calls (leave unset for no auth). - `gateway.remote.token` supplies the token for remote calls (leave unset for no auth).
- `gateway.remote.password` supplies the password for remote calls (leave unset for no auth). - `gateway.remote.password` supplies the password for remote calls (leave unset for no auth).
macOS app behavior:
- Clawdis.app watches `~/.clawdis/clawdis.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
- If `gateway.mode` is unset but `gateway.remote.url` is set, the macOS app treats it as remote mode.
- When you change connection mode in the macOS app, it writes `gateway.mode` (and `gateway.remote.url` in remote mode) back to the config file.
```json5 ```json5
{ {
gateway: { gateway: {
@@ -696,6 +832,48 @@ Remote client defaults (CLI):
} }
``` ```
### `gateway.reload` (Config hot reload)
The Gateway watches `~/.clawdis/clawdis.json` (or `CLAWDIS_CONFIG_PATH`) and applies changes automatically.
Modes:
- `hybrid` (default): hot-apply safe changes; restart the Gateway for critical changes.
- `hot`: only apply hot-safe changes; log when a restart is required.
- `restart`: restart the Gateway on any config change.
- `off`: disable hot reload.
```json5
{
gateway: {
reload: {
mode: "hybrid",
debounceMs: 300
}
}
}
```
#### Hot reload matrix (files + impact)
Files watched:
- `~/.clawdis/clawdis.json` (or `CLAWDIS_CONFIG_PATH`)
Hot-applied (no full gateway restart):
- `hooks` (webhook auth/path/mappings) + `hooks.gmail` (Gmail watcher restarted)
- `browser` (browser control server restart)
- `cron` (cron service restart + concurrency update)
- `agent.heartbeat` (heartbeat runner restart)
- `web` (WhatsApp web provider restart)
- `telegram`, `discord`, `signal`, `imessage` (provider restarts)
- `agent`, `models`, `routing`, `messages`, `session`, `whatsapp`, `logging`, `skills`, `ui`, `talk`, `identity`, `wizard` (dynamic reads)
Requires full Gateway restart:
- `gateway` (port/bind/auth/control UI/tailscale)
- `bridge`
- `discovery`
- `canvasHost`
- Any unknown/unsupported config path (defaults to restart for safety)
### Multi-instance isolation ### Multi-instance isolation
To run multiple gateways on one host, isolate per-instance state + config and use unique ports: To run multiple gateways on one host, isolate per-instance state + config and use unique ports:
+178 -8
View File
@@ -9,16 +9,27 @@ read_when:
Docker is **optional**. Use it only if you want a containerized gateway or to validate the Docker flow. Docker is **optional**. Use it only if you want a containerized gateway or to validate the Docker flow.
## Quick start (recommended) This guide covers:
- Containerized Gateway (full Clawdis in Docker)
- Per-session Agent Sandbox (host gateway + Docker-isolated agent tools)
From the repo root: ## Requirements
- Docker Desktop (or Docker Engine) + Docker Compose v2
- Enough disk for images + logs
## Containerized Gateway (Docker Compose)
### Quick start (recommended)
From repo root:
```bash ```bash
./docker-setup.sh ./docker-setup.sh
``` ```
This script: This script:
- builds the image - builds the gateway image
- runs the onboarding wizard - runs the onboarding wizard
- runs WhatsApp login - runs WhatsApp login
- starts the gateway via Docker Compose - starts the gateway via Docker Compose
@@ -27,7 +38,7 @@ It writes config/workspace on the host:
- `~/.clawdis/` - `~/.clawdis/`
- `~/clawd` - `~/clawd`
## Manual flow (compose) ### Manual flow (compose)
```bash ```bash
docker build -t clawdis:local -f Dockerfile . docker build -t clawdis:local -f Dockerfile .
@@ -36,14 +47,173 @@ docker compose run --rm clawdis-cli login
docker compose up -d clawdis-gateway docker compose up -d clawdis-gateway
``` ```
## E2E smoke test (Docker) ### Health check
```bash
docker compose exec clawdis-gateway node dist/index.js health --token "$CLAWDIS_GATEWAY_TOKEN"
```
### E2E smoke test (Docker)
```bash ```bash
scripts/e2e/onboard-docker.sh scripts/e2e/onboard-docker.sh
``` ```
## Notes ### Notes
- Gateway bind defaults to `lan` for container use. - Gateway bind defaults to `lan` for container use.
- Health check: - The gateway container is the source of truth for sessions (`~/.clawdis/sessions`).
`docker compose exec clawdis-gateway node dist/index.js health --token "$CLAWDIS_GATEWAY_TOKEN"`
## Per-session Agent Sandbox (host gateway + Docker tools)
### What it does
When `agent.sandbox` is enabled, **non-main sessions** run tools inside a Docker
container. The gateway stays on your host, but the tool execution is isolated:
- one container per session (hard wall)
- per-session workspace folder mounted at `/workspace`
- allow/deny tool policy (deny wins)
### Default behavior
- Image: `clawdis-sandbox:bookworm-slim`
- One container per session
- Workspace per session under `~/.clawdis/sandboxes`
- Auto-prune: idle > 24h OR age > 7d
- Default allow: `bash`, `process`, `read`, `write`, `edit`
- Default deny: `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`
### Enable sandboxing
```json5
{
agent: {
sandbox: {
mode: "non-main", // off | non-main | all
perSession: true,
workspaceRoot: "~/.clawdis/sandboxes",
docker: {
image: "clawdis-sandbox:bookworm-slim",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: ["/tmp", "/var/tmp", "/run"],
network: "bridge",
user: "1000:1000",
capDrop: ["ALL"],
env: { LANG: "C.UTF-8" },
setupCommand: "apt-get update && apt-get install -y git curl jq"
},
tools: {
allow: ["bash", "process", "read", "write", "edit"],
deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"]
},
prune: {
idleHours: 24, // 0 disables idle pruning
maxAgeDays: 7 // 0 disables max-age pruning
}
}
}
}
```
### Build the default sandbox image
```bash
scripts/sandbox-setup.sh
```
This builds `clawdis-sandbox:bookworm-slim` using `Dockerfile.sandbox`.
### Sandbox browser image
To run the browser tool inside the sandbox, build the browser image:
```bash
scripts/sandbox-browser-setup.sh
```
This builds `clawdis-sandbox-browser:bookworm-slim` using
`Dockerfile.sandbox-browser`. The container runs Chromium with CDP enabled and
an optional noVNC observer (headful via Xvfb).
Notes:
- Headful (Xvfb) reduces bot blocking vs headless.
- Headless can still be used by setting `agent.sandbox.browser.headless=true`.
- No full desktop environment (GNOME) is needed; Xvfb provides the display.
Use config:
```json5
{
agent: {
sandbox: {
browser: { enabled: true }
}
}
}
```
Custom browser image:
```json5
{
agent: {
sandbox: { browser: { image: "my-clawdis-browser" } }
}
}
```
When enabled, the agent receives:
- a sandbox browser control URL (for the `browser` tool)
- a noVNC URL (if enabled and headless=false)
Remember: if you use an allowlist for tools, add `browser` (and remove it from
deny) or the tool remains blocked.
Prune rules (`agent.sandbox.prune`) apply to browser containers too.
### Custom sandbox image
Build your own image and point config to it:
```bash
docker build -t my-clawdis-sbx -f Dockerfile.sandbox .
```
```json5
{
agent: {
sandbox: { docker: { image: "my-clawdis-sbx" } }
}
}
```
### Tool policy (allow/deny)
- `deny` wins over `allow`.
- If `allow` is empty: all tools (except deny) are available.
- If `allow` is non-empty: only tools in `allow` are available (minus deny).
### Pruning strategy
Two knobs:
- `prune.idleHours`: remove containers not used in X hours (0 = disable)
- `prune.maxAgeDays`: remove containers older than X days (0 = disable)
Example:
- Keep busy sessions but cap lifetime:
`idleHours: 24`, `maxAgeDays: 7`
- Never prune:
`idleHours: 0`, `maxAgeDays: 0`
### Security notes
- Hard wall only applies to **tools** (bash/read/write/edit).
- Host-only tools like browser/camera/canvas are blocked by default.
- Allowing `browser` in sandbox **breaks isolation** (browser runs on host).
## Troubleshooting
- Image missing: build with `scripts/sandbox-setup.sh` or set `agent.sandbox.docker.image`.
- Container not running: it will auto-create per session on demand.
- Permission errors in sandbox: set `docker.user` to a UID:GID that matches your
mounted workspace ownership (or chown the workspace folder).
+86
View File
@@ -0,0 +1,86 @@
{
"$schema": "https://mintlify.com/docs.json",
"name": "Clawdis",
"theme": "mint",
"logo": {
"light": "/assets/pixel-lobster.svg",
"dark": "/assets/pixel-lobster.svg"
},
"favicon": "/assets/pixel-lobster.svg",
"colors": {
"primary": "#FF5A36"
},
"topbarLinks": [
{
"name": "GitHub",
"url": "https://github.com/steipete/clawdis"
},
{
"name": "Releases",
"url": "https://github.com/steipete/clawdis/releases"
}
],
"navigation": {
"groups": [
{
"group": "Getting Started",
"pages": [
"index",
"onboarding",
"wizard",
"clawd",
"faq"
]
},
{
"group": "Core Concepts",
"pages": [
"architecture",
"agent",
"session",
"rpc",
"queue",
"tools"
]
},
{
"group": "Gateway & Providers",
"pages": [
"gateway",
"gateway/pairing",
"whatsapp",
"telegram",
"discord",
"imessage",
"webhook",
"gmail-pubsub"
]
},
{
"group": "Platforms",
"pages": [
"mac/dev-setup",
"mac/menu-bar",
"mac/voicewake",
"ios/connect",
"android/connect",
"webchat",
"web"
]
},
{
"group": "Operations",
"pages": [
"doctor",
"troubleshooting",
"logging",
"security",
"health",
"background-process",
"gateway-lock",
"remote"
]
}
]
}
}
+31
View File
@@ -0,0 +1,31 @@
---
summary: "Elevated bash mode and /elevated directives"
read_when:
- Adjusting elevated mode defaults, allowlists, or slash command behavior
---
# Elevated Mode (/elevated directives)
## What it does
- Elevated mode allows the bash tool to run with elevated privileges when the feature is available and the sender is approved.
- Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`.
- Only `on|off` are accepted; anything else returns a hint and does not change state.
## Resolution order
1. Inline directive on the message (applies only to that message).
2. Session override (set by sending a directive-only message).
3. Global default (`agent.elevatedDefault` in config).
## Setting a session default
- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated on`.
- Confirmation reply is sent (`Elevated mode enabled.` / `Elevated mode disabled.`).
- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies `elevated is not available right now.` and does not change session state.
## Availability + allowlists
- Feature gate: `agent.elevated.enabled` (default can be off via config even if the code supports it).
- Sender allowlist: `agent.elevated.allowFrom` with per-surface allowlists (e.g. `discord`, `whatsapp`).
- Both must pass; otherwise elevated is treated as unavailable.
- Discord fallback: if `agent.elevated.allowFrom.discord` is omitted, the `discord.dm.allowFrom` list is used as a fallback. Set `agent.elevated.allowFrom.discord` (even `[]`) to override.
## Logging + status
- Elevated bash calls are logged at info level.
- Session status includes elevated mode (e.g. `elevated=on`).
+27
View File
@@ -494,6 +494,7 @@ Quick reference (send these in chat):
| `/new` or `/reset` | Reset the session | | `/new` or `/reset` | Reset the session |
| `/think <level>` | Set thinking level (off\|minimal\|low\|medium\|high) | | `/think <level>` | Set thinking level (off\|minimal\|low\|medium\|high) |
| `/verbose on\|off` | Toggle verbose mode | | `/verbose on\|off` | Toggle verbose mode |
| `/elevated on\|off` | Toggle elevated bash mode (approved senders only) |
| `/activation mention\|always` | Group activation (owner-only) | | `/activation mention\|always` | Group activation (owner-only) |
| `/model <name>` | Switch AI model (see below) | | `/model <name>` | Switch AI model (see below) |
| `/queue instant\|batch\|serial` | Message queuing mode | | `/queue instant\|batch\|serial` | Message queuing mode |
@@ -530,6 +531,32 @@ Use `/model` to switch without restarting:
**Tip:** `/model` is processed at the gateway level — it works even if you're rate-limited (429) on the current model! **Tip:** `/model` is processed at the gateway level — it works even if you're rate-limited (429) on the current model!
### Alternative providers (OpenRouter, Z.AI)?
If you don't want to use Anthropic directly, you can use alternative providers:
**OpenRouter** (pay-per-token, many models):
```json5
{
agent: {
model: "openrouter/anthropic/claude-sonnet-4",
env: { OPENROUTER_API_KEY: "sk-or-..." }
}
}
```
**Z.AI** (flat-rate plans, GLM models):
```json5
{
agent: {
model: "zai/glm-4.7",
env: { ZAI_API_KEY: "..." }
}
}
```
**Important:** Always use the latest Claude models (4.5 series). Don't use older 3.x models — they're deprecated and less capable. Check [OpenRouter models](https://openrouter.ai/models?q=claude) for exact IDs.
### Model + thinking mode issues? ### Model + thinking mode issues?
Some models don't support extended thinking well: Some models don't support extended thinking well:
+4
View File
@@ -22,6 +22,10 @@ pnpm clawdis gateway --force
# dev loop (auto-reload on TS changes): # dev loop (auto-reload on TS changes):
pnpm gateway:watch pnpm gateway:watch
``` ```
- Config hot reload watches `~/.clawdis/clawdis.json` (or `CLAWDIS_CONFIG_PATH`).
- Default mode: `gateway.reload.mode="hybrid"` (hot-apply safe changes, restart on critical).
- Hot reload uses in-process restart via **SIGUSR1** when needed.
- Disable with `gateway.reload.mode="off"`.
- Binds WebSocket control plane to `127.0.0.1:<port>` (default 18789). - Binds WebSocket control plane to `127.0.0.1:<port>` (default 18789).
- The same port also serves HTTP (control UI, hooks, A2UI). Single-port multiplex. - The same port also serves HTTP (control UI, hooks, A2UI). Single-port multiplex.
- Starts a Canvas file server by default on `canvasHost.port` (default `18793`), serving `http://<gateway-host>:18793/__clawdis__/canvas/` from `~/clawd/canvas`. Disable with `canvasHost.enabled=false` or `CLAWDIS_SKIP_CANVAS_HOST=1`. - Starts a Canvas file server by default on `canvasHost.port` (default `18793`), serving `http://<gateway-host>:18793/__clawdis__/canvas/` from `~/clawd/canvas`. Disable with `canvasHost.enabled=false` or `CLAWDIS_SKIP_CANVAS_HOST=1`.
+1 -1
View File
@@ -50,7 +50,7 @@ message. If the reply is only `HEARTBEAT_OK`, it is dropped.
- `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`). - `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`).
## Behavior ## Behavior
- Runs in the main session (`session.mainKey`, or `global` when scope is global). - Runs in the main session (`main`, or `global` when scope is global).
- Uses the main lane queue; if requests are in flight, the wake is retried. - Uses the main lane queue; if requests are in flight, the wake is retried.
- Empty output or `HEARTBEAT_OK` is treated as “ok” and does **not** keep the - Empty output or `HEARTBEAT_OK` is treated as “ok” and does **not** keep the
session alive (`updatedAt` is restored). session alive (`updatedAt` is restored).
+1
View File
@@ -24,6 +24,7 @@ CLAWDIS is now **web-only** (Baileys). This document captures the current media
- **Images:** resize & recompress to JPEG (max side 2048px) targeting `agent.mediaMaxMb` (default 5 MB), capped at 6 MB. - **Images:** resize & recompress to JPEG (max side 2048px) targeting `agent.mediaMaxMb` (default 5 MB), capped at 6 MB.
- **Audio/Voice/Video:** pass-through up to 16 MB; audio is sent as a voice note (`ptt: true`). - **Audio/Voice/Video:** pass-through up to 16 MB; audio is sent as a voice note (`ptt: true`).
- **Documents:** anything else, up to 100 MB, with filename preserved when available. - **Documents:** anything else, up to 100 MB, with filename preserved when available.
- WhatsApp GIF-style playback: send an MP4 with `gifPlayback: true` (CLI: `--gif-playback`) so mobile clients loop inline.
- MIME detection prefers magic bytes, then headers, then file extension. - MIME detection prefers magic bytes, then headers, then file extension.
- Caption comes from `--message` or `reply.text`; empty caption is allowed. - Caption comes from `--message` or `reply.text`; empty caption is allowed.
- Logging: non-verbose shows `↩️`/`✅`; verbose includes size and source path/URL. - Logging: non-verbose shows `↩️`/`✅`; verbose includes size and source path/URL.
+95
View File
@@ -0,0 +1,95 @@
---
summary: "Location command for nodes (location.get), permission modes, and background behavior"
read_when:
- Adding location node support or permissions UI
- Designing background location + push flows
---
# Location command (nodes)
## TL;DR
- `location.get` is a node command (via `node.invoke`).
- Off by default.
- Settings use a selector: Off / While Using / Always.
- Separate toggle: Precise Location.
## Why a selector (not just a switch)
OS permissions are multi-level. We can expose a selector in-app, but the OS still decides the actual grant.
- iOS/macOS: user can choose **While Using** or **Always** in system prompts/Settings. App can request upgrade, but OS may require Settings.
- Android: background location is a separate permission; on Android 10+ it often requires a Settings flow.
- Precise location is a separate grant (iOS 14+ “Precise”, Android “fine” vs “coarse”).
Selector in UI drives our requested mode; actual grant lives in OS settings.
## Settings model
Per node device:
- `location.enabledMode`: `off | whileUsing | always`
- `location.preciseEnabled`: bool
UI behavior:
- Selecting `whileUsing` requests foreground permission.
- Selecting `always` first ensures `whileUsing`, then requests background (or sends user to Settings if required).
- If OS denies requested level, revert to the highest granted level and show status.
## Permissions mapping (node.permissions)
Optional. macOS node reports `location` via the permissions map; iOS/Android may omit it.
## Command: `location.get`
Called via `node.invoke`.
Params (suggested):
```json
{
"timeoutMs": 10000,
"maxAgeMs": 15000,
"desiredAccuracy": "coarse|balanced|precise"
}
```
Response payload:
```json
{
"lat": 48.20849,
"lon": 16.37208,
"accuracyMeters": 12.5,
"altitudeMeters": 182.0,
"speedMps": 0.0,
"headingDeg": 270.0,
"timestamp": "2026-01-03T12:34:56.000Z",
"isPrecise": true,
"source": "gps|wifi|cell|unknown"
}
```
Errors (stable codes):
- `LOCATION_DISABLED`: selector is off.
- `LOCATION_PERMISSION_REQUIRED`: permission missing for requested mode.
- `LOCATION_BACKGROUND_UNAVAILABLE`: app is backgrounded but only While Using allowed.
- `LOCATION_TIMEOUT`: no fix in time.
- `LOCATION_UNAVAILABLE`: system failure / no providers.
## Background behavior (future)
Goal: model can request location even when node is backgrounded, but only when:
- User selected **Always**.
- OS grants background location.
- App is allowed to run in background for location (iOS background mode / Android foreground service or special allowance).
Push-triggered flow (future):
1) Gateway sends a push to the node (silent push or FCM data).
2) Node wakes briefly and calls `location.get` internally.
3) Node forwards payload to Gateway.
Notes:
- iOS: Always permission + background location mode required. Silent push may be throttled; expect intermittent failures.
- Android: background location may require a foreground service; otherwise, expect denial.
## Model/tooling integration
- Tool surface: `nodes` tool adds `location_get` action (node required).
- CLI: `clawdis nodes location get --node <id>`.
- Agent guidelines: only call when user enabled location and understands the scope.
## UX copy (suggested)
- Off: “Location sharing is disabled.”
- While Using: “Only when Clawdis is open.”
- Always: “Allow background location. Requires system permission.”
- Precise: “Use precise GPS location. Toggle off to share approximate location.”
+1 -1
View File
@@ -11,7 +11,7 @@ read_when:
- The “Nodes” block in the menu lists **devices** only (gateway bridge nodes via `node.list`), not client/presence entries. - The “Nodes” block in the menu lists **devices** only (gateway bridge nodes via `node.list`), not client/presence entries.
## State model ## State model
- Sessions: events arrive with `runId` (session key). The “main” session is the key `main`; if absent, we fall back to the most recently updated session. - Sessions: events arrive with `runId` (per-run) plus `sessionKey` in the payload. The “main” session is the key `main`; if absent, we fall back to the most recently updated session.
- Priority: main always wins. If main is active, its state is shown immediately. If main is idle, the most recently active non‑main session is shown. We do not flip‑flop mid‑activity; we only switch when the current session goes idle or main becomes active. - Priority: main always wins. If main is active, its state is shown immediately. If main is idle, the most recently active non‑main session is shown. We do not flip‑flop mid‑activity; we only switch when the current session goes idle or main becomes active.
- Activity kinds: - Activity kinds:
- `job`: high‑level command execution (`state: started|streaming|done|error`). - `job`: high‑level command execution (`state: started|streaming|done|error`).
+1 -1
View File
@@ -5,7 +5,7 @@ read_when:
--- ---
# Web Chat (macOS app) # Web Chat (macOS app)
The macOS menu bar app shows the WebChat UI as a native SwiftUI view and reuses the **primary Clawd session** (`main` by default, configurable via `session.mainKey`). The macOS menu bar app shows the WebChat UI as a native SwiftUI view and reuses the **primary Clawd session** (`main`, or `global` when scope is global).
- **Local mode**: connects directly to the local Gateway WebSocket. - **Local mode**: connects directly to the local Gateway WebSocket.
- **Remote mode**: forwards the Gateway WebSocket control port over SSH and uses that as the data plane. - **Remote mode**: forwards the Gateway WebSocket control port over SSH and uses that as the data plane.
+30
View File
@@ -94,6 +94,36 @@ Notes:
- Screen recordings are clamped to `<= 60s`. - Screen recordings are clamped to `<= 60s`.
- `--no-audio` disables microphone capture (supported on iOS/Android; macOS uses system capture audio). - `--no-audio` disables microphone capture (supported on iOS/Android; macOS uses system capture audio).
## Location (nodes)
Nodes expose `location.get` when Location is enabled in settings.
CLI helper:
```bash
clawdis nodes location get --node <idOrNameOrIp>
clawdis nodes location get --node <idOrNameOrIp> --accuracy precise --max-age 15000 --location-timeout 10000
```
Notes:
- Location is **off by default**.
- “Always” requires system permission; background fetch is best-effort.
- The response includes lat/lon, accuracy (meters), and timestamp.
## SMS (Android nodes)
Android nodes can expose `sms.send` when the user grants **SMS** permission and the device supports telephony.
Low-level invoke:
```bash
clawdis nodes invoke --node <idOrNameOrIp> --command sms.send --params '{"to":"+15555550123","message":"Hello from Clawdis"}'
```
Notes:
- The permission prompt must be accepted on the Android device before the capability is advertised.
- Wi-Fi-only devices without telephony will not advertise `sms.send`.
## System commands (mac node) ## System commands (mac node)
The macOS node exposes `system.run` and `system.notify`. The macOS node exposes `system.run` and `system.notify`.
+6
View File
@@ -99,6 +99,12 @@ services:
network_mode: bridge # Limited network network_mode: bridge # Limited network
``` ```
### Per-session sandbox (Clawdis-native)
Clawdis can also run **non-main sessions** inside per-session Docker containers
(`agent.sandbox`). This keeps the gateway on your host while isolating agent
tools in a hard wall container. See `docs/configuration.md` for the full config.
Expose only the services your AI needs: Expose only the services your AI needs:
- ✅ GoWA API (for WhatsApp) - ✅ GoWA API (for WhatsApp)
- ✅ Specific HTTP APIs - ✅ Specific HTTP APIs
+119
View File
@@ -0,0 +1,119 @@
---
summary: "Agent session tools for listing sessions, fetching history, and sending cross-session messages"
read_when:
- Adding or modifying session tools
---
# Session Tools
Goal: small, hard-to-misuse tool surface so agents can list sessions, fetch history, and send to another session.
## Tool Names
- `sessions_list`
- `sessions_history`
- `sessions_send`
## Key Model
- Main direct chat bucket is always the literal key `"main"`.
- Group chats use `surface:group:<id>` or `surface:channel:<id>`.
- Cron jobs use `cron:<job.id>`.
- Hooks use `hook:<uuid>` unless explicitly set.
- Node bridge uses `node-<nodeId>` unless explicitly set.
`global` and `unknown` are internal-only and never listed. If `session.scope = "global"`, we alias it to `main` for all tools so callers never see `global`.
## sessions_list
List sessions as an array of rows.
Parameters:
- `kinds?: string[]` filter: any of `"main" | "group" | "cron" | "hook" | "node" | "other"`
- `limit?: number` max rows (default: server default, clamp e.g. 200)
- `activeMinutes?: number` only sessions updated within N minutes
- `messageLimit?: number` 0 = no messages (default 0); >0 = include last N messages
Behavior:
- `messageLimit > 0` fetches `chat.history` per session and includes the last N messages.
- Tool results are filtered out in list output; use `sessions_history` for tool messages.
Row shape (JSON):
- `key`: session key (string)
- `kind`: `main | group | cron | hook | node | other`
- `provider`: `whatsapp | telegram | discord | signal | imessage | webchat | internal | unknown`
- `displayName` (group display label if available)
- `updatedAt` (ms)
- `sessionId`
- `model`, `contextTokens`, `totalTokens`
- `thinkingLevel`, `verboseLevel`, `systemSent`, `abortedLastRun`
- `sendPolicy` (session override if set)
- `lastChannel`, `lastTo`
- `transcriptPath` (best-effort path derived from store dir + sessionId)
- `messages?` (only when `messageLimit > 0`)
## sessions_history
Fetch transcript for one session.
Parameters:
- `sessionKey` (required)
- `limit?: number` max messages (server clamps)
- `includeTools?: boolean` (default false)
Behavior:
- `includeTools=false` filters `role: "toolResult"` messages.
- Returns messages array in the raw transcript format.
## sessions_send
Send a message into another session.
Parameters:
- `sessionKey` (required)
- `message` (required)
- `timeoutSeconds?: number` (default >0; 0 = fire-and-forget)
Behavior:
- `timeoutSeconds = 0`: enqueue and return `{ runId, status: "accepted" }`.
- `timeoutSeconds > 0`: wait up to N seconds for completion, then return `{ runId, status: "ok", reply }`.
- If wait times out: `{ runId, status: "timeout", error }`. Run continues; call `sessions_history` later.
- If the run fails: `{ runId, status: "error", error }`.
- Waits via gateway `agent.wait` (server-side) so reconnects don't drop the wait.
- Agent-to-agent message context is injected for the primary run.
- After the primary run completes, Clawdis runs a **reply-back loop**:
- Round 2+ alternates between requester and target agents.
- Reply exactly `REPLY_SKIP` to stop the ping‑pong.
- Max turns is `session.agentToAgent.maxPingPongTurns` (0–5, default 5).
- Once the loop ends, Clawdis runs the **agent‑to‑agent announce step** (target agent only):
- Reply exactly `ANNOUNCE_SKIP` to stay silent.
- Any other reply is sent to the target channel.
- Announce step includes the original request + round‑1 reply + latest ping‑pong reply.
## Provider Field
- For groups, `provider` is the `surface` recorded on the session entry.
- For direct chats, `provider` maps from `lastChannel`.
- For cron/hook/node, `provider` is `internal`.
- If missing, `provider` is `unknown`.
## Security / Send Policy
Policy-based blocking by surface/chat type (not per session id).
```json
{
"session": {
"sendPolicy": {
"rules": [
{
"match": { "surface": "discord", "chatType": "group" },
"action": "deny"
}
],
"default": "allow"
}
}
}
```
Runtime override (per session entry):
- `sendPolicy: "allow" | "deny"` (unset = inherit config)
- Settable via `sessions.patch` or owner-only `/send on|off|inherit`.
Enforcement points:
- `chat.send` / `agent` (gateway)
- auto-reply delivery logic
+28 -2
View File
@@ -5,7 +5,7 @@ read_when:
--- ---
# Session Management # Session Management
Clawdis treats **one session as primary**. By default the canonical key is `main` for every direct chat; no configuration is required. You can rename it via `session.mainKey` if you really want, but there is still only a single primary session. Older/local sessions can stay on disk, but only the primary key is used for desktop/web chat and direct agent calls. Clawdis treats **one session as primary**. The canonical key is fixed to `main` for direct chats (or `global` when scope is global); no configuration is required. `session.mainKey` is ignored. Older/local sessions can stay on disk, but only the primary key is used for desktop/web chat and direct agent calls.
## Gateway is the source of truth ## Gateway is the source of truth
All session state is **owned by the gateway** (the “master” Clawdis). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files. All session state is **owned by the gateway** (the “master” Clawdis). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files.
@@ -26,12 +26,38 @@ All session state is **owned by the gateway** (the “master” Clawdis). UI cli
- Multiple phone numbers can map to that same key; they act as transports into the same conversation. - Multiple phone numbers can map to that same key; they act as transports into the same conversation.
- Group chats isolate state with `surface:group:<id>` keys (rooms/channels use `surface:channel:<id>`); do not reuse the primary key for groups. (Discord display names show `discord:<guildSlug>#<channelSlug>`.) - Group chats isolate state with `surface:group:<id>` keys (rooms/channels use `surface:channel:<id>`); do not reuse the primary key for groups. (Discord display names show `discord:<guildSlug>#<channelSlug>`.)
- Legacy `group:<surface>:<id>` and `group:<id>` keys are still recognized. - Legacy `group:<surface>:<id>` and `group:<id>` keys are still recognized.
- Other sources:
- Cron jobs: `cron:<job.id>`
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
- Node bridge runs: `node-<nodeId>`
## Lifecyle ## Lifecyle
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message. - Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdis runs a short “hello” greeting turn to confirm the reset. - Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdis runs a short “hello” greeting turn to confirm the reset.
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them. - Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
## Send policy (optional)
Block delivery for specific session types without listing individual ids.
```json5
{
session: {
sendPolicy: {
rules: [
{ action: "deny", match: { surface: "discord", chatType: "group" } },
{ action: "deny", match: { keyPrefix: "cron:" } }
],
default: "allow"
}
}
}
```
Runtime override (owner only):
- `/send on` → allow for this session
- `/send off` → deny for this session
- `/send inherit` → clear override and use config rules
## Configuration (optional rename example) ## Configuration (optional rename example)
```json5 ```json5
// ~/.clawdis/clawdis.json // ~/.clawdis/clawdis.json
@@ -41,7 +67,7 @@ All session state is **owned by the gateway** (the “master” Clawdis). UI cli
idleMinutes: 120, idleMinutes: 120,
resetTriggers: ["/new", "/reset"], resetTriggers: ["/new", "/reset"],
store: "~/.clawdis/sessions/sessions.json", store: "~/.clawdis/sessions/sessions.json",
mainKey: "main" // optional rename; still a single primary // mainKey is ignored; primary key is fixed to "main"
} }
} }
``` ```
+7
View File
@@ -73,6 +73,13 @@ You have access to your human's stuff. That doesn't mean you *share* their stuff
Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (camera names, SSH details, voice preferences) in `TOOLS.md`. Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (camera names, SSH details, voice preferences) in `TOOLS.md`.
**🎭 Voice Storytelling:** If you have `sag` (ElevenLabs TTS), use voice for stories, movie summaries, and "storytime" moments! Way more engaging than walls of text. Surprise people with funny voices.
**📝 Platform Formatting:**
- **Discord/WhatsApp:** No markdown tables! Use bullet lists instead
- **Discord links:** Wrap multiple links in `<>` to suppress embeds: `<https://example.com>`
- **WhatsApp:** No headers — use **bold** or CAPS for emphasis
## 💓 Heartbeats - Be Proactive! ## 💓 Heartbeats - Be Proactive!
When you receive a `HEARTBEAT` message, don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively! When you receive a `HEARTBEAT` message, don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
+5 -2
View File
@@ -1,7 +1,7 @@
--- ---
summary: "Directive syntax for /think levels and how they affect model reasoning" summary: "Directive syntax for /think + /verbose and how they affect model reasoning"
read_when: read_when:
- Adjusting thinking level parsing or defaults - Adjusting thinking or verbose directive parsing or defaults
--- ---
# Thinking Levels (/think directives) # Thinking Levels (/think directives)
@@ -34,6 +34,9 @@ read_when:
- Inline directive affects only that message; session/global defaults apply otherwise. - Inline directive affects only that message; session/global defaults apply otherwise.
- When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool result back as its own metadata-only message, prefixed with `<emoji> <tool-name>: <arg>` when available (path/command); the tool output itself is not forwarded. These tool summaries are sent as soon as each tool finishes (separate bubbles), not as streaming deltas. If you toggle `/verbose on|off` while a run is in-flight, subsequent tool bubbles honor the new setting. - When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool result back as its own metadata-only message, prefixed with `<emoji> <tool-name>: <arg>` when available (path/command); the tool output itself is not forwarded. These tool summaries are sent as soon as each tool finishes (separate bubbles), not as streaming deltas. If you toggle `/verbose on|off` while a run is in-flight, subsequent tool bubbles honor the new setting.
## Related
- Elevated mode docs live in `docs/elevated.md`.
## Heartbeats ## Heartbeats
- Heartbeat probe body is `HEARTBEAT`. Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats). - Heartbeat probe body is `HEARTBEAT`. Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
+34 -2
View File
@@ -18,10 +18,11 @@ Run shell commands in the workspace.
Core parameters: Core parameters:
- `command` (required) - `command` (required)
- `yieldMs` (auto-background after timeout, default 20000) - `yieldMs` (auto-background after timeout, default 10000)
- `background` (immediate background) - `background` (immediate background)
- `timeout` (seconds; kills the process if exceeded, default 1800) - `timeout` (seconds; kills the process if exceeded, default 1800)
- `stdinMode` (`pipe` | `pty`; `pty` uses node-pty for a real TTY with fallback warning) - `elevated` (bool; run on host if elevated mode is enabled/allowed)
- Need a real TTY? Use the tmux skill.
Notes: Notes:
- Returns `status: "running"` with a `sessionId` when backgrounded. - Returns `status: "running"` with a `sessionId` when backgrounded.
@@ -47,9 +48,23 @@ Core actions:
- `act` (UI actions: click/type/press/hover/drag/select/fill/resize/wait/evaluate) - `act` (UI actions: click/type/press/hover/drag/select/fill/resize/wait/evaluate)
- `navigate`, `console`, `pdf`, `upload`, `dialog` - `navigate`, `console`, `pdf`, `upload`, `dialog`
Profile management:
- `profiles` — list all browser profiles with status
- `create-profile` — create new profile with auto-allocated port (or `cdpUrl`)
- `delete-profile` — stop browser, delete user data, remove from config (local only)
- `reset-profile` — kill orphan process on profile's port (local only)
Common parameters:
- `controlUrl` (defaults from config)
- `profile` (optional; defaults to `browser.defaultProfile`)
Notes: Notes:
- Requires `browser.enabled=true` in `~/.clawdis/clawdis.json`. - Requires `browser.enabled=true` in `~/.clawdis/clawdis.json`.
- Uses `browser.controlUrl` unless `controlUrl` is passed explicitly. - Uses `browser.controlUrl` unless `controlUrl` is passed explicitly.
- All actions accept optional `profile` parameter for multi-instance support.
- When `profile` is omitted, uses `browser.defaultProfile` (defaults to "clawd").
- Profile names: lowercase alphanumeric + hyphens only (max 64 chars).
- Port range: 18800-18899 (~100 profiles max).
- Remote profiles are attach-only (no start/stop/reset).
- `snapshot` defaults to `ai`; use `aria` for the accessibility tree. - `snapshot` defaults to `ai`; use `aria` for the accessibility tree.
- `act` requires `ref` from `snapshot --format ai`; use `evaluate` for rare CSS selector needs. - `act` requires `ref` from `snapshot --format ai`; use `evaluate` for rare CSS selector needs.
- Avoid `act` → `wait` by default; use it only in exceptional cases (no reliable UI state to wait on). - Avoid `act` → `wait` by default; use it only in exceptional cases (no reliable UI state to wait on).
@@ -78,11 +93,13 @@ Core actions:
- `pending`, `approve`, `reject` (pairing) - `pending`, `approve`, `reject` (pairing)
- `notify` (macOS `system.notify`) - `notify` (macOS `system.notify`)
- `camera_snap`, `camera_clip`, `screen_record` - `camera_snap`, `camera_clip`, `screen_record`
- `location_get`
Notes: Notes:
- Camera/screen commands require the node app to be foregrounded. - Camera/screen commands require the node app to be foregrounded.
- Images return image blocks + `MEDIA:<path>`. - Images return image blocks + `MEDIA:<path>`.
- Videos return `FILE:<path>` (mp4). - Videos return `FILE:<path>` (mp4).
- Location returns a JSON payload (lat/lon/accuracy/timestamp).
### `cron` ### `cron`
Manage Gateway cron jobs and wakeups. Manage Gateway cron jobs and wakeups.
@@ -105,6 +122,21 @@ Core actions:
Notes: Notes:
- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply. - Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply.
### `sessions_list` / `sessions_history` / `sessions_send`
List sessions, inspect transcript history, or send to another session.
Core parameters:
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
- `sessions_history`: `sessionKey`, `limit?`, `includeTools?`
- `sessions_send`: `sessionKey`, `message`, `timeoutSeconds?` (0 = fire-and-forget)
Notes:
- `main` is the canonical direct-chat key; global/unknown are hidden.
- `messageLimit > 0` fetches last N messages per session (tool messages filtered).
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
- `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5).
- After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
### `discord` ### `discord`
Send Discord reactions, stickers, or polls. Send Discord reactions, stickers, or polls.
+2 -1
View File
@@ -28,7 +28,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
- `--url <url>`: Gateway WebSocket URL (defaults to config `gateway.remote.url` or `ws://127.0.0.1:18789`). - `--url <url>`: Gateway WebSocket URL (defaults to config `gateway.remote.url` or `ws://127.0.0.1:18789`).
- `--token <token>`: Gateway token (if required). - `--token <token>`: Gateway token (if required).
- `--password <password>`: Gateway password (if required). - `--password <password>`: Gateway password (if required).
- `--session <key>`: Session key (default: `session.mainKey` or `main`). - `--session <key>`: Session key (default: `main`, or `global` when scope is global).
- `--deliver`: Deliver assistant replies to the provider (default off). - `--deliver`: Deliver assistant replies to the provider (default off).
- `--thinking <level>`: Override thinking level for sends. - `--thinking <level>`: Override thinking level for sends.
- `--timeout-ms <ms>`: Agent timeout in ms (default 30000). - `--timeout-ms <ms>`: Agent timeout in ms (default 30000).
@@ -51,6 +51,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
- `/model <provider/model>` (or `/models`) - `/model <provider/model>` (or `/models`)
- `/think <off|minimal|low|medium|high>` - `/think <off|minimal|low|medium|high>`
- `/verbose <on|off>` - `/verbose <on|off>`
- `/elevated <on|off>`
- `/activation <mention|always>` - `/activation <mention|always>`
- `/deliver <on|off>` - `/deliver <on|off>`
- `/new` or `/reset` - `/new` or `/reset`
+4 -1
View File
@@ -92,6 +92,9 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
- Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`. - Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`.
- Caption only on first media item. - Caption only on first media item.
- Media fetch supports HTTP(S) and local paths. - Media fetch supports HTTP(S) and local paths.
- Animated GIFs: WhatsApp expects MP4 with `gifPlayback: true` for inline looping.
- CLI: `clawdis send --media <mp4> --gif-playback`
- Gateway: `send` params include `gifPlayback: true`
## Media limits + optimization ## Media limits + optimization
- Default cap: 5 MB (per media item). - Default cap: 5 MB (per media item).
@@ -123,7 +126,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
- `agent.heartbeat.model` (optional override) - `agent.heartbeat.model` (optional override)
- `agent.heartbeat.target` - `agent.heartbeat.target`
- `agent.heartbeat.to` - `agent.heartbeat.to`
- `session.*` (scope, idle, store, mainKey) - `session.*` (scope, idle, store; `mainKey` is ignored)
- `web.enabled` (disable provider startup when false) - `web.enabled` (disable provider startup when false)
- `web.heartbeatSeconds` - `web.heartbeatSeconds`
- `web.reconnect.*` - `web.reconnect.*`
+2 -1
View File
@@ -38,6 +38,8 @@
"scripts": { "scripts": {
"dev": "tsx src/index.ts", "dev": "tsx src/index.ts",
"docs:list": "tsx scripts/docs-list.ts", "docs:list": "tsx scripts/docs-list.ts",
"docs:dev": "cd docs && mint dev",
"docs:build": "cd docs && mint broken-links",
"build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts", "build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts",
"release:check": "tsx scripts/release-check.ts", "release:check": "tsx scripts/release-check.ts",
"ui:install": "pnpm -C ui install", "ui:install": "pnpm -C ui install",
@@ -95,7 +97,6 @@
"grammy": "^1.39.2", "grammy": "^1.39.2",
"json5": "^2.2.3", "json5": "^2.2.3",
"long": "5.3.2", "long": "5.3.2",
"node-pty": "^1.1.0",
"playwright-core": "1.57.0", "playwright-core": "1.57.0",
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
-10
View File
@@ -94,9 +94,6 @@ importers:
long: long:
specifier: 5.3.2 specifier: 5.3.2
version: 5.3.2 version: 5.3.2
node-pty:
specifier: ^1.1.0
version: 1.1.0
playwright-core: playwright-core:
specifier: 1.57.0 specifier: 1.57.0
version: 1.57.0 version: 1.57.0
@@ -2188,9 +2185,6 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
node-pty@1.1.0:
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
node-wav@0.0.2: node-wav@0.0.2:
resolution: {integrity: sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==} resolution: {integrity: sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==}
engines: {node: '>=4.4.0'} engines: {node: '>=4.4.0'}
@@ -4834,10 +4828,6 @@ snapshots:
fetch-blob: 3.2.0 fetch-blob: 3.2.0
formdata-polyfill: 4.0.10 formdata-polyfill: 4.0.10
node-pty@1.1.0:
dependencies:
node-addon-api: 7.1.1
node-wav@0.0.2: node-wav@0.0.2:
optional: true optional: true
+1 -1
View File
@@ -7,7 +7,7 @@ PRODUCT="Clawdis"
BIN="$BUILD_PATH/debug/$PRODUCT" BIN="$BUILD_PATH/debug/$PRODUCT"
printf "\n▶️ Building $PRODUCT (debug, build path: $BUILD_PATH)\n" printf "\n▶️ Building $PRODUCT (debug, build path: $BUILD_PATH)\n"
swift build -c debug --product "$PRODUCT" --product "${PRODUCT}CLI" --build-path "$BUILD_PATH" swift build -c debug --product "$PRODUCT" --build-path "$BUILD_PATH"
printf "\n⏹ Stopping existing $PRODUCT...\n" printf "\n⏹ Stopping existing $PRODUCT...\n"
killall -q "$PRODUCT" 2>/dev/null || true killall -q "$PRODUCT" 2>/dev/null || true
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
export DISPLAY=:1
export HOME=/tmp/clawdis-home
export XDG_CONFIG_HOME="${HOME}/.config"
export XDG_CACHE_HOME="${HOME}/.cache"
CDP_PORT="${CLAWDIS_BROWSER_CDP_PORT:-9222}"
VNC_PORT="${CLAWDIS_BROWSER_VNC_PORT:-5900}"
NOVNC_PORT="${CLAWDIS_BROWSER_NOVNC_PORT:-6080}"
ENABLE_NOVNC="${CLAWDIS_BROWSER_ENABLE_NOVNC:-1}"
HEADLESS="${CLAWDIS_BROWSER_HEADLESS:-0}"
mkdir -p "${HOME}" "${HOME}/.chrome" "${XDG_CONFIG_HOME}" "${XDG_CACHE_HOME}"
Xvfb :1 -screen 0 1280x800x24 -ac -nolisten tcp &
if [[ "${HEADLESS}" == "1" ]]; then
CHROME_ARGS=(
"--headless=new"
"--disable-gpu"
)
else
CHROME_ARGS=()
fi
CHROME_ARGS+=(
"--remote-debugging-address=0.0.0.0"
"--remote-debugging-port=${CDP_PORT}"
"--user-data-dir=${HOME}/.chrome"
"--no-first-run"
"--no-default-browser-check"
"--disable-dev-shm-usage"
"--disable-background-networking"
"--disable-features=TranslateUI"
"--disable-breakpad"
"--disable-crash-reporter"
"--metrics-recording-only"
"--no-sandbox"
)
chromium "${CHROME_ARGS[@]}" about:blank &
if [[ "${ENABLE_NOVNC}" == "1" && "${HEADLESS}" != "1" ]]; then
x11vnc -display :1 -rfbport "${VNC_PORT}" -shared -forever -nopw -localhost &
websockify --web /usr/share/novnc/ "${NOVNC_PORT}" "localhost:${VNC_PORT}" &
fi
wait -n
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE_NAME="clawdis-sandbox-browser:bookworm-slim"
docker build -t "${IMAGE_NAME}" -f Dockerfile.sandbox-browser .
echo "Built ${IMAGE_NAME}"
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_IMAGE="${BASE_IMAGE:-clawdis-sandbox:bookworm-slim}"
TARGET_IMAGE="${TARGET_IMAGE:-clawdis-sandbox-common:bookworm-slim}"
PACKAGES="${PACKAGES:-curl wget jq coreutils grep nodejs npm python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file}"
INSTALL_PNPM="${INSTALL_PNPM:-1}"
INSTALL_BUN="${INSTALL_BUN:-1}"
BUN_INSTALL_DIR="${BUN_INSTALL_DIR:-/opt/bun}"
INSTALL_BREW="${INSTALL_BREW:-1}"
BREW_INSTALL_DIR="${BREW_INSTALL_DIR:-/home/linuxbrew/.linuxbrew}"
if ! docker image inspect "${BASE_IMAGE}" >/dev/null 2>&1; then
echo "Base image missing: ${BASE_IMAGE}"
echo "Building base image via scripts/sandbox-setup.sh..."
scripts/sandbox-setup.sh
fi
echo "Building ${TARGET_IMAGE} with: ${PACKAGES}"
docker build \
-t "${TARGET_IMAGE}" \
--build-arg INSTALL_PNPM="${INSTALL_PNPM}" \
--build-arg INSTALL_BUN="${INSTALL_BUN}" \
--build-arg BUN_INSTALL_DIR="${BUN_INSTALL_DIR}" \
--build-arg INSTALL_BREW="${INSTALL_BREW}" \
--build-arg BREW_INSTALL_DIR="${BREW_INSTALL_DIR}" \
- <<EOF
FROM ${BASE_IMAGE}
ENV DEBIAN_FRONTEND=noninteractive
ARG INSTALL_PNPM=1
ARG INSTALL_BUN=1
ARG BUN_INSTALL_DIR=/opt/bun
ARG INSTALL_BREW=1
ARG BREW_INSTALL_DIR=/home/linuxbrew/.linuxbrew
ENV BUN_INSTALL=\${BUN_INSTALL_DIR}
ENV HOMEBREW_PREFIX="\${BREW_INSTALL_DIR}"
ENV HOMEBREW_CELLAR="\${BREW_INSTALL_DIR}/Cellar"
ENV HOMEBREW_REPOSITORY="\${BREW_INSTALL_DIR}/Homebrew"
ENV PATH="\${BUN_INSTALL_DIR}/bin:\${BREW_INSTALL_DIR}/bin:\${BREW_INSTALL_DIR}/sbin:\${PATH}"
RUN apt-get update \\
&& apt-get install -y --no-install-recommends ${PACKAGES} \\
&& rm -rf /var/lib/apt/lists/*
RUN if [ "\${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi
RUN if [ "\${INSTALL_BUN}" = "1" ]; then \\
curl -fsSL https://bun.sh/install | bash; \\
ln -sf "\${BUN_INSTALL_DIR}/bin/bun" /usr/local/bin/bun; \\
fi
RUN if [ "\${INSTALL_BREW}" = "1" ]; then \\
if ! id -u linuxbrew >/dev/null 2>&1; then useradd -m -s /bin/bash linuxbrew; fi; \\
mkdir -p "\${BREW_INSTALL_DIR}"; \\
chown -R linuxbrew:linuxbrew "\$(dirname "\${BREW_INSTALL_DIR}")"; \\
su - linuxbrew -c "NONINTERACTIVE=1 CI=1 /bin/bash -c '\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)'"; \\
if [ ! -e "\${BREW_INSTALL_DIR}/Library" ]; then ln -s "\${BREW_INSTALL_DIR}/Homebrew/Library" "\${BREW_INSTALL_DIR}/Library"; fi; \\
if [ ! -x "\${BREW_INSTALL_DIR}/bin/brew" ]; then echo "brew install failed"; exit 1; fi; \\
ln -sf "\${BREW_INSTALL_DIR}/bin/brew" /usr/local/bin/brew; \\
fi
EOF
cat <<NOTE
Built ${TARGET_IMAGE}.
To use it, set agent.sandbox.docker.image to "${TARGET_IMAGE}" and restart.
If you want a clean re-create, remove old sandbox containers:
docker rm -f \$(docker ps -aq --filter label=clawdis.sandbox=1)
NOTE
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE_NAME="clawdis-sandbox:bookworm-slim"
docker build -t "${IMAGE_NAME}" -f Dockerfile.sandbox .
echo "Built ${IMAGE_NAME}"
+48 -24
View File
@@ -1,43 +1,67 @@
--- ---
name: apple-reminders name: apple-reminders
description: Manage Apple Reminders via the `memo` CLI on macOS (create, complete, and delete reminders). Use when a user asks Clawdis to add a reminder, mark reminders as done, or manage their reminder list. description: Manage Apple Reminders via the `remindctl` CLI on macOS (list, add, edit, complete, delete). Supports lists, date filters, and JSON/plain output.
homepage: https://github.com/antoniorodr/memo homepage: https://github.com/steipete/remindctl
metadata: {"clawdis":{"emoji":"⏰","os":["darwin"],"requires":{"bins":["memo"]},"install":[{"id":"brew","kind":"brew","formula":"antoniorodr/memo/memo","bins":["memo"],"label":"Install memo via Homebrew"}]}} metadata: {"clawdis":{"emoji":"⏰","os":["darwin"],"requires":{"bins":["remindctl"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/remindctl","bins":["remindctl"],"label":"Install remindctl via Homebrew"}]}}
--- ---
# Apple Reminders CLI # Apple Reminders CLI (remindctl)
Use `memo rem` to manage Apple Reminders directly from the terminal. Create, complete, and delete reminders with simple commands. Use `remindctl` to manage Apple Reminders directly from the terminal. It supports list filtering, date-based views, and scripting output.
Setup Setup
- Install (Homebrew): `brew tap antoniorodr/memo && brew install antoniorodr/memo/memo` - Install (Homebrew): `brew install steipete/tap/remindctl`
- Manual (pip): `pip install .` (after cloning the repo) - From source: `pnpm install && pnpm build` (binary at `./bin/remindctl`)
- macOS-only; if prompted, grant Automation access to Reminders.app. - macOS-only; grant Reminders permission when prompted.
Permissions
- Check status: `remindctl status`
- Request access: `remindctl authorize`
View Reminders View Reminders
- List all reminders: `memo rem` - Default (today): `remindctl`
- Today: `remindctl today`
- Tomorrow: `remindctl tomorrow`
- Week: `remindctl week`
- Overdue: `remindctl overdue`
- Upcoming: `remindctl upcoming`
- Completed: `remindctl completed`
- All: `remindctl all`
- Specific date: `remindctl 2026-01-04`
Manage Lists
- List all lists: `remindctl list`
- Show list: `remindctl list Work`
- Create list: `remindctl list Projects --create`
- Rename list: `remindctl list Work --rename Office`
- Delete list: `remindctl list Work --delete`
Create Reminders Create Reminders
- Add a new reminder: `memo rem -a` - Quick add: `remindctl add "Buy milk"`
- Opens interactive prompt to create a reminder. - With list + due: `remindctl add --title "Call mom" --list Personal --due tomorrow`
- Quick add: `memo rem -a "Buy groceries"`
Edit Reminders
- Edit title/due: `remindctl edit 1 --title "New title" --due 2026-01-04`
Complete Reminders Complete Reminders
- Mark reminder as done: `memo rem -c` - Complete by id: `remindctl complete 1 2 3`
- Interactive selection of reminder to complete.
Delete Reminders Delete Reminders
- Delete a reminder: `memo rem -d` - Delete by id: `remindctl delete 4A83 --force`
- Interactive selection of reminder to delete.
Examples Output Formats
- Add a quick reminder: `memo rem -a "Call dentist tomorrow"` - JSON (scripting): `remindctl today --json`
- List all reminders: `memo rem` - Plain TSV: `remindctl today --plain`
- Complete a reminder: `memo rem -c` (then select from list) - Counts only: `remindctl today --quiet`
- Delete a reminder: `memo rem -d` (then select from list)
Date Formats
Accepted by `--due` and date filters:
- `today`, `tomorrow`, `yesterday`
- `YYYY-MM-DD`
- `YYYY-MM-DD HH:mm`
- ISO 8601 (`2026-01-04T12:34:56Z`)
Notes Notes
- macOS-only. - macOS-only.
- Requires Apple Reminders.app to be accessible. - If access is denied, enable Terminal/remindctl in System Settings → Privacy & Security → Reminders.
- For automation, grant permissions in System Settings > Privacy & Security > Automation. - If running over SSH, grant access on the Mac that runs the command.
- The `memo` CLI shares the same installation for both Notes and Reminders functionality.
+53
View File
@@ -0,0 +1,53 @@
---
name: clawdhub
description: Use the ClawdHub CLI to search, install, update, and publish agent skills from clawdhub.com. Use when you need to fetch new skills on the fly, sync installed skills to latest or a specific version, or publish new/updated skill folders with the npm-installed clawdhub CLI.
metadata: {"clawdis":{"requires":{"bins":["clawdhub"]},"install":[{"id":"node","kind":"node","package":"clawdhub","bins":["clawdhub"],"label":"Install ClawdHub CLI (npm)"}]}}
---
# ClawdHub CLI
Install
```bash
npm i -g clawdhub
```
Auth (publish)
```bash
clawdhub login
clawdhub whoami
```
Search
```bash
clawdhub search "postgres backups"
```
Install
```bash
clawdhub install my-skill
clawdhub install my-skill --version 1.2.3
```
Update (hash-based match + upgrade)
```bash
clawdhub update my-skill
clawdhub update my-skill --version 1.2.3
clawdhub update --all
clawdhub update my-skill --force
clawdhub update --all --no-input --force
```
List
```bash
clawdhub list
```
Publish
```bash
clawdhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.2.0 --changelog "Fixes + docs"
```
Notes
- Default registry: https://clawdhub.com (override with CLAWDHUB_REGISTRY or --registry)
- Default workdir: cwd; install dir: ./skills (override with --workdir / --dir)
- Update command hashes local files, resolves matching version, and upgrades to latest unless --version is set
+52 -1
View File
@@ -41,9 +41,15 @@ process action:kill sessionId:XXX
**Model:** `gpt-5.2-codex` is the default (set in ~/.codex/config.toml) **Model:** `gpt-5.2-codex` is the default (set in ~/.codex/config.toml)
### Building/Creating (use --full-auto) ### Building/Creating (use --full-auto or --yolo)
```bash ```bash
# --full-auto: sandboxed but auto-approves in workspace
bash workdir:~/project background:true command:"codex exec --full-auto \"Build a snake game with dark theme\"" bash workdir:~/project background:true command:"codex exec --full-auto \"Build a snake game with dark theme\""
# --yolo: NO sandbox, NO approvals (fastest, most dangerous)
bash workdir:~/project background:true command:"codex --yolo \"Build a snake game with dark theme\""
# Note: --yolo is a shortcut for --dangerously-bypass-approvals-and-sandbox
``` ```
### Reviewing PRs (vanilla, no flags) ### Reviewing PRs (vanilla, no flags)
@@ -144,6 +150,51 @@ Use the tmux skill for interactive coding sessions (always, except very simple o
--- ---
## Parallel Issue Fixing with git worktrees + tmux
For fixing multiple issues in parallel, use git worktrees (isolated branches) + tmux sessions:
```bash
# 1. Clone repo to temp location
cd /tmp && git clone git@github.com:user/repo.git repo-worktrees
cd repo-worktrees
# 2. Create worktrees for each issue (isolated branches!)
git worktree add -b fix/issue-78 /tmp/issue-78 main
git worktree add -b fix/issue-99 /tmp/issue-99 main
# 3. Set up tmux sessions
SOCKET="${TMPDIR:-/tmp}/codex-fixes.sock"
tmux -S "$SOCKET" new-session -d -s fix-78
tmux -S "$SOCKET" new-session -d -s fix-99
# 4. Launch Codex in each (after pnpm install!)
tmux -S "$SOCKET" send-keys -t fix-78 "cd /tmp/issue-78 && pnpm install && codex --yolo 'Fix issue #78: <description>. Commit and push.'" Enter
tmux -S "$SOCKET" send-keys -t fix-99 "cd /tmp/issue-99 && pnpm install && codex --yolo 'Fix issue #99: <description>. Commit and push.'" Enter
# 5. Monitor progress
tmux -S "$SOCKET" capture-pane -p -t fix-78 -S -30
tmux -S "$SOCKET" capture-pane -p -t fix-99 -S -30
# 6. Check if done (prompt returned)
tmux -S "$SOCKET" capture-pane -p -t fix-78 -S -3 | grep -q "❯" && echo "Done!"
# 7. Create PRs after fixes
cd /tmp/issue-78 && git push -u origin fix/issue-78
gh pr create --repo user/repo --head fix/issue-78 --title "fix: ..." --body "..."
# 8. Cleanup
tmux -S "$SOCKET" kill-server
git worktree remove /tmp/issue-78
git worktree remove /tmp/issue-99
```
**Why worktrees?** Each Codex works in isolated branch, no conflicts. Can run 5+ parallel fixes!
**Why tmux over bash background?** Codex is interactive — needs TTY for proper output. tmux provides persistent sessions with full history capture.
---
## ⚠️ Rules ## ⚠️ Rules
1. **Respect tool choice** — if user asks for Codex, use Codex. NEVER offer to build it yourself! 1. **Respect tool choice** — if user asks for Codex, use Codex. NEVER offer to build it yourself!
+36 -1
View File
@@ -1,6 +1,6 @@
--- ---
name: discord name: discord
description: Use when you need to control Discord from Clawdis via the discord tool: send messages, react, post stickers, run polls, manage threads/pins/search, fetch permissions or member/role/channel info, or handle moderation actions in Discord DMs or channels. description: Use when you need to control Discord from Clawdis via the discord tool: send messages, react, post or upload stickers, upload emojis, run polls, manage threads/pins/search, fetch permissions or member/role/channel info, or handle moderation actions in Discord DMs or channels.
--- ---
# Discord Actions # Discord Actions
@@ -15,6 +15,8 @@ Use `discord` to manage messages, reactions, threads, polls, and moderation. You
- For stickers/polls/sendMessage: a `to` target (`channel:<id>` or `user:<id>`). Optional `content` text. - For stickers/polls/sendMessage: a `to` target (`channel:<id>` or `user:<id>`). Optional `content` text.
- Polls also need a `question` plus 2–10 `answers`. - Polls also need a `question` plus 2–10 `answers`.
- For media: `mediaUrl` with `file:///path` for local files or `https://...` for remote. - For media: `mediaUrl` with `file:///path` for local files or `https://...` for remote.
- For emoji uploads: `guildId`, `name`, `mediaUrl`, optional `roleIds` (limit 256KB, PNG/JPG/GIF).
- For sticker uploads: `guildId`, `name`, `description`, `tags`, `mediaUrl` (limit 512KB, PNG/APNG/Lottie JSON).
Message context lines include `discord message id` and `channel` fields you can reuse directly. Message context lines include `discord message id` and `channel` fields you can reuse directly.
@@ -58,6 +60,37 @@ Message context lines include `discord message id` and `channel` fields you can
- Up to 3 sticker IDs per message. - Up to 3 sticker IDs per message.
- `to` can be `user:<id>` for DMs. - `to` can be `user:<id>` for DMs.
### Upload a custom emoji
```json
{
"action": "emojiUpload",
"guildId": "999",
"name": "party_blob",
"mediaUrl": "file:///tmp/party.png",
"roleIds": ["222"]
}
```
- Emoji images must be PNG/JPG/GIF and <= 256KB.
- `roleIds` is optional; omit to make the emoji available to everyone.
### Upload a sticker
```json
{
"action": "stickerUpload",
"guildId": "999",
"name": "clawdis_wave",
"description": "Clawdis waving hello",
"tags": "👋",
"mediaUrl": "file:///tmp/wave.png"
}
```
- Stickers require `name`, `description`, and `tags`.
- Uploads must be PNG/APNG/Lottie JSON and <= 512KB.
### Create a poll ### Create a poll
```json ```json
@@ -88,6 +121,7 @@ Message context lines include `discord message id` and `channel` fields you can
- React with ✅/⚠️ to mark status updates. - React with ✅/⚠️ to mark status updates.
- Post a quick poll for release decisions or meeting times. - Post a quick poll for release decisions or meeting times.
- Send celebratory stickers after successful deploys. - Send celebratory stickers after successful deploys.
- Upload new emojis/stickers for release moments.
- Run weekly “priority check” polls in team channels. - Run weekly “priority check” polls in team channels.
- DM stickers as acknowledgements when a user’s request is completed. - DM stickers as acknowledgements when a user’s request is completed.
@@ -96,6 +130,7 @@ Message context lines include `discord message id` and `channel` fields you can
Use `discord.actions.*` to disable action groups: Use `discord.actions.*` to disable action groups:
- `reactions` (react + reactions list + emojiList) - `reactions` (react + reactions list + emojiList)
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search` - `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
- `emojiUploads`, `stickerUploads`
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events` - `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
- `roles` (role add/remove, default `false`) - `roles` (role add/remove, default `false`)
- `moderation` (timeout/kick/ban, default `false`) - `moderation` (timeout/kick/ban, default `false`)
+156
View File
@@ -0,0 +1,156 @@
---
name: notion
description: Notion API for creating and managing pages, databases, and blocks.
homepage: https://developers.notion.com
metadata: {"clawdis":{"emoji":"📝"}}
---
# notion
Use the Notion API to create/read/update pages, data sources (databases), and blocks.
## Setup
1. Create an integration at https://notion.so/my-integrations
2. Copy the API key (starts with `ntn_` or `secret_`)
3. Store it:
```bash
mkdir -p ~/.config/notion
echo "ntn_your_key_here" > ~/.config/notion/api_key
```
4. Share target pages/databases with your integration (click "..." → "Connect to" → your integration name)
## API Basics
All requests need:
```bash
NOTION_KEY=$(cat ~/.config/notion/api_key)
curl -X GET "https://api.notion.com/v1/..." \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json"
```
> **Note:** The `Notion-Version` header is required. This skill uses `2025-09-03` (latest). In this version, databases are called "data sources" in the API.
## Common Operations
**Search for pages and data sources:**
```bash
curl -X POST "https://api.notion.com/v1/search" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{"query": "page title"}'
```
**Get page:**
```bash
curl "https://api.notion.com/v1/pages/{page_id}" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03"
```
**Get page content (blocks):**
```bash
curl "https://api.notion.com/v1/blocks/{page_id}/children" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03"
```
**Create page in a data source:**
```bash
curl -X POST "https://api.notion.com/v1/pages" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{
"parent": {"database_id": "xxx"},
"properties": {
"Name": {"title": [{"text": {"content": "New Item"}}]},
"Status": {"select": {"name": "Todo"}}
}
}'
```
**Query a data source (database):**
```bash
curl -X POST "https://api.notion.com/v1/data_sources/{data_source_id}/query" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{
"filter": {"property": "Status", "select": {"equals": "Active"}},
"sorts": [{"property": "Date", "direction": "descending"}]
}'
```
**Create a data source (database):**
```bash
curl -X POST "https://api.notion.com/v1/data_sources" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{
"parent": {"page_id": "xxx"},
"title": [{"text": {"content": "My Database"}}],
"properties": {
"Name": {"title": {}},
"Status": {"select": {"options": [{"name": "Todo"}, {"name": "Done"}]}},
"Date": {"date": {}}
}
}'
```
**Update page properties:**
```bash
curl -X PATCH "https://api.notion.com/v1/pages/{page_id}" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{"properties": {"Status": {"select": {"name": "Done"}}}}'
```
**Add blocks to page:**
```bash
curl -X PATCH "https://api.notion.com/v1/blocks/{page_id}/children" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{
"children": [
{"object": "block", "type": "paragraph", "paragraph": {"rich_text": [{"text": {"content": "Hello"}}]}}
]
}'
```
## Property Types
Common property formats for database items:
- **Title:** `{"title": [{"text": {"content": "..."}}]}`
- **Rich text:** `{"rich_text": [{"text": {"content": "..."}}]}`
- **Select:** `{"select": {"name": "Option"}}`
- **Multi-select:** `{"multi_select": [{"name": "A"}, {"name": "B"}]}`
- **Date:** `{"date": {"start": "2024-01-15", "end": "2024-01-16"}}`
- **Checkbox:** `{"checkbox": true}`
- **Number:** `{"number": 42}`
- **URL:** `{"url": "https://..."}`
- **Email:** `{"email": "a@b.com"}`
- **Relation:** `{"relation": [{"id": "page_id"}]}`
## Key Differences in 2025-09-03
- **Databases → Data Sources:** Use `/data_sources/` endpoints for queries and retrieval
- **Two IDs:** Each database now has both a `database_id` and a `data_source_id`
- Use `database_id` when creating pages (`parent: {"database_id": "..."}`)
- Use `data_source_id` when querying (`POST /v1/data_sources/{id}/query`)
- **Search results:** Databases return as `"object": "data_source"` with their `data_source_id`
- **Parent in responses:** Pages show `parent.data_source_id` alongside `parent.database_id`
- **Finding the data_source_id:** Search for the database, or call `GET /v1/data_sources/{data_source_id}`
## Notes
- Page/database IDs are UUIDs (with or without dashes)
- The API cannot set database view filters — that's UI-only
- Rate limit: ~3 requests/second average
- Use `is_inline: true` when creating data sources to embed them in pages
+92 -2
View File
@@ -9,7 +9,7 @@ metadata: {"clawdis":{"emoji":"👀","os":["darwin"],"requires":{"bins":["peekab
Peekaboo is a full macOS UI automation CLI: capture/inspect screens, target UI Peekaboo is a full macOS UI automation CLI: capture/inspect screens, target UI
elements, drive input, and manage apps/windows/menus. Commands share a snapshot elements, drive input, and manage apps/windows/menus. Commands share a snapshot
cache and most support `--json-output` for scripting. Run `peekaboo` or cache and support `--json`/`-j` for scripting. Run `peekaboo` or
`peekaboo <cmd> --help` for flags; `peekaboo --version` prints build metadata. `peekaboo <cmd> --help` for flags; `peekaboo --version` prints build metadata.
Tip: run via `polter peekaboo` to ensure fresh builds. Tip: run via `polter peekaboo` to ensure fresh builds.
@@ -33,7 +33,7 @@ Interaction
- `drag`: drag & drop across elements/coords/Dock - `drag`: drag & drop across elements/coords/Dock
- `hotkey`: modifier combos like `cmd,shift,t` - `hotkey`: modifier combos like `cmd,shift,t`
- `move`: cursor positioning with optional smoothing - `move`: cursor positioning with optional smoothing
- `paste`: set clipboard → paste → restore - `paste`: set clipboard -> paste -> restore
- `press`: special-key sequences with repeats - `press`: special-key sequences with repeats
- `scroll`: directional scrolling (targeted + smooth) - `scroll`: directional scrolling (targeted + smooth)
- `swipe`: gesture-style drags between targets - `swipe`: gesture-style drags between targets
@@ -58,6 +58,96 @@ Global runtime flags
- `--json`/`-j`, `--verbose`/`-v`, `--log-level <level>` - `--json`/`-j`, `--verbose`/`-v`, `--log-level <level>`
- `--no-remote`, `--bridge-socket <path>` - `--no-remote`, `--bridge-socket <path>`
## Quickstart (happy path)
```bash
peekaboo permissions
peekaboo list apps --json
peekaboo see --annotate --path /tmp/peekaboo-see.png
peekaboo click --on B1
peekaboo type "Hello" --return
```
## Common targeting parameters (most interaction commands)
- App/window: `--app`, `--pid`, `--window-title`, `--window-id`, `--window-index`
- Snapshot targeting: `--snapshot` (ID from `see`; defaults to latest)
- Element/coords: `--on`/`--id` (element ID), `--coords x,y`
- Focus control: `--no-auto-focus`, `--space-switch`, `--bring-to-current-space`,
`--focus-timeout-seconds`, `--focus-retry-count`
## Common capture parameters
- Output: `--path`, `--format png|jpg`, `--retina`
- Targeting: `--mode screen|window|frontmost`, `--screen-index`,
`--window-title`, `--window-id`
- Analysis: `--analyze "prompt"`, `--annotate`
- Capture engine: `--capture-engine auto|classic|cg|modern|sckit`
## Common motion/typing parameters
- Timing: `--duration` (drag/swipe), `--steps`, `--delay` (type/scroll/press)
- Human-ish movement: `--profile human|linear`, `--wpm` (typing)
- Scroll: `--direction up|down|left|right`, `--amount <ticks>`, `--smooth`
## Examples
### See -> click -> type (most reliable flow)
```bash
peekaboo see --app Safari --window-title "Login" --annotate --path /tmp/see.png
peekaboo click --on B3 --app Safari
peekaboo type "user@example.com" --app Safari
peekaboo press tab --count 1 --app Safari
peekaboo type "supersecret" --app Safari --return
```
### Target by window id
```bash
peekaboo list windows --app "Visual Studio Code" --json
peekaboo click --window-id 12345 --coords 120,160
peekaboo type "Hello from Peekaboo" --window-id 12345
```
### Capture screenshots + analyze
```bash
peekaboo image --mode screen --screen-index 0 --retina --path /tmp/screen.png
peekaboo image --app Safari --window-title "Dashboard" --analyze "Summarize KPIs"
peekaboo see --mode screen --screen-index 0 --analyze "Summarize the dashboard"
```
### Live capture (motion-aware)
```bash
peekaboo capture live --mode region --region 100,100,800,600 --duration 30 \
--active-fps 8 --idle-fps 2 --highlight-changes --path /tmp/capture
```
### App + window management
```bash
peekaboo app launch "Safari" --open https://example.com
peekaboo window focus --app Safari --window-title "Example"
peekaboo window set-bounds --app Safari --x 50 --y 50 --width 1200 --height 800
peekaboo app quit --app Safari
```
### Menus, menubar, dock
```bash
peekaboo menu click --app Safari --item "New Window"
peekaboo menu click --app TextEdit --path "Format > Font > Show Fonts"
peekaboo menu click-extra --title "WiFi"
peekaboo dock launch Safari
peekaboo menubar list --json
```
### Mouse + gesture input
```bash
peekaboo move 500,300 --smooth
peekaboo drag --from B1 --to T2
peekaboo swipe --from-coords 100,500 --to-coords 100,200 --duration 800
peekaboo scroll --direction down --amount 6 --smooth
```
### Keyboard input
```bash
peekaboo hotkey --keys "cmd,shift,t"
peekaboo press escape
peekaboo type "Line 1\nLine 2" --delay 10
```
Notes Notes
- Requires Screen Recording + Accessibility permissions. - Requires Screen Recording + Accessibility permissions.
- Use `peekaboo see --annotate` to identify targets before clicking. - Use `peekaboo see --annotate` to identify targets before clicking.
+35
View File
@@ -65,6 +65,41 @@ To monitor:
- tmux is supported on macOS/Linux. On Windows, use WSL and install tmux inside WSL. - tmux is supported on macOS/Linux. On Windows, use WSL and install tmux inside WSL.
- This skill is gated to `darwin`/`linux` and requires `tmux` on PATH. - This skill is gated to `darwin`/`linux` and requires `tmux` on PATH.
## Orchestrating Coding Agents (Codex, Claude Code)
tmux excels at running multiple coding agents in parallel:
```bash
SOCKET="${TMPDIR:-/tmp}/codex-army.sock"
# Create multiple sessions
for i in 1 2 3 4 5; do
tmux -S "$SOCKET" new-session -d -s "agent-$i"
done
# Launch agents in different workdirs
tmux -S "$SOCKET" send-keys -t agent-1 "cd /tmp/project1 && codex --yolo 'Fix bug X'" Enter
tmux -S "$SOCKET" send-keys -t agent-2 "cd /tmp/project2 && codex --yolo 'Fix bug Y'" Enter
# Poll for completion (check if prompt returned)
for sess in agent-1 agent-2; do
if tmux -S "$SOCKET" capture-pane -p -t "$sess" -S -3 | grep -q "❯"; then
echo "$sess: DONE"
else
echo "$sess: Running..."
fi
done
# Get full output from completed session
tmux -S "$SOCKET" capture-pane -p -t agent-1 -S -500
```
**Tips:**
- Use separate git worktrees for parallel fixes (no branch conflicts)
- `pnpm install` first before running codex in fresh clones
- Check for shell prompt (`❯` or `$`) to detect completion
- Codex needs `--yolo` or `--full-auto` for non-interactive fixes
## Cleanup ## Cleanup
- Kill a session: `tmux -S "$SOCKET" kill-session -t "$SESSION"`. - Kill a session: `tmux -S "$SOCKET" kill-session -t "$SESSION"`.
+22 -11
View File
@@ -1,31 +1,42 @@
--- ---
name: wacli name: wacli
description: WhatsApp CLI for sync, search, and sending messages. description: Send WhatsApp messages to other people or search/sync WhatsApp history via the wacli CLI (not for normal user chats).
homepage: https://wacli.sh homepage: https://wacli.sh
metadata: {"clawdis":{"emoji":"📱","requires":{"bins":["wacli"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/wacli/cmd/wacli@latest","bins":["wacli"],"label":"Install wacli (go)"}]}} metadata: {"clawdis":{"emoji":"📱","requires":{"bins":["wacli"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/wacli","bins":["wacli"],"label":"Install wacli (brew)"},{"id":"go","kind":"go","module":"github.com/steipete/wacli/cmd/wacli@latest","bins":["wacli"],"label":"Install wacli (go)"}]}}
--- ---
# wacli # wacli
Use `wacli` for WhatsApp sync/search/send via WhatsApp Web protocol. Use `wacli` only when the user explicitly asks you to message someone else on WhatsApp or when they ask to sync/search WhatsApp history.
Normal operation: the user chats with you via Telegram or WhatsApp and Clawdis handles it automatically. Do NOT use `wacli` for normal user chats; Clawdis routes WhatsApp conversations automatically.
Use `wacli` only when you need to talk to other people on WhatsApp (or to debug sync/search). If the user is chatting with you on WhatsApp, you should not reach for this tool unless they ask you to contact a third party.
Safety
- Require explicit recipient + message text.
- Confirm recipient + message before sending.
- If anything is ambiguous, ask a clarifying question.
Auth + sync Auth + sync
- `wacli auth` (QR login + initial sync) - `wacli auth` (QR login + initial sync)
- `wacli sync --follow` (continuous sync) - `wacli sync --follow` (continuous sync)
- `wacli doctor` - `wacli doctor`
Search + history Find chats + messages
- `wacli messages search "query"` - `wacli chats list --limit 20 --query "name or number"`
- `wacli history backfill --chat <jid> --requests 5 --count 50` - `wacli messages search "query" --limit 20 --chat <jid>`
- `wacli messages search "invoice" --after 2025-01-01 --before 2025-12-31`
History backfill
- `wacli history backfill --chat <jid> --requests 2 --count 50`
Send Send
- Text: `wacli send text --to 1234567890 --message "hello"` - Text: `wacli send text --to "+14155551212" --message "Hello! Are you free at 3pm?"`
- File: `wacli send file --to 1234567890 --file /path/pic.jpg --caption "hi"` - Group: `wacli send text --to "1234567890-123456789@g.us" --message "Running 5 min late."`
- File: `wacli send file --to "+14155551212" --file /path/agenda.pdf --caption "Agenda"`
Notes Notes
- Store dir: `~/.wacli` (override with `--store`). - Store dir: `~/.wacli` (override with `--store`).
- Use `--json` for machine-readable output when parsing.
- Backfill requires your phone online; results are best-effort. - Backfill requires your phone online; results are best-effort.
- WhatsApp CLI is not needed for routine user chats; it’s for messaging other people. - WhatsApp CLI is not needed for routine user chats; it’s for messaging other people.
- Confirm recipient + message before sending. - JIDs: direct chats look like `<number>@s.whatsapp.net`; groups look like `<id>@g.us` (use `wacli chats list` to find).
-2
View File
@@ -20,7 +20,6 @@ describe("bash process registry", () => {
id: "sess", id: "sess",
command: "echo test", command: "echo test",
child: { pid: 123 } as ChildProcessWithoutNullStreams, child: { pid: 123 } as ChildProcessWithoutNullStreams,
stdinMode: "pipe",
startedAt: Date.now(), startedAt: Date.now(),
cwd: "/tmp", cwd: "/tmp",
maxOutputChars: 10, maxOutputChars: 10,
@@ -49,7 +48,6 @@ describe("bash process registry", () => {
id: "sess", id: "sess",
command: "echo test", command: "echo test",
child: { pid: 123 } as ChildProcessWithoutNullStreams, child: { pid: 123 } as ChildProcessWithoutNullStreams,
stdinMode: "pipe",
startedAt: Date.now(), startedAt: Date.now(),
cwd: "/tmp", cwd: "/tmp",
maxOutputChars: 100, maxOutputChars: 100,
-5
View File
@@ -1,5 +1,4 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { IPty } from "node-pty";
const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes
const MIN_JOB_TTL_MS = 60 * 1000; // 1 minute const MIN_JOB_TTL_MS = 60 * 1000; // 1 minute
@@ -16,15 +15,11 @@ let jobTtlMs = clampTtl(
export type ProcessStatus = "running" | "completed" | "failed" | "killed"; export type ProcessStatus = "running" | "completed" | "failed" | "killed";
export type ProcessStdinMode = "pipe" | "pty";
export interface ProcessSession { export interface ProcessSession {
id: string; id: string;
command: string; command: string;
child?: ChildProcessWithoutNullStreams; child?: ChildProcessWithoutNullStreams;
pty?: IPty;
pid?: number; pid?: number;
stdinMode: ProcessStdinMode;
startedAt: number; startedAt: number;
cwd?: string; cwd?: string;
maxOutputChars: number; maxOutputChars: number;
-68
View File
@@ -1,68 +0,0 @@
import { describe, expect, it, vi } from "vitest";
describe("bash tool pty mode", () => {
it("falls back to pipe with warning when node-pty fails to load", async () => {
vi.resetModules();
vi.doMock("node-pty", () => {
throw new Error("boom");
});
const { createBashTool } = await import("./bash-tools.js");
const tool = createBashTool({ backgroundMs: 10, timeoutSec: 1 });
const result = await tool.execute("call", {
command: "echo test",
stdinMode: "pty",
});
const text = result.content.find((c) => c.type === "text")?.text ?? "";
expect(text).toContain("Warning: node-pty failed to load");
expect(text).toContain("falling back to pipe mode.");
vi.doUnmock("node-pty");
});
it("uses node-pty when available", async () => {
vi.resetModules();
const spawn = vi.fn(() => {
let onData: ((data: string) => void) | undefined;
let onExit:
| ((event: { exitCode: number | null; signal?: number | null }) => void)
| undefined;
const pty = {
pid: 4321,
onData: (cb: (data: string) => void) => {
onData = cb;
},
onExit: (
cb: (event: {
exitCode: number | null;
signal?: number | null;
}) => void,
) => {
onExit = cb;
},
write: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
onData?.("hello\n");
onExit?.({ exitCode: 0, signal: null });
}, 10);
return pty;
});
vi.doMock("node-pty", () => ({ spawn }));
const { createBashTool } = await import("./bash-tools.js");
const tool = createBashTool({ backgroundMs: 10, timeoutSec: 1 });
const result = await tool.execute("call", {
command: "ignored",
stdinMode: "pty",
});
const text = result.content.find((c) => c.type === "text")?.text ?? "";
expect(text).toContain("hello");
expect(text).not.toContain("Warning:");
vi.doUnmock("node-pty");
});
});
+13
View File
@@ -119,6 +119,19 @@ describe("bash tool backgrounding", () => {
expect(status).toBe("failed"); expect(status).toBe("failed");
}); });
it("rejects elevated requests when not allowed", async () => {
const customBash = createBashTool({
elevated: { enabled: true, allowed: false, defaultLevel: "off" },
});
await expect(
customBash.execute("call1", {
command: "echo hi",
elevated: true,
}),
).rejects.toThrow("elevated is not available right now.");
});
it("logs line-based slices and defaults to last lines", async () => { it("logs line-based slices and defaults to last lines", async () => {
const result = await bashTool.execute("call1", { const result = await bashTool.execute("call1", {
command: command:
+212 -229
View File
@@ -1,12 +1,13 @@
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { existsSync, statSync } from "node:fs"; import { existsSync, statSync } from "node:fs";
import fs from "node:fs/promises";
import { homedir } from "node:os"; import { homedir } from "node:os";
import path from "node:path"; import path from "node:path";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import type { IPty } from "node-pty";
import { logInfo } from "../logger.js";
import { import {
addSession, addSession,
appendOutput, appendOutput,
@@ -18,9 +19,9 @@ import {
listRunningSessions, listRunningSessions,
markBackgrounded, markBackgrounded,
markExited, markExited,
type ProcessStdinMode,
setJobTtlMs, setJobTtlMs,
} from "./bash-process-registry.js"; } from "./bash-process-registry.js";
import { assertSandboxPath } from "./sandbox-paths.js";
import { import {
getShellConfig, getShellConfig,
killProcessTree, killProcessTree,
@@ -34,23 +35,8 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
1_000, 1_000,
150_000, 150_000,
); );
const DEFAULT_SHELL_PATH = "/bin/sh";
const DEFAULT_PATH = const DEFAULT_PATH =
"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"; "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
const DEFAULT_PTY_NAME = "xterm-256color";
type PtyModule = typeof import("node-pty");
type PtyLoadResult = { module: PtyModule | null; error?: unknown };
let ptyModulePromise: Promise<PtyLoadResult> | null = null;
async function loadPtyModule(): Promise<PtyLoadResult> {
if (!ptyModulePromise) {
ptyModulePromise = import("node-pty")
.then((mod) => ({ module: mod }))
.catch((error) => ({ module: null, error }));
}
return ptyModulePromise;
}
const stringEnum = ( const stringEnum = (
values: readonly string[], values: readonly string[],
@@ -67,12 +53,27 @@ const stringEnum = (
export type BashToolDefaults = { export type BashToolDefaults = {
backgroundMs?: number; backgroundMs?: number;
timeoutSec?: number; timeoutSec?: number;
sandbox?: BashSandboxConfig;
elevated?: BashElevatedDefaults;
}; };
export type ProcessToolDefaults = { export type ProcessToolDefaults = {
cleanupMs?: number; cleanupMs?: number;
}; };
export type BashSandboxConfig = {
containerName: string;
workspaceDir: string;
containerWorkdir: string;
env?: Record<string, string>;
};
export type BashElevatedDefaults = {
enabled: boolean;
allowed: boolean;
defaultLevel: "on" | "off";
};
const bashSchema = Type.Object({ const bashSchema = Type.Object({
command: Type.String({ description: "Bash command to execute" }), command: Type.String({ description: "Bash command to execute" }),
workdir: Type.Optional( workdir: Type.Optional(
@@ -81,7 +82,7 @@ const bashSchema = Type.Object({
env: Type.Optional(Type.Record(Type.String(), Type.String())), env: Type.Optional(Type.Record(Type.String(), Type.String())),
yieldMs: Type.Optional( yieldMs: Type.Optional(
Type.Number({ Type.Number({
description: "Milliseconds to wait before backgrounding (default 20000)", description: "Milliseconds to wait before backgrounding (default 10000)",
}), }),
), ),
background: Type.Optional( background: Type.Optional(
@@ -92,9 +93,9 @@ const bashSchema = Type.Object({
description: "Timeout in seconds (optional, kills process on expiry)", description: "Timeout in seconds (optional, kills process on expiry)",
}), }),
), ),
stdinMode: Type.Optional( elevated: Type.Optional(
stringEnum(["pipe", "pty"] as const, { Type.Boolean({
description: "stdin mode (pipe or pty when node-pty is available)", description: "Run on the host with elevated permissions (if allowed)",
}), }),
), ),
}); });
@@ -120,7 +121,7 @@ export function createBashTool(
): AgentTool<any, BashToolDetails> { ): AgentTool<any, BashToolDetails> {
const defaultBackgroundMs = clampNumber( const defaultBackgroundMs = clampNumber(
defaults?.backgroundMs ?? readEnvInt("PI_BASH_YIELD_MS"), defaults?.backgroundMs ?? readEnvInt("PI_BASH_YIELD_MS"),
20_000, 10_000,
10, 10,
120_000, 120_000,
); );
@@ -133,7 +134,7 @@ export function createBashTool(
name: "bash", name: "bash",
label: "bash", label: "bash",
description: description:
"Execute bash with background continuation. Use yieldMs/background to continue later via process tool.", "Execute bash with background continuation. Use yieldMs/background to continue later via process tool. For real TTY mode, use the tmux skill.",
parameters: bashSchema, parameters: bashSchema,
execute: async (_toolCallId, args, signal, onUpdate) => { execute: async (_toolCallId, args, signal, onUpdate) => {
const params = args as { const params = args as {
@@ -143,7 +144,7 @@ export function createBashTool(
yieldMs?: number; yieldMs?: number;
background?: boolean; background?: boolean;
timeout?: number; timeout?: number;
stdinMode?: "pipe" | "pty"; elevated?: boolean;
}; };
if (!params.command) { if (!params.command) {
@@ -162,91 +163,78 @@ export function createBashTool(
const startedAt = Date.now(); const startedAt = Date.now();
const sessionId = randomUUID(); const sessionId = randomUUID();
const warnings: string[] = []; const warnings: string[] = [];
const workdir = resolveWorkdir( const elevatedDefaults = defaults?.elevated;
params.workdir?.trim() || process.cwd(), const elevatedRequested =
warnings, typeof params.elevated === "boolean"
); ? params.elevated
: elevatedDefaults?.defaultLevel === "on";
if (elevatedRequested) {
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
throw new Error("elevated is not available right now.");
}
logInfo(
`bash: elevated command (${sessionId.slice(0, 8)}) ${truncateMiddle(
params.command,
120,
)}`,
);
}
const sandbox = elevatedRequested ? undefined : defaults?.sandbox;
const rawWorkdir = params.workdir?.trim() || process.cwd();
let workdir = rawWorkdir;
let containerWorkdir = sandbox?.containerWorkdir;
if (sandbox) {
const resolved = await resolveSandboxWorkdir({
workdir: rawWorkdir,
sandbox,
warnings,
});
workdir = resolved.hostWorkdir;
containerWorkdir = resolved.containerWorkdir;
} else {
workdir = resolveWorkdir(rawWorkdir, warnings);
}
const { shell, args: shellArgs } = getShellConfig(); const { shell, args: shellArgs } = getShellConfig();
const env = params.env ? { ...process.env, ...params.env } : process.env; const baseEnv = coerceEnv(process.env);
const requestedStdinMode = params.stdinMode === "pty" ? "pty" : "pipe"; const mergedEnv = params.env ? { ...baseEnv, ...params.env } : baseEnv;
let stdinMode: ProcessStdinMode = requestedStdinMode; const env = sandbox
let warning: string | null = null; ? buildSandboxEnv({
let child: ChildProcessWithoutNullStreams | undefined; paramsEnv: params.env,
let pty: IPty | undefined; sandboxEnv: sandbox.env,
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
if (stdinMode === "pty") { })
const { module: ptyModule, error: ptyError } = await loadPtyModule(); : mergedEnv;
if (!ptyModule) { const child = sandbox
warning = ? spawn(
`Warning: node-pty failed to load${formatPtyError(ptyError)}; ` + "docker",
"falling back to pipe mode."; buildDockerExecArgs({
stdinMode = "pipe"; containerName: sandbox.containerName,
} else { command: params.command,
const ptyEnv = ensurePath({ workdir: containerWorkdir ?? sandbox.containerWorkdir,
...env, env,
TERM: env.TERM ?? DEFAULT_PTY_NAME, tty: false,
} as Record<string, string>); }),
const ptyShell = resolveShellPath(shell, ptyEnv); {
try {
pty = ptyModule.spawn(ptyShell, [...shellArgs, params.command], {
cwd: workdir, cwd: workdir,
env: ptyEnv, env: process.env,
name: ptyEnv.TERM || DEFAULT_PTY_NAME, detached: true,
cols: 120, stdio: ["pipe", "pipe", "pipe"],
rows: 30, },
}); )
} catch (error) { : spawn(shell, [...shellArgs, params.command], {
if ( cwd: workdir,
ptyShell !== DEFAULT_SHELL_PATH && env,
existsSync(DEFAULT_SHELL_PATH) detached: true,
) { stdio: ["pipe", "pipe", "pipe"],
try { });
pty = ptyModule.spawn(
DEFAULT_SHELL_PATH,
[...shellArgs, params.command],
{
cwd: workdir,
env: ptyEnv,
name: ptyEnv.TERM || DEFAULT_PTY_NAME,
cols: 120,
rows: 30,
},
);
} catch (fallbackError) {
warning =
`Warning: node-pty failed to start${formatPtyError(fallbackError)}; ` +
"falling back to pipe mode.";
stdinMode = "pipe";
}
} else {
warning =
`Warning: node-pty failed to start${formatPtyError(error)}; ` +
"falling back to pipe mode.";
stdinMode = "pipe";
}
}
}
}
if (stdinMode === "pipe") {
child = spawn(shell, [...shellArgs, params.command], {
cwd: workdir,
env,
detached: true,
stdio: ["pipe", "pipe", "pipe"],
});
}
if (warning) warnings.push(warning);
const session = { const session = {
id: sessionId, id: sessionId,
command: params.command, command: params.command,
child, child,
pty, pid: child?.pid,
pid: child?.pid ?? pty?.pid,
stdinMode,
startedAt, startedAt,
cwd: workdir, cwd: workdir,
maxOutputChars: maxOutput, maxOutputChars: maxOutput,
@@ -309,33 +297,21 @@ export function createBashTool(
}); });
}; };
if (child) { child.stdout.on("data", (data) => {
child.stdout.on("data", (data) => { const str = sanitizeBinaryOutput(data.toString());
const str = sanitizeBinaryOutput(data.toString()); for (const chunk of chunkString(str)) {
for (const chunk of chunkString(str)) { appendOutput(session, "stdout", chunk);
appendOutput(session, "stdout", chunk); emitUpdate();
emitUpdate(); }
} });
});
child.stderr.on("data", (data) => { child.stderr.on("data", (data) => {
const str = sanitizeBinaryOutput(data.toString()); const str = sanitizeBinaryOutput(data.toString());
for (const chunk of chunkString(str)) { for (const chunk of chunkString(str)) {
appendOutput(session, "stderr", chunk); appendOutput(session, "stderr", chunk);
emitUpdate(); emitUpdate();
} }
}); });
}
if (pty) {
pty.onData((data) => {
const str = sanitizeBinaryOutput(data);
for (const chunk of chunkString(str)) {
appendOutput(session, "stdout", chunk);
emitUpdate();
}
});
}
return new Promise<AgentToolResult<BashToolDetails>>( return new Promise<AgentToolResult<BashToolDetails>>(
(resolve, reject) => { (resolve, reject) => {
@@ -434,24 +410,16 @@ export function createBashTool(
); );
}; };
if (child) { child.once("exit", (code, exitSignal) => {
child.once("exit", (code, exitSignal) => { handleExit(code, exitSignal);
handleExit(code, exitSignal); });
});
child.once("error", (err) => { child.once("error", (err) => {
if (yieldTimer) clearTimeout(yieldTimer); if (yieldTimer) clearTimeout(yieldTimer);
if (timeoutTimer) clearTimeout(timeoutTimer); if (timeoutTimer) clearTimeout(timeoutTimer);
markExited(session, null, null, "failed"); markExited(session, null, null, "failed");
settle(() => reject(err)); settle(() => reject(err));
}); });
}
if (pty) {
pty.onExit(({ exitCode, signal }) => {
handleExit(exitCode ?? null, signal ?? null);
});
}
}, },
); );
}, },
@@ -747,43 +715,25 @@ export function createProcessTool(
details: { status: "failed" }, details: { status: "failed" },
}; };
} }
if (session.stdinMode === "pty") { if (!session.child?.stdin || session.child.stdin.destroyed) {
if (!session.pty || session.exited) { return {
return { content: [
content: [ {
{ type: "text",
type: "text", text: `Session ${params.sessionId} stdin is not writable.`,
text: `Session ${params.sessionId} stdin is not writable.`, },
}, ],
], details: { status: "failed" },
details: { status: "failed" }, };
}; }
} await new Promise<void>((resolve, reject) => {
session.pty.write(params.data ?? ""); session.child?.stdin.write(params.data ?? "", (err) => {
if (params.eof) { if (err) reject(err);
session.pty.write("\x04"); else resolve();
}
} else {
if (!session.child?.stdin || session.child.stdin.destroyed) {
return {
content: [
{
type: "text",
text: `Session ${params.sessionId} stdin is not writable.`,
},
],
details: { status: "failed" },
};
}
await new Promise<void>((resolve, reject) => {
session.child?.stdin.write(params.data ?? "", (err) => {
if (err) reject(err);
else resolve();
});
}); });
if (params.eof) { });
session.child.stdin.end(); if (params.eof) {
} session.child.stdin.end();
} }
return { return {
content: [ content: [
@@ -906,23 +856,94 @@ export function createProcessTool(
export const processTool = createProcessTool(); export const processTool = createProcessTool();
function buildSandboxEnv(params: {
paramsEnv?: Record<string, string>;
sandboxEnv?: Record<string, string>;
containerWorkdir: string;
}) {
const env: Record<string, string> = {
PATH: DEFAULT_PATH,
HOME: params.containerWorkdir,
};
for (const [key, value] of Object.entries(params.sandboxEnv ?? {})) {
env[key] = value;
}
for (const [key, value] of Object.entries(params.paramsEnv ?? {})) {
env[key] = value;
}
return env;
}
function coerceEnv(env?: NodeJS.ProcessEnv | Record<string, string>) {
const record: Record<string, string> = {};
if (!env) return record;
for (const [key, value] of Object.entries(env)) {
if (typeof value === "string") record[key] = value;
}
return record;
}
function buildDockerExecArgs(params: {
containerName: string;
command: string;
workdir?: string;
env: Record<string, string>;
tty: boolean;
}) {
const args = ["exec", "-i"];
if (params.tty) args.push("-t");
if (params.workdir) {
args.push("-w", params.workdir);
}
for (const [key, value] of Object.entries(params.env)) {
args.push("-e", `${key}=${value}`);
}
args.push(params.containerName, "sh", "-lc", params.command);
return args;
}
async function resolveSandboxWorkdir(params: {
workdir: string;
sandbox: BashSandboxConfig;
warnings: string[];
}) {
const fallback = params.sandbox.workspaceDir;
try {
const resolved = await assertSandboxPath({
filePath: params.workdir,
cwd: process.cwd(),
root: params.sandbox.workspaceDir,
});
const stats = await fs.stat(resolved.resolved);
if (!stats.isDirectory()) {
throw new Error("workdir is not a directory");
}
const relative = resolved.relative
? resolved.relative.split(path.sep).join(path.posix.sep)
: "";
const containerWorkdir = relative
? path.posix.join(params.sandbox.containerWorkdir, relative)
: params.sandbox.containerWorkdir;
return { hostWorkdir: resolved.resolved, containerWorkdir };
} catch {
params.warnings.push(
`Warning: workdir "${params.workdir}" is unavailable; using "${fallback}".`,
);
return {
hostWorkdir: fallback,
containerWorkdir: params.sandbox.containerWorkdir,
};
}
}
function killSession(session: { function killSession(session: {
pid?: number; pid?: number;
stdinMode: ProcessStdinMode;
pty?: IPty;
child?: ChildProcessWithoutNullStreams; child?: ChildProcessWithoutNullStreams;
}) { }) {
const pid = session.pid ?? session.child?.pid ?? session.pty?.pid; const pid = session.pid ?? session.child?.pid;
if (pid) { if (pid) {
killProcessTree(pid); killProcessTree(pid);
} }
if (session.stdinMode === "pty") {
try {
session.pty?.kill();
} catch {
// ignore kill failures
}
}
} }
function resolveWorkdir(workdir: string, warnings: string[]) { function resolveWorkdir(workdir: string, warnings: string[]) {
@@ -949,44 +970,6 @@ function safeCwd() {
} }
} }
function ensurePath(env: Record<string, string>) {
if (!env.PATH?.trim()) {
env.PATH = DEFAULT_PATH;
}
return env;
}
function resolveShellPath(shell: string, env: Record<string, string>) {
if (process.platform === "win32") return shell;
if (shell.includes("/") && existsSync(shell)) {
return shell;
}
const searchPath = env.PATH ?? "";
for (const segment of searchPath.split(path.delimiter)) {
if (!segment) continue;
const candidate = path.join(segment, shell);
if (existsSync(candidate)) return candidate;
}
if (existsSync(DEFAULT_SHELL_PATH)) {
return DEFAULT_SHELL_PATH;
}
return shell;
}
function formatPtyError(error: unknown) {
if (!error) return "";
if (typeof error === "string") return ` (${error})`;
if (error instanceof Error) {
const firstLine = error.message.split(/\r?\n/)[0]?.trim();
return firstLine ? ` (${firstLine})` : "";
}
try {
return ` (${JSON.stringify(error)})`;
} catch {
return "";
}
}
function clampNumber( function clampNumber(
value: number | undefined, value: number | undefined,
defaultValue: number, defaultValue: number,
+378
View File
@@ -0,0 +1,378 @@
import { describe, expect, it, vi } from "vitest";
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
vi.mock("../config/config.js", () => ({
loadConfig: () => ({
session: {
mainKey: "main",
scope: "per-sender",
agentToAgent: { maxPingPongTurns: 2 },
},
}),
resolveGatewayPort: () => 18789,
}));
import { createClawdisTools } from "./clawdis-tools.js";
describe("sessions tools", () => {
it("sessions_list filters kinds and includes messages", async () => {
callGatewayMock.mockReset();
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string };
if (request.method === "sessions.list") {
return {
path: "/tmp/sessions.json",
sessions: [
{
key: "main",
kind: "direct",
sessionId: "s-main",
updatedAt: 10,
lastChannel: "whatsapp",
},
{
key: "discord:group:dev",
kind: "group",
sessionId: "s-group",
updatedAt: 11,
surface: "discord",
displayName: "discord:g-dev",
},
{
key: "cron:job-1",
kind: "direct",
sessionId: "s-cron",
updatedAt: 9,
},
{ key: "global", kind: "global" },
{ key: "unknown", kind: "unknown" },
],
};
}
if (request.method === "chat.history") {
return {
messages: [
{ role: "toolResult", content: [] },
{
role: "assistant",
content: [{ type: "text", text: "hi" }],
},
],
};
}
return {};
});
const tool = createClawdisTools().find(
(candidate) => candidate.name === "sessions_list",
);
expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_list tool");
const result = await tool.execute("call1", { messageLimit: 1 });
const details = result.details as {
sessions?: Array<Record<string, unknown>>;
};
expect(details.sessions).toHaveLength(3);
const main = details.sessions?.find((s) => s.key === "main");
expect(main?.provider).toBe("whatsapp");
expect(main?.messages?.length).toBe(1);
expect(main?.messages?.[0]?.role).toBe("assistant");
const cronOnly = await tool.execute("call2", { kinds: ["cron"] });
const cronDetails = cronOnly.details as {
sessions?: Array<Record<string, unknown>>;
};
expect(cronDetails.sessions).toHaveLength(1);
expect(cronDetails.sessions?.[0]?.kind).toBe("cron");
});
it("sessions_history filters tool messages by default", async () => {
callGatewayMock.mockReset();
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string };
if (request.method === "chat.history") {
return {
messages: [
{ role: "toolResult", content: [] },
{ role: "assistant", content: [{ type: "text", text: "ok" }] },
],
};
}
return {};
});
const tool = createClawdisTools().find(
(candidate) => candidate.name === "sessions_history",
);
expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_history tool");
const result = await tool.execute("call3", { sessionKey: "main" });
const details = result.details as { messages?: unknown[] };
expect(details.messages).toHaveLength(1);
expect(details.messages?.[0]?.role).toBe("assistant");
const withTools = await tool.execute("call4", {
sessionKey: "main",
includeTools: true,
});
const withToolsDetails = withTools.details as { messages?: unknown[] };
expect(withToolsDetails.messages).toHaveLength(2);
});
it("sessions_send supports fire-and-forget and wait", async () => {
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let _historyCallCount = 0;
let sendCallCount = 0;
let lastWaitedRunId: string | undefined;
const replyByRunId = new Map<string, string>();
const requesterKey = "discord:group:req";
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
const params = request.params as
| { message?: string; sessionKey?: string }
| undefined;
const message = params?.message ?? "";
let reply = "REPLY_SKIP";
if (message === "ping" || message === "wait") {
reply = "done";
} else if (message === "Agent-to-agent announce step.") {
reply = "ANNOUNCE_SKIP";
} else if (params?.sessionKey === requesterKey) {
reply = "pong";
}
replyByRunId.set(runId, reply);
return {
runId,
status: "accepted",
acceptedAt: 1234 + agentCallCount,
};
}
if (request.method === "agent.wait") {
const params = request.params as { runId?: string } | undefined;
lastWaitedRunId = params?.runId;
return { runId: params?.runId ?? "run-1", status: "ok" };
}
if (request.method === "chat.history") {
_historyCallCount += 1;
const text =
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
return {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text,
},
],
timestamp: 20,
},
],
};
}
if (request.method === "send") {
sendCallCount += 1;
return { messageId: "m1" };
}
return {};
});
const tool = createClawdisTools({
agentSessionKey: requesterKey,
agentSurface: "discord",
}).find((candidate) => candidate.name === "sessions_send");
expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_send tool");
const fire = await tool.execute("call5", {
sessionKey: "main",
message: "ping",
timeoutSeconds: 0,
});
expect(fire.details).toMatchObject({ status: "accepted", runId: "run-1" });
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const waitPromise = tool.execute("call6", {
sessionKey: "main",
message: "wait",
timeoutSeconds: 1,
});
const waited = await waitPromise;
expect(waited.details).toMatchObject({
status: "ok",
reply: "done",
});
expect(typeof (waited.details as { runId?: string }).runId).toBe("string");
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const agentCalls = calls.filter((call) => call.method === "agent");
const waitCalls = calls.filter((call) => call.method === "agent.wait");
const historyOnlyCalls = calls.filter(
(call) => call.method === "chat.history",
);
expect(agentCalls).toHaveLength(8);
for (const call of agentCalls) {
expect(call.params).toMatchObject({ lane: "nested" });
}
expect(
agentCalls.some(
(call) =>
typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" &&
(
call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent message context"),
),
).toBe(true);
expect(
agentCalls.some(
(call) =>
typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" &&
(
call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent reply step"),
),
).toBe(true);
expect(
agentCalls.some(
(call) =>
typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" &&
(
call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent announce step"),
),
).toBe(true);
expect(waitCalls).toHaveLength(8);
expect(historyOnlyCalls).toHaveLength(8);
expect(
waitCalls.some(
(call) =>
typeof (call.params as { afterMs?: number })?.afterMs === "number",
),
).toBe(true);
expect(sendCallCount).toBe(0);
});
it("sessions_send runs ping-pong then announces", async () => {
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let lastWaitedRunId: string | undefined;
const replyByRunId = new Map<string, string>();
const requesterKey = "discord:group:req";
const targetKey = "discord:group:target";
let sendParams: { to?: string; provider?: string; message?: string } = {};
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
const params = request.params as
| {
message?: string;
sessionKey?: string;
extraSystemPrompt?: string;
}
| undefined;
let reply = "initial";
if (params?.extraSystemPrompt?.includes("Agent-to-agent reply step")) {
reply = params.sessionKey === requesterKey ? "pong-1" : "pong-2";
}
if (
params?.extraSystemPrompt?.includes("Agent-to-agent announce step")
) {
reply = "announce now";
}
replyByRunId.set(runId, reply);
return {
runId,
status: "accepted",
acceptedAt: 2000 + agentCallCount,
};
}
if (request.method === "agent.wait") {
const params = request.params as { runId?: string } | undefined;
lastWaitedRunId = params?.runId;
return { runId: params?.runId ?? "run-1", status: "ok" };
}
if (request.method === "chat.history") {
const text =
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
return {
messages: [
{
role: "assistant",
content: [{ type: "text", text }],
timestamp: 20,
},
],
};
}
if (request.method === "send") {
const params = request.params as
| { to?: string; provider?: string; message?: string }
| undefined;
sendParams = {
to: params?.to,
provider: params?.provider,
message: params?.message,
};
return { messageId: "m-announce" };
}
return {};
});
const tool = createClawdisTools({
agentSessionKey: requesterKey,
agentSurface: "discord",
}).find((candidate) => candidate.name === "sessions_send");
expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_send tool");
const waited = await tool.execute("call7", {
sessionKey: targetKey,
message: "ping",
timeoutSeconds: 1,
});
expect(waited.details).toMatchObject({
status: "ok",
reply: "initial",
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const replySteps = calls.filter(
(call) =>
call.method === "agent" &&
typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" &&
(
call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent reply step"),
);
expect(replySteps).toHaveLength(2);
expect(sendParams).toMatchObject({
to: "channel:target",
provider: "discord",
message: "announce now",
});
});
});
File diff suppressed because it is too large. Load diff
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { buildEmbeddedSandboxInfo } from "./pi-embedded-runner.js";
import type { SandboxContext } from "./sandbox.js";
describe("buildEmbeddedSandboxInfo", () => {
it("returns undefined when sandbox is missing", () => {
expect(buildEmbeddedSandboxInfo()).toBeUndefined();
});
it("maps sandbox context into prompt info", () => {
const sandbox = {
enabled: true,
sessionKey: "session:test",
workspaceDir: "/tmp/clawdis-sandbox",
containerName: "clawdis-sbx-test",
containerWorkdir: "/workspace",
docker: {
image: "clawdis-sandbox:bookworm-slim",
containerPrefix: "clawdis-sbx-",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: ["/tmp"],
network: "none",
user: "1000:1000",
capDrop: ["ALL"],
env: { LANG: "C.UTF-8" },
},
tools: {
allow: ["bash"],
deny: ["browser"],
},
browser: {
controlUrl: "http://localhost:9222",
noVncUrl: "http://localhost:6080",
containerName: "clawdis-sbx-browser-test",
},
} satisfies SandboxContext;
expect(buildEmbeddedSandboxInfo(sandbox)).toEqual({
enabled: true,
workspaceDir: "/tmp/clawdis-sandbox",
browserControlUrl: "http://localhost:9222",
browserNoVncUrl: "http://localhost:6080",
});
});
});
+91 -17
View File
@@ -26,14 +26,15 @@ import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js";
import { formatToolAggregate } from "../auto-reply/tool-meta.js"; import { formatToolAggregate } from "../auto-reply/tool-meta.js";
import type { ClawdisConfig } from "../config/config.js"; import type { ClawdisConfig } from "../config/config.js";
import { getMachineDisplayName } from "../infra/machine-name.js"; import { getMachineDisplayName } from "../infra/machine-name.js";
import { createSubsystemLogger } from "../logging.js";
import { splitMediaFromOutput } from "../media/parse.js"; import { splitMediaFromOutput } from "../media/parse.js";
import { import {
type enqueueCommand, type enqueueCommand,
enqueueCommandInLane, enqueueCommandInLane,
} from "../process/command-queue.js"; } from "../process/command-queue.js";
import { defaultRuntime } from "../runtime.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import { resolveClawdisAgentDir } from "./agent-paths.js"; import { resolveClawdisAgentDir } from "./agent-paths.js";
import type { BashElevatedDefaults } from "./bash-tools.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import { ensureClawdisModelsJson } from "./models-config.js"; import { ensureClawdisModelsJson } from "./models-config.js";
import { import {
@@ -95,13 +96,26 @@ type EmbeddedPiQueueHandle = {
abort: () => void; abort: () => void;
}; };
const log = createSubsystemLogger("agent/embedded");
const ACTIVE_EMBEDDED_RUNS = new Map<string, EmbeddedPiQueueHandle>(); const ACTIVE_EMBEDDED_RUNS = new Map<string, EmbeddedPiQueueHandle>();
type EmbeddedRunWaiter = {
resolve: (ended: boolean) => void;
timer: NodeJS.Timeout;
};
const EMBEDDED_RUN_WAITERS = new Map<string, Set<EmbeddedRunWaiter>>();
const OAUTH_FILENAME = "oauth.json"; const OAUTH_FILENAME = "oauth.json";
const DEFAULT_OAUTH_DIR = path.join(CONFIG_DIR, "credentials"); const DEFAULT_OAUTH_DIR = path.join(CONFIG_DIR, "credentials");
let oauthStorageConfigured = false; let oauthStorageConfigured = false;
type OAuthStorage = Record<string, OAuthCredentials>; type OAuthStorage = Record<string, OAuthCredentials>;
type EmbeddedSandboxInfo = {
enabled: boolean;
workspaceDir?: string;
browserControlUrl?: string;
browserNoVncUrl?: string;
};
function resolveSessionLane(key: string) { function resolveSessionLane(key: string) {
const cleaned = key.trim() || "main"; const cleaned = key.trim() || "main";
@@ -113,6 +127,18 @@ function resolveGlobalLane(lane?: string) {
return cleaned ? cleaned : "main"; return cleaned ? cleaned : "main";
} }
export function buildEmbeddedSandboxInfo(
sandbox?: Awaited<ReturnType<typeof resolveSandboxContext>>,
): EmbeddedSandboxInfo | undefined {
if (!sandbox?.enabled) return undefined;
return {
enabled: true,
workspaceDir: sandbox.workspaceDir,
browserControlUrl: sandbox.browser?.controlUrl,
browserNoVncUrl: sandbox.browser?.noVncUrl,
};
}
function resolveClawdisOAuthPath(): string { function resolveClawdisOAuthPath(): string {
const overrideDir = const overrideDir =
process.env.CLAWDIS_OAUTH_DIR?.trim() || DEFAULT_OAUTH_DIR; process.env.CLAWDIS_OAUTH_DIR?.trim() || DEFAULT_OAUTH_DIR;
@@ -227,6 +253,46 @@ export function isEmbeddedPiRunStreaming(sessionId: string): boolean {
return handle.isStreaming(); return handle.isStreaming();
} }
export function waitForEmbeddedPiRunEnd(
sessionId: string,
timeoutMs = 15_000,
): Promise<boolean> {
if (!sessionId || !ACTIVE_EMBEDDED_RUNS.has(sessionId))
return Promise.resolve(true);
return new Promise((resolve) => {
const waiters = EMBEDDED_RUN_WAITERS.get(sessionId) ?? new Set();
const waiter: EmbeddedRunWaiter = {
resolve,
timer: setTimeout(
() => {
waiters.delete(waiter);
if (waiters.size === 0) EMBEDDED_RUN_WAITERS.delete(sessionId);
resolve(false);
},
Math.max(100, timeoutMs),
),
};
waiters.add(waiter);
EMBEDDED_RUN_WAITERS.set(sessionId, waiters);
if (!ACTIVE_EMBEDDED_RUNS.has(sessionId)) {
waiters.delete(waiter);
if (waiters.size === 0) EMBEDDED_RUN_WAITERS.delete(sessionId);
clearTimeout(waiter.timer);
resolve(true);
}
});
}
function notifyEmbeddedRunEnded(sessionId: string) {
const waiters = EMBEDDED_RUN_WAITERS.get(sessionId);
if (!waiters || waiters.size === 0) return;
EMBEDDED_RUN_WAITERS.delete(sessionId);
for (const waiter of waiters) {
clearTimeout(waiter.timer);
waiter.resolve(true);
}
}
export function resolveEmbeddedSessionLane(key: string) { export function resolveEmbeddedSessionLane(key: string) {
return resolveSessionLane(key); return resolveSessionLane(key);
} }
@@ -325,6 +391,7 @@ export async function runEmbeddedPiAgent(params: {
model?: string; model?: string;
thinkLevel?: ThinkLevel; thinkLevel?: ThinkLevel;
verboseLevel?: VerboseLevel; verboseLevel?: VerboseLevel;
bashElevated?: BashElevatedDefaults;
timeoutMs: number; timeoutMs: number;
runId: string; runId: string;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
@@ -363,13 +430,7 @@ export async function runEmbeddedPiAgent(params: {
return enqueueCommandInLane(sessionLane, () => return enqueueCommandInLane(sessionLane, () =>
enqueueGlobal(async () => { enqueueGlobal(async () => {
const started = Date.now(); const started = Date.now();
const sandbox = await resolveSandboxContext({ const resolvedWorkspace = resolveUserPath(params.workspaceDir);
config: params.config,
sessionKey: params.sessionKey,
workspaceDir: params.workspaceDir,
});
const workspaceDir = sandbox?.workspaceDir ?? params.workspaceDir;
const resolvedWorkspace = resolveUserPath(workspaceDir);
const prevCwd = process.cwd(); const prevCwd = process.cwd();
const provider = const provider =
@@ -390,7 +451,7 @@ export async function runEmbeddedPiAgent(params: {
const thinkingLevel = mapThinkingLevel(params.thinkLevel); const thinkingLevel = mapThinkingLevel(params.thinkLevel);
defaultRuntime.log?.( log.debug(
`embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} surface=${params.surface ?? "unknown"}`, `embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} surface=${params.surface ?? "unknown"}`,
); );
@@ -415,6 +476,12 @@ export async function runEmbeddedPiAgent(params: {
config: params.config, config: params.config,
entries: skillEntries, entries: skillEntries,
}); });
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
const sandbox = await resolveSandboxContext({
config: params.config,
sessionKey: sandboxSessionKey,
workspaceDir: resolvedWorkspace,
});
restoreSkillEnv = params.skillsSnapshot restoreSkillEnv = params.skillsSnapshot
? applySkillEnvOverridesFromSnapshot({ ? applySkillEnvOverridesFromSnapshot({
snapshot: params.skillsSnapshot, snapshot: params.skillsSnapshot,
@@ -430,9 +497,13 @@ export async function runEmbeddedPiAgent(params: {
const contextFiles = buildBootstrapContextFiles(bootstrapFiles); const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries); const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries);
const tools = createClawdisCodingTools({ const tools = createClawdisCodingTools({
bash: params.config?.agent?.bash, bash: {
surface: params.surface, ...params.config?.agent?.bash,
elevated: params.bashElevated,
},
sandbox, sandbox,
surface: params.surface,
sessionKey: params.sessionKey ?? params.sessionId,
}); });
const machineName = await getMachineDisplayName(); const machineName = await getMachineDisplayName();
const runtimeInfo = { const runtimeInfo = {
@@ -442,6 +513,7 @@ export async function runEmbeddedPiAgent(params: {
node: process.version, node: process.version,
model: `${provider}/${modelId}`, model: `${provider}/${modelId}`,
}; };
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox);
const reasoningTagHint = provider === "ollama"; const reasoningTagHint = provider === "ollama";
const systemPrompt = buildSystemPrompt({ const systemPrompt = buildSystemPrompt({
appendPrompt: buildAgentSystemPromptAppend({ appendPrompt: buildAgentSystemPromptAppend({
@@ -451,6 +523,7 @@ export async function runEmbeddedPiAgent(params: {
ownerNumbers: params.ownerNumbers, ownerNumbers: params.ownerNumbers,
reasoningTagHint, reasoningTagHint,
runtimeInfo, runtimeInfo,
sandboxInfo,
}), }),
contextFiles, contextFiles,
skills: promptSkills, skills: promptSkills,
@@ -494,7 +567,7 @@ export async function runEmbeddedPiAgent(params: {
}; };
const queueHandle: EmbeddedPiQueueHandle = { const queueHandle: EmbeddedPiQueueHandle = {
queueMessage: async (text: string) => { queueMessage: async (text: string) => {
await session.queueMessage(text); await session.steer(text);
}, },
isStreaming: () => session.isStreaming, isStreaming: () => session.isStreaming,
abort: abortRun, abort: abortRun,
@@ -523,14 +596,14 @@ export async function runEmbeddedPiAgent(params: {
let abortWarnTimer: NodeJS.Timeout | undefined; let abortWarnTimer: NodeJS.Timeout | undefined;
const abortTimer = setTimeout( const abortTimer = setTimeout(
() => { () => {
defaultRuntime.log( log.warn(
`embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`, `embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`,
); );
abortRun(); abortRun();
if (!abortWarnTimer) { if (!abortWarnTimer) {
abortWarnTimer = setTimeout(() => { abortWarnTimer = setTimeout(() => {
if (!session.isStreaming) return; if (!session.isStreaming) return;
defaultRuntime.log( log.warn(
`embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`, `embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`,
); );
}, 10_000); }, 10_000);
@@ -556,7 +629,7 @@ export async function runEmbeddedPiAgent(params: {
let promptError: unknown = null; let promptError: unknown = null;
try { try {
const promptStartedAt = Date.now(); const promptStartedAt = Date.now();
defaultRuntime.log?.( log.debug(
`embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`, `embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`,
); );
try { try {
@@ -564,7 +637,7 @@ export async function runEmbeddedPiAgent(params: {
} catch (err) { } catch (err) {
promptError = err; promptError = err;
} finally { } finally {
defaultRuntime.log?.( log.debug(
`embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`, `embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`,
); );
} }
@@ -580,6 +653,7 @@ export async function runEmbeddedPiAgent(params: {
unsubscribe(); unsubscribe();
if (ACTIVE_EMBEDDED_RUNS.get(params.sessionId) === queueHandle) { if (ACTIVE_EMBEDDED_RUNS.get(params.sessionId) === queueHandle) {
ACTIVE_EMBEDDED_RUNS.delete(params.sessionId); ACTIVE_EMBEDDED_RUNS.delete(params.sessionId);
notifyEmbeddedRunEnded(params.sessionId);
} }
session.dispose(); session.dispose();
params.abortSignal?.removeEventListener?.("abort", onAbort); params.abortSignal?.removeEventListener?.("abort", onAbort);
@@ -653,7 +727,7 @@ export async function runEmbeddedPiAgent(params: {
p.text || p.mediaUrl || (p.mediaUrls && p.mediaUrls.length > 0), p.text || p.mediaUrl || (p.mediaUrls && p.mediaUrls.length > 0),
); );
defaultRuntime.log?.( log.debug(
`embedded run done: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - started} aborted=${aborted}`, `embedded run done: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - started} aborted=${aborted}`,
); );
return { return {
+8 -11
View File
@@ -4,8 +4,8 @@ import type { AgentSession } from "@mariozechner/pi-coding-agent";
import { formatToolAggregate } from "../auto-reply/tool-meta.js"; import { formatToolAggregate } from "../auto-reply/tool-meta.js";
import { emitAgentEvent } from "../infra/agent-events.js"; import { emitAgentEvent } from "../infra/agent-events.js";
import { createSubsystemLogger } from "../logging.js";
import { splitMediaFromOutput } from "../media/parse.js"; import { splitMediaFromOutput } from "../media/parse.js";
import { defaultRuntime } from "../runtime.js";
import { import {
extractAssistantText, extractAssistantText,
inferToolMetaFromArgs, inferToolMetaFromArgs,
@@ -15,6 +15,7 @@ const THINKING_TAG_RE = /<\s*\/?\s*think(?:ing)?\s*>/gi;
const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i; const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i;
const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i; const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i;
const TOOL_RESULT_MAX_CHARS = 8000; const TOOL_RESULT_MAX_CHARS = 8000;
const log = createSubsystemLogger("agent/embedded");
export type BlockReplyChunking = { export type BlockReplyChunking = {
minChars: number; minChars: number;
@@ -324,7 +325,7 @@ export function subscribeEmbeddedPiSession(params: {
const args = (evt as AgentEvent & { args: unknown }).args; const args = (evt as AgentEvent & { args: unknown }).args;
const meta = inferToolMetaFromArgs(toolName, args); const meta = inferToolMetaFromArgs(toolName, args);
toolMetaById.set(toolCallId, meta); toolMetaById.set(toolCallId, meta);
defaultRuntime.log?.( log.debug(
`embedded run tool start: runId=${params.runId} tool=${toolName} toolCallId=${toolCallId}`, `embedded run tool start: runId=${params.runId} tool=${toolName} toolCallId=${toolCallId}`,
); );
@@ -590,21 +591,19 @@ export function subscribeEmbeddedPiSession(params: {
const toolCallId = String( const toolCallId = String(
(evt as AgentEvent & { toolCallId: string }).toolCallId, (evt as AgentEvent & { toolCallId: string }).toolCallId,
); );
defaultRuntime.log?.( log.debug(
`embedded run tool end: runId=${params.runId} tool=${toolName} toolCallId=${toolCallId}`, `embedded run tool end: runId=${params.runId} tool=${toolName} toolCallId=${toolCallId}`,
); );
} }
if (evt.type === "agent_start") { if (evt.type === "agent_start") {
defaultRuntime.log?.(`embedded run agent start: runId=${params.runId}`); log.debug(`embedded run agent start: runId=${params.runId}`);
} }
if (evt.type === "auto_compaction_start") { if (evt.type === "auto_compaction_start") {
compactionInFlight = true; compactionInFlight = true;
ensureCompactionPromise(); ensureCompactionPromise();
defaultRuntime.log?.( log.debug(`embedded run compaction start: runId=${params.runId}`);
`embedded run compaction start: runId=${params.runId}`,
);
} }
if (evt.type === "auto_compaction_end") { if (evt.type === "auto_compaction_end") {
@@ -613,16 +612,14 @@ export function subscribeEmbeddedPiSession(params: {
if (willRetry) { if (willRetry) {
noteCompactionRetry(); noteCompactionRetry();
resetForCompactionRetry(); resetForCompactionRetry();
defaultRuntime.log?.( log.debug(`embedded run compaction retry: runId=${params.runId}`);
`embedded run compaction retry: runId=${params.runId}`,
);
} else { } else {
maybeResolveCompactionWait(); maybeResolveCompactionWait();
} }
} }
if (evt.type === "agent_end") { if (evt.type === "agent_end") {
defaultRuntime.log?.(`embedded run agent end: runId=${params.runId}`); log.debug(`embedded run agent end: runId=${params.runId}`);
if (pendingCompactionRetry > 0) { if (pendingCompactionRetry > 0) {
resolveCompactionRetry(); resolveCompactionRetry();
} else { } else {
+1
View File
@@ -10,4 +10,5 @@ export {
queueEmbeddedPiMessage, queueEmbeddedPiMessage,
resolveEmbeddedSessionLane, resolveEmbeddedSessionLane,
runEmbeddedPiAgent, runEmbeddedPiAgent,
waitForEmbeddedPiRunEnd,
} from "./pi-embedded-runner.js"; } from "./pi-embedded-runner.js";
Loaded 100 of 255 files, more files were not shown because too many files have changed in this diff. Show more