Compare commits
122
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faf046b426 | ||
|
|
71cda84bc9 | ||
|
|
a405619f56 | ||
|
|
7113b69534 | ||
|
|
77e03a2a90 | ||
|
|
378e4c9b6b | ||
|
|
5ce1eb791e | ||
|
|
529cf91ac3 | ||
|
|
672700f2b3 | ||
|
|
476bbd2915 | ||
|
|
9616add9b1 | ||
|
|
71fdf46f18 | ||
|
|
0d56a73118 | ||
|
|
1318276105 | ||
|
|
7aab2ae182 | ||
|
|
ec6980cda0 | ||
|
|
b234d82bf3 | ||
|
|
9958283ced | ||
|
|
d6f8b6ac51 | ||
|
|
8c38a7fee8 | ||
|
|
02d7e286ea | ||
|
|
3910b9b39e | ||
|
|
607de4a403 | ||
|
|
7701d395e9 | ||
|
|
0085b2e0a9 | ||
|
|
bf3d120f8c | ||
|
|
4b3ca29404 | ||
|
|
259b14d66a | ||
|
|
c9504a6f20 | ||
|
|
5e36e2c3f3 | ||
|
|
d2da305190 | ||
|
|
be9fa124df | ||
|
|
ff88f3c075 | ||
|
|
1315fc4caf | ||
|
|
a03895dfa9 | ||
|
|
40c3898ca1 | ||
|
|
6ea0eb438c | ||
|
|
04cd1bd11a | ||
|
|
fe0b3500cc | ||
|
|
b978cc4e91 | ||
|
|
72a9e58777 | ||
|
|
fd91da2b7f | ||
|
|
5673f4299a | ||
|
|
770daadaf7 | ||
|
|
13c2f22240 | ||
|
|
f2ce455c8c | ||
|
|
640ec465d7 | ||
|
|
70f79bd926 | ||
|
|
7d95f43a75 | ||
|
|
c2f3b653c2 | ||
|
|
12ba32c724 | ||
|
|
0e75aa2716 | ||
|
|
44990d837f | ||
|
|
3a28e3562c | ||
|
|
24aa3e3311 | ||
|
|
3c4c2aa98c | ||
|
|
3ebee63cb3 | ||
|
|
6d6038b855 | ||
|
|
55876f7be0 | ||
|
|
cd3c42d0c0 | ||
|
|
add1301a51 | ||
|
|
052cec70ae | ||
|
|
534de59f7c | ||
|
|
1d06164e18 | ||
|
|
fe67073b74 | ||
|
|
cbf41859aa | ||
|
|
12186e14a9 | ||
|
|
fbaa109a3a | ||
|
|
70d68d29d0 | ||
|
|
e7615c464a | ||
|
|
a1780efb9f | ||
|
|
53d954695e | ||
|
|
44bdd4ca0c | ||
|
|
8724c2aea8 | ||
|
|
e3c543ec06 | ||
|
|
412e8b3aee | ||
|
|
5862f95bd2 | ||
|
|
e17c038d18 | ||
|
|
e1dd764504 | ||
|
|
52f59e6dc1 | ||
|
|
3bc24bf179 | ||
|
|
e07fdd117d | ||
|
|
7c062e0ef2 | ||
|
|
0f1781fc2c | ||
|
|
0f6e566a20 | ||
|
|
03ee77b0e1 | ||
|
|
86038ec165 | ||
|
|
e7c9b9a749 | ||
|
|
919d5d1dbb | ||
|
|
3f7c69fa7f | ||
|
|
cc07ea82a4 | ||
|
|
30e22769bb | ||
|
|
6c406b488d | ||
|
|
f13f89e8b9 | ||
|
|
8b069e62fc | ||
|
|
e2709a3ebd | ||
|
|
18a89a31af | ||
|
|
934f891932 | ||
|
|
5493772910 | ||
|
|
c533593d8e | ||
|
|
fe1b894676 | ||
|
|
d88581eb7c | ||
|
|
3d39e2ad75 | ||
|
|
2dc10ce337 | ||
|
|
d8a417f7ff | ||
|
|
107dc1aa42 | ||
|
|
9d2d0c64c2 | ||
|
|
3872f32419 | ||
|
|
3b075dff8a | ||
|
|
7bad9f3fbd | ||
|
|
16e3535ac0 | ||
|
|
a15cffb7de | ||
|
|
03c1599544 | ||
|
|
6464d93bbb | ||
|
|
424d31af1f | ||
|
|
e9d7ac8e84 | ||
|
|
fac694fc03 | ||
|
|
3e84b9632d | ||
|
|
ce3fd09e14 | ||
|
|
641080a0b6 | ||
|
|
99c3fc1128 | ||
|
|
8c7b2aa2d3 |
No files matched your search
+67
-60
@@ -5,12 +5,33 @@ on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
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:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -77,37 +98,42 @@ jobs:
|
||||
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
|
||||
|
||||
- name: Lint (node)
|
||||
if: matrix.runtime == 'node'
|
||||
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
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
macos-app:
|
||||
if: github.event_name == 'pull_request'
|
||||
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:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -142,35 +168,8 @@ jobs:
|
||||
xcodebuild -version
|
||||
swift --version
|
||||
|
||||
- name: SwiftLint
|
||||
run: swiftlint --config .swiftlint.yml
|
||||
|
||||
- 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
|
||||
- name: Run ${{ matrix.task }}
|
||||
run: ${{ matrix.command }}
|
||||
ios:
|
||||
if: false # ignore iOS in CI for now
|
||||
runs-on: macos-latest
|
||||
@@ -346,6 +345,14 @@ jobs:
|
||||
|
||||
android:
|
||||
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:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -385,6 +392,6 @@ jobs:
|
||||
"platforms;android-36" \
|
||||
"build-tools;36.0.0"
|
||||
|
||||
- name: Android unit tests + debug build
|
||||
- name: Run Android ${{ matrix.task }}
|
||||
working-directory: apps/android
|
||||
run: ./gradlew --no-daemon :app:testDebugUnitTest :app:assembleDebug
|
||||
run: ${{ matrix.command }}
|
||||
@@ -5,18 +5,34 @@
|
||||
### 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`.
|
||||
- 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
|
||||
- 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.
|
||||
- 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.
|
||||
- Config: expose schema + UI hints for generic config forms (Web UI + future clients).
|
||||
- Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia.
|
||||
- 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.
|
||||
- Sessions: add agent‑to‑agent post step with `ANNOUNCE_SKIP` to suppress channel announcements.
|
||||
|
||||
### Fixes
|
||||
- Gateway/macOS: keep node presence fresh with periodic beacons + show presence status in Instances (#168) — thanks @mbelinky.
|
||||
- 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.
|
||||
- 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: default elevated to off when not allowed (#183) — thanks @cash-echo-bot.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -24,19 +40,24 @@
|
||||
- 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: 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: 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: format verbose tool summaries without brackets, with unique emojis and `tool: detail` style.
|
||||
- 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: 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: 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 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.
|
||||
- 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: 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.
|
||||
- WhatsApp: honor per-group mention gating overrides when group ids are stored as session keys.
|
||||
- Dependencies: bump pi-mono packages to 0.32.3.
|
||||
@@ -45,10 +66,15 @@
|
||||
- Skills: add Sheets/Docs examples to gog skill (#128) — thanks @mbelinky.
|
||||
- Skills: clarify bear-notes token + callback usage (#120) — thanks @tylerwince.
|
||||
- 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.
|
||||
- Gateway: document port configuration + multi-instance isolation.
|
||||
- Gateway: document config hot reload + reload matrix.
|
||||
- Onboarding/Config: add protocol notes for wizard + schema RPC.
|
||||
- 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 post step and `ANNOUNCE_SKIP`.
|
||||
|
||||
## 2.0.0-beta5 — 2026-01-03
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Welcome to the lobster tank! 🦞
|
||||
- **Peter Steinberger** - Benevolent Dictator
|
||||
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
|
||||
|
||||
- **Shadow** - Discord subsystem
|
||||
- **Shadow** - Discord + Slack subsystem
|
||||
- GitHub: [@4shadowed](https://github.com/4shadowed) · X: [@4shad0wed](https://x.com/4shad0wed)
|
||||
|
||||
- **Jos** - Telegram, API, Nix mode
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
+1
-1
Submodule Peekaboo updated: 9db365b73c...5c8eedd642.
@@ -18,7 +18,7 @@
|
||||
**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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -9,17 +9,18 @@
|
||||
<uses-permission
|
||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_COARSE_LOCATION"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.telephony"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
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)
|
||||
viewModel.camera.attachLifecycleOwner(this)
|
||||
viewModel.camera.attachPermissionRequester(permissionRequester)
|
||||
viewModel.sms.attachPermissionRequester(permissionRequester)
|
||||
viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester)
|
||||
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.CanvasController
|
||||
import com.clawdis.android.node.ScreenRecordManager
|
||||
import com.clawdis.android.node.SmsManager
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
@@ -15,6 +16,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val canvas: CanvasController = runtime.canvas
|
||||
val camera: CameraCaptureManager = runtime.camera
|
||||
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
|
||||
val sms: SmsManager = runtime.sms
|
||||
|
||||
val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
|
||||
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
|
||||
@@ -33,6 +35,8 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val instanceId: StateFlow<String> = runtime.instanceId
|
||||
val displayName: StateFlow<String> = runtime.displayName
|
||||
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 wakeWords: StateFlow<List<String>> = runtime.wakeWords
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode
|
||||
@@ -70,6 +74,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.setCameraEnabled(value)
|
||||
}
|
||||
|
||||
fun setLocationMode(mode: LocationMode) {
|
||||
runtime.setLocationMode(mode)
|
||||
}
|
||||
|
||||
fun setLocationPreciseEnabled(value: Boolean) {
|
||||
runtime.setLocationPreciseEnabled(value)
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
runtime.setPreventSleep(value)
|
||||
}
|
||||
@@ -106,6 +118,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.setTalkEnabled(enabled)
|
||||
}
|
||||
|
||||
fun refreshBridgeHello() {
|
||||
runtime.refreshBridgeHello()
|
||||
}
|
||||
|
||||
fun connect(endpoint: BridgeEndpoint) {
|
||||
runtime.connect(endpoint)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.clawdis.android
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
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.BridgeSession
|
||||
import com.clawdis.android.node.CameraCaptureManager
|
||||
import com.clawdis.android.node.LocationCaptureManager
|
||||
import com.clawdis.android.BuildConfig
|
||||
import com.clawdis.android.node.CanvasController
|
||||
import com.clawdis.android.node.ScreenRecordManager
|
||||
import com.clawdis.android.node.SmsManager
|
||||
import com.clawdis.android.protocol.ClawdisCapability
|
||||
import com.clawdis.android.protocol.ClawdisCameraCommand
|
||||
import com.clawdis.android.protocol.ClawdisCanvasA2UIAction
|
||||
import com.clawdis.android.protocol.ClawdisCanvasA2UICommand
|
||||
import com.clawdis.android.protocol.ClawdisCanvasCommand
|
||||
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.VoiceWakeManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -55,7 +61,9 @@ class NodeRuntime(context: Context) {
|
||||
val prefs = SecurePrefs(appContext)
|
||||
val canvas = CanvasController()
|
||||
val camera = CameraCaptureManager(appContext)
|
||||
val location = LocationCaptureManager(appContext)
|
||||
val screenRecorder = ScreenRecordManager(appContext)
|
||||
val sms = SmsManager(appContext)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val externalAudioCaptureActive = MutableStateFlow(false)
|
||||
@@ -186,6 +194,8 @@ class NodeRuntime(context: Context) {
|
||||
val instanceId: StateFlow<String> = prefs.instanceId
|
||||
val displayName: StateFlow<String> = prefs.displayName
|
||||
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 wakeWords: StateFlow<List<String>> = prefs.wakeWords
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = prefs.voiceWakeMode
|
||||
@@ -312,6 +322,14 @@ class NodeRuntime(context: Context) {
|
||||
prefs.setCameraEnabled(value)
|
||||
}
|
||||
|
||||
fun setLocationMode(mode: LocationMode) {
|
||||
prefs.setLocationMode(mode)
|
||||
}
|
||||
|
||||
fun setLocationPreciseEnabled(value: Boolean) {
|
||||
prefs.setLocationPreciseEnabled(value)
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
prefs.setPreventSleep(value)
|
||||
}
|
||||
@@ -349,104 +367,128 @@ class NodeRuntime(context: Context) {
|
||||
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) {
|
||||
scope.launch {
|
||||
_statusText.value = "Connecting…"
|
||||
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 =
|
||||
if (storedToken.isNullOrBlank()) {
|
||||
_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(
|
||||
endpoint = endpoint,
|
||||
hello =
|
||||
BridgePairingClient.Hello(
|
||||
nodeId = instanceId.value,
|
||||
displayName = displayName.value,
|
||||
token = null,
|
||||
platform = "Android",
|
||||
version = advertisedVersion,
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = modelIdentifier,
|
||||
caps = caps,
|
||||
commands = invokeCommands,
|
||||
),
|
||||
hello = buildPairingHello(token = null),
|
||||
)
|
||||
} else {
|
||||
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
val authToken = requireNotNull(resolved.token).trim()
|
||||
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(
|
||||
endpoint = endpoint,
|
||||
hello =
|
||||
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,
|
||||
),
|
||||
hello = buildSessionHello(token = authToken),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
val host = manualHost.value.trim()
|
||||
val port = manualPort.value
|
||||
@@ -667,6 +731,14 @@ class NodeRuntime(context: Context) {
|
||||
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) {
|
||||
ClawdisCanvasCommand.Present.rawValue -> {
|
||||
@@ -787,6 +859,59 @@ class NodeRuntime(context: Context) {
|
||||
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 -> {
|
||||
// Status pill mirrors screen recording state so it stays visible without overlay stacking.
|
||||
_screenRecordActive.value = true
|
||||
@@ -803,6 +928,17 @@ class NodeRuntime(context: Context) {
|
||||
_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 ->
|
||||
BridgeSession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
@@ -840,6 +976,25 @@ class NodeRuntime(context: Context) {
|
||||
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? {
|
||||
val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
|
||||
if (raw.isBlank()) return null
|
||||
|
||||
@@ -115,7 +115,7 @@ class PermissionRequester(private val activity: ComponentActivity) {
|
||||
|
||||
private fun buildRationaleMessage(permissions: List<String>): String {
|
||||
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 {
|
||||
@@ -127,6 +127,7 @@ class PermissionRequester(private val activity: ComponentActivity) {
|
||||
when (permission) {
|
||||
Manifest.permission.CAMERA -> "Camera"
|
||||
Manifest.permission.RECORD_AUDIO -> "Microphone"
|
||||
Manifest.permission.SEND_SMS -> "SMS"
|
||||
else -> permission
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,14 @@ class SecurePrefs(context: Context) {
|
||||
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
|
||||
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))
|
||||
val preventSleep: StateFlow<Boolean> = _preventSleep
|
||||
|
||||
@@ -93,6 +101,16 @@ class SecurePrefs(context: Context) {
|
||||
_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) {
|
||||
prefs.edit { putBoolean("screen.preventSleep", value) }
|
||||
_preventSleep.value = value
|
||||
|
||||
@@ -37,21 +37,21 @@ class BridgePairingClient {
|
||||
withContext(Dispatchers.IO) {
|
||||
val socket = Socket()
|
||||
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 {
|
||||
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(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("hello"))
|
||||
@@ -111,6 +111,9 @@ class BridgePairingClient {
|
||||
}
|
||||
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 {
|
||||
try {
|
||||
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() {
|
||||
desired = null
|
||||
// Unblock connectOnce() read loop. Coroutine cancellation alone won't interrupt BufferedReader.readLine().
|
||||
@@ -196,20 +203,7 @@ class BridgeSession(
|
||||
currentConnection = conn
|
||||
|
||||
try {
|
||||
conn.sendJson(
|
||||
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))) }
|
||||
},
|
||||
)
|
||||
conn.sendJson(buildHelloJson(hello))
|
||||
|
||||
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
||||
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? {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+20
@@ -4,7 +4,9 @@ enum class ClawdisCapability(val rawValue: String) {
|
||||
Canvas("canvas"),
|
||||
Camera("camera"),
|
||||
Screen("screen"),
|
||||
Sms("sms"),
|
||||
VoiceWake("voiceWake"),
|
||||
Location("location"),
|
||||
}
|
||||
|
||||
enum class ClawdisCanvasCommand(val rawValue: String) {
|
||||
@@ -49,3 +51,21 @@ enum class ClawdisScreenCommand(val rawValue: String) {
|
||||
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
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -42,12 +46,14 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.clawdis.android.BuildConfig
|
||||
import com.clawdis.android.LocationMode
|
||||
import com.clawdis.android.MainViewModel
|
||||
import com.clawdis.android.NodeForegroundService
|
||||
import com.clawdis.android.VoiceWakeMode
|
||||
@@ -58,6 +64,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
val instanceId by viewModel.instanceId.collectAsState()
|
||||
val displayName by viewModel.displayName.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 wakeWords by viewModel.wakeWords.collectAsState()
|
||||
val voiceWakeMode by viewModel.voiceWakeMode.collectAsState()
|
||||
@@ -101,11 +109,63 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
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 =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ ->
|
||||
// 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) {
|
||||
if (!checked) {
|
||||
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 =
|
||||
if (isConnected && remoteAddress != null) {
|
||||
bridges.filterNot { "${it.host}:${it.port}" == remoteAddress }
|
||||
@@ -149,7 +250,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
contentPadding = PaddingValues(16.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 {
|
||||
OutlinedTextField(
|
||||
@@ -423,6 +524,104 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
|
||||
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
|
||||
item { Text("Screen", style = MaterialTheme.typography.titleSmall) }
|
||||
item {
|
||||
@@ -453,3 +652,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -251,6 +255,9 @@ final class BridgeConnectionController {
|
||||
commands.append(ClawdisCameraCommand.snap.rawValue)
|
||||
commands.append(ClawdisCameraCommand.clip.rawValue)
|
||||
}
|
||||
if caps.contains(ClawdisCapability.location.rawValue) {
|
||||
commands.append(ClawdisLocationCommand.get.rawValue)
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
<string>Clawdis can capture photos or short video clips when requested via the bridge.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<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>
|
||||
<string>Clawdis needs microphone access for voice wake.</string>
|
||||
<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)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ final class NodeAppModel {
|
||||
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
|
||||
let voiceWake = VoiceWakeManager()
|
||||
let talkMode = TalkModeManager()
|
||||
private let locationService = LocationService()
|
||||
private var lastAutoA2uiURL: String?
|
||||
|
||||
var bridgeSession: BridgeSession { self.bridge }
|
||||
@@ -188,6 +189,19 @@ final class NodeAppModel {
|
||||
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(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello)
|
||||
@@ -466,6 +480,64 @@ final class NodeAppModel {
|
||||
|
||||
do {
|
||||
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:
|
||||
let params = (try? Self.decodeParams(ClawdisCanvasPresentParams.self, from: req.paramsJSON)) ??
|
||||
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 {
|
||||
guard let json, let data = json.data(using: .utf8) else {
|
||||
throw NSError(domain: "Bridge", code: 20, userInfo: [
|
||||
|
||||
@@ -23,6 +23,8 @@ struct SettingsTab: View {
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
@AppStorage("talk.button.enabled") private var talkButtonEnabled: 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("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
|
||||
@AppStorage("bridge.lastDiscoveredStableID") private var lastDiscoveredBridgeStableID: String = ""
|
||||
@@ -34,6 +36,7 @@ struct SettingsTab: View {
|
||||
@State private var connectStatus = ConnectStatusStore()
|
||||
@State private var connectingBridgeID: String?
|
||||
@State private var localIPAddress: String?
|
||||
@State private var lastLocationModeRaw: String = ClawdisLocationMode.off.rawValue
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -181,6 +184,22 @@ struct SettingsTab: View {
|
||||
.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") {
|
||||
Toggle("Prevent Sleep", isOn: self.$preventSleep)
|
||||
Text("Keeps the screen awake while Clawdis is open.")
|
||||
@@ -201,6 +220,7 @@ struct SettingsTab: View {
|
||||
}
|
||||
.onAppear {
|
||||
self.localIPAddress = Self.primaryIPv4Address()
|
||||
self.lastLocationModeRaw = self.locationEnabledModeRaw
|
||||
}
|
||||
.onChange(of: self.preferredBridgeStableID) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -210,6 +230,20 @@ struct SettingsTab: View {
|
||||
.onChange(of: self.appModel.bridgeServerName) { _, _ in
|
||||
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)"
|
||||
}
|
||||
|
||||
private var locationMode: ClawdisLocationMode {
|
||||
ClawdisLocationMode(rawValue: self.locationEnabledModeRaw) ?? .off
|
||||
}
|
||||
|
||||
private func appVersion() -> String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import SwiftUI
|
||||
@Observable
|
||||
final class AppState {
|
||||
private let isPreview: Bool
|
||||
private var isInitializing = true
|
||||
private var configWatcher: ConfigFileWatcher?
|
||||
private var suppressVoiceWakeGlobalSync = false
|
||||
private var voiceWakeGlobalSyncTask: Task<Void, Never>?
|
||||
|
||||
@@ -153,6 +155,7 @@ final class AppState {
|
||||
var connectionMode: ConnectionMode {
|
||||
didSet {
|
||||
self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
|
||||
self.syncGatewayConfigIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +184,10 @@ final class AppState {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -245,13 +251,44 @@ final class AppState {
|
||||
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)
|
||||
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
|
||||
} else {
|
||||
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.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||
@@ -278,6 +315,138 @@ final class AppState {
|
||||
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
||||
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) {
|
||||
|
||||
@@ -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 canvasEnabledKey = "clawdis.canvasEnabled"
|
||||
let cameraEnabledKey = "clawdis.cameraEnabled"
|
||||
let locationModeKey = "clawdis.locationMode"
|
||||
let locationPreciseKey = "clawdis.locationPreciseEnabled"
|
||||
let peekabooBridgeEnabledKey = "clawdis.peekabooBridgeEnabled"
|
||||
let deepLinkKeyKey = "clawdis.deepLinkKey"
|
||||
let modelCatalogPathKey = "clawdis.modelCatalogPath"
|
||||
|
||||
@@ -306,6 +306,7 @@ extension GatewayConnection {
|
||||
struct SnapshotConfig: Decodable, Sendable {
|
||||
struct Session: Decodable, Sendable {
|
||||
let mainKey: String?
|
||||
let scope: String?
|
||||
}
|
||||
|
||||
let session: Session?
|
||||
@@ -316,9 +317,11 @@ extension GatewayConnection {
|
||||
|
||||
static func mainSessionKey(fromConfigGetData data: Data) throws -> String {
|
||||
let snapshot = try JSONDecoder().decode(ConfigGetSnapshot.self, from: data)
|
||||
let raw = snapshot.config?.session?.mainKey
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "main" : trimmed
|
||||
let scope = snapshot.config?.session?.scope?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if scope == "global" {
|
||||
return "global"
|
||||
}
|
||||
return "main"
|
||||
}
|
||||
|
||||
func mainSessionKey(timeoutMs: Double = 15000) async -> String {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import AppKit
|
||||
import ClawdisIPC
|
||||
import ClawdisKit
|
||||
import CoreLocation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct GeneralSettings: View {
|
||||
@Bindable var state: AppState
|
||||
@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 gatewayManager = GatewayProcessManager.shared
|
||||
@State private var gatewayDiscovery = GatewayDiscoveryModel()
|
||||
@@ -18,6 +22,7 @@ struct GeneralSettings: View {
|
||||
@State private var showRemoteAdvanced = false
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
||||
@State private var lastLocationModeRaw: String = ClawdisLocationMode.off.rawValue
|
||||
|
||||
var body: some View {
|
||||
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.",
|
||||
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(
|
||||
title: "Enable Peekaboo Bridge",
|
||||
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 }
|
||||
self.refreshCLIStatus()
|
||||
self.refreshGatewayStatus()
|
||||
self.lastLocationModeRaw = self.locationModeRaw
|
||||
}
|
||||
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
||||
if !enabled {
|
||||
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> {
|
||||
@@ -104,6 +144,29 @@ struct GeneralSettings: View {
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Clawdis runs")
|
||||
|
||||
@@ -73,6 +73,7 @@ struct InstancesSettings: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Text(inst.host ?? "unknown host").font(.subheadline.bold())
|
||||
self.presenceIndicator(inst)
|
||||
if let ip = inst.ip { Text("(") + Text(ip).monospaced() + Text(")") }
|
||||
}
|
||||
|
||||
@@ -146,6 +147,29 @@ struct InstancesSettings: View {
|
||||
.font(.footnote)
|
||||
}
|
||||
|
||||
private func presenceIndicator(_ inst: InstanceInfo) -> some View {
|
||||
let status = self.presenceStatus(for: inst)
|
||||
return HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(status.color)
|
||||
.frame(width: 6, height: 6)
|
||||
.accessibilityHidden(true)
|
||||
Text(status.label)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.caption)
|
||||
.help("Presence updated \(inst.ageDescription).")
|
||||
.accessibilityLabel("\(status.label) presence")
|
||||
}
|
||||
|
||||
private func presenceStatus(for inst: InstanceInfo) -> (label: String, color: Color) {
|
||||
let nowMs = Date().timeIntervalSince1970 * 1000
|
||||
let ageSeconds = max(0, Int((nowMs - inst.ts) / 1000))
|
||||
if ageSeconds <= 120 { return ("Active", .green) }
|
||||
if ageSeconds <= 300 { return ("Idle", .yellow) }
|
||||
return ("Stale", .gray)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func leadingDeviceIcon(_ inst: InstanceInfo, device: DevicePresentation?) -> some View {
|
||||
let symbol = self.leadingDeviceSymbol(inst, device: device)
|
||||
@@ -307,6 +331,10 @@ struct InstancesSettings: View {
|
||||
return "Connect"
|
||||
case "disconnect":
|
||||
return "Disconnect"
|
||||
case "node-connected":
|
||||
return "Node connect"
|
||||
case "node-disconnected":
|
||||
return "Node disconnect"
|
||||
case "launch":
|
||||
return "Launch"
|
||||
case "periodic":
|
||||
|
||||
@@ -435,7 +435,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
compact.representedObject = row.key
|
||||
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: "")
|
||||
del.target = self
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -139,6 +143,9 @@ final class MacNodeModeCoordinator {
|
||||
commands.append(ClawdisCameraCommand.snap.rawValue)
|
||||
commands.append(ClawdisCameraCommand.clip.rawValue)
|
||||
}
|
||||
if capsSet.contains(ClawdisCapability.location.rawValue) {
|
||||
commands.append(ClawdisLocationCommand.get.rawValue)
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Foundation
|
||||
actor MacNodeRuntime {
|
||||
private let cameraCapture = CameraCaptureService()
|
||||
@MainActor private let screenRecorder = ScreenRecordService()
|
||||
@MainActor private let locationService = MacNodeLocationService()
|
||||
|
||||
// swiftlint:disable:next function_body_length cyclomatic_complexity
|
||||
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||
@@ -168,6 +169,63 @@ actor MacNodeRuntime {
|
||||
let payload = try Self.encodePayload(["devices": devices])
|
||||
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:
|
||||
let params = (try? Self.decodeParams(MacNodeScreenRecordParams.self, from: req.paramsJSON)) ??
|
||||
MacNodeScreenRecordParams()
|
||||
@@ -413,6 +471,16 @@ actor MacNodeRuntime {
|
||||
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(
|
||||
_ req: BridgeInvokeRequest,
|
||||
code: ClawdisNodeErrorCode,
|
||||
|
||||
@@ -6,6 +6,25 @@ import SwiftUI
|
||||
|
||||
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
|
||||
@Observable
|
||||
final class OnboardingWizardModel {
|
||||
@@ -285,11 +304,11 @@ struct OnboardingWizardStepView: View {
|
||||
return
|
||||
}
|
||||
let option = optionItems[selectedIndex].option
|
||||
onSubmit(option.value ?? AnyCodable(option.label))
|
||||
onSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
|
||||
case "multiselect":
|
||||
let values = optionItems
|
||||
.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))
|
||||
case "action":
|
||||
onSubmit(AnyCodable(true))
|
||||
@@ -307,12 +326,12 @@ private struct WizardOptionItem: Identifiable {
|
||||
}
|
||||
|
||||
private struct WizardOption {
|
||||
let value: AnyCodable?
|
||||
let value: ProtocolAnyCodable?
|
||||
let label: String
|
||||
let hint: String?
|
||||
}
|
||||
|
||||
private func decodeWizardStep(_ raw: [String: AnyCodable]?) -> WizardStep? {
|
||||
private func decodeWizardStep(_ raw: [String: ProtocolAnyCodable]?) -> WizardStep? {
|
||||
guard let raw else { return nil }
|
||||
do {
|
||||
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 [] }
|
||||
return raw.map { entry in
|
||||
let value = entry["value"]
|
||||
@@ -337,7 +356,7 @@ private func wizardStepType(_ step: WizardStep) -> String {
|
||||
(step.type.value as? String) ?? ""
|
||||
}
|
||||
|
||||
private func anyCodableString(_ value: AnyCodable?) -> String {
|
||||
private func anyCodableString(_ value: ProtocolAnyCodable?) -> String {
|
||||
switch value?.value {
|
||||
case let string as 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
|
||||
}
|
||||
|
||||
private func anyCodableBool(_ value: AnyCodable?) -> Bool {
|
||||
private func anyCodableBool(_ value: ProtocolAnyCodable?) -> Bool {
|
||||
switch value?.value {
|
||||
case let bool as 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 {
|
||||
case let arr as [AnyCodable]:
|
||||
case let arr as [ProtocolAnyCodable]:
|
||||
return arr
|
||||
case let arr as [Any]:
|
||||
return arr.map { AnyCodable($0) }
|
||||
return arr.map { ProtocolAnyCodable($0) }
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private func anyCodableEqual(_ lhs: AnyCodable?, _ rhs: AnyCodable?) -> Bool {
|
||||
private func anyCodableEqual(_ lhs: ProtocolAnyCodable?, _ rhs: ProtocolAnyCodable?) -> Bool {
|
||||
switch (lhs?.value, rhs?.value) {
|
||||
case let (l as String, r as String):
|
||||
return l == r
|
||||
|
||||
@@ -3,6 +3,7 @@ import ApplicationServices
|
||||
import AVFoundation
|
||||
import ClawdisIPC
|
||||
import CoreGraphics
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import Observation
|
||||
import Speech
|
||||
@@ -33,6 +34,8 @@ enum PermissionManager {
|
||||
await self.ensureSpeechRecognition(interactive: interactive)
|
||||
case .camera:
|
||||
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 {
|
||||
let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
let speech = SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||
@@ -176,6 +198,9 @@ enum PermissionManager {
|
||||
|
||||
case .camera:
|
||||
results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||
case .location:
|
||||
let status = CLLocationManager.authorizationStatus()
|
||||
results[cap] = status == .authorizedAlways || status == .authorizedWhenInUse
|
||||
}
|
||||
}
|
||||
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 {
|
||||
private static let logger = Logger(subsystem: "com.clawdis", category: "AppleScriptPermission")
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ struct PermissionRow: View {
|
||||
case .microphone: "Microphone"
|
||||
case .speechRecognition: "Speech Recognition"
|
||||
case .camera: "Camera"
|
||||
case .location: "Location"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +135,7 @@ struct PermissionRow: View {
|
||||
case .microphone: "Allow Voice Wake and audio capture"
|
||||
case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device"
|
||||
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 .speechRecognition: "waveform"
|
||||
case .camera: "camera"
|
||||
case .location: "location"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
<string>Clawdis captures the screen when the agent needs screenshots for context.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<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>
|
||||
<string>Clawdis needs the mic for Voice Wake tests and agent audio capture.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
|
||||
@@ -12,6 +12,7 @@ public enum Capability: String, Codable, CaseIterable, Sendable {
|
||||
case microphone
|
||||
case speechRecognition
|
||||
case camera
|
||||
case location
|
||||
}
|
||||
|
||||
public enum CameraFacing: String, Codable, Sendable {
|
||||
|
||||
@@ -340,6 +340,7 @@ public struct SendParams: Codable {
|
||||
public let to: String
|
||||
public let message: String
|
||||
public let mediaurl: String?
|
||||
public let gifplayback: Bool?
|
||||
public let provider: String?
|
||||
public let idempotencykey: String
|
||||
|
||||
@@ -347,12 +348,14 @@ public struct SendParams: Codable {
|
||||
to: String,
|
||||
message: String,
|
||||
mediaurl: String?,
|
||||
gifplayback: Bool?,
|
||||
provider: String?,
|
||||
idempotencykey: String
|
||||
) {
|
||||
self.to = to
|
||||
self.message = message
|
||||
self.mediaurl = mediaurl
|
||||
self.gifplayback = gifplayback
|
||||
self.provider = provider
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
@@ -360,6 +363,7 @@ public struct SendParams: Codable {
|
||||
case to
|
||||
case message
|
||||
case mediaurl = "mediaUrl"
|
||||
case gifplayback = "gifPlayback"
|
||||
case provider
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
@@ -374,6 +378,8 @@ public struct AgentParams: Codable {
|
||||
public let deliver: Bool?
|
||||
public let channel: String?
|
||||
public let timeout: Int?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
public let idempotencykey: String
|
||||
|
||||
public init(
|
||||
@@ -385,6 +391,8 @@ public struct AgentParams: Codable {
|
||||
deliver: Bool?,
|
||||
channel: String?,
|
||||
timeout: Int?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
idempotencykey: String
|
||||
) {
|
||||
self.message = message
|
||||
@@ -395,6 +403,8 @@ public struct AgentParams: Codable {
|
||||
self.deliver = deliver
|
||||
self.channel = channel
|
||||
self.timeout = timeout
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -406,10 +416,33 @@ public struct AgentParams: Codable {
|
||||
case deliver
|
||||
case channel
|
||||
case timeout
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
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 let mode: AnyCodable
|
||||
public let text: String
|
||||
@@ -613,27 +646,35 @@ public struct SessionsPatchParams: Codable {
|
||||
public let key: String
|
||||
public let thinkinglevel: AnyCodable?
|
||||
public let verboselevel: AnyCodable?
|
||||
public let elevatedlevel: AnyCodable?
|
||||
public let model: AnyCodable?
|
||||
public let sendpolicy: AnyCodable?
|
||||
public let groupactivation: AnyCodable?
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
thinkinglevel: AnyCodable?,
|
||||
verboselevel: AnyCodable?,
|
||||
elevatedlevel: AnyCodable?,
|
||||
model: AnyCodable?,
|
||||
sendpolicy: AnyCodable?,
|
||||
groupactivation: AnyCodable?
|
||||
) {
|
||||
self.key = key
|
||||
self.thinkinglevel = thinkinglevel
|
||||
self.verboselevel = verboselevel
|
||||
self.elevatedlevel = elevatedlevel
|
||||
self.model = model
|
||||
self.sendpolicy = sendpolicy
|
||||
self.groupactivation = groupactivation
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case thinkinglevel = "thinkingLevel"
|
||||
case verboselevel = "verboseLevel"
|
||||
case elevatedlevel = "elevatedLevel"
|
||||
case model
|
||||
case sendpolicy = "sendPolicy"
|
||||
case groupactivation = "groupActivation"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ import Testing
|
||||
}
|
||||
"""
|
||||
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
|
||||
#expect(key == "primary")
|
||||
#expect(key == "main")
|
||||
}
|
||||
|
||||
@Test func configGetSnapshotMainKeyFallsBackWhenEmptyOrWhitespace() throws {
|
||||
@@ -54,4 +54,14 @@ import Testing
|
||||
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
|
||||
#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 {
|
||||
let text = self.primaryText
|
||||
let split = ChatMarkdownSplitter.split(markdown: text)
|
||||
let textColor = self.isUser ? ClawdisChatTheme.userText : ClawdisChatTheme.assistantText
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
@@ -169,37 +168,40 @@ private struct ChatMessageBody: View {
|
||||
text: text,
|
||||
isUser: self.isUser)
|
||||
}
|
||||
} else {
|
||||
} else if self.isUser {
|
||||
let split = ChatMarkdownSplitter.split(markdown: text)
|
||||
ForEach(split.blocks) { block in
|
||||
switch block.kind {
|
||||
case .text:
|
||||
MarkdownTextView(text: block.text, textColor: textColor)
|
||||
MarkdownTextView(text: block.text, textColor: textColor, font: .system(size: 14))
|
||||
case let .code(language):
|
||||
CodeBlockView(code: block.text, language: language, isUser: self.isUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !split.images.isEmpty {
|
||||
ForEach(
|
||||
split.images,
|
||||
id: \ChatMarkdownSplitter.InlineImage.id)
|
||||
{ (item: ChatMarkdownSplitter.InlineImage) in
|
||||
if let img = item.image {
|
||||
ClawdisPlatformImageFactory.image(img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxHeight: 260)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
|
||||
} else {
|
||||
Text(item.label.isEmpty ? "Image" : item.label)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
if !split.images.isEmpty {
|
||||
ForEach(
|
||||
split.images,
|
||||
id: \ChatMarkdownSplitter.InlineImage.id)
|
||||
{ (item: ChatMarkdownSplitter.InlineImage) in
|
||||
if let img = item.image {
|
||||
ClawdisPlatformImageFactory.image(img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxHeight: 260)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
|
||||
} else {
|
||||
Text(item.label.isEmpty ? "Image" : item.label)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ChatAssistantTextBody(text: text)
|
||||
}
|
||||
|
||||
if !self.inlineAttachments.isEmpty {
|
||||
@@ -506,7 +508,7 @@ struct ChatStreamingAssistantBubble: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ChatMarkdownBody(text: self.text, textColor: ClawdisChatTheme.assistantText)
|
||||
ChatAssistantTextBody(text: self.text)
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
@@ -614,6 +616,7 @@ private struct TypingDots: View {
|
||||
private struct MarkdownTextView: View {
|
||||
let text: String
|
||||
let textColor: Color
|
||||
let font: Font
|
||||
|
||||
var body: some View {
|
||||
let normalized = self.text.replacingOccurrences(
|
||||
@@ -624,20 +627,36 @@ private struct MarkdownTextView: View {
|
||||
interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||
if let attributed = try? AttributedString(markdown: normalized, options: options) {
|
||||
Text(attributed)
|
||||
.font(.system(size: 14))
|
||||
.font(self.font)
|
||||
.foregroundStyle(self.textColor)
|
||||
} else {
|
||||
Text(normalized)
|
||||
.font(.system(size: 14))
|
||||
.font(self.font)
|
||||
.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
|
||||
private struct ChatMarkdownBody: View {
|
||||
let text: String
|
||||
let textColor: Color
|
||||
let font: Font
|
||||
|
||||
var body: some View {
|
||||
let split = ChatMarkdownSplitter.split(markdown: self.text)
|
||||
@@ -645,7 +664,7 @@ private struct ChatMarkdownBody: View {
|
||||
ForEach(split.blocks) { block in
|
||||
switch block.kind {
|
||||
case .text:
|
||||
MarkdownTextView(text: block.text, textColor: self.textColor)
|
||||
MarkdownTextView(text: block.text, textColor: self.textColor, font: self.font)
|
||||
case let .code(language):
|
||||
CodeBlockView(code: block.text, language: language, isUser: false)
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ public struct ClawdisChatView: View {
|
||||
.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)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
@@ -246,7 +246,7 @@ public struct ClawdisChatView: View {
|
||||
return true
|
||||
}
|
||||
if let text = self.viewModel.streamingAssistantText,
|
||||
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
AssistantTextParser.hasVisibleContent(in: text)
|
||||
{
|
||||
return true
|
||||
}
|
||||
@@ -261,7 +261,7 @@ public struct ClawdisChatView: View {
|
||||
|
||||
private var showsEmptyState: Bool {
|
||||
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.pendingToolCalls.isEmpty
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ public enum ClawdisCapability: String, Codable, Sendable {
|
||||
case camera
|
||||
case screen
|
||||
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
@@ -11,7 +11,7 @@ read_when:
|
||||
- Required: `--message <text>`
|
||||
- Session selection:
|
||||
- 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`).
|
||||
- Thinking/verbose:
|
||||
- Flags `--thinking <off|minimal|low|medium|high>` and `--verbose <on|off>` persist into the session store.
|
||||
|
||||
@@ -13,10 +13,11 @@ Clawdis runs shell commands through the `bash` tool and keeps long‑running tas
|
||||
|
||||
Key parameters:
|
||||
- `command` (required)
|
||||
- `yieldMs` (default 20000): auto‑background after this delay
|
||||
- `yieldMs` (default 10000): auto‑background after this delay
|
||||
- `background` (bool): background immediately
|
||||
- `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`
|
||||
|
||||
Behavior:
|
||||
@@ -30,7 +31,7 @@ Environment overrides:
|
||||
- `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m–3h)
|
||||
|
||||
Config (preferred):
|
||||
- `agent.bash.backgroundMs` (default 20000)
|
||||
- `agent.bash.backgroundMs` (default 10000)
|
||||
- `agent.bash.timeoutSec` (default 1800)
|
||||
- `agent.bash.cleanupMs` (default 1800000)
|
||||
|
||||
|
||||
+3
-15
@@ -12,18 +12,11 @@ Run shell commands in the workspace. Supports foreground + background execution
|
||||
## Parameters
|
||||
|
||||
- `command` (required)
|
||||
- `yieldMs` (default 20000): auto-background after delay
|
||||
- `yieldMs` (default 10000): auto-background after delay
|
||||
- `background` (bool): background immediately
|
||||
- `timeout` (seconds, default 1800): kill on expiry
|
||||
- `stdinMode` (`pipe` | `pty`):
|
||||
- `pipe` (default): classic stdin/stdout/stderr pipes
|
||||
- `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`).
|
||||
- `elevated` (bool): run on host if elevated mode is enabled/allowed
|
||||
- Need a real TTY? Use the tmux skill.
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -37,8 +30,3 @@ Background + poll:
|
||||
{"tool":"bash","command":"npm run build","yieldMs":1000}
|
||||
{"tool":"process","action":"poll","sessionId":"<id>"}
|
||||
```
|
||||
|
||||
TTY command:
|
||||
```json
|
||||
{"tool":"bash","command":"htop","stdinMode":"pty","background":true}
|
||||
```
|
||||
@@ -153,6 +153,59 @@ Hooks (arming):
|
||||
Clawdis should treat "open/closed" as a health check (fast path), not by scanning
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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:
|
||||
- `clawdis browser status`
|
||||
- `clawdis browser start`
|
||||
|
||||
+1
-2
@@ -138,8 +138,7 @@ Example:
|
||||
session: {
|
||||
scope: "per-sender",
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
idleMinutes: 10080,
|
||||
mainKey: "main"
|
||||
idleMinutes: 10080
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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 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).
|
||||
- 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)
|
||||
- Canvas: `canvas.present|navigate|eval|snapshot|a2ui.*`
|
||||
|
||||
+240
-5
@@ -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)
|
||||
|
||||
To prevent the bot from responding to WhatsApp @-mentions in groups (only respond to specific text triggers):
|
||||
@@ -287,6 +292,63 @@ Reaction notification modes:
|
||||
- `all`: all reactions on all messages.
|
||||
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
|
||||
|
||||
### `slack` (socket mode)
|
||||
|
||||
Slack runs in Socket Mode and requires both a bot token and app token:
|
||||
|
||||
```json5
|
||||
{
|
||||
slack: {
|
||||
enabled: true,
|
||||
botToken: "xoxb-...",
|
||||
appToken: "xapp-...",
|
||||
dm: {
|
||||
enabled: true,
|
||||
allowFrom: ["U123", "U456", "*"],
|
||||
groupEnabled: false,
|
||||
groupChannels: ["G123"]
|
||||
},
|
||||
channels: {
|
||||
C123: { allow: true, requireMention: true },
|
||||
"#general": { allow: true, requireMention: false }
|
||||
},
|
||||
reactionNotifications: "own", // off | own | all | allowlist
|
||||
reactionAllowlist: ["U123"],
|
||||
actions: {
|
||||
reactions: true,
|
||||
messages: true,
|
||||
pins: true,
|
||||
memberInfo: true,
|
||||
emojiList: true
|
||||
},
|
||||
slashCommand: {
|
||||
enabled: true,
|
||||
name: "clawd",
|
||||
sessionPrefix: "slack:slash",
|
||||
ephemeral: true
|
||||
},
|
||||
textChunkLimit: 4000,
|
||||
mediaMaxMb: 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Clawdis starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:<id>` (DM) or `channel:<id>` when specifying delivery targets for cron/CLI commands.
|
||||
|
||||
Reaction notification modes:
|
||||
- `off`: no reaction events.
|
||||
- `own`: reactions on the bot's own messages (default).
|
||||
- `all`: all reactions on all messages.
|
||||
- `allowlist`: reactions from `slack.reactionAllowlist` on all messages (empty list disables).
|
||||
|
||||
Slack action groups (gate `slack` tool actions):
|
||||
| Action group | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| reactions | enabled | React + list reactions |
|
||||
| messages | enabled | Read/send/edit/delete |
|
||||
| pins | enabled | Pin/unpin/list |
|
||||
| memberInfo | enabled | Member info |
|
||||
| emojiList | enabled | Custom emoji list |
|
||||
### `imessage` (imsg CLI)
|
||||
|
||||
Clawdis spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
|
||||
@@ -323,6 +385,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`
|
||||
|
||||
Controls inbound/outbound prefixes and timestamps.
|
||||
@@ -380,6 +445,7 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
|
||||
},
|
||||
thinkingDefault: "low",
|
||||
verboseDefault: "off",
|
||||
elevatedDefault: "on",
|
||||
timeoutSeconds: 600,
|
||||
mediaMaxMb: 5,
|
||||
heartbeat: {
|
||||
@@ -388,7 +454,7 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
|
||||
},
|
||||
maxConcurrent: 3,
|
||||
bash: {
|
||||
backgroundMs: 20000,
|
||||
backgroundMs: 10000,
|
||||
timeoutSec: 1800,
|
||||
cleanupMs: 1800000
|
||||
},
|
||||
@@ -427,14 +493,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`).
|
||||
|
||||
`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)
|
||||
- `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
|
||||
execute in parallel across sessions. Each session is still serialized (one run
|
||||
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)
|
||||
|
||||
Clawdis uses the **pi-coding-agent** model catalog. You can add custom providers
|
||||
@@ -541,11 +703,26 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
|
||||
idleMinutes: 60,
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
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)
|
||||
|
||||
Controls bundled allowlist, install preferences, extra skill folders, and per-skill
|
||||
@@ -598,11 +775,16 @@ Example:
|
||||
### `browser` (clawd-managed Chrome)
|
||||
|
||||
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:
|
||||
- enabled: `true`
|
||||
- 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)
|
||||
- Note: the control server is started by the running gateway (Clawdis.app menubar, or `clawdis gateway`).
|
||||
|
||||
@@ -611,7 +793,13 @@ Defaults:
|
||||
browser: {
|
||||
enabled: true,
|
||||
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",
|
||||
// Advanced:
|
||||
// headless: false,
|
||||
@@ -683,6 +871,11 @@ Remote client defaults (CLI):
|
||||
- `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).
|
||||
|
||||
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
|
||||
{
|
||||
gateway: {
|
||||
@@ -696,6 +889,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
|
||||
|
||||
To run multiple gateways on one host, isolate per-instance state + config and use unique ports:
|
||||
|
||||
+178
-8
@@ -9,16 +9,27 @@ read_when:
|
||||
|
||||
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
|
||||
./docker-setup.sh
|
||||
```
|
||||
|
||||
This script:
|
||||
- builds the image
|
||||
- builds the gateway image
|
||||
- runs the onboarding wizard
|
||||
- runs WhatsApp login
|
||||
- starts the gateway via Docker Compose
|
||||
@@ -27,7 +38,7 @@ It writes config/workspace on the host:
|
||||
- `~/.clawdis/`
|
||||
- `~/clawd`
|
||||
|
||||
## Manual flow (compose)
|
||||
### Manual flow (compose)
|
||||
|
||||
```bash
|
||||
docker build -t clawdis:local -f Dockerfile .
|
||||
@@ -36,14 +47,173 @@ docker compose run --rm clawdis-cli login
|
||||
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
|
||||
scripts/e2e/onboard-docker.sh
|
||||
```
|
||||
|
||||
## Notes
|
||||
### Notes
|
||||
|
||||
- Gateway bind defaults to `lan` for container use.
|
||||
- Health check:
|
||||
`docker compose exec clawdis-gateway node dist/index.js health --token "$CLAWDIS_GATEWAY_TOKEN"`
|
||||
- The gateway container is the source of truth for sessions (`~/.clawdis/sessions`).
|
||||
|
||||
## 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).
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -494,6 +494,7 @@ Quick reference (send these in chat):
|
||||
| `/new` or `/reset` | Reset the session |
|
||||
| `/think <level>` | Set thinking level (off\|minimal\|low\|medium\|high) |
|
||||
| `/verbose on\|off` | Toggle verbose mode |
|
||||
| `/elevated on\|off` | Toggle elevated bash mode (approved senders only) |
|
||||
| `/activation mention\|always` | Group activation (owner-only) |
|
||||
| `/model <name>` | Switch AI model (see below) |
|
||||
| `/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!
|
||||
|
||||
### 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?
|
||||
|
||||
Some models don't support extended thinking well:
|
||||
|
||||
@@ -22,6 +22,10 @@ pnpm clawdis gateway --force
|
||||
# dev loop (auto-reload on TS changes):
|
||||
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).
|
||||
- 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`.
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ message. If the reply is only `HEARTBEAT_OK`, it is dropped.
|
||||
- `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`).
|
||||
|
||||
## 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.
|
||||
- Empty output or `HEARTBEAT_OK` is treated as “ok” and does **not** keep the
|
||||
session alive (`updatedAt` is restored).
|
||||
|
||||
@@ -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.
|
||||
- **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.
|
||||
- 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.
|
||||
- Caption comes from `--message` or `reply.text`; empty caption is allowed.
|
||||
- Logging: non-verbose shows `↩️`/`✅`; verbose includes size and source path/URL.
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ Pairing details: `docs/gateway/pairing.md`.
|
||||
|
||||
## 5) Verify the node is connected
|
||||
|
||||
- In the macOS app: **Instances** tab should show something like `iOS Node (...)`.
|
||||
- In the macOS app: **Instances** tab should show something like `iOS Node (...)` with a green “Active” presence dot shortly after connect.
|
||||
- Via nodes status (paired + connected):
|
||||
```bash
|
||||
clawdis nodes status
|
||||
|
||||
@@ -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.”
|
||||
@@ -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.
|
||||
|
||||
## 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.
|
||||
- Activity kinds:
|
||||
- `job`: high‑level command execution (`state: started|streaming|done|error`).
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ read_when:
|
||||
---
|
||||
# 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.
|
||||
- **Remote mode**: forwards the Gateway WebSocket control port over SSH and uses that as the data plane.
|
||||
|
||||
@@ -94,6 +94,36 @@ Notes:
|
||||
- Screen recordings are clamped to `<= 60s`.
|
||||
- `--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)
|
||||
|
||||
The macOS node exposes `system.run` and `system.notify`.
|
||||
|
||||
+14
-1
@@ -24,7 +24,7 @@ Presence entries are structured objects with (some) fields:
|
||||
- `modelIdentifier` (optional): hardware model identifier like `iPad16,6` or `Mac16,6`
|
||||
- `mode`: e.g. `gateway`, `app`, `webchat`, `cli`
|
||||
- `lastInputSeconds` (optional): “seconds since last user input” for that client machine
|
||||
- `reason`: a short marker like `self`, `connect`, `periodic`, `instances-refresh`
|
||||
- `reason`: a short marker like `self`, `connect`, `node-connected`, `node-disconnected`, `periodic`, `instances-refresh`
|
||||
- `text`: legacy/debug summary string (kept for backwards compatibility and UI display)
|
||||
- `ts`: last update timestamp (ms since epoch)
|
||||
|
||||
@@ -61,6 +61,16 @@ Implementation:
|
||||
- Gateway: `src/gateway/server.ts` handles method `system-event` by calling `updateSystemPresence(...)`.
|
||||
- mac app beaconing: `apps/macos/Sources/Clawdis/PresenceReporter.swift`.
|
||||
|
||||
### 4) Node bridge beacons (gateway-owned presence)
|
||||
|
||||
When a node bridge connection authenticates, the Gateway emits a presence entry
|
||||
for that node and starts periodic refresh beacons so it does not expire.
|
||||
|
||||
- Connect/disconnect markers: `node-connected`, `node-disconnected`
|
||||
- Periodic heartbeat: every 3 minutes (`reason: periodic`)
|
||||
|
||||
Implementation: `src/gateway/server.ts` (node bridge handlers + timer beacons).
|
||||
|
||||
## Merge + dedupe rules (why `instanceId` matters)
|
||||
|
||||
All producers write into a single in-memory presence map.
|
||||
@@ -109,6 +119,9 @@ Implementation:
|
||||
- View: `apps/macos/Sources/Clawdis/InstancesSettings.swift`
|
||||
- Store: `apps/macos/Sources/Clawdis/InstancesStore.swift`
|
||||
|
||||
The Instances rows show a small presence indicator (Active/Idle/Stale) based on
|
||||
the last beacon age. The label is derived from the entry timestamp (`ts`).
|
||||
|
||||
The store refreshes periodically and also applies `presence` WS events.
|
||||
|
||||
## Debugging tips
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# Running Clawdis.app with a Remote Gateway
|
||||
|
||||
Clawdis.app uses SSH tunneling to connect to a remote gateway. This guide shows you how to set it up.
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ MacBook │
|
||||
│ │
|
||||
│ Clawdis.app ──► ws://127.0.0.1:18789 (local port) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ SSH Tunnel ────────────────────────────────────────────────│
|
||||
│ │ │
|
||||
└─────────────────────┼──────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Remote Machine │
|
||||
│ │
|
||||
│ Gateway WebSocket ──► ws://127.0.0.1:18789 ──► │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Setup
|
||||
|
||||
### Step 1: Add SSH Config
|
||||
|
||||
Edit `~/.ssh/config` and add:
|
||||
|
||||
```ssh
|
||||
Host remote-gateway
|
||||
HostName <REMOTE_IP> # e.g., 172.27.187.184
|
||||
User <REMOTE_USER> # e.g., jefferson
|
||||
LocalForward 18789 127.0.0.1:18789
|
||||
IdentityFile ~/.ssh/id_rsa
|
||||
```
|
||||
|
||||
Replace `<REMOTE_IP>` and `<REMOTE_USER>` with your values.
|
||||
|
||||
### Step 2: Copy SSH Key
|
||||
|
||||
Copy your public key to the remote machine (enter password once):
|
||||
|
||||
```bash
|
||||
ssh-copy-id -i ~/.ssh/id_rsa <REMOTE_USER>@<REMOTE_IP>
|
||||
```
|
||||
|
||||
### Step 3: Set Gateway Token
|
||||
|
||||
```bash
|
||||
launchctl setenv CLAWDIS_GATEWAY_TOKEN "<your-token>"
|
||||
```
|
||||
|
||||
### Step 4: Start SSH Tunnel
|
||||
|
||||
```bash
|
||||
ssh -N remote-gateway &
|
||||
```
|
||||
|
||||
### Step 5: Restart Clawdis.app
|
||||
|
||||
```bash
|
||||
killall Clawdis
|
||||
open /path/to/Clawdis.app
|
||||
```
|
||||
|
||||
The app will now connect to the remote gateway through the SSH tunnel.
|
||||
|
||||
---
|
||||
|
||||
## Auto-Start Tunnel on Login
|
||||
|
||||
To have the SSH tunnel start automatically when you log in, create a Launch Agent.
|
||||
|
||||
### Create the PLIST file
|
||||
|
||||
Save this as `~/Library/LaunchAgents/com.clawdis.ssh-tunnel.plist`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.clawdis.ssh-tunnel</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/bin/ssh</string>
|
||||
<string>-N</string>
|
||||
<string>remote-gateway</string>
|
||||
</array>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
### Load the Launch Agent
|
||||
|
||||
```bash
|
||||
launchctl load ~/Library/LaunchAgents/com.clawdis.ssh-tunnel.plist
|
||||
```
|
||||
|
||||
The tunnel will now:
|
||||
- Start automatically when you log in
|
||||
- Restart if it crashes
|
||||
- Keep running in the background
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Check if tunnel is running:**
|
||||
|
||||
```bash
|
||||
ps aux | grep "ssh -N remote-gateway" | grep -v grep
|
||||
lsof -i :18789
|
||||
```
|
||||
|
||||
**Restart the tunnel:**
|
||||
|
||||
```bash
|
||||
launchctl restart com.clawdis.ssh-tunnel
|
||||
```
|
||||
|
||||
**Stop the tunnel:**
|
||||
|
||||
```bash
|
||||
launchctl unload ~/Library/LaunchAgents/com.clawdis.ssh-tunnel.plist
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
| Component | What It Does |
|
||||
|-----------|--------------|
|
||||
| `LocalForward 18789 127.0.0.1:18789` | Forwards local port 18789 to remote port 18789 |
|
||||
| `ssh -N` | SSH without executing remote commands (just port forwarding) |
|
||||
| `KeepAlive` | Automatically restarts tunnel if it crashes |
|
||||
| `RunAtLoad` | Starts tunnel when the agent loads |
|
||||
|
||||
Clawdis.app connects to `ws://127.0.0.1:18789` on your MacBook. The SSH tunnel forwards that connection to port 18789 on the remote machine where the Gateway is running.
|
||||
@@ -99,6 +99,12 @@ services:
|
||||
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:
|
||||
- ✅ GoWA API (for WhatsApp)
|
||||
- ✅ Specific HTTP APIs
|
||||
|
||||
@@ -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
@@ -5,7 +5,7 @@ read_when:
|
||||
---
|
||||
# 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
|
||||
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.
|
||||
- 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.
|
||||
- Other sources:
|
||||
- Cron jobs: `cron:<job.id>`
|
||||
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
|
||||
- Node bridge runs: `node-<nodeId>`
|
||||
|
||||
## Lifecyle
|
||||
- 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.
|
||||
- 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)
|
||||
```json5
|
||||
// ~/.clawdis/clawdis.json
|
||||
@@ -41,7 +67,7 @@ All session state is **owned by the gateway** (the “master” Clawdis). UI cli
|
||||
idleMinutes: 120,
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
store: "~/.clawdis/sessions/sessions.json",
|
||||
mainKey: "main" // optional rename; still a single primary
|
||||
// mainKey is ignored; primary key is fixed to "main"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
# Slack (socket mode)
|
||||
|
||||
## Setup
|
||||
1) Create a Slack app (From scratch) in https://api.slack.com/apps.
|
||||
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
|
||||
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
|
||||
4) **Event Subscriptions** → enable events and subscribe to:
|
||||
- `message.*` (includes edits/deletes/thread broadcasts)
|
||||
- `app_mention`
|
||||
- `reaction_added`, `reaction_removed`
|
||||
- `member_joined_channel`, `member_left_channel`
|
||||
- `channel_rename`
|
||||
- `pin_added`, `pin_removed`
|
||||
5) Invite the bot to channels you want it to read.
|
||||
6) Slash Commands → create the `/clawd` command (or your preferred name).
|
||||
7) App Home → enable the **Messages Tab** so users can DM the bot.
|
||||
|
||||
Use the manifest below so scopes and events stay in sync.
|
||||
|
||||
## Manifest (optional)
|
||||
Use this Slack app manifest to create the app quickly (adjust the name/command if you want).
|
||||
|
||||
```json
|
||||
{
|
||||
"display_information": {
|
||||
"name": "Clawdbot",
|
||||
"description": "Slack connector for Clawdbot"
|
||||
},
|
||||
"features": {
|
||||
"bot_user": {
|
||||
"display_name": "Clawdbot",
|
||||
"always_online": false
|
||||
},
|
||||
"app_home": {
|
||||
"messages_tab_enabled": true,
|
||||
"messages_tab_read_only_enabled": false
|
||||
},
|
||||
"slash_commands": [
|
||||
{
|
||||
"command": "/clawd",
|
||||
"description": "Send a message to Clawdbot",
|
||||
"should_escape": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"oauth_config": {
|
||||
"scopes": {
|
||||
"bot": [
|
||||
"chat:write",
|
||||
"channels:history",
|
||||
"channels:read",
|
||||
"groups:history",
|
||||
"im:history",
|
||||
"mpim:history",
|
||||
"users:read",
|
||||
"app_mentions:read",
|
||||
"reactions:read",
|
||||
"reactions:write",
|
||||
"pins:read",
|
||||
"pins:write",
|
||||
"emoji:read",
|
||||
"commands",
|
||||
"files:read",
|
||||
"files:write"
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"socket_mode_enabled": true,
|
||||
"event_subscriptions": {
|
||||
"bot_events": [
|
||||
"app_mention",
|
||||
"message.channels",
|
||||
"message.groups",
|
||||
"message.im",
|
||||
"message.mpim",
|
||||
"reaction_added",
|
||||
"reaction_removed",
|
||||
"member_joined_channel",
|
||||
"member_left_channel",
|
||||
"channel_rename",
|
||||
"pin_added",
|
||||
"pin_removed"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Config
|
||||
Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens:
|
||||
|
||||
```json
|
||||
{
|
||||
"slack": {
|
||||
"enabled": true,
|
||||
"botToken": "xoxb-...",
|
||||
"appToken": "xapp-...",
|
||||
"dm": {
|
||||
"enabled": true,
|
||||
"allowFrom": ["U123", "U456", "*"],
|
||||
"groupEnabled": false,
|
||||
"groupChannels": ["G123"]
|
||||
},
|
||||
"channels": {
|
||||
"C123": { "allow": true, "requireMention": true },
|
||||
"#general": { "allow": true, "requireMention": false }
|
||||
},
|
||||
"reactionNotifications": "own",
|
||||
"reactionAllowlist": ["U123"],
|
||||
"actions": {
|
||||
"reactions": true,
|
||||
"messages": true,
|
||||
"pins": true,
|
||||
"memberInfo": true,
|
||||
"emojiList": true
|
||||
},
|
||||
"slashCommand": {
|
||||
"enabled": true,
|
||||
"name": "clawd",
|
||||
"sessionPrefix": "slack:slash",
|
||||
"ephemeral": true
|
||||
},
|
||||
"textChunkLimit": 4000,
|
||||
"mediaMaxMb": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Tokens can also be supplied via env vars:
|
||||
- `SLACK_BOT_TOKEN`
|
||||
- `SLACK_APP_TOKEN`
|
||||
|
||||
## Sessions + routing
|
||||
- DMs share the `main` session (like WhatsApp/Telegram).
|
||||
- Channels map to `slack:channel:<channelId>` sessions.
|
||||
- Slash commands use `slack:slash:<userId>` sessions.
|
||||
|
||||
## Delivery targets
|
||||
Use these with cron/CLI sends:
|
||||
- `user:<id>` for DMs
|
||||
- `channel:<id>` for channels
|
||||
|
||||
## Tool actions
|
||||
Slack tool actions can be gated with `slack.actions.*`:
|
||||
|
||||
| Action group | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| reactions | enabled | React + list reactions |
|
||||
| messages | enabled | Read/send/edit/delete |
|
||||
| pins | enabled | Pin/unpin/list |
|
||||
| memberInfo | enabled | Member info |
|
||||
| emojiList | enabled | Custom emoji list |
|
||||
|
||||
## Notes
|
||||
- Mention gating is controlled via `slack.channels` (set `requireMention` to `true`).
|
||||
- Reaction notifications follow `slack.reactionNotifications` (use `reactionAllowlist` with mode `allowlist`).
|
||||
- Attachments are downloaded to the media store when permitted and under the size limit.
|
||||
Vendored
+7
@@ -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`.
|
||||
|
||||
**🎭 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!
|
||||
|
||||
When you receive a `HEARTBEAT` message, don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
|
||||
|
||||
+5
-2
@@ -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:
|
||||
- Adjusting thinking level parsing or defaults
|
||||
- Adjusting thinking or verbose directive parsing or defaults
|
||||
---
|
||||
# Thinking Levels (/think directives)
|
||||
|
||||
@@ -34,6 +34,9 @@ read_when:
|
||||
- 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.
|
||||
|
||||
## Related
|
||||
- Elevated mode docs live in `docs/elevated.md`.
|
||||
|
||||
## Heartbeats
|
||||
- Heartbeat probe body is `HEARTBEAT`. Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
|
||||
|
||||
|
||||
+34
-2
@@ -18,10 +18,11 @@ Run shell commands in the workspace.
|
||||
|
||||
Core parameters:
|
||||
- `command` (required)
|
||||
- `yieldMs` (auto-background after timeout, default 20000)
|
||||
- `yieldMs` (auto-background after timeout, default 10000)
|
||||
- `background` (immediate background)
|
||||
- `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:
|
||||
- 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)
|
||||
- `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:
|
||||
- Requires `browser.enabled=true` in `~/.clawdis/clawdis.json`.
|
||||
- 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.
|
||||
- `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).
|
||||
@@ -78,11 +93,13 @@ Core actions:
|
||||
- `pending`, `approve`, `reject` (pairing)
|
||||
- `notify` (macOS `system.notify`)
|
||||
- `camera_snap`, `camera_clip`, `screen_record`
|
||||
- `location_get`
|
||||
|
||||
Notes:
|
||||
- Camera/screen commands require the node app to be foregrounded.
|
||||
- Images return image blocks + `MEDIA:<path>`.
|
||||
- Videos return `FILE:<path>` (mp4).
|
||||
- Location returns a JSON payload (lat/lon/accuracy/timestamp).
|
||||
|
||||
### `cron`
|
||||
Manage Gateway cron jobs and wakeups.
|
||||
@@ -105,6 +122,21 @@ Core actions:
|
||||
Notes:
|
||||
- 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`
|
||||
Send Discord reactions, stickers, or polls.
|
||||
|
||||
|
||||
+2
-1
@@ -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`).
|
||||
- `--token <token>`: Gateway token (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).
|
||||
- `--thinking <level>`: Override thinking level for sends.
|
||||
- `--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`)
|
||||
- `/think <off|minimal|low|medium|high>`
|
||||
- `/verbose <on|off>`
|
||||
- `/elevated <on|off>`
|
||||
- `/activation <mention|always>`
|
||||
- `/deliver <on|off>`
|
||||
- `/new` or `/reset`
|
||||
|
||||
+4
-1
@@ -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`.
|
||||
- Caption only on first media item.
|
||||
- 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
|
||||
- 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.target`
|
||||
- `agent.heartbeat.to`
|
||||
- `session.*` (scope, idle, store, mainKey)
|
||||
- `session.*` (scope, idle, store; `mainKey` is ignored)
|
||||
- `web.enabled` (disable provider startup when false)
|
||||
- `web.heartbeatSeconds`
|
||||
- `web.reconnect.*`
|
||||
|
||||
+5
-3
@@ -38,6 +38,8 @@
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.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",
|
||||
"release:check": "tsx scripts/release-check.ts",
|
||||
"ui:install": "pnpm -C ui install",
|
||||
@@ -77,6 +79,8 @@
|
||||
"@mariozechner/pi-ai": "^0.32.3",
|
||||
"@mariozechner/pi-coding-agent": "^0.32.3",
|
||||
"@mariozechner/pi-tui": "^0.32.3",
|
||||
"@slack/bolt": "^4.5.0",
|
||||
"@slack/web-api": "^7.11.1",
|
||||
"@sinclair/typebox": "0.34.46",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||
"ajv": "^8.17.1",
|
||||
@@ -95,7 +99,6 @@
|
||||
"grammy": "^1.39.2",
|
||||
"json5": "^2.2.3",
|
||||
"long": "5.3.2",
|
||||
"node-pty": "^1.1.0",
|
||||
"playwright-core": "1.57.0",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"sharp": "^0.34.5",
|
||||
@@ -137,8 +140,7 @@
|
||||
"@sinclair/typebox": "0.34.46"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch",
|
||||
"@mariozechner/pi-coding-agent@0.32.3": "patches/@mariozechner__pi-coding-agent@0.32.3.patch"
|
||||
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch"
|
||||
}
|
||||
},
|
||||
"vitest": {
|
||||
|
||||
@@ -1,8 +1,39 @@
|
||||
diff --git a/dist/providers/google-shared.js b/dist/providers/google-shared.js
|
||||
index ff9cbcfebfac6b4370d85dc838f5cacf2a60ed64..42096c82aec925b412258348a36ba4a7025b283b 100644
|
||||
index 7bc0a9f5d6241f191cd607ecb37b3acac8d58267..76166a34784cbc0718d4b9bd1fa6336a6dd394ec 100644
|
||||
--- a/dist/providers/google-shared.js
|
||||
+++ b/dist/providers/google-shared.js
|
||||
@@ -140,6 +140,72 @@ export function convertMessages(model, context) {
|
||||
@@ -51,9 +51,19 @@ export function convertMessages(model, context) {
|
||||
parts.push({ text: sanitizeSurrogates(block.text) });
|
||||
}
|
||||
else if (block.type === "thinking") {
|
||||
- // Thinking blocks require signatures for Claude via Antigravity.
|
||||
- // If signature is missing (e.g. from GPT-OSS), convert to regular text with delimiters.
|
||||
- if (block.thinkingSignature) {
|
||||
+ // Thinking blocks handling varies by model:
|
||||
+ // - Claude via Antigravity: requires thinkingSignature
|
||||
+ // - Gemini: skip entirely (doesn't understand thoughtSignature, and mimics <thinking> tags)
|
||||
+ // - Other models: convert to text with delimiters
|
||||
+ const isGemini = model.id.toLowerCase().includes("gemini");
|
||||
+ const isClaude = model.id.toLowerCase().includes("claude");
|
||||
+ if (isGemini) {
|
||||
+ // Skip thinking blocks entirely for Gemini - it doesn't support them
|
||||
+ // and will mimic <thinking> tags if we convert to text
|
||||
+ continue;
|
||||
+ }
|
||||
+ else if (block.thinkingSignature && isClaude) {
|
||||
+ // Claude via Antigravity requires the signature
|
||||
parts.push({
|
||||
thought: true,
|
||||
text: sanitizeSurrogates(block.thinking),
|
||||
@@ -61,6 +71,7 @@ export function convertMessages(model, context) {
|
||||
});
|
||||
}
|
||||
else {
|
||||
+ // Other models: convert to text with delimiters
|
||||
parts.push({
|
||||
text: `<thinking>\n${sanitizeSurrogates(block.thinking)}\n</thinking>`,
|
||||
});
|
||||
@@ -146,6 +157,77 @@ export function convertMessages(model, context) {
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
@@ -70,12 +101,17 @@ index ff9cbcfebfac6b4370d85dc838f5cacf2a60ed64..42096c82aec925b412258348a36ba4a7
|
||||
+ sanitized[key] = value;
|
||||
+ }
|
||||
+ }
|
||||
+ // Ensure type: "object" is present when properties or required exist
|
||||
+ // Google API requires type to be set when these fields are present
|
||||
+ if (('properties' in sanitized || 'required' in sanitized) && !('type' in sanitized)) {
|
||||
+ sanitized.type = 'object';
|
||||
+ }
|
||||
+ return sanitized;
|
||||
+}
|
||||
/**
|
||||
* Convert tools to Gemini function declarations format.
|
||||
*/
|
||||
@@ -151,7 +216,7 @@ export function convertTools(tools) {
|
||||
@@ -157,7 +239,7 @@ export function convertTools(tools) {
|
||||
functionDeclarations: tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
|
||||
Generated
+325
-25
@@ -9,11 +9,8 @@ overrides:
|
||||
|
||||
patchedDependencies:
|
||||
'@mariozechner/pi-ai':
|
||||
hash: 969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae
|
||||
hash: 628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5
|
||||
path: patches/@mariozechner__pi-ai.patch
|
||||
'@mariozechner/pi-coding-agent@0.32.3':
|
||||
hash: d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745
|
||||
path: patches/@mariozechner__pi-coding-agent@0.32.3.patch
|
||||
|
||||
importers:
|
||||
|
||||
@@ -33,16 +30,22 @@ importers:
|
||||
version: 0.32.3(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-ai':
|
||||
specifier: ^0.32.3
|
||||
version: 0.32.3(patch_hash=969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae)(ws@8.18.3)(zod@4.3.4)
|
||||
version: 0.32.3(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-coding-agent':
|
||||
specifier: ^0.32.3
|
||||
version: 0.32.3(patch_hash=d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745)(ws@8.18.3)(zod@4.3.4)
|
||||
version: 0.32.3(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-tui':
|
||||
specifier: ^0.32.3
|
||||
version: 0.32.3
|
||||
'@sinclair/typebox':
|
||||
specifier: 0.34.46
|
||||
version: 0.34.46
|
||||
'@slack/bolt':
|
||||
specifier: ^4.5.0
|
||||
version: 4.6.0(@types/express@5.0.6)
|
||||
'@slack/web-api':
|
||||
specifier: ^7.11.1
|
||||
version: 7.13.0
|
||||
'@whiskeysockets/baileys':
|
||||
specifier: 7.0.0-rc.9
|
||||
version: 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
|
||||
@@ -94,9 +97,6 @@ importers:
|
||||
long:
|
||||
specifier: 5.3.2
|
||||
version: 5.3.2
|
||||
node-pty:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
playwright-core:
|
||||
specifier: 1.57.0
|
||||
version: 1.57.0
|
||||
@@ -1101,6 +1101,32 @@ packages:
|
||||
'@sinclair/typebox@0.34.46':
|
||||
resolution: {integrity: sha512-kiW7CtS/NkdvTUjkjUJo7d5JsFfbJ14YjdhDk9KoEgK6nFjKNXZPrX0jfLA8ZlET4cFLHxOZ/0vFKOP+bOxIOQ==}
|
||||
|
||||
'@slack/bolt@4.6.0':
|
||||
resolution: {integrity: sha512-xPgfUs2+OXSugz54Ky07pA890+Qydk22SYToi8uGpXeHSt1JWwFJkRyd/9Vlg5I1AdfdpGXExDpwnbuN9Q/2dQ==}
|
||||
engines: {node: '>=18', npm: '>=8.6.0'}
|
||||
peerDependencies:
|
||||
'@types/express': ^5.0.0
|
||||
|
||||
'@slack/logger@4.0.0':
|
||||
resolution: {integrity: sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA==}
|
||||
engines: {node: '>= 18', npm: '>= 8.6.0'}
|
||||
|
||||
'@slack/oauth@3.0.4':
|
||||
resolution: {integrity: sha512-+8H0g7mbrHndEUbYCP7uYyBCbwqmm3E6Mo3nfsDvZZW74zKk1ochfH/fWSvGInYNCVvaBUbg3RZBbTp0j8yJCg==}
|
||||
engines: {node: '>=18', npm: '>=8.6.0'}
|
||||
|
||||
'@slack/socket-mode@2.0.5':
|
||||
resolution: {integrity: sha512-VaapvmrAifeFLAFaDPfGhEwwunTKsI6bQhYzxRXw7BSujZUae5sANO76WqlVsLXuhVtCVrBWPiS2snAQR2RHJQ==}
|
||||
engines: {node: '>= 18', npm: '>= 8.6.0'}
|
||||
|
||||
'@slack/types@2.19.0':
|
||||
resolution: {integrity: sha512-7+QZ38HGcNh/b/7MpvPG6jnw7mliV6UmrquJLqgdxkzJgQEYUcEztvFWRU49z0x4vthF0ixL5lTK601AXrS8IA==}
|
||||
engines: {node: '>= 12.13.0', npm: '>= 6.12.0'}
|
||||
|
||||
'@slack/web-api@7.13.0':
|
||||
resolution: {integrity: sha512-ERcExbWrnkDN8ovoWWe6Wgt/usanj1dWUd18dJLpctUI4mlPS0nKt81Joh8VI+OPbNnY1lIilVt9gdMBD9U2ig==}
|
||||
engines: {node: '>= 18', npm: '>= 8.6.0'}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
@@ -1159,6 +1185,9 @@ packages:
|
||||
'@types/http-errors@2.0.5':
|
||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
||||
|
||||
'@types/linkify-it@5.0.0':
|
||||
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||
|
||||
@@ -1174,6 +1203,9 @@ packages:
|
||||
'@types/mime-types@2.1.4':
|
||||
resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==}
|
||||
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/node@10.17.60':
|
||||
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
|
||||
|
||||
@@ -1189,6 +1221,9 @@ packages:
|
||||
'@types/range-parser@1.2.7':
|
||||
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
||||
|
||||
'@types/retry@0.12.0':
|
||||
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
|
||||
|
||||
'@types/send@1.2.1':
|
||||
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
|
||||
|
||||
@@ -1363,6 +1398,9 @@ packages:
|
||||
async-mutex@0.5.0:
|
||||
resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==}
|
||||
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
atomic-sleep@1.0.0:
|
||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@@ -1377,6 +1415,9 @@ packages:
|
||||
resolution: {integrity: sha512-En9AY6EG1qYqEy5L/quryzbA4akBpJrnBZNxeKTqGHC2xT9Qc4aZ8b7CcbOMFTTc/MGdoNyp+SN4zInZNKxMYA==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
axios@1.13.2:
|
||||
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
@@ -1492,6 +1533,10 @@ packages:
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
commander@14.0.2:
|
||||
resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -1546,6 +1591,10 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
delayed-stream@1.0.0:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
depd@2.0.0:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -1624,6 +1673,10 @@ packages:
|
||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-set-tostringtag@2.1.0:
|
||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
esbuild@0.27.2:
|
||||
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1647,6 +1700,9 @@ packages:
|
||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
eventemitter3@4.0.7:
|
||||
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
|
||||
|
||||
eventemitter3@5.0.1:
|
||||
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
||||
|
||||
@@ -1703,10 +1759,23 @@ packages:
|
||||
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
|
||||
engines: {node: '>= 18.0.0'}
|
||||
|
||||
follow-redirects@1.15.11:
|
||||
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
peerDependencies:
|
||||
debug: '*'
|
||||
peerDependenciesMeta:
|
||||
debug:
|
||||
optional: true
|
||||
|
||||
foreground-child@3.3.1:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
form-data@4.0.5:
|
||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
formdata-polyfill@4.0.10:
|
||||
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
@@ -1803,6 +1872,10 @@ packages:
|
||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hashery@1.4.0:
|
||||
resolution: {integrity: sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -1856,6 +1929,9 @@ packages:
|
||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-electron@2.2.2:
|
||||
resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==}
|
||||
|
||||
is-extglob@2.1.1:
|
||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1875,6 +1951,10 @@ packages:
|
||||
is-promise@4.0.0:
|
||||
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
|
||||
|
||||
is-stream@2.0.1:
|
||||
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-url@1.2.4:
|
||||
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
||||
|
||||
@@ -1938,6 +2018,10 @@ packages:
|
||||
jsonc-parser@3.3.1:
|
||||
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
||||
|
||||
jsonwebtoken@9.0.3:
|
||||
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
|
||||
jszip@3.10.1:
|
||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||
|
||||
@@ -2039,6 +2123,27 @@ packages:
|
||||
lit@3.3.2:
|
||||
resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==}
|
||||
|
||||
lodash.includes@4.3.0:
|
||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||
|
||||
lodash.isboolean@3.0.3:
|
||||
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||
|
||||
lodash.isinteger@4.0.4:
|
||||
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
|
||||
|
||||
lodash.isnumber@3.0.3:
|
||||
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
|
||||
|
||||
lodash.isplainobject@4.0.6:
|
||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||
|
||||
lodash.isstring@4.0.1:
|
||||
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
|
||||
|
||||
lodash.once@4.1.1:
|
||||
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||
|
||||
lodash.snakecase@4.1.1:
|
||||
resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==}
|
||||
|
||||
@@ -2118,10 +2223,18 @@ packages:
|
||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
mime-db@1.52.0:
|
||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-db@1.54.0:
|
||||
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@2.1.35:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@3.0.2:
|
||||
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2167,9 +2280,6 @@ packages:
|
||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
|
||||
node-domexception@1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
engines: {node: '>=10.5.0'}
|
||||
@@ -2188,9 +2298,6 @@ packages:
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==}
|
||||
engines: {node: '>=4.4.0'}
|
||||
@@ -2256,10 +2363,26 @@ packages:
|
||||
oxlint-tsgolint:
|
||||
optional: true
|
||||
|
||||
p-finally@1.0.0:
|
||||
resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
p-queue@6.6.2:
|
||||
resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-queue@9.0.1:
|
||||
resolution: {integrity: sha512-RhBdVhSwJb7Ocn3e8ULk4NMwBEuOxe+1zcgphUy9c2e5aR/xbEsdVXxHJ3lynw6Qiqu7OINEyHlZkiblEpaq7w==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
p-retry@4.6.2:
|
||||
resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-timeout@3.2.0:
|
||||
resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-timeout@7.0.1:
|
||||
resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -2383,6 +2506,9 @@ packages:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
proxy-from-env@1.1.0:
|
||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||
|
||||
punycode.js@2.3.1:
|
||||
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2456,6 +2582,10 @@ packages:
|
||||
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
retry@0.13.1:
|
||||
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
reusify@1.1.0:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
@@ -2703,6 +2833,10 @@ packages:
|
||||
resolution: {integrity: sha512-XuELoRpMR+sq8fuWwX7P0bcj+PRNiicOKDEb3fGNURhxWVyykCi9BNq7c4uVz7h7P0sj8qgBsr5SWS6yBClq3g==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
tsscmp@1.0.6:
|
||||
resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==}
|
||||
engines: {node: '>=0.6.x'}
|
||||
|
||||
tsx@4.21.0:
|
||||
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -3353,7 +3487,7 @@ snapshots:
|
||||
|
||||
'@mariozechner/pi-agent-core@0.32.3(ws@8.18.3)(zod@4.3.4)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 0.32.3(patch_hash=969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-ai': 0.32.3(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-tui': 0.32.3
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
@@ -3363,7 +3497,7 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-ai@0.32.3(patch_hash=969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae)(ws@8.18.3)(zod@4.3.4)':
|
||||
'@mariozechner/pi-ai@0.32.3(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.4)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.71.2(zod@4.3.4)
|
||||
'@google/genai': 1.34.0
|
||||
@@ -3383,10 +3517,10 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.32.3(patch_hash=d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745)(ws@8.18.3)(zod@4.3.4)':
|
||||
'@mariozechner/pi-coding-agent@0.32.3(ws@8.18.3)(zod@4.3.4)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-agent-core': 0.32.3(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-ai': 0.32.3(patch_hash=969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-ai': 0.32.3(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-tui': 0.32.3
|
||||
chalk: 5.6.2
|
||||
cli-highlight: 2.1.11
|
||||
@@ -3632,6 +3766,71 @@ snapshots:
|
||||
|
||||
'@sinclair/typebox@0.34.46': {}
|
||||
|
||||
'@slack/bolt@4.6.0(@types/express@5.0.6)':
|
||||
dependencies:
|
||||
'@slack/logger': 4.0.0
|
||||
'@slack/oauth': 3.0.4
|
||||
'@slack/socket-mode': 2.0.5
|
||||
'@slack/types': 2.19.0
|
||||
'@slack/web-api': 7.13.0
|
||||
'@types/express': 5.0.6
|
||||
axios: 1.13.2
|
||||
express: 5.2.1
|
||||
path-to-regexp: 8.3.0
|
||||
raw-body: 3.0.2
|
||||
tsscmp: 1.0.6
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- debug
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@slack/logger@4.0.0':
|
||||
dependencies:
|
||||
'@types/node': 25.0.3
|
||||
|
||||
'@slack/oauth@3.0.4':
|
||||
dependencies:
|
||||
'@slack/logger': 4.0.0
|
||||
'@slack/web-api': 7.13.0
|
||||
'@types/jsonwebtoken': 9.0.10
|
||||
'@types/node': 25.0.3
|
||||
jsonwebtoken: 9.0.3
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
'@slack/socket-mode@2.0.5':
|
||||
dependencies:
|
||||
'@slack/logger': 4.0.0
|
||||
'@slack/web-api': 7.13.0
|
||||
'@types/node': 25.0.3
|
||||
'@types/ws': 8.18.1
|
||||
eventemitter3: 5.0.1
|
||||
ws: 8.18.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- debug
|
||||
- utf-8-validate
|
||||
|
||||
'@slack/types@2.19.0': {}
|
||||
|
||||
'@slack/web-api@7.13.0':
|
||||
dependencies:
|
||||
'@slack/logger': 4.0.0
|
||||
'@slack/types': 2.19.0
|
||||
'@types/node': 25.0.3
|
||||
'@types/retry': 0.12.0
|
||||
axios: 1.13.2
|
||||
eventemitter3: 5.0.1
|
||||
form-data: 4.0.5
|
||||
is-electron: 2.2.2
|
||||
is-stream: 2.0.1
|
||||
p-queue: 6.6.2
|
||||
p-retry: 4.6.2
|
||||
retry: 0.13.1
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
@@ -3709,6 +3908,11 @@ snapshots:
|
||||
|
||||
'@types/http-errors@2.0.5': {}
|
||||
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
'@types/node': 25.0.3
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
'@types/long@4.0.2': {}
|
||||
@@ -3722,6 +3926,8 @@ snapshots:
|
||||
|
||||
'@types/mime-types@2.1.4': {}
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/node@10.17.60': {}
|
||||
|
||||
'@types/node@25.0.3':
|
||||
@@ -3734,6 +3940,8 @@ snapshots:
|
||||
|
||||
'@types/range-parser@1.2.7': {}
|
||||
|
||||
'@types/retry@0.12.0': {}
|
||||
|
||||
'@types/send@1.2.1':
|
||||
dependencies:
|
||||
'@types/node': 25.0.3
|
||||
@@ -3967,6 +4175,8 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
|
||||
audio-buffer@5.0.0:
|
||||
@@ -3987,6 +4197,14 @@ snapshots:
|
||||
audio-type@2.2.1:
|
||||
optional: true
|
||||
|
||||
axios@1.13.2:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11
|
||||
form-data: 4.0.5
|
||||
proxy-from-env: 1.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
balanced-match@3.0.1: {}
|
||||
@@ -4119,6 +4337,10 @@ snapshots:
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
commander@14.0.2: {}
|
||||
|
||||
commander@8.3.0: {}
|
||||
@@ -4155,6 +4377,8 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
depd@2.0.0: {}
|
||||
|
||||
dequal@2.0.3:
|
||||
@@ -4228,6 +4452,13 @@ snapshots:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
es-set-tostringtag@2.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.2
|
||||
|
||||
esbuild@0.27.2:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.27.2
|
||||
@@ -4269,6 +4500,8 @@ snapshots:
|
||||
|
||||
event-target-shim@5.0.1: {}
|
||||
|
||||
eventemitter3@4.0.7: {}
|
||||
|
||||
eventemitter3@5.0.1: {}
|
||||
|
||||
events@3.3.0: {}
|
||||
@@ -4359,11 +4592,21 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
follow-redirects@1.15.11: {}
|
||||
|
||||
foreground-child@3.3.1:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
|
||||
form-data@4.0.5:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.2
|
||||
mime-types: 2.1.35
|
||||
|
||||
formdata-polyfill@4.0.10:
|
||||
dependencies:
|
||||
fetch-blob: 3.2.0
|
||||
@@ -4484,6 +4727,10 @@ snapshots:
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
dependencies:
|
||||
has-symbols: 1.1.0
|
||||
|
||||
hashery@1.4.0:
|
||||
dependencies:
|
||||
hookified: 1.14.0
|
||||
@@ -4533,6 +4780,8 @@ snapshots:
|
||||
dependencies:
|
||||
binary-extensions: 2.3.0
|
||||
|
||||
is-electron@2.2.2: {}
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
@@ -4545,6 +4794,8 @@ snapshots:
|
||||
|
||||
is-promise@4.0.0: {}
|
||||
|
||||
is-stream@2.0.1: {}
|
||||
|
||||
is-url@1.2.4: {}
|
||||
|
||||
isarray@1.0.0: {}
|
||||
@@ -4606,6 +4857,19 @@ snapshots:
|
||||
|
||||
jsonc-parser@3.3.1: {}
|
||||
|
||||
jsonwebtoken@9.0.3:
|
||||
dependencies:
|
||||
jws: 4.0.1
|
||||
lodash.includes: 4.3.0
|
||||
lodash.isboolean: 3.0.3
|
||||
lodash.isinteger: 4.0.4
|
||||
lodash.isnumber: 3.0.3
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.isstring: 4.0.1
|
||||
lodash.once: 4.1.1
|
||||
ms: 2.1.3
|
||||
semver: 7.7.3
|
||||
|
||||
jszip@3.10.1:
|
||||
dependencies:
|
||||
lie: 3.3.0
|
||||
@@ -4706,6 +4970,20 @@ snapshots:
|
||||
lit-element: 4.2.2
|
||||
lit-html: 3.3.2
|
||||
|
||||
lodash.includes@4.3.0: {}
|
||||
|
||||
lodash.isboolean@3.0.3: {}
|
||||
|
||||
lodash.isinteger@4.0.4: {}
|
||||
|
||||
lodash.isnumber@3.0.3: {}
|
||||
|
||||
lodash.isplainobject@4.0.6: {}
|
||||
|
||||
lodash.isstring@4.0.1: {}
|
||||
|
||||
lodash.once@4.1.1: {}
|
||||
|
||||
lodash.snakecase@4.1.1: {}
|
||||
|
||||
lodash@4.17.21: {}
|
||||
@@ -4769,8 +5047,14 @@ snapshots:
|
||||
braces: 3.0.3
|
||||
picomatch: 2.3.1
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
|
||||
mime-db@1.54.0: {}
|
||||
|
||||
mime-types@2.1.35:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
|
||||
mime-types@3.0.2:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
@@ -4820,8 +5104,6 @@ snapshots:
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
node-addon-api@7.1.1: {}
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
@@ -4834,10 +5116,6 @@ snapshots:
|
||||
fetch-blob: 3.2.0
|
||||
formdata-polyfill: 4.0.10
|
||||
|
||||
node-pty@1.1.0:
|
||||
dependencies:
|
||||
node-addon-api: 7.1.1
|
||||
|
||||
node-wav@0.0.2:
|
||||
optional: true
|
||||
|
||||
@@ -4902,11 +5180,27 @@ snapshots:
|
||||
'@oxlint/win32-x64': 1.36.0
|
||||
oxlint-tsgolint: 0.10.1
|
||||
|
||||
p-finally@1.0.0: {}
|
||||
|
||||
p-queue@6.6.2:
|
||||
dependencies:
|
||||
eventemitter3: 4.0.7
|
||||
p-timeout: 3.2.0
|
||||
|
||||
p-queue@9.0.1:
|
||||
dependencies:
|
||||
eventemitter3: 5.0.1
|
||||
p-timeout: 7.0.1
|
||||
|
||||
p-retry@4.6.2:
|
||||
dependencies:
|
||||
'@types/retry': 0.12.0
|
||||
retry: 0.13.1
|
||||
|
||||
p-timeout@3.2.0:
|
||||
dependencies:
|
||||
p-finally: 1.0.0
|
||||
|
||||
p-timeout@7.0.1: {}
|
||||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
@@ -5046,6 +5340,8 @@ snapshots:
|
||||
forwarded: 0.2.0
|
||||
ipaddr.js: 1.9.1
|
||||
|
||||
proxy-from-env@1.1.0: {}
|
||||
|
||||
punycode.js@2.3.1: {}
|
||||
|
||||
qified@0.5.3:
|
||||
@@ -5132,6 +5428,8 @@ snapshots:
|
||||
|
||||
retry@0.12.0: {}
|
||||
|
||||
retry@0.13.1: {}
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rimraf@5.0.10:
|
||||
@@ -5442,6 +5740,8 @@ snapshots:
|
||||
|
||||
tslog@4.10.2: {}
|
||||
|
||||
tsscmp@1.0.6: {}
|
||||
|
||||
tsx@4.21.0:
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
|
||||
@@ -7,7 +7,7 @@ PRODUCT="Clawdis"
|
||||
BIN="$BUILD_PATH/debug/$PRODUCT"
|
||||
|
||||
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"
|
||||
killall -q "$PRODUCT" 2>/dev/null || true
|
||||
|
||||
Executable
+50
@@ -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
|
||||
Executable
+7
@@ -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}"
|
||||
Executable
+65
@@ -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
|
||||
Executable
+7
@@ -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}"
|
||||
@@ -1,43 +1,67 @@
|
||||
---
|
||||
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.
|
||||
homepage: https://github.com/antoniorodr/memo
|
||||
metadata: {"clawdis":{"emoji":"⏰","os":["darwin"],"requires":{"bins":["memo"]},"install":[{"id":"brew","kind":"brew","formula":"antoniorodr/memo/memo","bins":["memo"],"label":"Install memo via Homebrew"}]}}
|
||||
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/steipete/remindctl
|
||||
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
|
||||
- Install (Homebrew): `brew tap antoniorodr/memo && brew install antoniorodr/memo/memo`
|
||||
- Manual (pip): `pip install .` (after cloning the repo)
|
||||
- macOS-only; if prompted, grant Automation access to Reminders.app.
|
||||
- Install (Homebrew): `brew install steipete/tap/remindctl`
|
||||
- From source: `pnpm install && pnpm build` (binary at `./bin/remindctl`)
|
||||
- macOS-only; grant Reminders permission when prompted.
|
||||
|
||||
Permissions
|
||||
- Check status: `remindctl status`
|
||||
- Request access: `remindctl authorize`
|
||||
|
||||
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
|
||||
- Add a new reminder: `memo rem -a`
|
||||
- Opens interactive prompt to create a reminder.
|
||||
- Quick add: `memo rem -a "Buy groceries"`
|
||||
- Quick add: `remindctl add "Buy milk"`
|
||||
- With list + due: `remindctl add --title "Call mom" --list Personal --due tomorrow`
|
||||
|
||||
Edit Reminders
|
||||
- Edit title/due: `remindctl edit 1 --title "New title" --due 2026-01-04`
|
||||
|
||||
Complete Reminders
|
||||
- Mark reminder as done: `memo rem -c`
|
||||
- Interactive selection of reminder to complete.
|
||||
- Complete by id: `remindctl complete 1 2 3`
|
||||
|
||||
Delete Reminders
|
||||
- Delete a reminder: `memo rem -d`
|
||||
- Interactive selection of reminder to delete.
|
||||
- Delete by id: `remindctl delete 4A83 --force`
|
||||
|
||||
Examples
|
||||
- Add a quick reminder: `memo rem -a "Call dentist tomorrow"`
|
||||
- List all reminders: `memo rem`
|
||||
- Complete a reminder: `memo rem -c` (then select from list)
|
||||
- Delete a reminder: `memo rem -d` (then select from list)
|
||||
Output Formats
|
||||
- JSON (scripting): `remindctl today --json`
|
||||
- Plain TSV: `remindctl today --plain`
|
||||
- Counts only: `remindctl today --quiet`
|
||||
|
||||
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
|
||||
- macOS-only.
|
||||
- Requires Apple Reminders.app to be accessible.
|
||||
- For automation, grant permissions in System Settings > Privacy & Security > Automation.
|
||||
- The `memo` CLI shares the same installation for both Notes and Reminders functionality.
|
||||
- If access is denied, enable Terminal/remindctl in System Settings → Privacy & Security → Reminders.
|
||||
- If running over SSH, grant access on the Mac that runs the command.
|
||||
@@ -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
|
||||
@@ -41,9 +41,15 @@ process action:kill sessionId:XXX
|
||||
|
||||
**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
|
||||
# --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\""
|
||||
|
||||
# --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)
|
||||
@@ -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
|
||||
|
||||
1. **Respect tool choice** — if user asks for Codex, use Codex. NEVER offer to build it yourself!
|
||||
@@ -154,3 +205,71 @@ Use the tmux skill for interactive coding sessions (always, except very simple o
|
||||
6. **Parallel is OK** — run many Codex processes at once for batch work
|
||||
7. **NEVER start Codex in ~/clawd/** — it'll read your soul docs and get weird ideas about the org chart! Use the target project dir or /tmp for blank slate chats
|
||||
8. **NEVER checkout branches in ~/Projects/clawdis/** — that's the LIVE Clawdis instance! Clone to /tmp or use git worktree for PR reviews
|
||||
|
||||
---
|
||||
|
||||
## PR Template (The Razor Standard)
|
||||
|
||||
When submitting PRs to external repos, use this format for quality & maintainer-friendliness:
|
||||
|
||||
````markdown
|
||||
## Original Prompt
|
||||
[Exact request/problem statement]
|
||||
|
||||
## What this does
|
||||
[High-level description]
|
||||
|
||||
**Features:**
|
||||
- [Key feature 1]
|
||||
- [Key feature 2]
|
||||
|
||||
**Example usage:**
|
||||
```bash
|
||||
# Example
|
||||
command example
|
||||
```
|
||||
````
|
||||
|
||||
## Feature intent (maintainer-friendly)
|
||||
[Why useful, how it fits, workflows it enables]
|
||||
|
||||
## Prompt history (timestamped)
|
||||
- YYYY-MM-DD HH:MM UTC: [Step 1]
|
||||
- YYYY-MM-DD HH:MM UTC: [Step 2]
|
||||
|
||||
## How I tested
|
||||
**Manual verification:**
|
||||
1. [Test step] - Output: `[result]`
|
||||
2. [Test step] - Result: [result]
|
||||
|
||||
**Files tested:**
|
||||
- [Detail]
|
||||
- [Edge cases]
|
||||
|
||||
## Session logs (implementation)
|
||||
- [What was researched]
|
||||
- [What was discovered]
|
||||
- [Time spent]
|
||||
|
||||
## Implementation details
|
||||
**New files:**
|
||||
- `path/file.ts` - [description]
|
||||
|
||||
**Modified files:**
|
||||
- `path/file.ts` - [change]
|
||||
|
||||
**Technical notes:**
|
||||
- [Detail 1]
|
||||
- [Detail 2]
|
||||
|
||||
---
|
||||
*Submitted by Razor 🥷 - Mariano's AI agent*
|
||||
```
|
||||
|
||||
**Key principles:**
|
||||
1. Human-written description (no AI slop)
|
||||
2. Feature intent for maintainers
|
||||
3. Timestamped prompt history
|
||||
4. Session logs if using Codex/agent
|
||||
|
||||
**Example:** https://github.com/steipete/bird/pull/22
|
||||
+36
-1
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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
|
||||
@@ -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.
|
||||
- Polls also need a `question` plus 2–10 `answers`.
|
||||
- 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.
|
||||
|
||||
@@ -58,6 +60,37 @@ Message context lines include `discord message id` and `channel` fields you can
|
||||
- Up to 3 sticker IDs per message.
|
||||
- `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
|
||||
|
||||
```json
|
||||
@@ -88,6 +121,7 @@ Message context lines include `discord message id` and `channel` fields you can
|
||||
- React with ✅/⚠️ to mark status updates.
|
||||
- Post a quick poll for release decisions or meeting times.
|
||||
- Send celebratory stickers after successful deploys.
|
||||
- Upload new emojis/stickers for release moments.
|
||||
- Run weekly “priority check” polls in team channels.
|
||||
- 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:
|
||||
- `reactions` (react + reactions list + emojiList)
|
||||
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
|
||||
- `emojiUploads`, `stickerUploads`
|
||||
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
|
||||
- `roles` (role add/remove, default `false`)
|
||||
- `moderation` (timeout/kick/ban, default `false`)
|
||||
|
||||
+22
-11
@@ -1,27 +1,38 @@
|
||||
---
|
||||
name: mcporter
|
||||
description: Manage and call MCP servers (list, call, auth, daemon).
|
||||
description: Use the mcporter CLI to list, configure, auth, and call MCP servers/tools directly (HTTP or stdio), including ad-hoc servers, config edits, and CLI/type generation.
|
||||
homepage: http://mcporter.dev
|
||||
metadata: {"clawdis":{"emoji":"📦","requires":{"bins":["mcporter"]},"install":[{"id":"node","kind":"node","package":"mcporter","bins":["mcporter"],"label":"Install mcporter (node)"}]}}
|
||||
---
|
||||
|
||||
# mcporter
|
||||
|
||||
Use `mcporter` to list MCP servers and call tools.
|
||||
Use `mcporter` to work with MCP servers directly.
|
||||
|
||||
Quick start
|
||||
- `mcporter list`
|
||||
- `mcporter list <server> --schema`
|
||||
- `mcporter call <server.tool> arg=value`
|
||||
- `mcporter call <server.tool> key=value`
|
||||
|
||||
Auth + lifecycle
|
||||
- OAuth: `mcporter auth <server>`
|
||||
- Daemon: `mcporter daemon status|start|stop`
|
||||
Call tools
|
||||
- Selector: `mcporter call linear.list_issues team=ENG limit:5`
|
||||
- Function syntax: `mcporter call "linear.create_issue(title: \"Bug\")"`
|
||||
- Full URL: `mcporter call https://api.example.com/mcp.fetch url:https://example.com`
|
||||
- Stdio: `mcporter call --stdio "bun run ./server.ts" scrape url=https://example.com`
|
||||
- JSON payload: `mcporter call <server.tool> --args '{"limit":5}'`
|
||||
|
||||
Ad-hoc servers
|
||||
- HTTP: `mcporter list --http-url https://host/mcp --name <name>`
|
||||
- STDIO: `mcporter call --stdio "bun run ./server.ts" --name <name>`
|
||||
Auth + config
|
||||
- OAuth: `mcporter auth <server | url> [--reset]`
|
||||
- Config: `mcporter config list|get|add|remove|import|login|logout`
|
||||
|
||||
Daemon
|
||||
- `mcporter daemon start|status|stop|restart`
|
||||
|
||||
Codegen
|
||||
- CLI: `mcporter generate-cli --server <name>` or `--command <url>`
|
||||
- Inspect: `mcporter inspect-cli <path> [--json]`
|
||||
- TS: `mcporter emit-ts <server> --mode client|types`
|
||||
|
||||
Notes
|
||||
- Config sources: `~/.mcporter/mcporter.json[c]` and `config/mcporter.json`.
|
||||
- Prefer `--json` when you need machine-readable output.
|
||||
- Config default: `./config/mcporter.json` (override with `--config`).
|
||||
- Prefer `--output json` for machine-readable results.
|
||||
@@ -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
|
||||
@@ -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
|
||||
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.
|
||||
Tip: run via `polter peekaboo` to ensure fresh builds.
|
||||
|
||||
@@ -33,7 +33,7 @@ Interaction
|
||||
- `drag`: drag & drop across elements/coords/Dock
|
||||
- `hotkey`: modifier combos like `cmd,shift,t`
|
||||
- `move`: cursor positioning with optional smoothing
|
||||
- `paste`: set clipboard → paste → restore
|
||||
- `paste`: set clipboard -> paste -> restore
|
||||
- `press`: special-key sequences with repeats
|
||||
- `scroll`: directional scrolling (targeted + smooth)
|
||||
- `swipe`: gesture-style drags between targets
|
||||
@@ -58,6 +58,96 @@ Global runtime flags
|
||||
- `--json`/`-j`, `--verbose`/`-v`, `--log-level <level>`
|
||||
- `--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
|
||||
- Requires Screen Recording + Accessibility permissions.
|
||||
- Use `peekaboo see --annotate` to identify targets before clicking.
|
||||
@@ -0,0 +1,143 @@
|
||||
---
|
||||
name: slack
|
||||
description: Use when you need to control Slack from Clawdis via the slack tool, including reacting to messages or pinning/unpinning items in Slack channels or DMs.
|
||||
---
|
||||
|
||||
# Slack Actions
|
||||
|
||||
## Overview
|
||||
|
||||
Use `slack` to react, manage pins, send/edit/delete messages, and fetch member info. The tool uses the bot token configured for Clawdis.
|
||||
|
||||
## Inputs to collect
|
||||
|
||||
- `channelId` and `messageId` (Slack message timestamp, e.g. `1712023032.1234`).
|
||||
- For reactions, an `emoji` (Unicode or `:name:`).
|
||||
- For message sends, a `to` target (`channel:<id>` or `user:<id>`) and `content`.
|
||||
|
||||
Message context lines include `slack message id` and `channel` fields you can reuse directly.
|
||||
|
||||
## Actions
|
||||
|
||||
### Action groups
|
||||
|
||||
| Action group | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| reactions | enabled | React + list reactions |
|
||||
| messages | enabled | Read/send/edit/delete |
|
||||
| pins | enabled | Pin/unpin/list |
|
||||
| memberInfo | enabled | Member info |
|
||||
| emojiList | enabled | Custom emoji list |
|
||||
|
||||
### React to a message
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "react",
|
||||
"channelId": "C123",
|
||||
"messageId": "1712023032.1234",
|
||||
"emoji": "✅"
|
||||
}
|
||||
```
|
||||
|
||||
### List reactions
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "reactions",
|
||||
"channelId": "C123",
|
||||
"messageId": "1712023032.1234"
|
||||
}
|
||||
```
|
||||
|
||||
### Send a message
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "sendMessage",
|
||||
"to": "channel:C123",
|
||||
"content": "Hello from Clawdis"
|
||||
}
|
||||
```
|
||||
|
||||
### Edit a message
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "editMessage",
|
||||
"channelId": "C123",
|
||||
"messageId": "1712023032.1234",
|
||||
"content": "Updated text"
|
||||
}
|
||||
```
|
||||
|
||||
### Delete a message
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "deleteMessage",
|
||||
"channelId": "C123",
|
||||
"messageId": "1712023032.1234"
|
||||
}
|
||||
```
|
||||
|
||||
### Read recent messages
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "readMessages",
|
||||
"channelId": "C123",
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
### Pin a message
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "pinMessage",
|
||||
"channelId": "C123",
|
||||
"messageId": "1712023032.1234"
|
||||
}
|
||||
```
|
||||
|
||||
### Unpin a message
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "unpinMessage",
|
||||
"channelId": "C123",
|
||||
"messageId": "1712023032.1234"
|
||||
}
|
||||
```
|
||||
|
||||
### List pinned items
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "listPins",
|
||||
"channelId": "C123"
|
||||
}
|
||||
```
|
||||
|
||||
### Member info
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "memberInfo",
|
||||
"userId": "U123"
|
||||
}
|
||||
```
|
||||
|
||||
### Emoji list
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "emojiList"
|
||||
}
|
||||
```
|
||||
|
||||
## Ideas to try
|
||||
|
||||
- React with ✅ to mark completed tasks.
|
||||
- Pin key decisions or weekly status updates.
|
||||
@@ -65,6 +65,41 @@ To monitor:
|
||||
- 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.
|
||||
|
||||
## 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
|
||||
|
||||
- Kill a session: `tmux -S "$SOCKET" kill-session -t "$SESSION"`.
|
||||
|
||||
+22
-11
@@ -1,31 +1,42 @@
|
||||
---
|
||||
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
|
||||
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
|
||||
|
||||
Use `wacli` for WhatsApp sync/search/send via WhatsApp Web protocol.
|
||||
Normal operation: the user chats with you via Telegram or WhatsApp and Clawdis handles it automatically.
|
||||
Use `wacli` only when you need to talk to other people on WhatsApp (or to debug sync/search).
|
||||
Use `wacli` only when the user explicitly asks you to message someone else on WhatsApp or when they ask to sync/search WhatsApp history.
|
||||
Do NOT use `wacli` for normal user chats; Clawdis routes WhatsApp conversations automatically.
|
||||
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
|
||||
- `wacli auth` (QR login + initial sync)
|
||||
- `wacli sync --follow` (continuous sync)
|
||||
- `wacli doctor`
|
||||
|
||||
Search + history
|
||||
- `wacli messages search "query"`
|
||||
- `wacli history backfill --chat <jid> --requests 5 --count 50`
|
||||
Find chats + messages
|
||||
- `wacli chats list --limit 20 --query "name or number"`
|
||||
- `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
|
||||
- Text: `wacli send text --to 1234567890 --message "hello"`
|
||||
- File: `wacli send file --to 1234567890 --file /path/pic.jpg --caption "hi"`
|
||||
- Text: `wacli send text --to "+14155551212" --message "Hello! Are you free at 3pm?"`
|
||||
- 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
|
||||
- Store dir: `~/.wacli` (override with `--store`).
|
||||
- Use `--json` for machine-readable output when parsing.
|
||||
- Backfill requires your phone online; results are best-effort.
|
||||
- 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).
|
||||
@@ -20,7 +20,6 @@ describe("bash process registry", () => {
|
||||
id: "sess",
|
||||
command: "echo test",
|
||||
child: { pid: 123 } as ChildProcessWithoutNullStreams,
|
||||
stdinMode: "pipe",
|
||||
startedAt: Date.now(),
|
||||
cwd: "/tmp",
|
||||
maxOutputChars: 10,
|
||||
@@ -49,7 +48,6 @@ describe("bash process registry", () => {
|
||||
id: "sess",
|
||||
command: "echo test",
|
||||
child: { pid: 123 } as ChildProcessWithoutNullStreams,
|
||||
stdinMode: "pipe",
|
||||
startedAt: Date.now(),
|
||||
cwd: "/tmp",
|
||||
maxOutputChars: 100,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { IPty } from "node-pty";
|
||||
|
||||
const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
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 ProcessStdinMode = "pipe" | "pty";
|
||||
|
||||
export interface ProcessSession {
|
||||
id: string;
|
||||
command: string;
|
||||
child?: ChildProcessWithoutNullStreams;
|
||||
pty?: IPty;
|
||||
pid?: number;
|
||||
stdinMode: ProcessStdinMode;
|
||||
startedAt: number;
|
||||
cwd?: string;
|
||||
maxOutputChars: number;
|
||||
|
||||
Loaded 100 of 290 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user