Compare commits

..
Author SHA1 Message Date
Peter Steinberger 12d9a13af0 fix(mac): preserve SwiftUI menu delegate 2025-12-13 03:11:06 +00:00
Peter Steinberger 164841f299 refactor(mac): inject context card as NSMenuItem view 2025-12-13 03:03:08 +00:00
Peter Steinberger 778361686c macOS: widen settings window 2025-12-13 03:00:35 +00:00
Peter Steinberger 29907a4c3f docs(mac): drop screenshot alias plan 2025-12-13 02:51:48 +00:00
Peter Steinberger 81f38342bf Merge remote-tracking branch 'origin/main' 2025-12-13 02:50:57 +00:00
Peter Steinberger 36b93c8dc7 security(macos): require TeamID for control socket 2025-12-13 02:50:20 +00:00
Peter Steinberger e95fdbbc37 fix(ios): prettify bonjour endpoint labels 2025-12-13 02:48:06 +00:00
Peter Steinberger 3001f115b6 fix(mac): keep context row labels together 2025-12-13 02:47:39 +00:00
Peter Steinberger 21649d81d2 fix(presence): report bridged iOS nodes 2025-12-13 02:35:35 +00:00
Peter Steinberger 5118ba3dd2 macOS: add Cron settings tab 2025-12-13 02:34:38 +00:00
Peter Steinberger f9409cbe43 Cron: add scheduler, wakeups, and run history 2025-12-13 02:34:38 +00:00
Peter Steinberger 572d17f46b feat(mac): tighten context session row 2025-12-13 02:34:37 +00:00
Peter Steinberger f466f1bf46 feat(mac): compact context session rows 2025-12-13 02:34:37 +00:00
Peter Steinberger 594315d90b ui(ios): glassy settings button 2025-12-13 02:19:34 +00:00
Peter Steinberger f84895f1f1 fix(ios): make canvas full-bleed 2025-12-13 02:15:03 +00:00
Peter Steinberger 952810f76c docs(agents): forbid git stash without consent 2025-12-13 02:08:18 +00:00
Peter Steinberger 73ccbedcdb ui(ios): clean up connected bridge list 2025-12-13 02:02:38 +00:00
Peter Steinberger 7ef83311bb feat(bridge): show node ip in pairing 2025-12-13 01:57:40 +00:00
Peter Steinberger 416c376077 feat(ios): add close button and ready canvas 2025-12-13 01:49:04 +00:00
Peter Steinberger ef83a07066 fix(macos): harden remote ssh tunnel 2025-12-13 01:43:23 +00:00
Peter Steinberger ae0c1573fd refactor(swift): rename ClawdisNodeKit to ClawdisKit 2025-12-13 01:33:30 +00:00
Peter Steinberger 378e5acd23 feat(deeplink): forward agent links via bridge 2025-12-13 01:19:36 +00:00
Peter Steinberger a56daa6c06 feat(macos): add Allow Canvas toggle to settings 2025-12-13 01:19:36 +00:00
Peter Steinberger 84399e62ae fix(mac): render context sessions card with labels 2025-12-13 01:18:42 +00:00
Peter Steinberger 387615e99f feat(mac): show session labels under context bars 2025-12-13 01:10:17 +00:00
Peter Steinberger f98ab2d037 fix(macos): prevent control socket hangs 2025-12-13 01:02:47 +00:00
Peter Steinberger 19ce08b4d0 fix(mac): avoid collapsed context pills in menu 2025-12-13 00:51:05 +00:00
Peter Steinberger 8cc2dc715c refactor(ios): minimal full-screen canvas 2025-12-13 00:50:20 +00:00
Peter Steinberger ca20a2dc06 Merge remote-tracking branch 'origin/main' 2025-12-13 00:48:01 +00:00
Peter Steinberger f9b1a96c89 chore(macos): move Permissions tab after Tools 2025-12-13 00:47:08 +00:00
Peter Steinberger 854f07d735 feat(mac): compact context sessions in menu 2025-12-13 00:39:25 +00:00
Peter Steinberger 7f4f01009b refactor(ios): remove manual URL controls 2025-12-13 00:31:52 +00:00
Peter Steinberger 117b01acbd fix(ios): avoid MainActor isolation in audio tap 2025-12-13 00:27:15 +00:00
Peter Steinberger 2b38ddf78d fix(ios): avoid actor isolation in audio tap 2025-12-13 00:27:15 +00:00
Peter Steinberger 5e51107711 fix(mac): size context bar to menu 2025-12-13 00:23:00 +00:00
Peter Steinberger 3bb33bdeed fix(mac): render context bar as image 2025-12-13 00:19:29 +00:00
Peter Steinberger 9b9fa009d1 fix(mac): render context bar reliably 2025-12-13 00:13:33 +00:00
Peter Steinberger 072ad8d371 fix(mac): show cached context usage 2025-12-12 23:44:55 +00:00
Peter Steinberger 8846ffec64 fix: expose heartbeat controls and harden mac CLI 2025-12-12 23:34:26 +00:00
Peter Steinberger 3b72ed6e1a feat(macos): add clawdis://agent deep link 2025-12-12 23:33:38 +00:00
Peter Steinberger 35b7c0f558 feat(mac): show context usage bars 2025-12-12 23:33:15 +00:00
Peter Steinberger d5d80f4247 feat(gateway)!: switch handshake to req:connect (protocol v2) 2025-12-12 23:29:57 +00:00
Peter Steinberger e915ed182d fix(macos): clarify presence update source label 2025-12-12 23:27:08 +00:00
Peter Steinberger c3aed2543e fix(status): account cached prompt tokens 2025-12-12 23:22:24 +00:00
Peter Steinberger e502ad13f9 fix(node): prevent iOS VoiceWake crash 2025-12-12 23:07:30 +00:00
Peter Steinberger 952d924581 fix(mac): recover control tunnel after restart
# Conflicts:
#	apps/macos/Sources/Clawdis/GatewayConnection.swift
2025-12-12 23:07:30 +00:00
Peter Steinberger 0484aba892 test(web): retry session tmp cleanup 2025-12-12 22:55:39 +00:00
Peter Steinberger 03c84d0f11 fix(mac): make Canvas file watcher reliable 2025-12-12 22:50:25 +00:00
Peter Steinberger 086f98471e docs: finalize gateway refactor notes 2025-12-12 22:27:18 +00:00
Peter Steinberger cc4f0d8acc test(macos): cover gateway endpoint store 2025-12-12 22:27:18 +00:00
Peter Steinberger c7bd4b5c1d refactor(macos): extract gateway payload decoding 2025-12-12 22:27:18 +00:00
Peter Steinberger 14e3b34a8e refactor(macos): centralize gateway endpoint resolution 2025-12-12 22:27:18 +00:00
Peter Steinberger 6354dddff2 fix(macos): avoid ptt audio teardown race 2025-12-12 22:24:24 +00:00
Peter Steinberger c50c3699d9 fix(macos): keep voice wake overlay on top 2025-12-12 22:09:14 +00:00
Peter Steinberger 6a7f955818 refactor(macos): replace gateway NotificationCenter with event bus 2025-12-12 22:06:40 +00:00
Peter Steinberger 9cf457be0a fix(bridge): use default Bonjour domain 2025-12-12 21:59:04 +00:00
Peter Steinberger e31383a8f1 fix(ios): harden voice wake callbacks 2025-12-12 21:59:04 +00:00
Peter Steinberger 13b8dc61ba fix(mac): timeout ClawdisCLI socket calls 2025-12-12 21:57:33 +00:00
Peter Steinberger 61085f6141 fix(macos): avoid external open for about:blank 2025-12-12 21:56:54 +00:00
Peter Steinberger d8cb1daa78 test(macos): cover gateway connection reuse 2025-12-12 21:42:16 +00:00
Peter Steinberger de2e341947 fix(mac): avoid double-trigger voice wake 2025-12-12 21:37:59 +00:00
Peter Steinberger e944a0239d fix(macos): share gateway websocket connection 2025-12-12 21:35:00 +00:00
Peter Steinberger ce8db12b22 fix(mac): keep voice overlay above canvas 2025-12-12 21:26:04 +00:00
Peter Steinberger 1d41129b6c feat(ios): add settings UI 2025-12-12 21:19:39 +00:00
Peter Steinberger 6d6c3ad2c4 feat(ios): add ClawdisNode app scaffold 2025-12-12 21:19:39 +00:00
Peter Steinberger 0b532579d8 feat(bridge): add Bonjour node bridge 2025-12-12 21:19:39 +00:00
Peter Steinberger b9007dc721 feat(mac): add rolling diagnostics log 2025-12-12 21:19:39 +00:00
Peter Steinberger 211efffa10 fix(gateway): treat webchat last as whatsapp 2025-12-12 21:05:39 +00:00
Peter Steinberger e3b50b7d12 fix(macos): show tool-use badge glyph 2025-12-12 21:02:38 +00:00
Peter Steinberger aae49f1d68 fix(gateway): don"t let webchat clobber last route 2025-12-12 21:00:33 +00:00
Peter Steinberger 6b4141247e feat(macos): enlarge tool-use badge 2025-12-12 20:45:51 +00:00
Peter Steinberger 327f6e7e25 fix(mac): persist Canvas frame across reopen 2025-12-12 20:33:40 +00:00
Peter Steinberger 296c0a6b70 feat(mac): allow Canvas placement and resizing 2025-12-12 20:28:19 +00:00
Peter Steinberger 356b6e0483 fix(mac): keep voice wake listening 2025-12-12 20:13:41 +00:00
Peter Steinberger 08a473fb35 fix(mac): keep Canvas below Voice Wake overlay 2025-12-12 20:10:29 +00:00
Peter Steinberger 893eef846d fix(mac): add draggable/closable Canvas hover chrome 2025-12-12 20:08:15 +00:00
Peter Steinberger 4ecd35c275 fix(mac): render Canvas HTML correctly 2025-12-12 20:01:12 +00:00
Peter Steinberger 27a7d9f9d1 feat(mac): add agent-controlled Canvas panel 2025-12-12 19:54:01 +00:00
Peter Steinberger c0abab226d Merge remote-tracking branch 'origin/main' 2025-12-12 19:28:10 +00:00
Peter Steinberger f1320b79ce feat(mac): add overlay notification delivery 2025-12-12 19:27:38 +00:00
Peter Steinberger bf41197b97 fix(mac): open settings for microphone permission 2025-12-12 19:25:21 +00:00
Peter Steinberger 3f7fcad9ac fix(mac): ignore cancelled webchat navigations 2025-12-12 19:20:47 +00:00
Peter Steinberger e2ad0ed9f7 fix(mac): disable restricted time-sensitive entitlement 2025-12-12 19:20:47 +00:00
Peter Steinberger d2158966db fix(mac): treat timeSensitive as best-effort 2025-12-12 18:58:07 +00:00
Peter Steinberger 8086c66ab8 fix(mac): keep remote control tunnel alive 2025-12-12 18:44:44 +00:00
Peter Steinberger 7d37195c1a fix(mac): serve webchat locally in remote mode 2025-12-12 18:41:38 +00:00
Peter Steinberger 241cf10bdb refactor(mac): embed work badge in status icon 2025-12-12 18:40:33 +00:00
Peter Steinberger 337ae05ed8 build(mac): enable time-sensitive notifications 2025-12-12 18:40:09 +00:00
Peter Steinberger 378e39d7ad test(cli): verify gateway exits 0 on SIGTERM 2025-12-12 18:30:19 +00:00
Peter Steinberger 8fb3aef917 fix(gateway): handle SIGTERM shutdown cleanly 2025-12-12 18:28:08 +00:00
Peter Steinberger c86cb4e9a5 macOS: add --priority flag for time-sensitive notifications
Add NotificationPriority enum with passive/active/timeSensitive levels
that map to UNNotificationInterruptionLevel. timeSensitive breaks
through Focus modes for urgent notifications.

Usage: clawdis-mac notify --title X --body Y --priority timeSensitive
2025-12-12 18:27:12 +00:00
Peter Steinberger 8ca240fb2c fix(gateway): ignore stale lastTo for voice 2025-12-12 18:11:26 +00:00
Peter Steinberger 9ea697ac09 style(test): biome format 2025-12-12 18:07:33 +00:00
Peter Steinberger 37eaa49e4c fix(mac): allow typing in web chat panel 2025-12-12 18:07:27 +00:00
Peter Steinberger e64ca7c583 fix(agent): send tau rpc prompt as string 2025-12-12 18:04:13 +00:00
Peter Steinberger 62a7a07127 fix(gateway): ack agent requests immediately 2025-12-12 18:00:49 +00:00
Peter Steinberger bc618ec290 refactor(auto-reply): remove pi json fallback 2025-12-12 17:43:11 +00:00
Peter Steinberger 0780859a4d fix(auto-reply): prefer Pi RPC by default 2025-12-12 17:30:34 +00:00
Peter Steinberger 79818f73c0 fix(mac): harden gateway frame decoding 2025-12-12 17:30:21 +00:00
Peter Steinberger 957d7fbe2a test(voice): cover gateway last-channel whatsapp 2025-12-12 17:29:04 +00:00
Peter Steinberger 6e9d3092a7 fix(voice): persist WhatsApp last route 2025-12-12 17:28:07 +00:00
Peter Steinberger 7dab927260 fix(presence): hide cli sessions; use numeric mac build 2025-12-12 17:27:11 +00:00
Peter Steinberger c417517f43 fix(mac): reflect agent activity in menu icon 2025-12-12 17:20:06 +00:00
Peter Steinberger fd0314a6bd fix(mac): avoid static UserDefaults in InstanceIdentity 2025-12-12 16:59:51 +00:00
Peter Steinberger 6a05d60f41 fix(presence): dedupe instances via stable instanceId 2025-12-12 16:57:25 +00:00
Peter Steinberger cd84c5ad08 fix(macos): prevent gateway request double-resume 2025-12-12 16:52:36 +00:00
Peter Steinberger b0187d7f28 Merge remote-tracking branch 'origin/main' 2025-12-12 16:47:24 +00:00
Peter Steinberger 7a1d64fff9 style(tests): format imports 2025-12-12 16:47:10 +00:00
Peter Steinberger debcf19199 fix(presence): stabilize instance identity 2025-12-12 16:47:07 +00:00
Peter Steinberger ea76425f86 docs(readme): describe voice wake reply routing 2025-12-12 16:42:09 +00:00
Peter Steinberger 88936b6216 fix(macos): fix clawdis-mac --version 2025-12-12 16:40:50 +00:00
Peter Steinberger e6edcd9a7f Merge remote-tracking branch 'origin/main' 2025-12-12 16:39:27 +00:00
Peter Steinberger af78762421 style(mac): hud glass voice overlay 2025-12-12 16:39:11 +00:00
Peter Steinberger bf159bd316 fix(mac): prevent crash decoding GatewayFrame 2025-12-12 16:37:59 +00:00
Peter Steinberger 9eda40234f test: cover main last-channel routing 2025-12-12 16:35:47 +00:00
Peter Steinberger 00336f554f docs: clarify voice wake last-channel routing 2025-12-12 16:26:19 +00:00
Peter Steinberger a524b9ae9b feat(voicewake): route replies to last channel 2025-12-12 16:22:30 +00:00
Peter Steinberger 3f1bcac077 Merge remote-tracking branch 'origin/main' 2025-12-12 16:10:02 +00:00
Peter Steinberger 679ced7840 mac: remove voice wake forward pref 2025-12-12 16:09:31 +00:00
Peter Steinberger 7422f54212 mac: add gog CLI, remove Gmail/Calendar MCPs
- Add gog (unified Google CLI for Gmail, Calendar, Drive, Contacts)
- Remove Gmail MCP and Google Calendar MCP entries (replaced by gog)
- gog installs via brew: steipete/tap/gog
2025-12-12 15:48:36 +00:00
Peter Steinberger b0384d0335 fix(mac): cache webchat panel 2025-12-12 15:33:41 +00:00
Peter Steinberger 6b64039fcb fix(mac): keep webchat boot dots 2025-12-12 15:01:20 +00:00
Peter Steinberger 19e7c708ce test(mac): cover concurrent gateway connect 2025-12-12 14:29:09 +00:00
Peter Steinberger c8ca5803fc fix(mac): webchat ws connect 2025-12-12 14:18:53 +00:00
Peter Steinberger 5f48abb451 fix(mac): serialize gateway connect 2025-12-12 14:14:33 +00:00
Peter Steinberger 491fd6b74d mac: lock control socket to team-signed peers 2025-12-12 01:22:24 +00:00
Peter Steinberger f1ff24d634 web: default to self-only without config 2025-12-12 01:22:03 +00:00
Peter Steinberger 0242383ec3 test(gateway): cover port lock guard 2025-12-11 18:53:40 +00:00
Peter Steinberger 768887fc0f style(pi): wrap mode arg lookup 2025-12-11 18:53:34 +00:00
Peter Steinberger 958c13e02d mac: replace xpc with unix socket control channel 2025-12-11 16:31:15 +01:00
Peter Steinberger f417b51fb6 chore(gateway): use ws bind as lock 2025-12-11 15:17:40 +00:00
Peter Steinberger 47a1f757a9 lint: format and stabilize gateway health 2025-12-10 18:00:33 +00:00
Peter Steinberger 27ad3b917f chore(gateway): log pre-hello ws closures 2025-12-10 16:58:56 +00:00
Peter Steinberger 93a5784c58 feat(gateway): allow webchat port override 2025-12-10 16:55:17 +00:00
Peter Steinberger e9fd73141d health: gateway-only status and stable reconnect 2025-12-10 16:47:38 +00:00
Peter Steinberger 6c005b3d35 fix(session): ignore agent meta session id 2025-12-10 16:38:22 +00:00
Peter Steinberger 2967bc5988 health: stop direct baileys probes 2025-12-10 16:35:42 +00:00
Peter Steinberger 55772eec5a gateway: force ws-only clients 2025-12-10 16:27:54 +00:00
Peter Steinberger c2adda1cfe chore: drop rpc->json fallback 2025-12-10 15:58:45 +00:00
Peter Steinberger 51d77aea2e fix(auto-reply): acknowledge reset triggers 2025-12-10 15:55:20 +00:00
Peter Steinberger 8f456ea73b fix(agent): send structured prompt to tau rpc 2025-12-10 15:52:39 +00:00
Peter Steinberger 6459582952 gateway: add webchat handshake logging 2025-12-10 15:32:34 +00:00
Peter Steinberger 3796882d22 webchat: improve logging and static serving 2025-12-10 15:32:29 +00:00
Peter Steinberger 4db69c8eac fix(auto-reply): fall back to json when rpc prompt empty 2025-12-10 14:58:03 +00:00
Peter Steinberger f6a86e5527 telegram: fix verbose log ordering 2025-12-10 14:33:09 +00:00
Peter Steinberger 063b35f1dc mac: surface gateway auth failures 2025-12-10 14:32:54 +00:00
Peter Steinberger b61147aed0 fix(auto-reply): guard empty rpc prompt 2025-12-10 14:26:03 +00:00
Peter Steinberger fd3516bc82 fix(pi): skip -p when running rpc 2025-12-10 14:21:38 +00:00
Peter Steinberger 5e4a91f996 Auto-reply: reject empty inbound messages 2025-12-10 13:51:06 +00:00
Peter Steinberger df4331da04 gateway: dedupe system-event presence 2025-12-10 11:48:17 +00:00
Peter Steinberger fe3a983d35 mac: include instance id in presence beacons 2025-12-10 11:48:13 +00:00
Peter Steinberger 53c349cb86 RPC: auto-cancel hook UI prompts 2025-12-10 11:46:28 +00:00
Peter Steinberger 8f37f15a33 RPC: handle tau auto-compaction retries 2025-12-10 11:40:32 +00:00
Peter Steinberger 81385cf820 pi: parse turn_end streams 2025-12-10 11:31:28 +00:00
Peter Steinberger cce65e19e1 mac: add attach-only gateway toggle 2025-12-10 11:31:28 +00:00
Peter Steinberger c4b02645f5 fix: persist usage from rpc 2025-12-10 11:31:28 +00:00
Peter Steinberger 49e70746f0 webchat: show real ws errors 2025-12-10 11:31:28 +00:00
Peter Steinberger 00ace3bb63 test: add semver and gateway helpers coverage 2025-12-10 11:31:28 +00:00
Peter Steinberger efde37eb36 test: add gateway/runtime utility coverage 2025-12-10 11:31:28 +00:00
Peter Steinberger 84499ab969 mac: drop yarn fallback 2025-12-10 03:49:25 +01:00
Peter Steinberger 5d26bb2566 gateway: include last input in presence events 2025-12-10 03:48:53 +01:00
Peter Steinberger 657450c40c fix(voice): unify overlay send flow 2025-12-10 02:52:42 +01:00
Peter Steinberger cf2b659491 mac: simplify package manager picker 2025-12-10 02:49:39 +01:00
Peter Steinberger e9679ce993 chore(mac): align remote ssh controls 2025-12-10 02:48:46 +01:00
Peter Steinberger 68c5d61d60 mac: move debug toggles to footer 2025-12-10 02:48:19 +01:00
Peter Steinberger c4f0236ec0 mac: inline gateway status row 2025-12-10 02:46:59 +01:00
Peter Steinberger 1839c144fa mac: remove divider above active toggle 2025-12-10 02:44:56 +01:00
Peter Steinberger d077936a21 mac: align web chat UI with web 2025-12-10 02:18:50 +01:00
Peter Steinberger 6c1638890c chore(test): document force run and relax coverage scope 2025-12-10 01:06:44 +00:00
Peter Steinberger 7f0f789953 webchat: add centered boot loader 2025-12-10 01:04:34 +00:00
Peter Steinberger 83a2a7a1c2 mac: add swiftui web chat option 2025-12-10 02:03:59 +01:00
Peter Steinberger 70fb4d452e mac: tidy menu and gateway support 2025-12-10 01:00:53 +00:00
Peter Steinberger 5ed1d4e178 test: drop obsolete reply session placeholder 2025-12-10 01:00:44 +00:00
Peter Steinberger 35834d3dba webchat: handle bind errors gracefully 2025-12-10 01:00:34 +00:00
Peter Steinberger 260d9b9770 test: add test:force helper 2025-12-10 01:00:29 +00:00
Peter Steinberger 3907e9eedd test: isolate gateway lock per run 2025-12-10 00:58:59 +00:00
Peter Steinberger cf8b00890f fix: stabilize health probe and gateway handshake 2025-12-10 00:52:43 +00:00
Peter Steinberger f1fd25e95e chore: update dependencies 2025-12-10 00:48:50 +00:00
Peter Steinberger 426503e062 infra: use flock gateway lock 2025-12-10 00:46:50 +00:00
Peter Steinberger b1834b7cf8 mac: avoid spawning local gateway in remote mode 2025-12-10 01:44:03 +01:00
Peter Steinberger 27f9cd591d mac: route remote mode through SSH 2025-12-10 01:43:59 +01:00
Peter Steinberger 5bbc7c8ba2 mac: silence proc_pidpath warning 2025-12-10 01:43:34 +01:00
Peter Steinberger 08f8f58971 mac: add browser webchat debug entry 2025-12-10 01:33:15 +01:00
Peter Steinberger 7871e705bf mac: show full command and kill controls for ports 2025-12-10 01:24:05 +01:00
Peter Steinberger 1820308ba2 fix: expand gateway attach log 2025-12-10 00:19:18 +00:00
Peter Steinberger a07229846f mac: treat pnpm/bun processes as expected gateways 2025-12-10 01:10:50 +01:00
Peter Steinberger a7e4656834 mac: drop legacy log path 2025-12-10 00:05:05 +00:00
Peter Steinberger 872d54a2dd mac: guard ports and sweep stale tunnels 2025-12-10 01:04:37 +01:00
Peter Steinberger 496136b52c style(webchat): add body padding class on error 2025-12-10 00:04:22 +00:00
Peter Steinberger c4eff00ed7 mac: centralize log path lookup 2025-12-10 00:03:37 +00:00
Peter Steinberger 27d8aa0f04 style(webchat): pad error view 2025-12-10 00:02:51 +00:00
Peter Steinberger bb057b1dad fix: keep tools list stable 2025-12-10 00:02:18 +00:00
Peter Steinberger 3b9d84e2b1 mac: global outside-click monitor and highlight helper 2025-12-10 00:51:02 +01:00
Peter Steinberger 1a17de9d39 fix(webchat): serve root assets correctly 2025-12-09 23:50:28 +00:00
Peter Steinberger f6ade5dc84 mac: add port diagnostics for gateway 2025-12-10 00:49:33 +01:00
Peter Steinberger 2116f19106 fix(mac): keep overlay on token mismatch 2025-12-10 00:48:15 +01:00
Peter Steinberger b73a7e07d2 mac: open latest log file 2025-12-09 23:45:50 +00:00
Peter Steinberger 14d3a624d8 fix(webchat): load root path 2025-12-09 23:40:26 +00:00
Peter Steinberger dd88345483 gateway: cache health snapshot 2025-12-09 23:39:02 +00:00
Peter Steinberger e58d5a54b1 mac: toggle panel purely from visibility 2025-12-09 23:36:51 +01:00
Peter Steinberger 2a95a5bf8a Add package manager selector and hide uninstalled tools 2025-12-09 22:32:20 +00:00
Peter Steinberger 0c4e67a951 mac: ensure panel toggle doesn't reopen 2025-12-09 23:32:01 +01:00
Peter Steinberger 78d41b8e41 test: cover chat attachments 2025-12-09 23:31:14 +01:00
Peter Steinberger d5347176e1 mac: close panel on second click 2025-12-09 23:25:49 +01:00
Peter Steinberger 6d91dad8e4 mac: tie highlight to panel visibility 2025-12-09 23:20:16 +01:00
Peter Steinberger 1dd5c97ae0 feat: add ws chat attachments 2025-12-09 23:16:57 +01:00
Peter Steinberger e80e5b0801 mac: revert webchat menu fallback 2025-12-09 23:15:35 +01:00
Peter Steinberger 052d8ba879 fix(macos): harden presence decode 2025-12-09 22:08:55 +00:00
Peter Steinberger d08ca9585a mac: clear status highlight via menu delegate 2025-12-09 23:02:02 +01:00
Peter Steinberger 50c33dfcdf chore: bump pi deps for tau rpc 2025-12-09 21:53:00 +00:00
Peter Steinberger 42c3c2b804 fix: prevent stuck mac health checks 2025-12-09 21:53:00 +00:00
Peter Steinberger f83eeac5e2 fix(mac): keep webchat panel alive 2025-12-09 21:53:00 +00:00
Peter Steinberger d5517ede45 mac: clear highlight on panel close 2025-12-09 22:40:11 +01:00
Peter Steinberger 2339f1a01d chore(mac): add separator before general toggles 2025-12-09 21:28:46 +00:00
Peter Steinberger 6129924eb2 chore: remove legacy rpc command 2025-12-09 21:28:39 +00:00
Peter Steinberger fce9ded30a feat(webchat): sync theme with system 2025-12-09 21:22:21 +00:00
Peter Steinberger 8489907cf5 feat(telegram): add typing cue 2025-12-09 21:14:10 +00:00
Peter Steinberger 84ccde268e mac/webchat: remove panel padding 2025-12-09 21:14:10 +00:00
Peter Steinberger c191df5434 fix: relaunch app after debug restart 2025-12-09 22:13:43 +01:00
Peter Steinberger f49934a75b mac: respect webchat disabled for left click 2025-12-09 22:11:10 +01:00
Peter Steinberger be3326d0d9 chore(webchat): log url on gateway start 2025-12-09 21:10:49 +00:00
Peter Steinberger 7919019b67 fix(mac): disable smoothing and await watchdog 2025-12-09 22:09:25 +01:00
Peter Steinberger 89d856a487 fix(mac): snap critter drawing to pixels 2025-12-09 22:08:21 +01:00
Peter Steinberger 978a24ffab fix(mac): keep ptt overlay until release 2025-12-09 22:08:17 +01:00
Peter Steinberger bd41cf377a feat(webchat): auto-start at root 2025-12-09 21:07:53 +00:00
Peter Steinberger 3ee3f7e30b mac: add gateway reconnect watchdog 2025-12-09 21:07:39 +00:00
Peter Steinberger a032614dc7 mac: make status rows disabled menu items 2025-12-09 22:02:15 +01:00
Peter Steinberger 0377d13d3d mac: disable status rows in menu 2025-12-09 21:59:17 +01:00
Peter Steinberger 06fdfc2e14 mac icon: render 36px retina backing 2025-12-09 21:56:37 +01:00
Peter Steinberger 510552c5e6 mac: harden webchat panel 2025-12-09 21:43:54 +01:00
Peter Steinberger 6675c273fd mac: panel highlight when webchat open 2025-12-09 21:41:24 +01:00
Peter Steinberger 9131a69983 Debug menu: add sessions icon and separator 2025-12-09 21:40:04 +01:00
Peter Steinberger a8baf0ef45 chore(gateway): color ws direction logs 2025-12-09 20:37:01 +00:00
Peter Steinberger 5e4f32d808 chore(mac): include os version and locale in handshake 2025-12-09 20:37:01 +00:00
Peter Steinberger 5a8d18edf3 web: reuse active listener for sends 2025-12-09 20:37:01 +00:00
Peter Steinberger f34b238713 Debug menu: session controls and thinking/verbose 2025-12-09 21:32:21 +01:00
Peter Steinberger ad5c7d97ca mac: left-click webchat panel 2025-12-09 21:29:21 +01:00
Peter Steinberger c35f9c1315 docs: refresh gateway cli params 2025-12-09 20:28:10 +00:00
Peter Steinberger e84ed61339 cli: gateway subcommands, drop ipc probes 2025-12-09 20:27:35 +00:00
Peter Steinberger 8265829105 Menu: add icons to debug submenu 2025-12-09 21:24:36 +01:00
Peter Steinberger a76d00a08e chore: drop gateway ipc remnants 2025-12-09 20:21:41 +00:00
Peter Steinberger 131864b940 gateway: drop ipc and simplify cli 2025-12-09 20:18:50 +00:00
Peter Steinberger d33a3f619a fix(mac): harden gateway lock and ip decoding 2025-12-09 20:12:54 +00:00
Peter Steinberger 1a0e57d926 Menu: add more debug utilities 2025-12-09 21:11:28 +01:00
Peter Steinberger 5e5845547e gateway: improve conflict handling and logging 2025-12-09 20:07:24 +00:00
Peter Steinberger 0de944be28 telegram: show name and id in envelope 2025-12-09 19:56:18 +00:00
Peter Steinberger 5df438fd2a fix: enforce gateway single instance 2025-12-09 19:40:01 +00:00
Peter Steinberger 6329f60dff chore(mac): add divider before session toggles 2025-12-09 19:14:01 +00:00
Peter Steinberger 0bf9a87293 chore(mac): dedupe local gateway label 2025-12-09 19:13:46 +00:00
Peter Steinberger 6ae4c49c1a fix(mac): encode gateway params with protocol AnyCodable 2025-12-09 19:10:19 +00:00
Peter Steinberger c683ae69af gateway: log provider errors verbosely 2025-12-09 19:10:10 +00:00
Peter Steinberger ab9b12e883 gateway: enforce hello order and modern json 2025-12-09 19:09:06 +00:00
Peter Steinberger c41b506741 mac: fix gateway hello types 2025-12-09 19:02:53 +00:00
Peter Steinberger 848180dc08 mac: fix local path string 2025-12-09 19:02:53 +00:00
Peter Steinberger a7d39913fd mac: fix actor call and label warnings 2025-12-09 19:02:53 +00:00
Peter Steinberger 85ca2152e4 feat(mac): reuse running gateway 2025-12-09 19:02:53 +00:00
Peter Steinberger b11b33b63c test(overlay): cover token guard outcomes 2025-12-09 19:51:51 +01:00
Peter Steinberger 239f58b584 fix(overlay): dismiss on token mismatch; keep gateway log clear helper 2025-12-09 19:50:05 +01:00
Peter Steinberger 474cb48a14 fix(ptt): dismiss empty overlay immediately on key up 2025-12-09 19:48:35 +01:00
Peter Steinberger 577b0dfe1d mac: show local gateway path when overridden 2025-12-09 18:46:31 +00:00
Peter Steinberger 2918e00d33 fix(mac): restore gateway clear log 2025-12-09 18:44:22 +00:00
Peter Steinberger ffc930b871 surface: envelope inbound messages for agent 2025-12-09 18:43:21 +00:00
Peter Steinberger 55bffeba4a chore: add gateway env/process manager after rename 2025-12-09 19:38:19 +01:00
Peter Steinberger 2adb14c320 fix: improve app restart and gateway logs 2025-12-09 18:37:04 +00:00
Peter Steinberger 0d4bf1c15a fix(ptt): ignore stale recognition callbacks 2025-12-09 19:17:16 +01:00
Peter Steinberger a3bf2bdd8c chore: rename relay to gateway 2025-12-09 18:00:01 +00:00
Peter Steinberger bc3a14cde2 docs: add docs:list helper and front matter 2025-12-09 17:51:05 +00:00
Peter Steinberger b3d4e5cfdf mac: simplify degraded labels 2025-12-09 17:45:27 +00:00
Peter Steinberger 885355ce53 settings: clarify pause toggles gateway messaging 2025-12-09 17:40:59 +00:00
Peter Steinberger a4d5b68134 mac: honor local relay path 2025-12-09 17:40:44 +00:00
Peter Steinberger 67f2bc1385 web: log disconnect error detail in reconnect loop 2025-12-09 17:38:49 +00:00
Peter Steinberger d8fb2f9175 chore(mac): make package/restart skip ts relay 2025-12-09 17:36:24 +00:00
Peter Steinberger fcc8d59588 fix(mac): avoid crash decoding gateway frames 2025-12-09 17:36:16 +00:00
Peter Steinberger 1f19ca1665 chore: drop runner shim and add committer helper 2025-12-09 17:24:25 +00:00
Peter Steinberger d04f7fc6e9 msg: retry web/telegram sends and add regression tests 2025-12-09 17:23:04 +00:00
Peter Steinberger f9370718bc web: show surface + host/ip chips in chat UI 2025-12-09 17:23:00 +00:00
Peter Steinberger 8d888b426f chore: format swift/ts and fix gateway lint 2025-12-09 17:11:25 +00:00
Peter Steinberger b6bd39660f IPC: rename relay socket to gateway.sock 2025-12-09 17:04:58 +00:00
Peter Steinberger 959ba94eca macOS: add settings previews 2025-12-09 18:04:11 +01:00
Peter Steinberger d5cd1058ab Mac: surface gateway errors in remote test 2025-12-09 18:01:15 +01:00
Peter Steinberger 80c7b04831 Menu: add debug submenu actions 2025-12-09 17:57:21 +01:00
Peter Steinberger 7017756140 UI: unify refresh buttons 2025-12-09 17:54:12 +01:00
Peter Steinberger d9a132b649 chore: update dependencies 2025-12-09 17:43:22 +01:00
Peter Steinberger 60a68aa136 Gateway: start providers and route sends to their surface 2025-12-09 16:38:43 +00:00
Peter Steinberger 464e4c1938 Gateway: honor verbose for Baileys and show log path 2025-12-09 16:33:04 +00:00
Peter Steinberger 796f630a7c Status: color provider lines 2025-12-09 16:31:38 +00:00
Peter Steinberger dc8f9e043d Tests: cover gateway --force helpers 2025-12-09 16:31:28 +00:00
Peter Steinberger 6afcf43ff2 CLI: add gateway --force option 2025-12-09 16:28:26 +00:00
Peter Steinberger e0ea7be499 Docs: rename relay command to gateway 2025-12-09 17:24:57 +01:00
Peter Steinberger 4bf968a45a CLI: add gateway verbose flag 2025-12-09 17:17:58 +01:00
Peter Steinberger a86963d62d Debug: rename restart button to Gateway 2025-12-09 16:16:14 +00:00
Peter Steinberger e40f9c9730 Mac: launch gateway and add relay installer 2025-12-09 16:15:53 +00:00
Peter Steinberger 96be7c8990 tests: cover agent sequencing, tick watchdog, presence fingerprint 2025-12-09 17:05:47 +01:00
Peter Steinberger 3ced3f4c82 ci/docs: enforce protocol check and deprecate control api 2025-12-09 17:03:05 +01:00
Peter Steinberger 72eb240c3b gateway: harden ws protocol and liveness 2025-12-09 17:02:58 +01:00
Peter Steinberger 20d247b3f7 Mac: type agent events end-to-end 2025-12-09 15:38:22 +01:00
Peter Steinberger 318457cb2c chore(swabble): apply swiftformat 2025-12-09 15:36:41 +01:00
Peter Steinberger 336c9d6caa Mac: build GatewayProtocol target and typed presence handling 2025-12-09 15:35:06 +01:00
Peter Steinberger a7737912b0 Mac: use typed GatewayFrame + forward-compatible Swift generator 2025-12-09 15:26:31 +01:00
Peter Steinberger f244aba03d Protocol: legacy shim file for Xcode references 2025-12-09 15:23:51 +01:00
Peter Steinberger b0c196cf82 Protocol: add TypeBox-driven Swift generator 2025-12-09 15:21:16 +01:00
Peter Steinberger cf5769753a Protocol: lint fixes for client/program 2025-12-09 15:18:34 +01:00
Peter Steinberger d1217e84c7 CLI: remove relay/heartbeat legacy commands 2025-12-09 15:06:44 +01:00
Peter Steinberger 172ce6c79f Gateway: discriminated protocol schema + CLI updates 2025-12-09 15:01:13 +01:00
Peter Steinberger 2746efeb25 WebChat: loopback snapshot hydration 2025-12-09 14:41:55 +01:00
Peter Steinberger b2e7fb01a9 Gateway: finalize WS control plane 2025-12-09 14:41:41 +01:00
Peter Steinberger 9ef1545d06 Coordinator: centralize voice sessions for wake and push-to-talk 2025-12-09 05:41:41 +01:00
Peter Steinberger fc1d58b631 WebChat: fix packaged root resolution 2025-12-09 04:36:15 +00:00
Peter Steinberger 2ebad55a59 Relay: force app to run relay via system node 2025-12-09 04:36:05 +00:00
Peter Steinberger d66a05dc41 RPC: route logs to stderr to keep stdout JSON clean 2025-12-09 04:30:22 +00:00
Peter Steinberger 998a5b080d Update auto-reply and voice wake runtime 2025-12-09 04:15:01 +00:00
Peter Steinberger 39a0f54b0d Runtime: drop bun support 2025-12-09 04:13:56 +00:00
Peter Steinberger 024a823c78 Runtime: delay restart inside actor; log RPC unexpected payload 2025-12-09 05:02:56 +01:00
Peter Steinberger 1bbb424322 Overlay: block new sessions while sending; delay runtime restart 2025-12-09 05:02:03 +01:00
Peter Steinberger b04f04776b fix(mac): make rpc parsing tolerate stray stdout 2025-12-09 05:01:50 +01:00
Peter Steinberger f0860ec145 chore(instances): harden presence refresh and fix lint 2025-12-09 04:51:54 +01:00
Peter Steinberger 658e0c6b03 Presence: resilient local fallback 2025-12-09 04:48:21 +01:00
Peter Steinberger 49fa093767 Overlay: log token drops and immediate auto-send 2025-12-09 04:47:05 +01:00
Peter Steinberger 51aed3ca0a chore(mac): apply swiftformat and lint fixes 2025-12-09 04:42:44 +01:00
Peter Steinberger b9cc914729 Docs: clarify relay launch mechanism 2025-12-09 03:36:16 +00:00
Peter Steinberger d084a37e11 feat(mac): tokenized voice overlay adoption 2025-12-09 04:35:13 +01:00
Peter Steinberger cfd2c41c21 fix(rpc): keep stdout json-only 2025-12-09 04:34:11 +01:00
Peter Steinberger 9dee4c158d chore(instances): log empty payloads and add local fallback 2025-12-09 04:29:34 +01:00
Peter Steinberger 6b8011228e fix(presence): always seed self entry and log counts 2025-12-09 03:21:59 +00:00
Peter Steinberger 2cd27d0d4a Relay: enforce single instance lock 2025-12-09 03:17:23 +00:00
Peter Steinberger 3dff09424d VoiceWake: drop unused forward health check state 2025-12-09 03:12:37 +00:00
Peter Steinberger 8e15a6e798 Overlay: safety dismiss and logging; keep PTT final send 2025-12-09 04:04:45 +01:00
Peter Steinberger 2756e12762 VoiceWake: drop remote ssh config and harden template parsing 2025-12-09 03:04:08 +00:00
Peter Steinberger 4eb71bcd14 rpc: ensure worker is killed if it hangs on shutdown 2025-12-09 03:04:00 +00:00
Peter Steinberger 2177df51a8 feat(status): enrich session details 2025-12-09 03:00:10 +00:00
Peter Steinberger 40c8e4832a WebChat: make tunnel restart handler hop to MainActor 2025-12-09 03:58:28 +01:00
Peter Steinberger 3377bd4ae5 PTT: wait for final transcript before send/dismiss 2025-12-09 03:57:08 +01:00
Peter Steinberger 38c4f4f76c feat(instances): beacon on connect and relay self-entry 2025-12-09 03:57:08 +01:00
Peter Steinberger 280c7c851f tests: cover voicewake template defaults 2025-12-09 02:52:04 +00:00
Peter Steinberger af9ccf0c09 VoiceWake: route forwarding via agent rpc 2025-12-09 02:50:58 +00:00
Peter Steinberger e7cdac90f5 mac: stop leaking ssh processes on quit 2025-12-09 02:50:58 +00:00
Peter Steinberger 7aefcab8b0 Health: clean degraded message; PTT hotkey monitors 2025-12-09 03:46:52 +01:00
Peter Steinberger 514b90ac69 VoiceWake: autoplay chime on selection 2025-12-09 03:42:03 +01:00
Peter Steinberger dbcb97949f macOS: centralize sound effect catalog/player 2025-12-09 03:42:03 +01:00
Peter Steinberger 76d559efc1 macOS: log control responses 2025-12-09 02:41:18 +00:00
Peter Steinberger 8d8584849c RPC: fix presence imports 2025-12-09 02:39:41 +00:00
Peter Steinberger 59a2cbefcb RPC: extract stdio loop and tests 2025-12-09 02:37:04 +00:00
Peter Steinberger c568284f1b Build: fix RPC sendable params and CLI imports 2025-12-09 03:33:16 +01:00
Peter Steinberger a8b26570e0 macOS: include mail sounds in chime picker 2025-12-09 03:28:29 +01:00
Peter Steinberger 5a74b40ae4 macOS: broaden chime sound catalog 2025-12-09 03:27:17 +01:00
Peter Steinberger 04f595cd97 Control: route health/heartbeat over RPC stdio 2025-12-09 02:26:08 +00:00
Peter Steinberger 99a3102134 Docs: voice overlay plan and fix web mocks 2025-12-09 03:25:55 +01:00
Peter Steinberger 3a42979e53 Voice wake: log overlay lifecycle and enforce PTT cooldown 2025-12-09 03:20:52 +01:00
Peter Steinberger 912a53318e fix(voicewake): snap overlay to top-right 2025-12-09 03:18:05 +01:00
Peter Steinberger 421401ae3f Voice wake: drop stale recognition callbacks 2025-12-09 03:08:22 +01:00
Peter Steinberger e15475449c fix merge; add control logging 2025-12-09 01:46:09 +00:00
Peter Steinberger 31750b5ee5 style(macos): remove quit separator and resize settings 2025-12-09 02:28:05 +01:00
Peter Steinberger bc92f6d4a4 feat(macos): add instances tab and presence beacons 2025-12-09 02:25:45 +01:00
Peter Steinberger 1969e78d54 feat: surface system presence for the agent 2025-12-09 02:25:37 +01:00
Peter Steinberger 317f666d4c Voice wake: send or dismiss on release 2025-12-09 02:25:06 +01:00
Peter Steinberger 3fe68a051a fix: block partial replies on external chat surfaces 2025-12-09 01:48:12 +01:00
Peter Steinberger 5bfecc6152 fix: stop partial replies for whatsapp/telegram surfaces 2025-12-09 01:41:05 +01:00
Peter Steinberger e44ed2681f refactor: type tau rpc stream events 2025-12-09 01:41:05 +01:00
Peter Steinberger 27a545f79d chore: harden rpc assistant streaming types 2025-12-09 01:41:05 +01:00
Peter Steinberger 6b10f4241d feat(macos): surface session activity in menu bar 2025-12-09 01:41:05 +01:00
Peter Steinberger 73cc34467a control: log incoming health requests 2025-12-09 00:38:42 +00:00
Peter Steinberger ec1ff52dfb control: reconnect on EOF and relax rpc text parse 2025-12-09 00:29:31 +00:00
Peter Steinberger 2761c40781 test: ensure tool events emit without verbose 2025-12-09 01:24:16 +01:00
Peter Steinberger e981d90209 fix: always emit tool events 2025-12-09 01:22:50 +01:00
Peter Steinberger f965e1c3ff chore: single-source working state from agent events 2025-12-09 01:17:01 +01:00
Peter Steinberger 5b5a79b90b chore(mac): drop duplicate job-state tracking 2025-12-09 01:06:46 +01:00
Peter Steinberger 15729e9ea0 macos: log health timeout and control requests 2025-12-09 00:00:50 +00:00
Peter Steinberger d9eb320bba ci: test node and bun runtimes 2025-12-09 01:00:35 +01:00
Peter Steinberger cba016df74 chore(mac): prefer host runtime for remote relay 2025-12-09 00:59:56 +01:00
Peter Steinberger cf36f5a23b chore: guard host runtime and simplify packaging 2025-12-09 00:59:56 +01:00
Peter Steinberger 34d2527606 chore: tidy agent event streaming types 2025-12-09 00:59:56 +01:00
Peter Steinberger 8e8e695db9 feat(mac): add agent events debug window 2025-12-09 00:59:56 +01:00
Peter Steinberger 9928f1b3c1 macOS: extract attributed string helper 2025-12-09 00:59:56 +01:00
Peter Steinberger 36c91c3984 relay: don't crash when webchat port is busy 2025-12-08 23:49:57 +00:00
Peter Steinberger b7b1714f32 feat: forward tool/assistant events to agent bus 2025-12-09 00:44:30 +01:00
Peter Steinberger 2d1f1640f3 chore: ignore macOS swiftpm cache 2025-12-09 00:43:45 +01:00
Peter Steinberger 371a30f08b feat: stream tool/job events over control channel 2025-12-09 00:31:39 +01:00
Peter Steinberger 40dd23337c feat: broadcast agent events over control channel 2025-12-09 00:28:03 +01:00
Peter Steinberger 3114dfd39b refactor(mac): split menubar UI into smaller files 2025-12-09 00:27:53 +01:00
Peter Steinberger 04b34adec6 macos: show detailed health failure 2025-12-08 23:20:14 +00:00
Peter Steinberger 594e837440 feat: emit job-state events from rpc 2025-12-09 00:18:14 +01:00
Peter Steinberger c77fa12bda fix(mac): stabilize voice wake visuals 2025-12-09 00:12:43 +01:00
Peter Steinberger 5674c9f4c2 Mac: clarify runtime comments 2025-12-09 00:08:19 +01:00
Peter Steinberger bc01488a75 fix(mac): switch push-to-talk to right option 2025-12-08 23:50:31 +01:00
Peter Steinberger c3c6880382 macos: timeout control health probes 2025-12-08 22:45:58 +00:00
Peter Steinberger 1f2f5858c0 docs: note Mac app for relay debugging 2025-12-08 23:37:46 +01:00
Peter Steinberger 22259a322d macos: keep remote control tunnel alive 2025-12-08 23:28:03 +01:00
Peter Steinberger 06f59f4e8a Build: update webchat bundle 2025-12-08 23:20:10 +01:00
Peter Steinberger 2b7adeb220 VoiceWake: track listening state for PTT 2025-12-08 23:17:11 +01:00
Peter Steinberger 05bd452f76 control: drop runtime export of type-only HeartbeatEventPayload 2025-12-08 23:15:33 +01:00
Peter Steinberger a6426d0ac5 macos: swap bubble shadow for 1px border 2025-12-08 23:14:00 +01:00
Peter Steinberger 5dd5c9c605 macos: add inset margin so overlay shadow isn't clipped 2025-12-08 22:56:49 +01:00
Peter Steinberger 0e4b28ac25 macos: fail fast when SSH tunnel exits 2025-12-08 22:53:40 +01:00
Peter Steinberger 62fecdcaa8 VoiceWake: guard trigger chime 2025-12-08 22:52:51 +01:00
Peter Steinberger 440558c44f macos: add soft shadow behind overlay bubble 2025-12-08 22:51:04 +01:00
Peter Steinberger fa9a92f214 macos: deepen shadow on close pill 2025-12-08 22:45:40 +01:00
Peter Steinberger c5af11f6bd Remove overlay bar meter 2025-12-08 22:45:40 +01:00
Peter Steinberger ad3254deb6 macos: restore overlay close button 2025-12-08 21:40:18 +00:00
Peter Steinberger fce04b9424 macos: stabilize close hover and unclipped button 2025-12-08 22:38:51 +01:00
Peter Steinberger 2d512c714b VoiceWake: button meter + fix label color 2025-12-08 22:38:30 +01:00
Peter Steinberger 6298c586fd macos: stabilize control connection wait 2025-12-08 21:37:07 +00:00
Peter Steinberger abca8535cf macos: blink critter when overlay dismisses empty 2025-12-08 22:34:11 +01:00
Peter Steinberger 677374de86 macos: sync ears with overlay visibility 2025-12-08 22:31:03 +01:00
Peter Steinberger 92d015333a VoiceWake: add level meter 2025-12-08 22:28:49 +01:00
Peter Steinberger 6c91304400 macos: refine speech noise floor tracking 2025-12-08 22:24:12 +01:00
Peter Steinberger 04b5002d8f macos: polish voice overlay and remote command handling 2025-12-08 22:23:24 +01:00
Peter Steinberger 9bde7a6daa macos: harden control channel connect continuation 2025-12-08 22:16:05 +01:00
Peter Steinberger 33b54f3d0c ux: float close button outside bubble, stronger shadow 2025-12-08 22:11:38 +01:00
Peter Steinberger c5b073702c macos: control channel diagnostics and tunnel-based testing 2025-12-08 22:04:02 +01:00
Peter Steinberger e38bdd0d2d control: seed events, add tests, update remote doc 2025-12-08 22:03:46 +01:00
Peter Steinberger 9c54e48194 fix: avoid auto-send task init error 2025-12-08 22:02:03 +01:00
Peter Steinberger 12e048a7fb ux: float close button outside bubble and reduce hover flicker 2025-12-08 21:59:05 +01:00
Peter Steinberger 11400e43dc chore: sync webchat bundle and voice wake settings 2025-12-08 21:51:08 +01:00
Peter Steinberger 293b4960f3 macos: use control channel for health and heartbeat 2025-12-08 21:50:51 +01:00
Peter Steinberger 22996854f7 relay: add control channel and heartbeat stream 2025-12-08 21:50:24 +01:00
Peter Steinberger 71e58c768c docs: add control channel reference 2025-12-08 21:50:16 +01:00
Peter Steinberger bb3606b64f VoiceWake: centralize send chime and guard play 2025-12-08 21:25:30 +01:00
Peter Steinberger 7a82777fc5 ux: add hover/ edit close button and keep overlay until escape or send 2025-12-08 21:22:04 +01:00
Peter Steinberger ec046411f1 VoiceWake: skip send chime when nothing to send 2025-12-08 20:57:41 +01:00
Peter Steinberger ffaf968940 VoiceWake: streamline chimes, default to Glass 2025-12-08 20:50:34 +01:00
Peter Steinberger feb70aeb6b VoiceWake: add chimes for trigger and send 2025-12-08 20:45:05 +01:00
Peter Steinberger ded106b9e3 ux: keep window in edit, add escape to cancel; fix lint drift 2025-12-08 20:22:56 +01:00
Peter Steinberger cfdcabc8b4 VoiceWake: sanitize triggers only when applying 2025-12-08 20:20:56 +01:00
Peter Steinberger ab448988ff RPC: stream heartbeat events to menu 2025-12-08 20:18:54 +01:00
Peter Steinberger e3089d60ea HeartbeatStore: fix main-actor cleanup 2025-12-08 20:17:38 +01:00
Peter Steinberger 34f892ae82 VoiceWake: keep empty trigger rows 2025-12-08 20:13:49 +01:00
Peter Steinberger fbbf0ed41c ux: top-align overlay content 2025-12-08 20:10:39 +01:00
Peter Steinberger 66a8780fa2 ui: strip label color attributes so text uses primary color 2025-12-08 20:00:36 +01:00
Peter Steinberger 2c610258d1 ux: use primary text color in display label 2025-12-08 19:57:29 +01:00
Peter Steinberger f7430d74a7 ux: wrap label to overlay width, remove label background 2025-12-08 19:43:07 +01:00
Peter Steinberger 421d6db592 ux: keep vibrancy, brighten label, ensure wrapping 2025-12-08 19:36:48 +01:00
Peter Steinberger 1d385fd35a ui: drop translucency for overlay background 2025-12-08 19:20:46 +01:00
Peter Steinberger 7cb31581d5 ux: brighten display label and wrap properly 2025-12-08 19:15:58 +01:00
Peter Steinberger 768d550ee2 ux: show vibrant label until edit, then switch to text view 2025-12-08 19:11:59 +01:00
Peter Steinberger 4fd7480557 chore: launch app in restart script instead of launch agent 2025-12-08 19:01:29 +01:00
Peter Steinberger 7c0f0a59eb tweak: strengthen partial transcript tint 2025-12-08 18:54:02 +01:00
Peter Steinberger 93aeee1611 tweak: centralize overlay max/min heights 2025-12-08 18:52:19 +01:00
Peter Steinberger 86d9e1e816 fix: hide overlay scrollbar unless content overflows 2025-12-08 18:50:14 +01:00
Peter Steinberger 73211c900b perf(mac): move blocking launchctl/webchat work off main 2025-12-08 18:42:13 +01:00
Peter Steinberger a19d4c19d3 tweak: allow overlay to grow to 400px then scroll 2025-12-08 18:33:14 +01:00
Peter Steinberger cf3b7f2c16 fix: keep overlay attributed colors and auto-resize 2025-12-08 18:28:17 +01:00
Peter Steinberger 2f21dd81b0 docs/macos: simplify sag install (auto-tap) 2025-12-08 18:19:54 +01:00
Peter Steinberger db3b3ed9eb fix: polish voice overlay and webchat lint 2025-12-08 17:32:34 +01:00
Peter Steinberger 9625d94aa0 fix(mac): surface webchat load failures and preflight reachability 2025-12-08 17:24:08 +01:00
Peter Steinberger 5dec7d534f docs: document push-to-talk hotkey 2025-12-08 17:24:08 +01:00
Peter Steinberger 0317eec10d feat(mac): add push-to-talk hotkey 2025-12-08 17:24:08 +01:00
Peter Steinberger a34ab1d36e Webchat: clean server build and add ws types 2025-12-08 16:21:56 +00:00
Peter Steinberger 7144a0fb9b Webchat: push updates over WebSocket 2025-12-08 16:19:33 +00:00
Peter Steinberger 421924b73f fix: restart webchat tunnel on main actor 2025-12-08 17:14:43 +01:00
Peter Steinberger 466236e32f fix(mac): harden remote webchat tunnel and keep it alive 2025-12-08 17:14:43 +01:00
Peter Steinberger 636f2d659f chore: tighten webchat types and formatting 2025-12-08 17:14:43 +01:00
Peter Steinberger 838a9c000c fix: resize overlay on text updates and keep final tint 2025-12-08 17:14:43 +01:00
Peter Steinberger 7a7c59e91a Webchat: poll session for messages/thinking 2025-12-08 16:14:12 +00:00
Peter Steinberger 1ac6ab4428 Agent: add thinkingOnce flag 2025-12-08 16:12:24 +00:00
Peter Steinberger dc3c82ad40 Webchat: sync thinking level with session 2025-12-08 16:10:14 +00:00
Peter Steinberger 0f0a2dddfe chore: use 5s silence before speech, 2s after 2025-12-08 17:06:12 +01:00
Peter Steinberger c3f955d3f1 chore: fix lint warnings and formatting 2025-12-08 17:05:27 +01:00
Peter Steinberger 7b1832bd24 chore: extend voice capture hard stop to 120s 2025-12-08 16:58:38 +01:00
Peter Steinberger 148c9533ae chore: use 2s silence or 5s max capture 2025-12-08 16:55:08 +01:00
Peter Steinberger df96318662 fix(mac): run remote health with pnpm under zsh 2025-12-08 16:52:42 +01:00
Peter Steinberger d9d0be0256 fix: finalize only after full 1s silence 2025-12-08 16:52:13 +01:00
Peter Steinberger de70d82cea fix(mac): surface health errors instead of pending 2025-12-08 16:50:20 +01:00
Peter Steinberger 81db44f584 feat: add outcome-based dismiss animations 2025-12-08 16:49:58 +01:00
Peter Steinberger d733d246f0 chore: remove overlay shadow/border 2025-12-08 16:45:25 +01:00
Peter Steinberger 1c5170b759 fix: animate overlay resizing on updates 2025-12-08 16:44:44 +01:00
Peter Steinberger 367526f750 feat: show partial transcripts with subdued tint 2025-12-08 16:44:00 +01:00
Peter Steinberger 7a0830de15 feat: tint partial transcripts and stabilize delays 2025-12-08 16:41:33 +01:00
Peter Steinberger a5fbfa3748 fix: delay logic waits for post-trigger content 2025-12-08 16:38:33 +01:00
Peter Steinberger 912a7a1781 test: cover trigger trimming for voice wake 2025-12-08 16:36:53 +01:00
Peter Steinberger 563701fed8 fix: trim overlay transcript to post-trigger 2025-12-08 16:35:03 +01:00
Peter Steinberger 414889e03b feat: add adaptive voice wake delays 2025-12-08 16:34:06 +01:00
Peter Steinberger 8d2de036d5 feat: refine voice wake overlay animations 2025-12-08 16:34:06 +01:00
Peter Steinberger 764761cfa5 feat: add voice wake overlay 2025-12-08 16:34:06 +01:00
Peter Steinberger 90a0bb5acb feat(cli): unify relay providers and heartbeat flag 2025-12-08 16:34:06 +01:00
Peter Steinberger 0e4379f075 Webchat: cap/ persist attachments and strip data URLs 2025-12-08 14:59:26 +00:00
Peter Steinberger 968c5dc4aa Webchat: update bundled assets after attachment support 2025-12-08 14:48:03 +00:00
Peter Steinberger fedb15d5d0 Webchat: inline attachments to agent RPC and fix status compile 2025-12-08 14:46:33 +00:00
Peter Steinberger ccc6bf05e8 status: read token usage from pi session logs 2025-12-08 14:46:15 +00:00
Peter Steinberger a40e56bcb7 Docs: webchat now served in-process, no CLI spawn 2025-12-08 14:15:03 +00:00
Peter Steinberger 52453eaeff Webchat: run agent in-process for RPC 2025-12-08 14:14:00 +00:00
Peter Steinberger ff3337feed Webchat: resolve static root in packaged app 2025-12-08 14:07:20 +00:00
Peter Steinberger cd30a99fae feat(macos): add voice wake mic picker 2025-12-08 15:05:57 +01:00
Peter Steinberger 081460e59d macOS webchat: use relay HTTP transport directly 2025-12-08 13:12:34 +00:00
Peter Steinberger 17a6d716ad Webchat: auto-start server and simplify config 2025-12-08 13:12:34 +00:00
Peter Steinberger d833de793d Split clawdis node vs mac helper commands 2025-12-08 13:26:12 +01:00
Peter Steinberger a6ff62c79c SSH remote uses clawdis only 2025-12-08 13:20:55 +01:00
Peter Steinberger 92457f7fab Remote web chat tunnel and onboarding polish 2025-12-08 12:50:37 +01:00
Peter Steinberger 17fa2f4053 refactor(cli): drop tmux helpers and update help copy 2025-12-08 12:43:13 +01:00
Peter Steinberger bce84376d3 webchat: send via http rpc endpoint and show errors 2025-12-08 12:23:45 +01:00
Peter Steinberger be87cdddeb webchat: surface bootstrap errors in UI 2025-12-08 12:17:39 +01:00
Peter Steinberger dc22661744 webchat: move serving to relay loopback and tunnel from mac app 2025-12-08 11:54:30 +01:00
Peter Steinberger dc69d20ec9 docs: outline web chat move to relay server 2025-12-08 11:25:00 +01:00
Peter Steinberger 22ed7ea3f2 build: silence grammy type errors for mac packaging 2025-12-08 11:04:17 +01:00
Peter Steinberger 2112fa919a webchat: fetch remote sessions via CLI and log missing history 2025-12-08 01:55:09 +01:00
Peter Steinberger f65702a8a8 chore(ci): fix lint and swiftformat failures 2025-12-08 01:48:53 +01:00
Peter Steinberger 68d19d4717 webchat: load remote history from tau fallback and send to session 2025-12-08 01:36:00 +01:00
Peter Steinberger a6e0ec38e7 VoiceWake: capture utterance and add prefix 2025-12-08 01:35:42 +01:00
Peter Steinberger 6415ae79be webchat: make remote mode load history and send via rpc 2025-12-08 01:27:18 +01:00
Peter Steinberger 79b76fb5f4 ui: drop default sound picker; use cli per-notification sound 2025-12-08 00:56:36 +01:00
Peter Steinberger 42012389c4 health: surface ssh output when probe fails 2025-12-08 00:52:31 +01:00
Peter Steinberger 4b5c43f080 copy: rename menu toggle to Remote Clawdis Active when remote 2025-12-08 00:41:31 +01:00
Peter Steinberger d16e5090a6 copy: capitalize send heartbeats menu label 2025-12-08 00:40:30 +01:00
Peter Steinberger ddbe680a58 feat(macos): add Sparkle updates and release docs 2025-12-08 00:18:16 +01:00
Peter Steinberger 2f50b57e76 ui: remove duplicate health row in General 2025-12-08 00:17:29 +01:00
Peter Steinberger dc291fa811 ui: move Clawdis active toggle to top 2025-12-08 00:16:25 +01:00
Peter Steinberger a1d499ed64 copy: shorten tailscale tip 2025-12-08 00:14:58 +01:00
Peter Steinberger 629f2e0043 fix: stop voice wake tester after short post-trigger silence 2025-12-07 23:43:50 +01:00
Peter Steinberger 5d321c4dac copy: rename recognition language label 2025-12-07 23:35:58 +01:00
Peter Steinberger 9d751e0c72 ui: place health row under remote picker and improve timeout message 2025-12-07 23:34:49 +01:00
Peter Steinberger 6f8fb561c6 ui: tidy tables, links, and hide redundant voice wake forwarder 2025-12-07 23:26:28 +01:00
Peter Steinberger 1019872832 ui: move health/cli info to Debug; add single health row in General 2025-12-07 23:22:54 +01:00
Peter Steinberger 091471293d ui: fold remote mode label into picker 2025-12-07 23:21:00 +01:00
Peter Steinberger d7281286ba ui: reuse compact remote card in General and hide voice wake forwarder 2025-12-07 23:20:14 +01:00
Peter Steinberger 5cfda2803d fix: remote test uses CLI path discovery again 2025-12-07 23:12:33 +01:00
Peter Steinberger 9ee7a14685 ui: make General tab scrollable 2025-12-07 23:06:10 +01:00
Peter Steinberger 40a6574b95 ui: align voice wake forwarding with remote mode 2025-12-07 23:04:51 +01:00
Peter Steinberger 891e1388ba style: bump onboarding height to 840px 2025-12-07 22:58:05 +01:00
Peter Steinberger 0fba7d41a6 chore: refresh webchat bundle 2025-12-07 22:57:12 +01:00
Peter Steinberger 1595fb8739 docs: move grammY research note to docs/grammy.md 2025-12-07 22:53:58 +01:00
Peter Steinberger ebc852b358 chore: update dependencies 2025-12-07 22:53:36 +01:00
Peter Steinberger 5f5846a08b Telegram: enable grammY throttler and webhook tests 2025-12-07 22:52:57 +01:00
Peter Steinberger 4d3d9cca2a Add Bun bundle docs and Telegram grammY support 2025-12-07 22:47:05 +01:00
Peter Steinberger 7b77e9f9ae macOS: surface stderr in health failure text 2025-12-07 21:37:06 +00:00
Peter Steinberger 0f74e372ba MenuBar: fix health label age string 2025-12-07 19:03:49 +01:00
Peter Steinberger a3b99dc309 Utilities: add age helper for menu health label 2025-12-07 19:02:50 +01:00
Peter Steinberger d73d571f19 Launch agent: disable autostart without killing running app 2025-12-07 19:01:14 +01:00
Peter Steinberger 8a8ac1ffe6 style: increase onboarding window height 2025-12-07 19:01:14 +01:00
Peter Steinberger d463c82c95 build: add local node bin to restart script PATH 2025-12-07 19:01:14 +01:00
Peter Steinberger 558af7a454 chore: surface helper install status in onboarding 2025-12-07 19:01:14 +01:00
Peter Steinberger d57ebb3c94 style: enlarge onboarding window to fit full permission list 2025-12-07 19:01:14 +01:00
Peter Steinberger 855976df84 style: compact remote setup card and move advanced ssh fields 2025-12-07 19:01:14 +01:00
Peter Steinberger 6c2a8d6047 style: increase onboarding content height 2025-12-07 19:01:14 +01:00
Peter Steinberger 38a856f7ff style: tighten onboarding hero spacing 2025-12-07 19:01:14 +01:00
Peter Steinberger fb2a7d8cd1 VoiceWake: add escaping regression tests 2025-12-07 19:01:14 +01:00
Peter Steinberger b3f79e5b02 macOS: fix web chat agent PATH and surface stderr 2025-12-07 17:31:14 +00:00
Peter Steinberger 1722148333 macOS: show last health result with age in menu 2025-12-07 17:23:51 +00:00
Peter Steinberger 27e96999cf VoiceWake: document escape path and reset stale forward command 2025-12-07 18:23:34 +01:00
Peter Steinberger 7efa152418 VoiceWake: document escape path and reset stale forward command 2025-12-07 18:23:34 +01:00
Peter Steinberger 2a45455c80 feat: add remote clawd toggle 2025-12-07 18:23:34 +01:00
Peter Steinberger c06f49cb3e macOS: merge status row and fix webchat bundle deps 2025-12-07 17:20:42 +00:00
Peter Steinberger b837c68df8 VoiceWake: remove python hop; use escaped literal under /bin/sh 2025-12-07 18:03:25 +01:00
Peter Steinberger f3ebb2e9ce test(mac): cover voice wake helpers 2025-12-07 17:56:40 +01:00
Peter Steinberger df9f72134b refactor(mac): split voice wake settings 2025-12-07 17:55:07 +01:00
Peter Steinberger 4ff5004d7c webchat: bypass api key prompts in embedded mode 2025-12-07 17:55:07 +01:00
Peter Steinberger bdf3d60148 webchat: hide model selector in embedded UI 2025-12-07 17:55:07 +01:00
Peter Steinberger e2c6546b61 auto-reply: enrich chat status 2025-12-07 16:53:33 +00:00
Peter Steinberger 1f0ee9837b macOS: fix health shell timeout race 2025-12-07 16:53:32 +00:00
Peter Steinberger 71072f084e VoiceWake: send transcript via python/base64 instead of stdin 2025-12-07 17:45:43 +01:00
Peter Steinberger 98651c2a14 webchat: bundle assets with rolldown 2025-12-07 17:44:37 +01:00
Peter Steinberger 74e5e5e182 docs(mac): document privacy-off logging 2025-12-07 17:35:13 +01:00
Peter Steinberger 16f9dbfe37 VoiceWake: include ssh cmd on failure 2025-12-07 17:30:45 +01:00
Peter Steinberger 12f74de9b3 VoiceWake: pipe transcript to ssh forwarder 2025-12-07 16:59:22 +01:00
Peter Steinberger fec49e1e28 chore(webchat): increase server logging for module load debugging 2025-12-07 16:55:49 +01:00
Peter Steinberger 9dd9bb7092 chore(webchat): add server logging and ensure buildable 2025-12-07 16:49:08 +01:00
Peter Steinberger 9c07aab2d6 voice wake: log ssh command at info level 2025-12-07 16:43:18 +01:00
Peter Steinberger 41a84cef23 chore(webchat): wait for local server and add debug logging 2025-12-07 16:39:21 +01:00
Peter Steinberger 8942e3e78d voice wake: log full ssh command for debug 2025-12-07 16:38:49 +01:00
Peter Steinberger 040fe58693 chore: format macOS sources 2025-12-07 16:35:58 +01:00
Peter Steinberger 45398b7660 voice wake: use clean PATH (no inherited junk) 2025-12-07 16:33:56 +01:00
Peter Steinberger f3950a5a65 feat(macos): serve web chat over localhost to avoid cors 2025-12-07 16:30:10 +01:00
Peter Steinberger 6f6c5129d1 chore: bump version to 2.0.0 2025-12-07 16:28:57 +01:00
Peter Steinberger 139697b9cd voice wake: keep default key when identity is blank 2025-12-07 16:23:35 +01:00
Peter Steinberger ddd459426d voice wake: show identity not found when configured 2025-12-07 16:18:42 +01:00
Peter Steinberger 3387c135ad Icon: add ear holes on voice wake 2025-12-07 16:15:40 +01:00
Peter Steinberger 73133b61fb chore(macos): allow file access for web chat modules 2025-12-07 16:14:13 +01:00
Peter Steinberger ba0f594548 voice wake: surface ssh failures (missing key/no output) 2025-12-07 16:13:40 +01:00
Peter Steinberger f4fa9bf51a fix(macos): load web chat from bundled html 2025-12-07 16:13:40 +01:00
Peter Steinberger 9aea85a953 General: add bottom inset to quit button 2025-12-07 15:11:47 +00:00
Peter Steinberger f878e5e635 fix(mac): keep pnpm health output json-safe 2025-12-07 15:09:56 +00:00
Peter Steinberger 4e2fb38d62 debug: hide helper subtext while sending 2025-12-07 15:47:30 +01:00
Peter Steinberger ee845376b5 rpc: surface raw error lines and auto-start worker 2025-12-07 15:46:26 +01:00
Peter Steinberger 75234da135 Debug: surface detailed voice send errors 2025-12-07 14:41:45 +00:00
Peter Steinberger 7dc9434aec chore(macos): enlarge about icon 2025-12-07 15:34:44 +01:00
Peter Steinberger 5986cf4254 docs: record current rpc protocol and heartbeat toggle 2025-12-07 15:34:02 +01:00
Peter Steinberger f6db636473 Debug: make voice wake test follow config 2025-12-07 14:33:46 +00:00
Peter Steinberger b30db08110 feat: add heartbeat toggle with live RPC control 2025-12-07 15:32:48 +01:00
Peter Steinberger 2dbef6105d agent: allow deliver when json output 2025-12-07 15:16:55 +01:00
Peter Steinberger eeee9625c1 chore(macos): tighten voice wake control widths 2025-12-07 15:09:16 +01:00
Peter Steinberger 76559b352b debug: surface ssh error details in voice test 2025-12-07 15:07:56 +01:00
Peter Steinberger a3bf0d6002 fix(macos): honor pnpm/node when locating clawdis for health 2025-12-07 15:07:38 +01:00
Peter Steinberger 96ae0dd23a fix(macos): handle missing clawdis CLI for health check 2025-12-07 15:03:05 +01:00
Peter Steinberger 9c9e04c5a0 debug: add voice forward test button 2025-12-07 15:00:02 +01:00
Peter Steinberger 15381c7832 ci: use macos-latest with Xcode 26.1 2025-12-07 15:00:01 +01:00
Peter Steinberger 175f929023 macOS: widen voice wake label spacing 2025-12-07 13:57:05 +00:00
Peter Steinberger a23846b3a1 chore(macos): simplify health status menu and messaging 2025-12-07 14:54:58 +01:00
Peter Steinberger 42c74e864a chore(macos): align recognition language row styling 2025-12-07 14:52:43 +01:00
Peter Steinberger 809f5d6d8e chore(macos): align mic level bar width 2025-12-07 14:52:05 +01:00
Peter Steinberger ff41a61432 chore(macos): clean up CLI helper subtext 2025-12-07 14:49:56 +01:00
Peter Steinberger 28b531593a fix(macos): resolve clawdis path for health check 2025-12-07 14:49:18 +01:00
Peter Steinberger 4d2f4f1be3 chore(macos): make debug settings scrollable 2025-12-07 14:48:12 +01:00
Peter Steinberger f97415755b chore(macos): remove focus ring on about icon 2025-12-07 14:46:54 +01:00
Peter Steinberger 67fa82cf14 agent: deliver via rpc and voice forward 2025-12-07 06:05:00 +01:00
Peter Steinberger 1d38f5a4d5 Revert "fix: auto-start rpc worker for agent calls"
This reverts commit e70f8471a8.
2025-12-07 05:54:47 +01:00
Peter Steinberger e70f8471a8 fix: auto-start rpc worker for agent calls 2025-12-07 05:54:15 +01:00
Peter Steinberger 093e737af9 fix: keep launch agent alive and inject PATH 2025-12-07 05:49:59 +01:00
Peter Steinberger 1ae0b44bc5 fix(health): reveal logs alerts when missing; align actions 2025-12-07 05:46:47 +01:00
Peter Steinberger 17aeec59a3 fix: raise voice wake forward timeout to 30s 2025-12-07 05:46:05 +01:00
Peter Steinberger b20507ef0a chore(health): kick off health refresh at app launch 2025-12-07 05:44:09 +01:00
Peter Steinberger 753995a91d Docs: add no-real-data rule to AGENTS 2025-12-07 04:43:25 +00:00
Peter Steinberger 67c67dd86d Docs: swap to obviously fake phone numbers 2025-12-07 04:42:58 +00:00
Peter Steinberger fdc0b283d7 Docs: scrub personal phone example 2025-12-07 04:40:08 +00:00
Peter Steinberger 2abc51789e UI: streamline relay status label 2025-12-07 04:39:45 +00:00
Peter Steinberger 1190b9c278 Health: strengthen probe tests 2025-12-07 04:39:24 +00:00
Peter Steinberger 3a8e049093 chore: fix test import and lint 2025-12-07 05:38:29 +01:00
Peter Steinberger f32a647a20 test: cover command resolver fallbacks 2025-12-07 05:38:29 +01:00
Peter Steinberger 4645f512d1 fix: reuse resolver for agent rpc launch 2025-12-07 05:38:29 +01:00
Peter Steinberger 3d89999a06 docs: add voice wake forwarding tips to agents 2025-12-07 05:38:29 +01:00
Peter Steinberger cb5c932447 Health: CLI probe and mac UI surfacing 2025-12-07 04:38:20 +00:00
Peter Steinberger ddf8aef4f7 Settings: move session store path to Debug 2025-12-07 04:38:08 +00:00
Peter Steinberger 2714ed503b CLI: add health probe command 2025-12-07 04:33:22 +00:00
Peter Steinberger 78d96355dd Settings: inline heartbeat inputs 2025-12-07 04:32:28 +00:00
Peter Steinberger bf429b7e87 Settings: add heartbeat controls 2025-12-07 04:30:24 +00:00
Peter Steinberger 2f44046622 chore(agent): start rpc worker at launch, fail if not running 2025-12-07 05:24:54 +01:00
Peter Steinberger fb106967bc fix(macos): guard unavailable speech recognizer 2025-12-07 05:22:20 +01:00
Peter Steinberger 32720bd372 feat(agent): add rpc status command and tests; rpc only path 2025-12-07 05:20:50 +01:00
Peter Steinberger fb1de5c1c6 chore(agent): drop cli fallback, rpc only for sends 2025-12-07 05:16:16 +01:00
Peter Steinberger 69cb71ad7e feat(agent): use persistent rpc worker for agent sends 2025-12-07 05:14:45 +01:00
Peter Steinberger 0a9b98ed67 feat(cli): add stdin/stdout rpc loop for agent sends 2025-12-07 05:10:58 +01:00
Peter Steinberger e1c4a5989b docs: outline RPC plan for agent CLI 2025-12-07 05:08:14 +01:00
Peter Steinberger cac988f8e2 fix(webchat): wire agent CLI send into web chat view 2025-12-07 05:04:34 +01:00
Peter Steinberger bbe92a3a40 Mac: fix agent XPC by invoking CLI agent 2025-12-07 04:03:06 +00:00
Peter Steinberger a489550752 feat(cli): add agent send command and wire through XPC 2025-12-07 05:00:52 +01:00
Peter Steinberger f1dbff1dd4 fix(voicewake): log ssh/cli failure instead of staying silent 2025-12-07 04:58:57 +01:00
Peter Steinberger 55ea0f398b test(voicewake): cover trigger matching for runtime listener 2025-12-07 04:53:59 +01:00
Peter Steinberger 38abb044d0 feat(macos): run live voice wake listener and animate ears 2025-12-07 04:52:27 +01:00
Peter Steinberger ca4e76b34f test: add voice wake forwarder cache coverage 2025-12-07 04:52:26 +01:00
Peter Steinberger 55e0086958 fix: harden remote voice wake CLI lookup 2025-12-07 04:43:08 +01:00
Peter Steinberger 050ebb3b19 Mac: add relay restart button in Debug 2025-12-07 03:42:50 +00:00
Peter Steinberger 31f788eb5e CLI: allow --provider flag for login/logout (default whatsapp) 2025-12-07 03:41:27 +00:00
Peter Steinberger f23b16db2b build: require signing identity for mac packaging 2025-12-07 04:38:45 +01:00
Peter Steinberger 060f80c239 feat: add icon animation setting 2025-12-07 04:38:45 +01:00
Peter Steinberger 6c3d3b98b8 chore: purge warelay references 2025-12-07 03:36:57 +00:00
Peter Steinberger 21dfbd0103 feat(macos): detect installed CLI helper 2025-12-07 04:35:34 +01:00
Peter Steinberger 1a10569f6d Logging: use /tmp/clawdis for default pino logs 2025-12-07 03:32:37 +00:00
Peter Steinberger 33396ca9c1 Mac: debug log button shows path and opens in Finder 2025-12-07 03:29:58 +00:00
Peter Steinberger 36ba1ff790 Mac: debug log button falls back to legacy path 2025-12-07 03:20:04 +00:00
Peter Steinberger fdfcff2bb5 Mac: link Debug log button to pino log 2025-12-07 03:15:30 +00:00
Peter Steinberger c74c1a0c5f fix: stabilize tools action width 2025-12-07 04:13:19 +01:00
Peter Steinberger faca83e1e8 fix: ensure remote clawdis-mac path 2025-12-07 04:12:54 +01:00
Peter Steinberger 759ab54e59 VoiceWake: ssh check also verifies remote clawdis-mac 2025-12-07 04:01:00 +01:00
Peter Steinberger 3c61524f26 Mac: allow signed CLI + same-uid XPC clients 2025-12-07 02:48:24 +00:00
Peter Steinberger 40013c2b61 fix(mac): bundle WebChat resources when packaging 2025-12-07 03:36:47 +01:00
Peter Steinberger 5d5e7393f8 docs(mac): document webchat auto-open and debug flow 2025-12-07 03:34:49 +01:00
Peter Steinberger 71c5511e6c chore(mac): add webchat auto-open flag and verbose logging 2025-12-07 03:31:03 +01:00
Peter Steinberger ea83982062 Docs: add clawlog helper note 2025-12-07 03:30:24 +01:00
Peter Steinberger cdbbdcba5f Docs: describe mac XPC setup 2025-12-07 02:27:59 +00:00
Peter Steinberger aeb708fe07 Mac: secure XPC and register mach service via launchd 2025-12-07 02:27:17 +00:00
Peter Steinberger 78c67ed53d Mac: stabilize XPC and voice wake handling 2025-12-07 02:09:54 +00:00
Peter Steinberger ea37ee6cb3 feat(mac): add automation permission 2025-12-07 02:34:21 +01:00
Peter Steinberger 2e67c5a045 VoiceWake: stabilize test card height 2025-12-07 02:33:32 +01:00
Peter Steinberger 752bc5a454 VoiceWake: align mic + level rows 2025-12-07 02:32:57 +01:00
Peter Steinberger 3a4bf8f213 VoiceWake: compact SSH test row 2025-12-07 02:32:05 +01:00
Peter Steinberger bc20664c18 tools: add clawlog helper for unified logs 2025-12-07 02:25:55 +01:00
Peter Steinberger e27690e894 VoiceWake: log detection, hold to 1s silence, ssh log clarity 2025-12-07 02:24:18 +01:00
Peter Steinberger bac5ac18f7 fix: gate voice wake permissions 2025-12-07 02:19:50 +01:00
Peter Steinberger e906b87450 VoiceWake: keep listening until silence, gate enable on permissions 2025-12-07 02:18:37 +01:00
Peter Steinberger 9d0415f9e9 VoiceWake: make tab content scrollable 2025-12-07 02:17:17 +01:00
Peter Steinberger 1d807911e4 VoiceWake: better ssh target parsing and error detail 2025-12-07 02:17:17 +01:00
Peter Steinberger f51f8ffe45 scripts: make restart clean step resilient 2025-12-07 02:17:17 +01:00
Peter Steinberger ea9930816f Mac: disable KeepAlive; launch toggle controls agent 2025-12-07 01:13:48 +00:00
Peter Steinberger 699cb92e86 Mac: let launch checkbox toggle launchd agent 2025-12-07 01:09:49 +00:00
Peter Steinberger f4f4f2d314 Mac: run via launchd agent with mach service 2025-12-07 01:05:05 +00:00
Peter Steinberger 374472deda VoiceWake: add SSH connectivity check UI 2025-12-07 02:03:54 +01:00
Peter Steinberger b27f0dd490 Settings: keep tabs fixed, only content scrolls 2025-12-07 02:03:54 +01:00
Peter Steinberger 141d2b5626 VoiceWake: add SSH forwarder tests 2025-12-07 02:03:54 +01:00
Peter Steinberger cf0f44823a VoiceWake: add SSH forward target 2025-12-07 02:03:54 +01:00
Peter Steinberger 6355113af9 chore(mac): move relay status row directly under Active toggle 2025-12-07 02:03:54 +01:00
Peter Steinberger 00ef7ec522 Mac: align app version with package.json 2025-12-07 01:00:47 +00:00
Peter Steinberger 9497a4cb5a CLI: fix --version by reading app Info.plist 2025-12-07 00:59:37 +00:00
Peter Steinberger 0f71667625 CLI: add --version flag 2025-12-07 00:55:33 +00:00
Peter Steinberger 8b20e0166d CLI: add --help and usage 2025-12-07 00:53:22 +00:00
Peter Steinberger 567644dabd Mac: privileged CLI helper install via osascript 2025-12-07 00:50:56 +00:00
Peter Steinberger 9ef8cdadf6 Mac: lighten tool cards 2025-12-07 00:17:54 +00:00
Peter Steinberger c911568306 Mac: remove Tools & MCP header 2025-12-07 00:16:39 +00:00
Peter Steinberger ce02f798e4 Mac: fix voice wake actor crash; add mic entitlement 2025-12-07 00:10:29 +00:00
Peter Steinberger 21bb2fb03f Mac: add mic entitlement to signing helper 2025-12-06 23:52:54 +00:00
Peter Steinberger 11311d07e5 mac: tidy About metadata layout 2025-12-07 00:48:05 +01:00
Peter Steinberger 4426bf2615 Docs: note SIGN_IDENTITY for mac signing 2025-12-06 23:45:17 +00:00
Peter Steinberger 515e973964 Mac: fix permission prompt crash 2025-12-06 23:31:56 +00:00
Peter Steinberger 0a6b934ac1 mac: show build metadata in About 2025-12-07 00:30:58 +01:00
Peter Steinberger b2e3013898 mac: add signing helper and document debug bundle 2025-12-07 00:30:58 +01:00
Peter Steinberger 757cedc233 fix: remove legacy relay references 2025-12-06 23:21:25 +00:00
Peter Steinberger ab316b348a docs: update relay run mode 2025-12-07 00:16:32 +01:00
Peter Steinberger 7b7c4bd116 chore: fix swiftlint after split 2025-12-07 00:14:03 +01:00
Peter Steinberger 82e751a153 macOS: split AppMain into focused modules 2025-12-07 00:10:35 +01:00
Peter Steinberger c5c50a2141 fix(mac): bundle web chat UI deps 2025-12-07 00:05:38 +01:00
Peter Steinberger 9c32e630a0 docs: add 500 LOC cap to guardrails 2025-12-06 23:59:08 +01:00
Peter Steinberger 02e26996c1 fix(mac): run relay with cwd set to configured project root 2025-12-06 23:57:40 +01:00
Peter Steinberger b25b72ae19 chore(mac): rename relay root label to Clawdis project root 2025-12-06 23:56:23 +01:00
Peter Steinberger ff36375581 feat(mac): show relay attention badge without dimming paused state 2025-12-06 23:54:56 +01:00
Peter Steinberger c9f5edbc1d feat(mac): make relay project root configurable from Debug tab 2025-12-06 23:51:34 +01:00
Peter Steinberger ec00e0a952 fix(mac): run pnpm from project root and set PNPM_HOME for relay 2025-12-06 23:49:59 +01:00
Peter Steinberger 51a4b86495 fix(mac): resolve relay executable via common paths and pnpm fallback 2025-12-06 23:48:44 +01:00
Peter Steinberger c3866b7d6b docs: document debug signing and bundle id 2025-12-06 23:46:25 +01:00
Peter Steinberger 6dafca79be build: sign debug app and use stable bundle id 2025-12-06 23:46:19 +01:00
Peter Steinberger 7aca8d2d1c fix(mac): harden relay spawn path and show status 2025-12-06 23:45:16 +01:00
Peter Steinberger 7daef74fc6 chore: move relay status below toggles 2025-12-06 23:44:20 +01:00
Peter Steinberger 58d0f3053d feat(mac): show relay run indicator in menu 2025-12-06 23:43:36 +01:00
Peter Steinberger 649f644c75 chore: reorder settings tabs 2025-12-06 23:41:21 +01:00
Peter Steinberger ad2a26611a chore: move model reload to debug tab 2025-12-06 23:40:50 +01:00
Peter Steinberger 89bb7d0211 fix(macos): avoid voice tester crash 2025-12-06 23:39:13 +01:00
Peter Steinberger b3564bf2b4 chore(mac): guard Darwin import for relay manager 2025-12-06 23:26:29 +01:00
Peter Steinberger 16f452cf2e feat(macos): add tools tab installers 2025-12-06 23:25:17 +01:00
Peter Steinberger 56cedad707 chore: remove bin/warelay.js 2025-12-06 23:17:01 +01:00
Peter Steinberger 4b6325908b feat: unify main session and icon cues 2025-12-06 23:16:23 +01:00
Peter Steinberger 460d8fc094 feat(mac): add child relay process manager 2025-12-06 22:05:14 +01:00
Peter Steinberger c435236ceb mac: streamline model config UI 2025-12-06 21:39:25 +01:00
Peter Steinberger 39254229a0 mac: fix notification prompt and center onboarding toggle 2025-12-06 21:38:21 +01:00
Peter Steinberger 6182b205c8 mac: fix web chat boot in WKWebView 2025-12-06 21:33:35 +01:00
Peter Steinberger e528b439bc build: add mac icon pipeline 2025-12-06 21:00:32 +01:00
Peter Steinberger 629140d66c docs: document macOS Voice Wake and on-device processing 2025-12-06 05:24:27 +01:00
Peter Steinberger 46ed4f2de1 docs: clarify Voice Wake runs on-device 2025-12-06 05:23:28 +01:00
Peter Steinberger 46d55a8ada fix: harden model catalog parsing 2025-12-06 05:21:07 +01:00
Peter Steinberger 5e6af3d732 fix: add Config tab title case 2025-12-06 05:17:57 +01:00
Peter Steinberger 0d07c58989 fix: expose Config tab in settings 2025-12-06 05:15:15 +01:00
Peter Steinberger 6f80be0653 mac: add webview debug logging 2025-12-06 05:13:33 +01:00
Peter Steinberger 1916e688a6 feat: load PI model catalog and add dropdown in Config tab 2025-12-06 05:10:21 +01:00
Peter Steinberger 07e56ddeb5 docs: note bundled web chat assets 2025-12-06 05:03:51 +01:00
Peter Steinberger 88c8009116 feat: move CLI config into its own Settings tab 2025-12-06 05:03:03 +01:00
Peter Steinberger 42d843297d mac: bundle web chat assets 2025-12-06 05:01:28 +01:00
Peter Steinberger 15cdeeddaf feat: add config editor for clawdis model and session store 2025-12-06 04:27:50 +01:00
Peter Steinberger 3c13a265bc mac: add web chat bridge and docs 2025-12-06 04:14:14 +01:00
Peter Steinberger 93eec9ac3c mac: expand settings layout and dock toggle 2025-12-06 04:11:31 +01:00
Peter Steinberger df7dbff683 ui: align live level row with mic picker 2025-12-06 04:08:26 +01:00
Peter Steinberger 2e6265963b chore: align lint/format configs with peekaboo defaults 2025-12-06 04:07:22 +01:00
Peter Steinberger b88b18df93 fix: apply dock icon preference at launch 2025-12-06 04:04:23 +01:00
Peter Steinberger fbf5333b39 chore: run formatters and lint 2025-12-06 04:03:48 +01:00
Peter Steinberger c6e3b490f5 ci: add swiftlint/swiftformat for mac app 2025-12-06 04:02:43 +01:00
Peter Steinberger 19677f0622 ci: add macOS app build 2025-12-06 03:56:49 +01:00
Peter Steinberger a8932c2c25 feat: add additional voice wake languages + clean locale labels 2025-12-06 03:55:47 +01:00
Peter Steinberger f93e33d9de fix: ignore cancellation and keep mic meter during test 2025-12-06 03:55:47 +01:00
Peter Steinberger 649e6efc4a fix: decouple voice tester from main actor 2025-12-06 03:55:47 +01:00
Peter Steinberger a7d3619ec4 fix: avoid audio tap isolation crash 2025-12-06 03:55:47 +01:00
Peter Steinberger daca3a5fc9 fix: stabilize voice wake test 2025-12-06 03:55:47 +01:00
Peter Steinberger 135a52020c fix: run speech tap and handlers on safe queues 2025-12-06 03:55:47 +01:00
Peter Steinberger bf21ed7282 feat: add language picker for Voice Wake 2025-12-06 03:55:47 +01:00
Peter Steinberger b5f65e3304 chore: gate Voice Wake on macOS 26 2025-12-06 03:55:47 +01:00
Peter Steinberger 45400a1758 fix: show live transcript in voice wake test 2025-12-06 03:55:47 +01:00
Peter Steinberger acc88bc2b4 tweak: faster mic meter response 2025-12-06 03:55:47 +01:00
Peter Steinberger 98b0595275 fix: pause mic meter while running voice wake test 2025-12-06 03:55:47 +01:00
Peter Steinberger 4efecfdfa0 feat: add live mic meter to Voice Wake 2025-12-06 03:55:47 +01:00
Peter Steinberger b5afb9d3ab feat: add mic selection to Voice Wake settings 2025-12-06 03:55:47 +01:00
Peter Steinberger 6a1d58d4e7 mac: fix voice wake mic picker build 2025-12-06 03:55:47 +01:00
Peter Steinberger d2a3db4c78 mac: add app icon and tidy voice picker 2025-12-06 03:55:47 +01:00
Peter Steinberger f207788c0a refactor: make voice wake tester an actor 2025-12-06 03:55:46 +01:00
Peter Steinberger 84b44069c8 fix: run voice wake permission callbacks off the main actor 2025-12-06 03:55:46 +01:00
Peter Steinberger 09ed3f37db fix: keep voice wake permission callbacks on main actor 2025-12-06 03:55:46 +01:00
Peter Steinberger f444604e7c feat: surface mic and speech permissions 2025-12-06 03:55:46 +01:00
Peter Steinberger e1c9885566 chore: vendor swabble and add speech usage strings 2025-12-06 03:55:46 +01:00
Peter Steinberger 4e7d905783 mac: lock onboarding page width to 640 2025-12-06 03:55:46 +01:00
Peter Steinberger cb35e3a766 mac: add sessions tab to settings 2025-12-06 03:55:46 +01:00
Peter Steinberger 67fe5ed699 chore(mac): widen settings and keep critter static when paused 2025-12-06 03:55:46 +01:00
Peter Steinberger e863fd78d6 CLI: compact sessions table output 2025-12-06 00:49:21 +00:00
Peter Steinberger 4ea2518e79 mac: align settings window layout 2025-12-06 01:33:28 +01:00
Peter Steinberger b508ab240f fix(mac): stop critter animation when paused 2025-12-06 01:29:53 +01:00
Peter Steinberger c545ec727c chore(mac): rely on status item disable for dimming 2025-12-06 01:27:20 +01:00
Peter Steinberger f290b9a145 fix(mac): align restart/package to use .build 2025-12-06 01:23:54 +01:00
Peter Steinberger fa1eb9bf25 fix(mac): rebuild into .build-local and clean cache 2025-12-06 01:21:31 +01:00
Peter Steinberger 3a32b83181 chore(mac): label toggle as Clawdis Active 2025-12-06 01:17:25 +01:00
Peter Steinberger 2e393b7d5c docs: note trimmy-style restart and dimming 2025-12-06 01:15:42 +01:00
Peter Steinberger 12e5b8124e chore(mac): rebuild and relaunch like trimmy 2025-12-06 01:15:01 +01:00
Peter Steinberger 26e939c1eb fix(mac): dim menubar icon like trimmy 2025-12-06 01:07:15 +01:00
Peter Steinberger f09390a412 mac: fix settings window size persistence 2025-12-06 00:56:06 +01:00
Peter Steinberger 3067807802 mac: trimmy-style padding and debug toggle 2025-12-06 00:55:10 +01:00
Peter Steinberger 60f4c9f5b3 mac: tighten onboarding card layout 2025-12-06 00:52:22 +01:00
Peter Steinberger b0ecafcb8d mac: bring onboarding layout closer to vibetunnel 2025-12-06 00:50:22 +01:00
Peter Steinberger ddfb76e9e0 fix: bundle pi dependency and directive handling 2025-12-06 00:49:46 +01:00
Peter Steinberger 6f27f742fe feat(mac): add critter ear/leg wiggles 2025-12-06 00:49:30 +01:00
Peter Steinberger c1a64301ce mac: lock settings window size 2025-12-06 00:46:24 +01:00
Peter Steinberger 1ee690e87c mac: match trimmy about layout 2025-12-06 00:44:22 +01:00
Peter Steinberger 28e0dbc02f fix: harden directive handling 2025-12-05 23:43:30 +00:00
Peter Steinberger a2604a36bc mac: tighten settings layout 2025-12-06 00:42:41 +01:00
Peter Steinberger d031c5c7fa mac: auto-show onboarding on first run 2025-12-06 00:40:09 +01:00
Peter Steinberger 5d01b32c10 mac: polish onboarding and lifecycle 2025-12-06 00:38:02 +01:00
Peter Steinberger 4fe651079c fix(mac): align critter legs 2025-12-06 00:38:02 +01:00
Peter Steinberger a573ea4aeb chore: open settings from menu and restart packaged app 2025-12-06 00:38:02 +01:00
Peter Steinberger 13704d9da5 chore: add settings shortcut and restart packaging 2025-12-06 00:38:02 +01:00
Peter Steinberger 73a1e137e6 feat: trimmy-style settings tabs and CLI helper bundling 2025-12-06 00:38:02 +01:00
Peter Steinberger 0ec9c6c3cf fix(mac): show critter menubar icon 2025-12-06 00:38:02 +01:00
Peter Steinberger 4aa275e13c feat(mac): animate menubar icon 2025-12-06 00:38:02 +01:00
Peter Steinberger b557a73c3f feat: richer mac settings panes and template icon 2025-12-06 00:38:02 +01:00
Peter Steinberger b66098ea20 chore: bundle mac app and custom menu icon 2025-12-06 00:38:02 +01:00
Peter Steinberger d0cefecd0d chore: add mac build+run helper 2025-12-06 00:38:02 +01:00
Peter Steinberger 38a4e9806f chore: ignore mac build artifacts 2025-12-06 00:38:02 +01:00
Peter Steinberger 3c64a57c84 revert prompt-too-long fallback and keep inline directives 2025-12-05 23:18:03 +00:00
Peter Steinberger 36b0796976 fix: handle prompt-too-long by resetting session and continuing inline directives 2025-12-05 23:01:37 +00:00
Peter Steinberger 3241d81ce5 fix: allow inline directives to continue and add mixed-message test 2025-12-05 22:57:52 +00:00
Peter Steinberger d7a188fb34 fix: broaden prompt-echo guard and add heartbeat directive test 2025-12-05 22:56:07 +00:00
Peter Steinberger 5b217b2042 fix: suppress heartbeat directive acks and add coverage 2025-12-05 22:54:17 +00:00
Peter Steinberger 4cb2a92037 fix: avoid echoing prompts when rpc returns empty 2025-12-05 22:52:21 +00:00
Peter Steinberger 24d90c17c2 fix: ignore directives inside history blocks 2025-12-05 22:49:41 +00:00
Peter Steinberger c95c6d72e9 test: cover directive parsing and abort/restart prefixes 2025-12-05 22:29:49 +00:00
Peter Steinberger 99b174f495 fix: avoid directive hits inside URLs and add tests 2025-12-05 22:28:36 +00:00
Peter Steinberger 5949ef0e2c chore: rename package to clawdis 2025-12-05 23:19:46 +01:00
Peter Steinberger d75d64df64 chore: ignore macOS .DS_Store globally 2025-12-05 23:19:04 +01:00
Peter Steinberger a5164df293 feat: add mac companion app 2025-12-05 23:18:47 +01:00
Peter Steinberger 690113dd73 Add bundled pi default and session token reporting 2025-12-05 23:18:43 +01:00
Peter Steinberger fe87160b19 chore: add system marker to directives and abort 2025-12-05 21:37:11 +00:00
Peter Steinberger fffe1be521 docs: note directive short-circuit 2025-12-05 21:30:01 +00:00
Peter Steinberger dc02bcee74 fix: normalize directive triggers and short-circuit 2025-12-05 21:29:41 +00:00
Peter Steinberger e7a9313135 chore: remove twilio and expand pi cli detection 2025-12-05 21:13:23 +00:00
Peter Steinberger 5492845659 feat: stream turn completions and tighten rpc timeout 2025-12-05 21:13:17 +00:00
Peter Steinberger 29dfe89137 chore: redact long texts in web logs 2025-12-05 19:21:23 +00:00
Peter Steinberger 0da3f84a2e fix: ignore rpc toolcall deltas to avoid duplicate replies 2025-12-05 19:16:03 +00:00
Peter Steinberger c25b0c1a66 docs: update for web-only pi rpc 2025-12-05 19:04:09 +00:00
Peter Steinberger 7c7314f673 chore: drop twilio and go web-only 2025-12-05 19:03:59 +00:00
Peter Steinberger 869cc3d497 Route pi agent prompts via RPC stdin 2025-12-05 18:34:05 +00:00
Peter Steinberger f315bf074b fix: harden pi rpc prompt handling 2025-12-05 18:24:45 +00:00
Peter Steinberger d33f9ddf44 docs: add repo link to homepage 2025-12-05 17:51:11 +00:00
Peter Steinberger fcf0c28132 chore: make pi-only rpc with fixed sessions 2025-12-05 17:50:02 +00:00
Peter Steinberger b3e50cbb33 Switch to clawdis RPC mode and complete rebrand 2025-12-05 17:22:53 +00:00
Peter Steinberger 20cb709ae3 chore: organize imports after rebrand 2025-12-04 18:02:51 +00:00
Peter Steinberger 916a41ed60 branding: default to clawdis paths and launchd label 2025-12-04 18:01:30 +00:00
Peter Steinberger 9797a9993a docs: document agent CLI and changelog 2025-12-04 17:55:38 +00:00
Peter Steinberger 04ce98148d web: fix mentioned JID extraction typing 2025-12-04 17:54:51 +00:00
Peter Steinberger 34eb75f634 auto-reply: honor /new after timestamp prefixes 2025-12-04 17:54:20 +00:00
Peter Steinberger 05b76281f7 CLI: add agent command for direct agent runs 2025-12-04 17:54:20 +00:00
Eng. Juan Combetto 4a35bcec21 fix: resolve lint errors (unused vars, imports, formatting)
- Prefix unused test variables with underscore
- Remove unused piSpec import and idleMs class member
- Fix import ordering and code formatting
2025-12-04 16:15:17 +00:00
Eng. Juan Combetto 518af0ef24 config: support clawdis.json path for rebranding
- Add CONFIG_PATH_CLAWDIS (~/.clawdis/clawdis.json) as preferred path
- Keep CONFIG_PATH_LEGACY (~/.warelay/warelay.json) for backward compatibility
- Update loadConfig() to check clawdis.json first, fallback to warelay.json
- Fix TypeScript type error in extractMentionedJids (null handling)

Part of the warelay → clawdis rebranding effort.
2025-12-04 16:15:17 +00:00
Peter Steinberger a155ec0599 auto-reply: handle group think/verbose directives 2025-12-04 02:29:32 +00:00
Peter Steinberger 80979cf4d0 🦞 Add backlinks to clawd.me, soul.md, steipete.me 2025-12-03 15:46:29 +00:00
Peter Steinberger a27ee2366e 🦞 Rebrand to CLAWDIS - add docs, update README
- New README with CLAWDIS branding
- docs/index.md - Main landing page
- docs/configuration.md - Config guide
- docs/agents.md - Agent integration guide
- docs/security.md - Security lessons (including the find ~ incident)
- docs/troubleshooting.md - Debug guide
- docs/lore.md - The origin story

EXFOLIATE!
2025-12-03 15:45:43 +00:00
Peter Steinberger 7bc56d7cfe test: cover verbose directive in group batches 2025-12-03 15:45:43 +00:00
Peter Steinberger 088bdb3313 fix: allow directive-only toggles inside group batches 2025-12-03 15:45:43 +00:00
Peter Steinberger 89d49cd925 chore: bump version to 1.4.0 2025-12-03 15:45:43 +00:00
Peter Steinberger 84f8d8733e docs: note media-only mention fix 2025-12-03 15:45:43 +00:00
Peter Steinberger 07f323222b fix(web): capture mentions from media captions 2025-12-03 15:45:43 +00:00
Peter Steinberger a321bf1a90 fix(web): surface media fetch failures 2025-12-03 15:45:43 +00:00
Peter Steinberger 92a0763a74 changelog: note verbose tool emoji/previews 2025-12-03 15:45:43 +00:00
Peter Steinberger e878780808 auto-reply: single emoji per verbose tool line 2025-12-03 15:45:43 +00:00
Peter Steinberger cb5f1fa99d auto-reply: emoji + result preview for verbose tool calls 2025-12-03 15:45:43 +00:00
Peter Steinberger b55ac994ea feat(web): prime group sessions with member roster 2025-12-03 15:45:43 +00:00
Peter Steinberger 3a8d6b80e0 auto-reply: surface tool args from rpc start events 2025-12-03 15:45:43 +00:00
Peter Steinberger 3354a68373 Create CNAME 2025-12-03 16:44:03 +01:00
Peter Steinberger edc894f6c7 fix(web): annotate group replies with sender 2025-12-03 13:25:34 +00:00
Peter Steinberger f68714ec8e fix(web): unwrap ephemeral/view-once and keep mentions 2025-12-03 13:15:46 +00:00
Peter Steinberger 7be9352a3a test(web): ensure group messages carry sender + bypass allowFrom 2025-12-03 13:12:05 +00:00
Peter Steinberger 3a782b6ace fix(web): let group pings bypass allowFrom 2025-12-03 13:11:01 +00:00
Peter Steinberger 47d0b6fc14 changelog: note logging capture and verbose trace 2025-12-03 13:09:29 +00:00
Peter Steinberger 8204351d67 fix(web): allow group replies past allowFrom 2025-12-03 13:08:54 +00:00
Peter Steinberger 4c3635a7c0 logging: route console output into pino 2025-12-03 13:07:47 +00:00
Peter Steinberger 7ea43b0145 fix(web): detect self number mentions in group chats 2025-12-03 12:43:20 +00:00
Peter Steinberger 6afe6f4ecb feat(web): add group chat mention support 2025-12-03 12:35:18 +00:00
Peter Steinberger 273f2b61d0 Docs: document /restart WhatsApp command 2025-12-03 12:16:51 +00:00
Peter Steinberger 0824873ffb Add /restart WhatsApp command to restart warelay 2025-12-03 12:14:32 +00:00
Peter Steinberger 8f99b13305 Pi: stream tool results faster (0.5s, flush after 5) 2025-12-03 12:08:58 +00:00
Peter Steinberger 9253702966 Pi: stream assistant text during RPC runs 2025-12-03 11:50:49 +00:00
Peter Steinberger 3958450223 Tau RPC: resolve on agent_end or exit 2025-12-03 11:34:00 +00:00
Peter Steinberger cc596ef011 Pi: resume Tau sessions with --continue 2025-12-03 11:33:51 +00:00
Peter Steinberger 8220b11770 Tau RPC: wait for agent_end when tools run 2025-12-03 11:29:12 +00:00
Peter Steinberger 62c54cd47c Web: simplify logout message 2025-12-03 11:04:12 +00:00
Peter Steinberger e34d0d69aa Chore: satisfy lint after tool-meta refactor 2025-12-03 10:42:10 +00:00
Peter Steinberger 597e7e6f13 Refactor: extract tool meta formatter + debouncer 2025-12-03 10:30:01 +00:00
Peter Steinberger b460fd61bd Verbose: shorten meta paths when aggregating 2025-12-03 10:26:41 +00:00
Peter Steinberger c9b5df8184 Verbose: collapse tool meta paths by directory 2025-12-03 10:24:41 +00:00
Peter Steinberger 341ecf3bbe Docs: note 1s tool coalescing window 2025-12-03 10:19:10 +00:00
Peter Steinberger b6b5144ddf Verbose: slow tool batch window to 1s 2025-12-03 10:13:02 +00:00
Peter Steinberger deac5ff585 Verbose: shorten home paths in tool meta 2025-12-03 10:12:27 +00:00
Peter Steinberger 38a03ff2c8 Verbose: batch rapid tool results 2025-12-03 10:11:41 +00:00
Peter Steinberger 527bed2b53 Verbose: include tool arg metadata in prefixes 2025-12-03 09:57:41 +00:00
Peter Steinberger 318166f8b0 Verbose: send tool result metadata only 2025-12-03 09:40:05 +00:00
Peter Steinberger 394c751d7d Tau RPC: resolve on agent_end 2025-12-03 09:39:26 +00:00
Peter Steinberger 86d707ad51 Docs: note streaming verbose tool results 2025-12-03 09:22:43 +00:00
Peter Steinberger c3792db0e5 Auto-reply: stream verbose tool results via tau rpc 2025-12-03 09:21:31 +00:00
Peter Steinberger 16e42e6d6d Auto-reply: show tool results before main reply in verbose mode 2025-12-03 09:14:10 +00:00
Peter Steinberger 53c1674382 Chore: format + lint fixes 2025-12-03 09:09:34 +00:00
Peter Steinberger 85917d4769 Docs: mention verbose hints 2025-12-03 09:08:03 +00:00
Peter Steinberger ae0d35c727 Auto-reply: add verbose session hint 2025-12-03 09:07:17 +00:00
Peter Steinberger 086dd284d6 Auto-reply: add /verbose directives and tool result replies 2025-12-03 09:04:37 +00:00
Peter Steinberger 8ba35a2dc3 Auto-reply: treat prefixed think directives as directive-only 2025-12-03 08:57:30 +00:00
Peter Steinberger 48dfb1c8ca Auto-reply: ack think directives 2025-12-03 08:54:38 +00:00
Peter Steinberger 5a83a44112 Docs: document thinking levels 2025-12-03 08:45:30 +00:00
Peter Steinberger 58520859e5 Auto-reply: add thinking directives 2025-12-03 08:45:23 +00:00
Peter Steinberger 4faba0fe8b Changelog: heartbeat array handling 2025-12-03 01:03:59 +00:00
Peter Steinberger c4b0155cc2 Format: align thinking helpers 2025-12-03 01:02:10 +00:00
Peter Steinberger 38b18202fc Heartbeat: guard optional heartbeatCommand 2025-12-03 00:45:27 +00:00
Peter Steinberger 0f17a7d828 Heartbeat: normalize reply arrays for twilio/web 2025-12-03 00:43:28 +00:00
Peter Steinberger 9da5b9f4bb Heartbeat: normalize array replies 2025-12-03 00:40:19 +00:00
Peter Steinberger a7fdc7b992 Auto-reply: allow array payloads in signature 2025-12-03 00:35:57 +00:00
Peter Steinberger f519e22e6d CI: fix command-reply payload typing 2025-12-03 00:33:58 +00:00
Peter Steinberger ecac4dd72a Auto-reply: format and lint fixes 2025-12-03 00:30:05 +00:00
Peter Steinberger b6c45485bc Auto-reply: smarter chunking breaks 2025-12-03 00:25:01 +00:00
Peter Steinberger ec46932259 web: handle multi-payload replies 2025-12-02 23:46:11 +00:00
Peter Steinberger 10182f1182 limits: chunk replies for twilio/web 2025-12-02 23:10:16 +00:00
Peter Steinberger cfaec9d608 auto-reply: support multi-text RPC outputs 2025-12-02 23:03:55 +00:00
Peter Steinberger 0f6157a49d logging: emit agent/session meta at command start 2025-12-02 21:30:28 +00:00
Peter Steinberger 1df6373cb1 revert: mark system prompt sent on first turn 2025-12-02 21:23:56 +00:00
Peter Steinberger ea32cd85fe chore: cut 1.3.1 in changelog 2025-12-02 21:13:47 +00:00
Peter Steinberger 716524c151 docs: note media cleanup and tau rpc typing 2025-12-02 21:13:21 +00:00
Peter Steinberger 96722bba08 ci: fix lint and tau rpc typing 2025-12-02 21:12:51 +00:00
Peter Steinberger 4e20a20927 fix(media): clean up files after response finishes 2025-12-02 21:10:18 +00:00
Peter Steinberger a0d1004909 test(media): add redirect coverage and update changelog 2025-12-02 21:09:26 +00:00
Peter Steinberger ccab950d16 Merge branch 'fix/media-replies' 2025-12-02 21:07:45 +00:00
Peter Steinberger 2018c90ae2 chore: tidy claude prompt and drop npm lock 2025-12-02 21:07:37 +00:00
Joao Lisboa 793360c5bb style: fix biome formatting 2025-12-02 21:07:13 +00:00
Joao Lisboa d8b1a38350 style: fix biome lint errors 2025-12-02 21:07:13 +00:00
Joao Lisboa 499a3e3227 style: fix biome formatting 2025-12-02 21:07:13 +00:00
Joao LisboaandClaude 73a9fdca2a fix: send Claude identity prefix on first session message
The systemSent variable was being set to true before being passed to
runCommandReply, causing the identity prefix to never be injected.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 21:07:13 +00:00
Joao LisboaandClaude 06dd9b8ed8 fix: follow redirects when downloading Twilio media
node:https request() doesn't follow redirects by default, causing
Twilio media URLs (which 302 to CDN) to save placeholder/metadata
instead of actual images.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 21:07:13 +00:00
Joao LisboaandClaude a86cb932cf chore: user-agnostic Claude identity and tests
- Use ~/Clawd instead of hardcoded /Users/steipete/clawd
- Add MEDIA: syntax instructions to identity prefix
- Update tests to check for 'scratchpad' instead of specific path

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 21:07:13 +00:00
Joao LisboaandClaude 2fae0a9f47 fix: media serving and id consistency
- server.ts: Replace sendFile with manual readFile+send to fix
  NotFoundError when serving media (sendFile failed even after stat)
- store.ts: Return id with file extension so it matches actual filename

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 21:07:13 +00:00
Joao LisboaandClaude 2ec9192010 fix: use export type for type-only re-exports
Fixes build error with isolatedModules.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 21:06:27 +00:00
Peter Steinberger 202eff984d docs: update agent guidance and changelog 2025-12-02 20:10:43 +00:00
Peter Steinberger b172b538fc perf(pi): reuse tau rpc for command auto-replies 2025-12-02 20:09:51 +00:00
Peter Steinberger a34271adf9 chore: credit media fix contributor 2025-12-02 18:38:02 +00:00
Peter Steinberger 2cf134668c fix(media): block symlink traversal 2025-12-02 18:37:15 +00:00
Joao LisboaandClaude b94b220156 Fix path traversal vulnerability in media server
The /media/:id endpoint was vulnerable to path traversal attacks.
Since this endpoint is exposed via Tailscale Funnel (unlike the
WhatsApp webhook which requires Twilio signature validation),
attackers could directly request paths like /media/%2e%2e%2fwarelay.json
to access sensitive files in ~/.warelay/ (e.g. warelay.json), or even
escape further to the user's home directory via multiple ../ sequences.

Fix: validate resolved paths stay within the media directory.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 19:33:21 +01:00
Peter Steinberger 26921cbe68 chore(logs): rotate daily and prune after 24h 2025-12-02 17:11:43 +00:00
Peter Steinberger 8844674825 chore(security): purge session store on logout 2025-12-02 16:33:44 +00:00
Peter Steinberger c9fbe2cb92 chore(security): harden ipc socket 2025-12-02 16:09:40 +00:00
Peter SteinbergerandRealSid08 2b941ccc93 Changelog: note multi-agent and batching
Co-authored-by: RealSid08 <RealSid08@users.noreply.github.com>
2025-12-02 11:11:50 +00:00
Peter SteinbergerandRealSid08 ed080ae988 Tests: cover agents and fix web defaults
Co-authored-by: RealSid08 <RealSid08@users.noreply.github.com>
2025-12-02 11:08:00 +00:00
Peter SteinbergerandRealSid08 f31e89d5af Agents: add pluggable CLIs
Co-authored-by: RealSid08 <RealSid08@users.noreply.github.com>
2025-12-02 11:07:46 +00:00
Peter Steinberger 52c311e47f chore: bump version to 1.3.0 2025-12-02 07:54:49 +00:00
Peter Steinberger 5b54d4de7a feat(web): batch inbound messages 2025-12-02 07:54:13 +00:00
Peter Steinberger 96152f6577 Add typing indicator after IPC send
After sending via IPC, automatically show "composing" indicator so
user knows more messages may be coming from the running session.
2025-12-02 06:58:17 +00:00
Peter Steinberger e881b3c5de Document exclamation mark escaping workaround for Claude Code
Add symlink CLAUDE.md -> AGENTS.md for Claude Code compatibility.
2025-12-02 06:52:56 +00:00
Peter Steinberger e86b507da7 Add IPC to prevent Signal session corruption from concurrent connections
When the relay is running, `warelay send` and `warelay heartbeat` now
communicate via Unix socket IPC (~/.warelay/relay.sock) to send messages
through the relay's existing WhatsApp connection.

Previously, these commands created new Baileys sockets that wrote to the
same auth state files, corrupting the Signal session ratchet and causing
the relay's subsequent sends to fail silently.

Changes:
- Add src/web/ipc.ts with Unix socket server/client
- Relay starts IPC server after connecting
- send command tries IPC first, falls back to direct
- heartbeat uses sendWithIpcFallback helper
- inbound.ts exposes sendMessage on listener object
- Messages sent via IPC are added to echo detection set
2025-12-02 06:31:07 +00:00
Peter Steinberger 2fc3a822c8 web: isolate session fixtures and skip heartbeat when busy 2025-12-02 06:17:16 +00:00
Peter Steinberger 1b0e1edb08 Update changelog with error message and test isolation fixes 2025-12-02 05:59:31 +00:00
Peter Steinberger d107b79c63 Fix test corrupting production sessions.json
The test 'falls back to most recent session when no to is provided' was
using resolveStorePath() which returns the real ~/.warelay/sessions.json.
This overwrote production session data with test values, causing session
fragmentation issues.

Changed to use a temp directory like other tests.
2025-12-02 05:54:31 +00:00
Peter Steinberger c5ab442f46 Fix empty result JSON dump and missing heartbeat prefix
Bug fixes:
- Empty result field handling: Changed truthy check to explicit type
  check (`typeof parsed?.text === "string"`) in command-reply.ts.
  Previously, Claude CLI returning `result: ""` would cause raw JSON
  to be sent to WhatsApp.
- Response prefix on heartbeat: Apply `responsePrefix` to heartbeat
  alert messages in runReplyHeartbeat, matching behavior of regular
  message handler.
2025-12-02 04:29:17 +00:00
Peter Steinberger c5677df56e Increase watchdog timeout to 30 minutes
Changed from 10 to 30 minutes to avoid false positives when
heartbeatMinutes is set to 10. The watchdog should be significantly
longer than the heartbeat interval to account for:
- Network latency
- Slow command responses
- Brief connection hiccups

With heartbeatMinutes=10, a 30-minute watchdog gives 3x buffer before
triggering auto-restart.
2025-11-30 18:03:19 +00:00
Peter Steinberger 21ba0fb8a4 Fix test isolation to prevent loading real user config
Tests were picking up real ~/.warelay/warelay.json with emojis and
prefixes (like "🦞"), causing test assertions to fail. Added proper
config mocks to all test files.

Changes:
- Mock loadConfig() in index.core.test.ts, inbound.media.test.ts,
  monitor-inbox.test.ts
- Update test-helpers.ts default mock to disable all prefixes
- Tests now use clean config: no messagePrefix, no responsePrefix,
  no timestamp, allowFrom=["*"]

This ensures tests validate core behavior without user-specific config.
The responsePrefix feature itself is already fully config-driven - this
only fixes test isolation.
2025-11-30 18:00:57 +00:00
Peter Steinberger 69319a0569 Add auto-recovery from stuck WhatsApp sessions
Fixes issue where unauthorized messages from +212652169245 (5elements spa)
triggered Bad MAC errors and silently killed the event emitter, preventing
all future message processing.

Changes:
1. Early allowFrom filtering in inbound.ts - blocks unauthorized senders
   before they trigger encryption errors
2. Message timeout watchdog - auto-restarts connection if no messages
   received for 10 minutes
3. Health monitoring in heartbeat - warns if >30 min without messages
4. Mock loadConfig in tests to handle new dependency

Root cause: Event emitter stopped firing after Bad MAC errors from
decryption attempts on messages from unauthorized senders. Connection
stayed alive but all subsequent messages.upsert events silently failed.
2025-11-30 17:53:32 +00:00
Peter Steinberger 37d8e55991 Skip responsePrefix for HEARTBEAT_OK responses
Preserve exact match so warelay recognizes heartbeat responses
and doesn't send them as messages.
2025-11-29 06:02:21 +00:00
Peter Steinberger 8d20edb028 Simplify timestampPrefix: bool or timezone string, default true
- timestampPrefix: true (UTC), false (off), or 'America/New_York'
- Removed separate timestampTimezone option
- Default is now enabled (true/UTC) unless explicitly false
2025-11-29 05:29:29 +00:00
Peter Steinberger 7564c4e7f4 Generalize prefix config: messagePrefix + responsePrefix
Replaces samePhoneMarker/samePhoneResponsePrefix with:
- messagePrefix: prefix for all inbound messages
  - Default: '[warelay]' if no allowFrom, else ''
- responsePrefix: prefix for all outbound replies

Also adds timestamp options:
- timestampPrefix: boolean to enable [Nov 29 06:30] format
- timestampTimezone: IANA timezone (default UTC)

Updated README with new config table entries.
2025-11-29 05:27:58 +00:00
Peter Steinberger 26e02a9b8b Add timestampPrefix config for datetime awareness
New config options:
- timestampPrefix: boolean - prepend timestamp to messages
- timestampTimezone: string - IANA timezone (default: UTC)

Format: [Nov 29 06:30] - compact but informative
Helps AI assistants stay aware of current date/time.
2025-11-29 05:25:53 +00:00
Peter Steinberger 25ec133574 Add samePhoneResponsePrefix config option
Automatically prefixes responses with a configurable string when in
same-phone mode. This helps distinguish bot replies from user messages
in the same chat bubble.

Example config:
  "samePhoneResponsePrefix": "🦞"

Will prefix all same-phone replies with the lobster emoji.
2025-11-29 05:24:01 +00:00
Peter Steinberger d88ede92b9 feat: same-phone mode with echo detection and configurable marker
Adds full support for self-messaging setups where you chat with yourself
and an AI assistant replies in the same WhatsApp bubble.

Changes:
- Same-phone mode (from === to) always allowed, bypasses allowFrom check
- Echo detection via bounded Set (max 100) prevents infinite loops
- Configurable samePhoneMarker in config (default: "[same-phone]")
- Messages prefixed with marker so assistants know the context
- fromMe filter removed from inbound.ts (echo detection in auto-reply)
- Verbose logging for same-phone detection and echo skips

Tests:
- Same-phone allowed without/despite allowFrom configuration
- Body prefixed only when from === to
- Non-same-phone rejected when not in allowFrom
2025-11-29 04:52:21 +00:00
Peter Steinberger 5bafe9483d chore: release 1.2.2 2025-11-28 08:17:22 +01:00
Peter Steinberger 4e3663b4d4 chore: move heartbeat notes to unreleased 1.2.2 2025-11-28 08:14:51 +01:00
Peter Steinberger 12d7be7cad feat(heartbeat): allow manual message and dry-run for web/twilio 2025-11-28 08:14:07 +01:00
Peter Steinberger 84f2595349 docs: note changelog not needed for pure tests 2025-11-28 08:13:59 +01:00
Peter Steinberger c11abc1134 chore: release 1.2.1 2025-11-28 08:11:07 +01:00
Peter Steinberger f63bdda628 docs: document mime-first media handling 2025-11-28 08:07:53 +01:00
Peter Steinberger 7d6a4f5204 fix(media): sniff mime and keep extensions 2025-11-28 08:07:53 +01:00
Peter Steinberger f871869c79 Fix broken link: claude-config.md -> clawd.md 2025-11-28 05:19:43 +00:00
Peter Steinberger 8ebe72951f docs: Add Twitter automation and music recognition examples
- Added Twitter automation patterns using Peekaboo + AppleScript
- Documented JS injection for reliable button clicks on Twitter's dynamic UI
- Added audd.io music recognition API example
- These are the techniques Clawd uses to reply to tweets autonomously
2025-11-27 21:00:28 +00:00
Peter Steinberger 8d4b31a301 Expand heartbeat capabilities in docs 2025-11-27 19:09:30 +01:00
Peter Steinberger 8912b3e035 Rename claude-config.md to clawd.md, update credits
- Renamed docs/claude-config.md → docs/clawd.md
- Credits now include Clawd (they/them) as co-author
2025-11-27 19:07:35 +01:00
Peter Steinberger f5d7057042 Add browser-tools CLI and example tweets to docs
- Added browser-tools to CLI tools table (lightweight DevTools CLI)
- Added browser-tools usage section for web scraping
- Added "See It In Action" section with 3 example tweets
- Links to agent-scripts repo
2025-11-27 18:59:01 +01:00
Peter Steinberger 6d7e620430 Release 1.2.0 2025-11-27 18:52:26 +01:00
Peter Steinberger 0cc732dce3 Docs: refresh 1.2.0 changelog; fix webhook host import 2025-11-27 18:46:46 +01:00
Peter Steinberger 8acd82aa0d Add gowa WhatsApp MCP to power user add-ons 2025-11-27 18:45:05 +01:00
Peter Steinberger 7377c676fd Add WhatsApp screenshot to claude-config.md
Shows Clawd in action in the "Meet Clawd" section
2025-11-27 18:43:24 +01:00
Peter Steinberger 9b3c4db10d Heartbeat defaults and ws guard; format 2025-11-27 18:37:30 +01:00
Peter Steinberger 49ada54f6d Docs: add useful CLI tools section (spotify-player, TTS, etc.) 2025-11-27 18:33:38 +01:00
Peter Steinberger c43cdc5ac3 Docs: new Clawd session intro with personality and powers 2025-11-27 18:32:47 +01:00
Peter Steinberger e1bd9976b3 Docs: explain two-phone setup for dedicated AI number 2025-11-27 18:29:41 +01:00
Peter Steinberger a888564251 Docs: mention Claude Code reuses existing subscription 2025-11-27 18:28:51 +01:00
Peter Steinberger e2ccde6434 Fix: warelay lowercase 2025-11-27 18:27:09 +01:00
Peter Steinberger e88ff78816 Add Peekaboo and mcporter links to recommended tools 2025-11-27 18:26:40 +01:00
Peter Steinberger 5bc151fdca Redact phone number from example config 2025-11-27 18:24:12 +01:00
Peter Steinberger f0a5cdc6e4 Add warning disclaimer to claude-config.md 2025-11-27 18:23:56 +01:00
Peter Steinberger 85f53a4174 Fix WebSocket crash + heartbeat default 10min + docs refresh
- Wrap Baileys connection.update listeners in try-catch to prevent
  unhandled exceptions from crashing the relay process
- Add WebSocket-level error handlers in session.ts
- Add global unhandledRejection/uncaughtException handlers in index.ts
- Make listener.onClose error-safe with .catch() in auto-reply.ts
- Change default heartbeat from 30min to 10min
- Rewrite claude-config.md with personality, better explain personal
  assistant features, add recommended MCPs section
2025-11-27 18:21:14 +01:00
Peter Steinberger 549ad272fc Docs: link Clawd setup and current config 2025-11-27 18:17:17 +01:00
Peter Steinberger 537348d995 Update README.md 2025-11-27 18:14:54 +01:00
Peter Steinberger d4580d1a31 Fix CI: type gaps and hasMedia check 2025-11-27 18:14:20 +01:00
Peter Steinberger 93a103dde5 Tests: cover identity prefix gating 2025-11-27 04:40:03 +01:00
Peter Steinberger 9e6ad97cfb Claude prompt: only prepend on first turn 2025-11-27 03:53:13 +01:00
Peter Steinberger 8d995a8529 Heartbeat: add ultrathink marker 2025-11-27 03:15:51 +01:00
Peter Steinberger f869cd4b79 Heartbeat: shorten prompt to token 2025-11-27 02:48:23 +01:00
Peter Steinberger 26b087c1b4 Heartbeat: honor session override 2025-11-26 18:32:25 +01:00
Peter Steinberger 63bf4683c5 Heartbeat: allow session-id override (with test) 2025-11-26 18:28:02 +01:00
Peter Steinberger 73456a68d7 Fix heartbeat CLI import for recipients resolution 2025-11-26 18:22:28 +01:00
Peter Steinberger aa6637b47a Heartbeat: session-id override and safer fallback 2025-11-26 18:19:54 +01:00
Peter Steinberger 8f6e43fd66 Changelog: bump to 1.2.0 unreleased 2025-11-26 18:18:13 +01:00
Peter Steinberger ebce6ef263 Docs: show --all heartbeat example 2025-11-26 18:17:30 +01:00
Peter Steinberger c20a266a11 Heartbeat: harden targeting and support lid mapping 2025-11-26 18:15:57 +01:00
Marcus Neves b825f141f3 fix: add @lid format support and allowFrom wildcard handling
- Add support for WhatsApp Linked ID (@lid) format in jidToE164()
- Use existing lid-mapping-*_reverse.json files for LID resolution
- Fix allowFrom wildcard '*' to actually allow all senders
- Maintain backward compatibility with @s.whatsapp.net format

Fixes issues where:
- Messages from newer WhatsApp versions are silently dropped
- allowFrom: ['*'] configuration doesn't work as documented
2025-11-26 18:03:12 +01:00
Peter Steinberger 7e5b3958cc CLI: rename heartbeat tmux helper and log file path 2025-11-26 18:00:23 +01:00
Peter Steinberger deded848ee Heartbeat: add relay helper and fix CLI tests 2025-11-26 17:49:34 +01:00
Peter Steinberger 117161e6ff docs: document heartbeat idle override and tests 2025-11-26 17:31:56 +01:00
Peter Steinberger 98d52edcc9 test: cover heartbeat skip preserving session timestamp 2025-11-26 17:29:12 +01:00
Peter Steinberger 135d930c99 feat: add heartbeat idle override and preserve session freshness 2025-11-26 17:26:17 +01:00
Peter Steinberger e6c78df975 chore: add verbose heartbeat session logging 2025-11-26 17:21:59 +01:00
Peter Steinberger 3749797434 chore: log heartbeat session snapshot 2025-11-26 17:20:48 +01:00
Peter Steinberger 507ed25289 chore: log heartbeat fallback and add test 2025-11-26 17:12:28 +01:00
Peter Steinberger 0d5e5f8dee fix: heartbeat falls back to last session contact 2025-11-26 17:08:43 +01:00
Peter Steinberger 3998933b30 docs: document heartbeat triggers 2025-11-26 17:05:09 +01:00
Peter Steinberger 271004bf60 feat: add heartbeat cli and relay trigger 2025-11-26 17:04:43 +01:00
Peter Steinberger c9e2d69bfb docs: open 1.1.x unreleased section 2025-11-26 03:33:44 +01:00
Peter Steinberger c194247dab test(auto-reply): cover cwd timeout hint and queue meta 2025-11-26 03:03:13 +01:00
Peter Steinberger a48420d85f docs: finalize web refactor and coverage 2025-11-26 02:54:43 +01:00
Peter Steinberger 5c66e8273b chore: update changelog and surface web relay settings 2025-11-26 02:43:24 +01:00
Peter Steinberger 5992e629c3 web: add reconnect logging + troubleshooting doc 2025-11-26 02:41:10 +01:00
Peter Steinberger 765d67cd18 web: extract reconnect helpers and add tests 2025-11-26 02:39:31 +01:00
Peter Steinberger baf20af17f web: add heartbeat and bounded reconnect tuning 2025-11-26 02:34:43 +01:00
Peter Steinberger e482e7768b chore: commit pending cli/web test tweaks 2025-11-26 02:19:45 +01:00
Peter Steinberger 8682352edb docs: trim changelog to user-facing auto-reply changes 2025-11-26 02:19:21 +01:00
Peter Steinberger ef1222ff31 chore: drop refactor note 2025-11-26 02:18:57 +01:00
Peter Steinberger 0145f3a585 docs: note auto-reply helper split 2025-11-26 02:18:39 +01:00
Peter Steinberger 4a8bb56a1e chore(auto-reply): include cwd in timeout message 2025-11-26 02:18:16 +01:00
Peter Steinberger ce5b02a9ad test(auto-reply): add helper coverage and docs 2025-11-26 02:09:50 +01:00
Peter Steinberger 5c8ce41e12 refactor(auto-reply): split reply helpers 2025-11-26 02:03:51 +01:00
Peter Steinberger a2586b8b06 feat(web): add logout command and tests 2025-11-26 01:29:02 +01:00
Peter Steinberger 1fd4485716 Auto-reply: refresh typing indicator every 8s 2025-11-26 01:27:51 +01:00
Peter Steinberger b029ab933e chore(tests): organize web test imports 2025-11-26 01:24:34 +01:00
Peter Steinberger e0b28b6718 test(web): split provider web suite 2025-11-26 01:23:34 +01:00
Peter Steinberger 4dd2f3b7f7 refactor(web): split provider module 2025-11-26 01:16:54 +01:00
Peter Steinberger e5f677803f chore: format to 2-space and bump changelog 2025-11-26 00:53:53 +01:00
Peter Steinberger a67f4db5e2 chore: format + lint 2025-11-26 00:30:30 +01:00
Peter Steinberger 8a01dc7f4c style: normalize indentation to 2 spaces 2025-11-26 00:15:10 +01:00
Peter Steinberger e107f115e2 chore: bump version to 1.1.0 2025-11-26 00:11:42 +01:00
Peter Steinberger af8af4881b docs/tests: typing interval docs and coverage 2025-11-26 00:10:38 +01:00
Peter Steinberger d871dad85f feat: keep typing indicators alive during commands 2025-11-26 00:05:11 +01:00
Peter Steinberger 5b83d30887 test: cover sendSystemOnce default 2025-11-25 23:57:41 +01:00
Peter Steinberger 2e3b8a03aa feat: send session prompt once 2025-11-25 23:52:38 +01:00
Peter Steinberger d924b7d283 docs: document media caps and tidy web tests 2025-11-25 23:43:57 +01:00
Peter Steinberger e0425ad3e1 feat: support audio/video/doc media caps and transcript context 2025-11-25 23:21:35 +01:00
Peter Steinberger 5dced02a20 docs: clarify transcript prompt and config 2025-11-25 23:14:23 +01:00
Peter Steinberger e642f128ae feat: transcribe audio and surface transcript to prompts 2025-11-25 23:13:22 +01:00
Peter Steinberger 7d0ae151e8 feat: optional audio transcription via CLI 2025-11-25 23:06:54 +01:00
Peter Steinberger f945e284e1 test: cover media formats + doc resize cap 2025-11-25 22:23:06 +01:00
Peter Steinberger 7166efef08 docs: document media resize config 2025-11-25 22:16:09 +01:00
Peter Steinberger a81689e902 chore: approve build scripts 2025-11-25 20:11:36 +01:00
Peter Steinberger 0a0418b973 web: compress auto-reply media 2025-11-25 20:09:03 +01:00
Peter Steinberger f81f432af5 Release 1.0.4 2025-11-25 18:12:44 +01:00
Peter Steinberger dda017df23 Web relay: auto-reconnect Baileys and test 2025-11-25 18:09:57 +01:00
Peter Steinberger 46be5eac7d Auto-reply: send timeout fallback and tests 2025-11-25 17:52:57 +01:00
Peter Steinberger e4076d14c0 Docs: clarify release body should not repeat title 2025-11-25 17:11:24 +01:00
Peter Steinberger 20fc412765 Refactor: derive version from package.json 2025-11-25 17:10:53 +01:00
Peter Steinberger c251681a40 Chore: prep 0.1.4 unreleased placeholder and release guardrails 2025-11-25 17:08:13 +01:00
Peter Steinberger 9c25e15e92 Release 0.1.3 2025-11-25 16:53:30 +01:00
Peter Steinberger bcbf0de240 Add cwd option for command replies 2025-11-25 16:19:24 +01:00
Peter Steinberger 1ef7f4dbad Update README.md 2025-11-25 14:52:43 +01:00
Peter Steinberger 9db969e94e docs: simplify FAQ heading 2025-11-25 14:51:20 +01:00
Peter Steinberger 4f27f4cf79 chore: trim 0.1.2 changelog entry 2025-11-25 14:50:34 +01:00
Peter Steinberger b7c46909cd chore: reorder changelog and add 0.1.3 stub 2025-11-25 14:47:48 +01:00
Peter Steinberger 953f9af419 docs: restyle README badges below header 2025-11-25 14:42:12 +01:00
Peter Steinberger 6ea32873df docs: add README header image 2025-11-25 14:29:21 +01:00
4195 changed files with 832102 additions and 6903 deletions

No files matched your search

+75 -4
View File
@@ -7,20 +7,39 @@ on:
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
runtime: [node, bun]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
if: matrix.runtime == 'node'
uses: actions/setup-node@v4
with:
node-version: 22
check-latest: true
- name: Node version
- name: Setup Bun
if: matrix.runtime == 'bun'
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.x"
- name: Setup Node.js (tooling for bun)
if: matrix.runtime == 'bun'
uses: actions/setup-node@v4
with:
node-version: 22
check-latest: true
- name: Runtime versions
run: |
node -v
npm -v
if [ "${{ matrix.runtime }}" = "bun" ]; then bun -v; fi
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
@@ -41,11 +60,63 @@ 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
- name: Lint (node)
if: matrix.runtime == 'node'
run: pnpm lint
- name: Test
- name: Test (node)
if: matrix.runtime == 'node'
run: pnpm test
- name: Build
- name: Build (node)
if: matrix.runtime == 'node'
run: pnpm build
- name: Protocol check (node)
if: matrix.runtime == 'node'
run: pnpm protocol:check
- name: Lint (bun)
if: matrix.runtime == 'bun'
run: bunx biome check src
- name: Test (bun)
if: matrix.runtime == 'bun'
run: bunx vitest run
- name: Build (bun)
if: matrix.runtime == 'bun'
run: bunx tsc -p tsconfig.json
macos-app:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Select Xcode 26.1
run: |
sudo xcode-select -s /Applications/Xcode_26.1.app
xcodebuild -version
- name: Install SwiftLint / SwiftFormat
run: |
brew install swiftlint swiftformat
- name: Show toolchain
run: |
sw_vers
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: swift build --package-path apps/macos --configuration release
- name: Swift tests
run: swift test --package-path apps/macos --parallel
+9
View File
@@ -4,3 +4,12 @@ dist
pnpm-lock.yaml
coverage
.pnpm-store
**/.DS_Store
apps/macos/.build/
bin/clawdis-mac
apps/macos/.build-local/
apps/macos/.swiftpm/
Core/
apps/ios/*.xcodeproj/
apps/ios/*.xcworkspace/
apps/ios/.swiftpm/
+1
View File
@@ -0,0 +1 @@
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext
+51
View File
@@ -0,0 +1,51 @@
# SwiftFormat configuration adapted from Peekaboo defaults (Swift 6 friendly)
--swiftversion 6.2
# Self handling
--self insert
--selfrequired
# Imports / extensions
--importgrouping testable-bottom
--extensionacl on-declarations
# Indentation
--indent 4
--indentcase false
--ifdef no-indent
--xcodeindentation enabled
# Line breaks
--linebreaks lf
--maxwidth 120
# Whitespace
--trimwhitespace always
--emptybraces no-space
--nospaceoperators ...,..<
--ranges no-space
--someAny true
--voidtype void
# Wrapping
--wraparguments before-first
--wrapparameters before-first
--wrapcollections before-first
--closingparen same-line
# Organization
--organizetypes class,struct,enum,extension
--extensionmark "MARK: - %t + %p"
--marktypes always
--markextensions always
--structthreshold 0
--enumthreshold 0
# Other
--stripunusedargs closure-only
--header ignore
--allman false
# Exclusions
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata
+142
View File
@@ -0,0 +1,142 @@
# SwiftLint configuration adapted from Peekaboo defaults (Swift 6 friendly)
included:
- apps/macos/Sources
excluded:
- .build
- DerivedData
- "**/.build"
- "**/.swiftpm"
- "**/DerivedData"
- "**/Generated"
- "**/Resources"
- "**/Package.swift"
- "**/Tests/Resources"
- node_modules
- dist
- coverage
- "*.playground"
analyzer_rules:
- unused_declaration
- unused_import
opt_in_rules:
- array_init
- closure_spacing
- contains_over_first_not_nil
- empty_count
- empty_string
- explicit_init
- fallthrough
- fatal_error_message
- first_where
- joined_default_parameter
- last_where
- literal_expression_end_indentation
- multiline_arguments
- multiline_parameters
- operator_usage_whitespace
- overridden_super_call
- pattern_matching_keywords
- private_outlet
- prohibited_super_call
- redundant_nil_coalescing
- sorted_first_last
- switch_case_alignment
- unneeded_parentheses_in_closure_argument
- vertical_parameter_alignment_on_call
disabled_rules:
# SwiftFormat handles these
- trailing_whitespace
- trailing_newline
- trailing_comma
- vertical_whitespace
- indentation_width
# Style exclusions
- explicit_self
- identifier_name
- file_header
- explicit_top_level_acl
- explicit_acl
- explicit_type_interface
- missing_docs
- required_deinit
- prefer_nimble
- quick_discouraged_call
- quick_discouraged_focused_test
- quick_discouraged_pending_test
- anonymous_argument_in_multiline_closure
- no_extension_access_modifier
- no_grouping_extension
- switch_case_on_newline
- strict_fileprivate
- extension_access_modifier
- convenience_type
- no_magic_numbers
- one_declaration_per_file
- vertical_whitespace_between_cases
- vertical_whitespace_closing_braces
- superfluous_else
- number_separator
- prefixed_toplevel_constant
- opening_brace
- trailing_closure
- contrasted_opening_brace
- sorted_imports
- redundant_type_annotation
- shorthand_optional_binding
- untyped_error_in_catch
- file_name
- todo
force_cast: warning
force_try: warning
type_name:
min_length:
warning: 2
error: 1
max_length:
warning: 60
error: 80
function_body_length:
warning: 150
error: 300
file_length:
warning: 1500
error: 2500
ignore_comment_only_lines: true
type_body_length:
warning: 800
error: 1200
cyclomatic_complexity:
warning: 20
error: 120
large_tuple:
warning: 4
error: 5
nesting:
type_level:
warning: 4
error: 6
function_level:
warning: 5
error: 7
line_length:
warning: 120
error: 250
ignores_comments: true
ignores_urls: true
reporter: "xcode"
+38 -8
View File
@@ -1,13 +1,15 @@
READ ~/Projects/agent-scripts/AGENTS.MD BEFORE ANYTHING (skip if missing).
# Repository Guidelines
## Project Structure & Module Organization
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, Twilio in `src/twilio`, Web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
- Tests: colocated `*.test.ts` plus e2e in `src/cli/relay.e2e.test.ts`.
- Docs: `docs/` (images, queue, Claude config). Built output lives in `dist/`.
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
- Tests: colocated `*.test.ts`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
## Build, Test, and Development Commands
- Install deps: `pnpm install`
- Run CLI in dev: `pnpm warelay ...` (tsx entry) or `pnpm dev` for `src/index.ts`.
- Run CLI in dev: `pnpm clawdis ...` (tsx entry) or `pnpm dev` for `src/index.ts`.
- Type-check/build: `pnpm build` (tsc)
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
@@ -16,22 +18,50 @@
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
- Formatting/linting via Biome; run `pnpm lint` before commits.
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
- Keep every file ≤ 500 LOC; refactor or split before exceeding and check frequently.
## Testing Guidelines
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
## Commit & Pull Request Guidelines
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- Group related changes; avoid bundling unrelated refactors.
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
## Security & Configuration Tips
- Environment: copy `.env.example`; set Twilio creds and WhatsApp sender (`TWILIO_WHATSAPP_FROM`).
- Web provider stores creds at `~/.warelay/credentials/`; rerun `warelay login` if logged out.
- Media hosting relies on Tailscale Funnel when using Twilio; use `warelay webhook --ingress tailscale` or `--serve-media` for local hosting.
- Web provider stores creds at `~/.clawdis/credentials/`; rerun `clawdis login` if logged out.
- Pi/Tau sessions live under `~/.clawdis/sessions/` by default; the base directory is not configurable.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
## Agent-Specific Notes
- If the relay is running in tmux (`warelay-relay`), restart it after code changes: kill pane/session and run `pnpm warelay relay --verbose` inside tmux. Check tmux before editing; keep the watcher healthy if you start it.
- Gateway currently runs only as the menubar app (launchctl shows `application.com.steipete.clawdis.debug.*`), there is no separate LaunchAgent/helper label installed. Restart via the Clawdis Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdis` rather than expecting `com.steipete.clawdis`. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdis`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks. Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
- When asked to open a “session” file, open the Pi/Tau session logs under `~/.tau/agent/sessions/clawdis/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
- Voice wake forwarding tips:
- Command template should stay `clawdis-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
- launchd PATH is minimal; ensure the app’s launch agent sets PATH to include `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/steipete/Library/pnpm` so `pnpm`/`clawdis` binaries resolve when invoked via `clawdis-mac`.
- For manual `clawdis send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
## Exclamation Mark Escaping Workaround
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdis send` with messages containing exclamation marks, use heredoc syntax:
```bash
# WRONG - will send "Hello\\!" with backslash
clawdis send --to "+1234" --message 'Hello!'
# CORRECT - use heredoc to avoid escaping
clawdis send --to "+1234" --message "$(cat <<'EOF'
Hello!
EOF
)"
```
This is a Claude Code quirk, not a clawdis bug.
+168 -36
View File
@@ -1,48 +1,180 @@
# Changelog
## 0.1.1 — 2025-11-25
## 2.0.0 — Unreleased
### CLI polish
- Added a proper executable shim so `npx warelay@0.1.x --help` runs the CLI directly.
- Help/version banner now uses the README tagline with color, and the help footer includes colored examples with short explanations.
- `send` and `status` gained a `--verbose` flag for consistent noisy output when debugging.
- Lowercased branding in docs/UA; web provider UA is `warelay/cli/0.1.1`.
First Clawdis release post rebrand. This is a semver-major because we dropped legacy providers/agents and moved defaults to new paths while adding a full macOS companion app.
## 0.1.2 — 2025-11-25
### Breaking
- Renamed to **Clawdis**: defaults now live under `~/.clawdis` (sessions in `~/.clawdis/sessions/`, IPC at `~/.clawdis/clawdis.sock`, logs in `/tmp/clawdis`). Launchd labels and config filenames follow the new name; legacy stores are copied forward on first run.
- Pi/Tau only: `inbound.reply.agent.kind` accepts only `"pi"`, and the agent CLI/CLI flags for Claude/Codex/Gemini were removed. The Pi CLI runs in RPC mode with a persistent worker.
- WhatsApp Web is the only transport; Twilio support and related CLI flags/tests were removed.
- Direct chats now collapse into a single `main` session by default (no config needed); groups stay isolated as `group:<jid>`.
- Gateway background helpers were removed; run `clawdis gateway --verbose` under your supervisor of choice if you want it detached.
### CI/build fix
- Fixed commander help configuration (`subcommandTerm`) so TypeScript builds pass in CI.
- Bumped version/UA to 0.1.2; no functional changes beyond the CI fix.
### macOS companion app
- **Clawdis.app menu bar companion**: packaged, signed bundle with gateway start/stop, launchd toggle, project-root and pnpm/node auto-resolution, live log shortcut, restart button, and status/recipient table plus badges/dimming for attention and paused states.
- **On-device Voice Wake**: Apple speech recognizer with wake-word table, language picker, live mic meter, “hold until silence,” animated ears/legs, and main-session routing that replies on the **last used surface** (WhatsApp/Telegram/WebChat). Delivery failures are logged, and the run remains visible via WebChat/session logs.
- **WebChat & Debugging**: bundled WebChat UI, Debug tab with heartbeat sliders, session-store picker, log opener (`clawlog`), gateway restart, health probes, and scrollable settings panes.
## 0.1.0 — 2025-11-25
### WhatsApp & agent experience
- Group chats fully supported: mention-gated triggers (including media-only captions), sender attribution, session primer with subject/member roster, allowlist bypass when you’re @‑mentioned, and safer handling of view-once/ephemeral media.
- Thinking/verbosity directives: `/think` and `/verbose` acknowledge and persist per session while allowing inline overrides; verbose mode streams tool metadata with emoji/args/previews and coalesces bursts to reduce WhatsApp noise.
- Heartbeats: configurable cadence with CLI/GUI toggles; directive acks suppressed during heartbeats; array/multi-payload replies normalized for Baileys.
- Reply quality: smarter chunking on words/newlines, fallback warnings when media fails to send, self-number mention detection, and primed group sessions send the roster on first turn.
- In-chat `/status`: prints agent readiness, session context usage %, current thinking/verbose options, and when the WhatsApp web creds were refreshed (helps decide when to re-scan QR); still available via `clawdis status` CLI for web session health.
### CLI & Providers
- Bundles a single `warelay` CLI with commands for `send`, `relay`, `status`, `webhook`, `login`, and tmux helpers `relay:tmux` / `relay:tmux:attach` (see `src/cli/program.ts`); `webhook` accepts `--ingress tailscale|none`.
- Supports two messaging backends: **Twilio** (default) and **personal WhatsApp Web**; `relay --provider auto` selects Web when a cached login exists, otherwise falls back to Twilio polling (`provider-web.ts`, `cli/program.ts`).
- `send` can target either provider, optionally wait for delivery status (Twilio only), output JSON, dry-run payloads, and attach media (`commands/send.ts`).
- `status` merges inbound + outbound Twilio traffic with formatted lines or JSON output (`commands/status.ts`, `twilio/messages.ts`).
### CLI, RPC, and health
- New `clawdis agent` command plus a persistent Pi RPC worker (auto-started) enables direct agent chats; `clawdis status` renders a colored session/recipient table.
- `clawdis health` probes WhatsApp link status, connect latency, heartbeat interval, session-store recency, and IPC socket presence (JSON mode for monitors).
- Added `--help`/`--version` flags; login/logout accept `--provider` (WhatsApp default). Console output is mirrored into pino logs under `/tmp/clawdis`.
- RPC stability: stdin/stdout loop for Tau/Pi, auto-restart worker, raw error surfacing, and deliver-via-RPC when JSON agent output is returned.
### Webhook, Funnel & Port Management
- `webhook` starts an Express server for inbound Twilio callbacks, logs requests, and optionally auto-replies with static text or config-driven replies (`twilio/webhook.ts`, `commands/webhook.ts`).
- `webhook --ingress tailscale` automates end-to-end webhook setup: ensures required binaries, enables Tailscale Funnel, starts the webhook on the chosen port/path, discovers the WhatsApp sender SID, and updates Twilio webhook URLs with multiple fallbacks (`commands/up.ts`, `infra/tailscale.ts`, `twilio/update-webhook.ts`, `twilio/senders.ts`).
- Guardrails detect busy ports with helpful diagnostics and aborts when conflicts are found (`infra/ports.ts`).
### Security & hardening
- Media server blocks symlink/path traversal, clears temporary downloads, and rotates logs daily (24h retention).
- Session store purged on logout; IPC socket directory permissions tightened (0700/0600).
- Launchd PATH and helper lookup hardened for packaged macOS builds; health probes surface missing binaries quickly.
### Auto-Reply Engine
- Configurable via `~/.warelay/warelay.json` (JSON5) with allowlist support, text or command-driven replies, templating (`{{Body}}`, `{{From}}`, `{{MediaPath}}`, etc.), optional body prefixes, and per-sender or global conversation sessions with `/new` resets and idle expiry (`auto-reply/reply.ts`, `config/config.ts`, `config/sessions.ts`, `auto-reply/templating.ts`).
- Command replies run through a process-wide FIFO queue to avoid concurrent executions across webhook, poller, and web listener flows (`process/command-queue.ts`); verbose mode surfaces wait times.
- Claude CLI integration auto-injects identity, output-format flags, session args, and parses JSON output while preserving metadata (`auto-reply/claude.ts`, `auto-reply/reply.ts`).
- Typing indicators fire before replies for Twilio, and Web provider sends “composing/available” presence when possible (`twilio/typing.ts`, `provider-web.ts`).
### Docs
- Added `docs/telegram.md` outlining the Telegram Bot API provider (grammY) and how it shares the `main` session. Default grammY throttler keeps Bot API calls under rate limits.
- CLI gateway now auto-starts WhatsApp and Telegram when configured (single `gateway` command with `--provider` selector); text/media sends still use `--provider telegram`; webhook/proxy options documented.
### Media Pipeline
- `send --media` works on both providers: Web accepts local paths or URLs; Twilio requires HTTPS and transparently hosts local files (≤5 MB) via the Funnel/webhook media endpoint, auto-spawning a short-lived media server when `--serve-media` is requested (`commands/send.ts`, `media/host.ts`, `media/server.ts`).
- Auto-replies may include `mediaUrl` from config or command output (`MEDIA:` token extraction) and will host local media when needed before sending (`auto-reply/reply.ts`, `media/parse.ts`, `media/host.ts`).
- Inbound media from Twilio or Web is downloaded to `~/.warelay/media` with TTL cleanup and passed to commands via `MediaPath`/`MediaType` for richer prompts (`twilio/webhook.ts`, `provider-web.ts`, `media/store.ts`).
## 1.5.0 — 2025-12-05
### Relay & Monitoring
- `relay` polls Twilio on an interval with exponential-backoff resilience, auto-replying to inbound messages, or listens live via WhatsApp Web with automatic read receipts and presence updates (`cli/program.ts`, `twilio/monitor.ts`, `provider-web.ts`).
- `send` + `waitForFinalStatus` polls Twilio until a terminal delivery state (delivered/read) or timeout, with clear failure surfaces (`twilio/send.ts`).
### Breaking
- Dropped all non-Pi agents (Claude, Codex, Gemini, Opencode); `inbound.reply.agent.kind` now only accepts `"pi"` and related CLI helpers have been removed.
- Removed Twilio support and all related commands/options (webhook/up/provider flags/wait-poll); CLAWDIS is Baileys Web-only.
### Developer & Ops Ergonomics
- `relay:tmux` helper restarts/attaches to a dedicated `warelay-relay` tmux session for long-running relays (`cli/relay_tmux.ts`).
- Environment validation enforces Twilio credentials early and supports either auth token or API key/secret pairs (`env.ts`).
- Shared logging utilities, binary checks, and runtime abstractions keep CLI output consistent (`globals.ts`, `logger.ts`, `infra/binaries.ts`).
### Changes
- Default agent handling now favors Pi RPC while falling back to plain command execution for non-Pi invocations, keeping heartbeat/session plumbing intact.
- Documentation updated to reflect Pi-only support and to mark legacy Claude paths as historical.
- Status command reports web session health + session recipients; config paths are locked to `~/.clawdis` with session metadata stored under `~/.clawdis/sessions/`.
- Simplified send/agent/gateway/heartbeat to web-only delivery; removed Twilio mocks/tests and dead code.
- Tau RPC timeout is now inactivity-based (5m without events) and error messages show seconds only.
- Pi/Tau sessions now write to `~/.clawdis/sessions/` by default (legacy `~/.tau/agent/sessions/clawdis` files are copied over when present).
- Directive triggers (`/think`, `/verbose`, `/stop` et al.) now reply immediately using normalized bodies (timestamps/group prefixes stripped) without waiting for the agent.
- Directive/system acks carry a `⚙️` prefix and verbose parsing rejects typoed `/ver*` strings so unrelated text doesn’t flip verbosity.
- Batched history blocks no longer trip directive parsing; `/think` in prior messages won't emit stray acknowledgements.
- RPC fallbacks no longer echo the user's prompt (e.g., pasting a link) when the agent returns no assistant text.
- Heartbeat prompts with `/think` no longer send directive acks; heartbeat replies stay silent on settings.
- `clawdis sessions` now renders a colored table (a la oracle) with context usage shown in k tokens and percent of the context window.
## 1.4.1 — 2025-12-04
### Changes
- Added `clawdis agent` CLI command to talk directly to the configured agent using existing session handling (no WhatsApp send), with JSON output and delivery option.
- `/new` reset trigger now works even when inbound messages have timestamp prefixes (e.g., `[Dec 4 17:35]`).
- WhatsApp mention parsing accepts nullable arrays and flattens safely to avoid missed mentions.
## 1.4.0 — 2025-12-03
### Highlights
- **Thinking directives & state:** `/t|/think|/thinking <level>` (aliases off|minimal|low|medium|high|max/highest). Inline applies to that message; directive-only message pins the level for the session; `/think:off` clears. Resolution: inline > session override > `inbound.reply.thinkingDefault` > off. Pi/Tau get `--thinking <level>` (except off); other agents append cue words (`think` → `think hard` → `think harder` → `ultrathink`). Heartbeat probe uses `HEARTBEAT /think:high`.
- **Group chats (web provider):** Clawdis now fully supports WhatsApp groups: mention-gated triggers (including image-only @ mentions), recent group history injection, per-group sessions, sender attribution, and a first-turn primer with group subject/member roster; heartbeats are skipped for groups.
- **Group session primer:** The first turn of a group session now tells the agent it is in a WhatsApp group and lists known members/subject so it can address the right speaker.
- **Media failures are surfaced:** When a web auto-reply media fetch/send fails (e.g., HTTP 404), we now append a warning to the fallback text so you know the attachment was skipped.
- **Verbose directives + session hints:** `/v|/verbose on|full|off` mirrors thinking: inline > session > config default. Directive-only replies with an acknowledgement; invalid levels return a hint. When enabled, tool results from JSON-emitting agents (Pi/Tau, etc.) are forwarded as metadata-only `[🛠️ <tool-name> <arg>]` messages (now streamed as they happen), and new sessions surface a `🧭 New session: <id>` hint.
- **Verbose tool coalescing:** successive tool results of the same tool within ~1s are batched into one `[🛠️ tool] arg1, arg2` message to reduce WhatsApp noise.
- **Directive confirmations:** Directive-only messages now reply with an acknowledgement (`Thinking level set to high.` / `Thinking disabled.`) and reject unknown levels with a helpful hint (state is unchanged).
- **Pi/Tau stability:** RPC replies buffered until the assistant turn finishes; parsers return consistent `texts[]`; web auto-replies keep a warm Tau RPC process to avoid cold starts.
- **Claude prompt flow:** One-time `sessionIntro` with per-message `/think:high` bodyPrefix; system prompt always sent on first turn even with `sendSystemOnce`.
- **Heartbeat UX:** Backpressure skips reply heartbeats while other commands run; skips don’t refresh session `updatedAt`; web heartbeats normalize array payloads and optional `heartbeatCommand`.
- **Control via WhatsApp:** Send `/restart` to restart the launchd service (`com.steipete.clawdis`) from your allowed numbers.
- **Tau completion signal:** RPC now resolves on Tau’s `agent_end` (or process exit) so late assistant messages aren’t truncated; 5-minute hard cap only as a failsafe.
### Reliability & UX
- Outbound chunking prefers newlines/word boundaries and enforces caps (~4000 chars for web/WhatsApp).
- Web auto-replies fall back to caption-only if media send fails; hosted media MIME-sniffed and cleaned up immediately.
- IPC gateway send shows typing indicator; batched inbound messages keep timestamps; watchdog restarts WhatsApp after long inactivity.
- Early `allowFrom` filtering prevents decryption errors; same-phone mode supported with echo suppression.
- All console output is now mirrored into pino logs (still printed to stdout/stderr), so verbose runs keep full traces.
- `--verbose` now forces log level `trace` (was `debug`) to capture every event.
- Verbose tool messages now include emoji + args + a short result preview for bash/read/edit/write/attach (derived from RPC tool start/end events).
### Security / Hardening
- IPC socket hardened (0700 dir / 0600 socket, no symlinks/foreign owners); `clawdis logout` also prunes session store.
- Media server blocks symlinks and enforces path containment; logging rotates daily and prunes >24h.
### Bug Fixes
- Web group chats now bypass the second `allowFrom` check (we still enforce it on the group participant at inbox ingest), so mentioned group messages reply even when the group JID isn’t in your allowlist.
- `logVerbose` also writes to the configured Pino logger at debug level (without breaking stdout).
- Group auto-replies now append the triggering sender (`[from: Name (+E164)]`) to the batch body so agents can address the right person in group chats.
- Media-only pings now pick up mentions inside captions (image/video/etc.), so @-mentions on media-only messages trigger replies.
- MIME sniffing and redirect handling for downloads/hosted media.
- Response prefix applied to heartbeat alerts; heartbeat array payloads handled for both providers.
- Tau RPC typing exposes `signal`/`killed`; NDJSON parsers normalized across agents.
- Tau (pi) session resumes now append `--continue`, so existing history/think level are reloaded instead of starting empty.
### Testing
- Fixtures isolate session stores; added coverage for thinking directives, stateful levels, heartbeat backpressure, and agent parsing.
## 1.3.0 — 2025-12-02
### Highlights
- **Pluggable agents (Claude, Pi, Codex, Opencode):** `inbound.reply.agent` selects CLI/parser; per-agent argv builders and NDJSON parsers enable swapping without template changes.
- **Safety stop words:** `stop|esc|abort|wait|exit` immediately reply “Agent was aborted.” and mark the session so the next prompt is prefixed with an abort reminder.
- **Agent session reliability:** Only Claude returns a stable `session_id`; others may reset between runs.
### Bug Fixes
- Empty `result` fields no longer leak raw JSON to users.
- Heartbeat alerts now honor `responsePrefix`.
- Command failures return user-friendly messages.
- Test session isolation to avoid touching real `sessions.json`.
- (Removed in 2.0.0) IPC reuse for `clawdis send/heartbeat` prevents Signal/WhatsApp session corruption.
- Web send respects media kind (image/audio/video/document) with correct limits.
### Changes
- (Removed in 2.0.0) IPC gateway socket at `~/.clawdis/ipc/gateway.sock` with automatic CLI fallback.
- Batched inbound messages with timestamps; typing indicator after sends.
- Watchdog restarts WhatsApp after long inactivity; heartbeat logging includes minutes since last message.
- Early `allowFrom` filtering before decryption.
- Same-phone mode with echo detection and optional `inbound.samePhoneMarker`.
## 1.2.2 — 2025-11-28
### Changes
- Manual heartbeat sends: `clawdis heartbeat --message/--body` (web provider only); `--dry-run` previews payloads.
## 1.2.1 — 2025-11-28
### Changes
- Media MIME-first handling; hosted media extensions derived from detected MIME with tests.
### Planned / in progress (from prior notes)
- Heartbeat targeting quality: clearer recipient resolution and verbose logs.
- Heartbeat delivery preview (Claude path) dry-run.
- Simulated inbound hook for local testing.
## 1.2.0 — 2025-11-27
### Changes
- Heartbeat interval default 10m for command mode; prompt `HEARTBEAT /think:high`; skips don’t refresh session; session `heartbeatIdleMinutes` support.
- Heartbeat tooling: `--session-id`, `--heartbeat-now` (inline flag on `gateway`) for immediate startup probes.
- Prompt structure: `sessionIntro` plus per-message `/think:high`; session idle up to 7 days.
- Thinking directives: `/think:<level>`; Pi uses `--thinking`; others append cue; `/think:off` no-op.
- Robustness: Baileys/WebSocket guards; global unhandled error handlers; WhatsApp LID mapping; hosted media MIME-sniffing and cleanup.
- Docs: README Clawd setup; `docs/claude-config.md` for live config.
## 1.1.0 — 2025-11-26
### Changes
- Web auto-replies resize/recompress media and honor `inbound.reply.mediaMaxMb`.
- Detect media kind, enforce provider caps (images ≤6MB, audio/video ≤16MB, docs ≤100MB).
- `session.sendSystemOnce` and optional `sessionIntro`.
- Typing indicator refresh during commands; configurable via `inbound.reply.typingIntervalSeconds`.
- Optional audio transcription via external CLI.
- Command replies return structured payload/meta; respect `mediaMaxMb`; log Claude metadata; include `cwd` in timeout messages.
- Web provider refactor; logout command; web-only gateway start helper.
- Structured reconnect/heartbeat logging; bounded backoff with CLI/config knobs; troubleshooting guide.
- Relay help prints effective heartbeat/backoff when in web mode.
## 1.0.4 — 2025-11-25
### Changes
- Timeout fallbacks send partial stdout (≤800 chars) to the user instead of silence; tests added.
- Web gateway auto-reconnects after Baileys/WebSocket drops; close propagation tests.
## 0.1.3 — 2025-11-25
### Changes
- Auto-replies send a WhatsApp fallback message on command/Claude timeout with truncated stdout.
- Added tests for timeout fallback and partial-output truncation.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+171 -102
View File
@@ -1,131 +1,200 @@
# 📡 warelay — Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked.
[![CI](https://github.com/steipete/warelay/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/steipete/warelay/actions/workflows/ci.yml)
[![npm version](https://img.shields.io/npm/v/warelay.svg)](https://www.npmjs.com/package/warelay)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
# 🦞 CLAWDIS — WhatsApp & Telegram Gateway for AI Agents
Send, receive, auto-reply, and inspect WhatsApp messages over **Twilio** or your personal **WhatsApp Web** session. Ships with a one-command webhook setup (Tailscale Funnel + Twilio callback) and a configurable auto-reply engine (plain text or command/Claude driven).
<p align="center">
<img src="docs/whatsapp-clawd.jpg" alt="CLAWDIS" width="400">
</p>
## Quick Start (pick your engine)
Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **one** path:
<p align="center">
<strong>EXFOLIATE! EXFOLIATE!</strong>
</p>
**A) Personal WhatsApp Web (preferred: no Twilio creds, fastest setup)**
1. Link your account: `warelay login` (scan the QR).
2. Send a message: `warelay send --to +12345550000 --message "Hi from warelay"` (add `--provider web` if you want to force the web session).
3. Stay online & auto-reply: `warelay relay --verbose` (defaults to Web when logged in, falls back to Twilio otherwise).
<p align="center">
<a href="https://github.com/steipete/clawdis/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/steipete/clawdis/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
<a href="https://www.npmjs.com/package/clawdis"><img src="https://img.shields.io/npm/v/clawdis.svg?style=for-the-badge" alt="npm version"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
</p>
**B) Twilio WhatsApp number (for delivery status + webhooks)**
1. Copy `.env.example` → `.env`; set `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN` **or** `TWILIO_API_KEY`/`TWILIO_API_SECRET`, and `TWILIO_WHATSAPP_FROM=whatsapp:+19995550123` (optional `TWILIO_SENDER_SID`).
2. Send a message: `warelay send --to +12345550000 --message "Hi from warelay"`.
3. Receive replies:
- Polling (no ingress): `warelay relay --provider twilio --interval 5 --lookback 10`
- Webhook + public URL via Tailscale Funnel: `warelay webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose`
**CLAWDIS** is a WhatsApp- and Telegram-to-AI gateway. Send a message, get an AI response. It's like having a genius lobster in your pocket 24/7.
> Already developing locally? You can still run `pnpm install` and `pnpm warelay ...` from the repo, but end users only need the npm package.
```
┌─────────────┐ ┌──────────┐ ┌─────────────┐
│ WhatsApp │ ───▶ │ CLAWDIS │ ───▶ │ AI Agent │
│ Telegram │ ───▶ │ 🦞⏱️💙 │ ◀─── │ (Pi/Tau) │
│ (You) │ ◀─── │ │ │ │
└─────────────┘ └──────────┘ └─────────────┘
```
## Main Features
- **Two providers:** Twilio (default) for reliable delivery + status; Web provider for quick personal sends/receives via QR login.
- **Auto-replies:** Static templates or external commands (Claude-aware), with per-sender or global sessions and `/new` resets.
- Claude setup guide: see `docs/claude-config.md` for the exact Claude CLI configuration we support.
- **Webhook in one go:** `warelay webhook --ingress tailscale` enables Tailscale Funnel, runs the webhook server, and updates the Twilio sender callback URL.
- **Polling fallback:** `relay` polls Twilio when webhooks aren’t available; works headless.
- **Status + delivery tracking:** `status` shows recent inbound/outbound; `send` can wait for final Twilio status.
## Why "CLAWDIS"?
## Command Cheat Sheet
| Command | What it does | Core flags |
| --- | --- | --- |
| `warelay send` | Send a WhatsApp message (Twilio or Web) | `--to <e164>` `--message <text>` `--wait <sec>` `--poll <sec>` `--provider twilio\|web` `--json` `--dry-run` `--verbose` |
| `warelay relay` | Auto-reply loop (poll Twilio or listen on Web) | `--provider <auto\|twilio\|web>` `--interval <sec>` `--lookback <min>` `--verbose` |
| `warelay status` | Show recent sent/received messages | `--limit <n>` `--lookback <min>` `--json` `--verbose` |
| `warelay webhook` | Run inbound webhook (`ingress=tailscale` updates Twilio; `none` is local-only) | `--ingress tailscale\|none` `--port <port>` `--path <path>` `--reply <text>` `--verbose` `--yes` `--dry-run` |
| `warelay login` | Link personal WhatsApp Web via QR | `--verbose` |
**CLAWDIS** = CLAW + TARDIS
### Sending images
- Twilio: `warelay send --to +1... --message "Hi" --media ./pic.jpg --serve-media` (needs `warelay webhook --ingress tailscale` or `--serve-media` to auto-host via Funnel; max 5 MB).
- Web: `warelay send --provider web --media ./pic.jpg --message "Hi"` (local path or URL; no hosting needed).
- Auto-replies can attach `mediaUrl` in `~/.warelay/warelay.json` (used alongside `text` when present).
Because every space lobster needs a time-and-space machine. The Doctor has a TARDIS. [Clawd](https://clawd.me) has a CLAWDIS. Both are blue. Both are chaotic. Both are loved.
## Providers
- **Twilio (default):** needs `.env` creds + WhatsApp-enabled number; supports delivery tracking, polling, webhooks, and auto-reply typing indicators.
- **Web (`--provider web`):** uses your personal WhatsApp via Baileys; supports send/receive + auto-reply, but no delivery-status wait; cache lives in `~/.warelay/credentials/` (rerun `login` if logged out).
- **Auto-select (`relay` only):** `--provider auto` uses Web when logged in, otherwise Twilio polling.
## Features
Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business account) for automation instead of your primary personal account to avoid unexpected logouts or rate limits.
- 📱 **WhatsApp Integration** — Personal WhatsApp Web (Baileys)
- ✈️ **Telegram (Bot API)** — DMs and groups via grammY
- 🤖 **AI Agent Gateway** — Pi/Tau only (Pi CLI in RPC mode)
- 💬 **Session Management** — Per-sender conversation context
- 🔔 **Heartbeats** — Periodic check-ins for proactive AI
- 👥 **Group Chat Support** — Mention-based triggering
- 📎 **Media Support** — Images, audio, documents, voice notes
- 🎤 **Voice Transcription** — Whisper integration
- 🔧 **Tool Streaming** — Real-time display (💻📄✍️📝)
- 🖥️ **macOS Companion (Clawdis.app)** — Menu bar controls, on-device Voice Wake, model/config editor
Only the Pi/Tau CLI is supported now; legacy Claude/Codex/Gemini paths have been removed.
## Quick Start
Mac signing tip: set `SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` in your shell profile so `scripts/restart-mac.sh` signs with your cert (defaults to ad-hoc). Debug bundle ID remains `com.steipete.clawdis.debug`.
Runtime requirement: **Node ≥22.0.0** (not bundled). The macOS app and CLI both use the host runtime; install via Homebrew or official installers before running `clawdis`.
```bash
# Install
npm install -g clawdis
# Link your WhatsApp
clawdis login
# Send a message
clawdis send --to +1234567890 --message "Hello from the CLAWDIS!"
# Talk directly to the agent (no WhatsApp send)
clawdis agent --to +1234567890 --message "Ship checklist" --thinking high
# Start the gateway (WebSocket control plane)
clawdis gateway --port 18789 --verbose
# If the port is busy, force-kill listeners then start
clawdis gateway --force
```
## macOS Companion App (Clawdis.app)
- **On-device Voice Wake:** listens for wake words (e.g. “Claude”) using Apple’s on-device speech recognizer (macOS 26+). macOS still shows the standard Speech/Mic permissions prompt, but audio stays on device.
- **Push-to-talk (Right Option hold):** hold right Option to speak; the voice overlay shows live partials and sends when you release.
- **Config tab:** pick the model from your local Pi model catalog (`pi-mono/packages/ai/src/models.generated.ts`), or enter a custom model ID; edit session store path and context tokens.
- **Voice settings:** language + additional languages, mic picker, live level meter, trigger-word table, and a built-in test harness.
- **Menu bar toggle:** enable/disable Voice Wake from the menu bar; respects Dock-icon preference.
### Voice Wake reply routing
Voice Wake sends messages into the `main` session and replies on the **last used surface**:
- WhatsApp: last direct message you sent/received.
- Telegram: last DM chat id (bot mode).
- WebChat: last WebChat thread you used.
If delivery fails (e.g. WhatsApp disconnected / Telegram token missing), Clawdis logs the error and you can still inspect the run via WebChat/session logs.
Build/run the mac app with `./scripts/restart-mac.sh` (packages, installs, and launches), or `swift build --package-path apps/macos && open dist/Clawdis.app`.
## Configuration
### Environment (.env)
| Variable | Required | Description |
| --- | --- | --- |
| `TWILIO_ACCOUNT_SID` | Yes (Twilio provider) | Twilio Account SID |
| `TWILIO_AUTH_TOKEN` | Yes* | Auth token (or use API key/secret) |
| `TWILIO_API_KEY` | Yes* | API key if not using auth token |
| `TWILIO_API_SECRET` | Yes* | API secret paired with `TWILIO_API_KEY` |
| `TWILIO_WHATSAPP_FROM` | Yes (Twilio provider) | WhatsApp-enabled sender, e.g. `whatsapp:+19995550123` |
| `TWILIO_SENDER_SID` | Optional | Overrides auto-discovery of the sender SID |
(*Provide either auth token OR api key/secret.)
### Auto-reply config (`~/.warelay/warelay.json`, JSON5)
- Controls who is allowed to trigger replies (`allowFrom`), reply mode (`text` or `command`), templates, and session behavior.
- Example (Claude command):
Create `~/.clawdis/clawdis.json`:
```json5
{
inbound: {
allowFrom: ["+12345550000"],
allowFrom: ["+1234567890"],
reply: {
mode: "command",
bodyPrefix: "You are a concise WhatsApp assistant.\n\n",
command: ["claude", "--dangerously-skip-permissions", "{{BodyStripped}}"],
claudeOutputFormat: "text",
session: { scope: "per-sender", resetTriggers: ["/new"], idleMinutes: 60 }
command: ["tau", "--mode", "json", "{{BodyStripped}}"],
session: {
scope: "per-sender",
idleMinutes: 1440
},
heartbeatMinutes: 10
}
}
}
```
### Claude CLI setup (how we run it)
1) Install the official Claude CLI (e.g., `brew install anthropic-ai/cli/claude` or follow the Anthropic docs) and run `claude login` so it can read your API key.
2) In `warelay.json`, set `reply.mode` to `"command"` and point `command[0]` to `"claude"`; set `claudeOutputFormat` to `"text"` (or `"json"`/`"stream-json"` if you want warelay to parse and trim the JSON output).
3) (Optional) Add `bodyPrefix` to inject a system prompt and `session` settings to keep multi-turn context (`/new` resets by default).
4) Run `pnpm warelay relay --provider auto` (or `--provider web|twilio`) and send a WhatsApp message; warelay will queue the Claude call, stream typing indicators (Twilio provider), parse the result, and send back the text.
## Documentation
### Auto-reply parameter table (compact)
| Key | Type & default | Notes |
| --- | --- | --- |
| `inbound.allowFrom` | `string[]` (default: empty) | E.164 numbers allowed to trigger auto-reply (no `whatsapp:`). |
| `inbound.reply.mode` | `"text"` \| `"command"` (default: —) | Reply style. |
| `inbound.reply.text` | `string` (default: —) | Used when `mode=text`; templating supported. |
| `inbound.reply.command` | `string[]` (default: —) | Argv for `mode=command`; each element templated. Stdout (trimmed) is sent. |
| `inbound.reply.template` | `string` (default: —) | Injected as argv[1] (prompt prefix) before the body. |
| `inbound.reply.bodyPrefix` | `string` (default: —) | Prepended to `Body` before templating (great for system prompts). |
| `inbound.reply.timeoutSeconds` | `number` (default: `600`) | Command timeout. |
| `inbound.reply.claudeOutputFormat` | `"text"`\|`"json"`\|`"stream-json"` (default: —) | When command starts with `claude`, auto-adds `--output-format` + `-p/--print` and trims reply text. |
| `inbound.reply.session.scope` | `"per-sender"`\|`"global"` (default: `per-sender`) | Session bucket for conversation memory. |
| `inbound.reply.session.resetTriggers` | `string[]` (default: `["/new"]`) | Exact match or prefix (`/new hi`) resets session. |
| `inbound.reply.session.idleMinutes` | `number` (default: `60`) | Session expires after idle period. |
| `inbound.reply.session.store` | `string` (default: `~/.warelay/sessions.json`) | Custom session store path. |
| `inbound.reply.session.sessionArgNew` | `string[]` (default: `["--session-id","{{SessionId}}"]`) | Args injected for a new session run. |
| `inbound.reply.session.sessionArgResume` | `string[]` (default: `["--resume","{{SessionId}}"]`) | Args for resumed sessions. |
| `inbound.reply.session.sessionArgBeforeBody` | `boolean` (default: `true`) | Place session args before final body arg. |
- [Configuration Guide](./docs/configuration.md)
- [Agent Integration](./docs/agents.md)
- [Group Chats](./docs/group-messages.md)
- [Security](./docs/security.md)
- [Troubleshooting](./docs/troubleshooting.md)
- [The Lore](./docs/lore.md) 🦞
- [Telegram (Bot API)](./docs/telegram.md)
Templating tokens: `{{Body}}`, `{{BodyStripped}}`, `{{From}}`, `{{To}}`, `{{MessageSid}}`, plus `{{SessionId}}` and `{{IsNewSession}}` when sessions are enabled.
## Clawd
## Webhook & Tailscale Flow
- `warelay webhook --ingress none` starts the local Express server on your chosen port/path; add `--reply "Got it"` for a static reply when no config file is present.
- `warelay webhook --ingress tailscale` enables Tailscale Funnel, prints the public URL (`https://<tailnet-host><path>`), starts the webhook, discovers the WhatsApp sender SID, and updates Twilio callbacks to the Funnel URL.
- If Funnel is not allowed on your tailnet, the CLI exits with guidance; you can still use `relay --provider twilio` to poll without webhooks.
CLAWDIS was built for **Clawd**, a space lobster AI assistant. See the full setup in [`docs/clawd.md`](./docs/clawd.md).
## Troubleshooting Tips
- Send/receive issues: run `pnpm warelay status --limit 20 --lookback 240 --json` to inspect recent traffic.
- Auto-reply not firing: ensure sender is in `allowFrom` (or unset), and confirm `.env` + `warelay.json` are loaded (reload shell after edits).
- Web provider dropped: rerun `pnpm warelay login`; credentials live in `~/.warelay/credentials/`.
- Tailscale Funnel errors: update tailscale/tailscaled; check admin console that Funnel is enabled for this device.
- 🦞 **Clawd's Home:** [clawd.me](https://clawd.me)
- 📜 **Clawd's Soul:** [soul.md](https://soul.md)
- 👨‍💻 **Peter's Blog:** [steipete.me](https://steipete.me)
- 🐦 **Twitter:** [@steipete](https://twitter.com/steipete)
## FAQ & Safety (quick answers)
- Twilio errors: **63016 “permission to send an SMS has not been enabled”** → ensure your number is WhatsApp-enabled; **63007 template not approved** → send a free-form session message within 24h or use an approved template; **63112 policy violation** → adjust content, shorten to <1600 chars, avoid links that trigger spam filters. Re-run `pnpm warelay status` to see the exact Twilio response body.
- Does this store my messages? warelay only writes `~/.warelay/warelay.json` (config), `~/.warelay/credentials/` (WhatsApp Web auth), and `~/.warelay/sessions.json` (session IDs + timestamps). It does **not** persist message bodies beyond the session store. Logs print to stdout/stderr; redirect or rotate if needed.
- Personal WhatsApp safety: Automation on personal accounts can be rate-limited or logged out by WhatsApp. Use `--provider web` sparingly, keep messages human-like, and re-run `login` if the session is dropped.
- Limits to remember: WhatsApp text limit ~1600 chars; avoid rapid bursts—space sends by a few seconds; keep webhook replies under a couple seconds for good UX; command auto-replies time out after 600s by default.
- Deploy / keep running: Use `tmux` or `screen` for ad-hoc (`tmux new -s warelay -- pnpm warelay relay --provider twilio`). For long-running hosts, wrap `pnpm warelay relay ...` or `pnpm warelay webhook --ingress tailscale ...` in a systemd service or macOS LaunchAgent; ensure environment variables are loaded in that context.
- Rotating credentials: Update `.env` (Twilio keys), rerun your process; for Web provider, delete `~/.warelay/credentials/` and rerun `pnpm warelay login` to relink.
## Provider
### WhatsApp Web
```bash
clawdis login # Scan QR code
clawdis gateway # Start listening (WS on 127.0.0.1:18789)
```
### Telegram (Bot API)
Bot-mode support (grammY only) shares the same `main` session as WhatsApp/WebChat, with groups kept isolated. Text and media send work via `clawdis send --provider telegram`. The unified `clawdis gateway` starts WhatsApp and, when `TELEGRAM_BOT_TOKEN` or `telegram.botToken` is set, Telegram too (use `--provider` to force web|telegram|all). Webhook mode: `--webhook --port … --webhook-secret … --webhook-url …` (or register via BotFather). See `docs/telegram.md` for setup and limits.
## Commands
| Command | Description |
|---------|-------------|
| Command | Description |
|---------|-------------|
| `clawdis login` | Link WhatsApp Web via QR |
| `clawdis send` | Send a message (WhatsApp default; `--provider telegram` for bot mode). Always uses the Gateway WS; requires a running gateway. |
| `clawdis agent` | Talk directly to the agent (no WhatsApp send) |
| `clawdis gateway` | Start the Gateway server (WS control plane). Params: `--port`, `--token`, `--force`, `--verbose`. |
| `clawdis gateway health|status|send|agent|call` | Gateway WS clients; assume a running gateway. |
| `clawdis status` | Web session health + session store summary |
| `clawdis health` | Reports cached provider state from the running gateway. |
| `clawdis heartbeat` | Trigger a heartbeat |
#### Gateway client params (WS only)
- `--url` (default `ws://127.0.0.1:18789`)
- `--token` (shared secret if set on the gateway)
- `--timeout <ms>` (WS call timeout)
#### Send
- `--provider whatsapp|telegram` (default whatsapp)
- `--media <path-or-url>`
- `--json` for machine-readable output
#### Health
- Reads gateway/provider state (no direct Baileys socket from the CLI).
In chat, send `/status` to see if the agent is reachable, how much context the session has used, and the current thinking/verbose toggles—no agent call required.
`/status` also shows whether your WhatsApp web session is linked and how long ago the creds were refreshed so you know when to re-scan the QR.
### Sessions, surfaces, and WebChat
- Direct chats now share a canonical session key `main` by default (configurable via `inbound.reply.session.mainKey`). Groups stay isolated as `group:<jid>`.
- WebChat always attaches to the `main` session and hydrates the full Tau history from `~/.clawdis/sessions/<SessionId>.jsonl`, so desktop view mirrors WhatsApp/Telegram turns.
- Inbound contexts carry a `Surface` hint (e.g., `whatsapp`, `webchat`, `telegram`) for logging; replies still go back to the originating surface deterministically.
- Every inbound message is wrapped for the agent as `[Surface FROM HOST/IP TIMESTAMP] body`:
- WhatsApp: `[WhatsApp +15551234567 2025-12-09 12:34] …`
- Telegram: `[Telegram Ada Lovelace (@ada_bot) id:123456789 2025-12-09 12:34] …`
- WebChat: `[WebChat my-mac.local 10.0.0.5 2025-12-09 12:34] …`
This keeps the model aware of the transport, sender, host, and time without relying on implicit context.
## Credits
- **Peter Steinberger** ([@steipete](https://twitter.com/steipete)) — Creator
- **Mario Zechner** ([@badlogicgames](https://twitter.com/badlogicgames)) — Tau/Pi, security testing
- **Clawd** 🦞 — The space lobster who demanded a better name
## License
MIT — Free as a lobster in the ocean.
---
*"We're all just playing with our own prompts."*
🦞💙
+54
View File
@@ -0,0 +1,54 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build-and-test:
runs-on: macos-latest
defaults:
run:
shell: bash
working-directory: swabble
steps:
- name: Checkout swabble
uses: actions/checkout@v4
with:
path: swabble
- name: Select Xcode 26.1 (prefer 26.1.1)
run: |
set -euo pipefail
# pick the newest installed 26.1.x, fallback to newest 26.x
CANDIDATE="$(ls -d /Applications/Xcode_26.1*.app 2>/dev/null | sort -V | tail -1 || true)"
if [[ -z "$CANDIDATE" ]]; then
CANDIDATE="$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1 || true)"
fi
if [[ -z "$CANDIDATE" ]]; then
echo "No Xcode 26.x found on runner" >&2
exit 1
fi
echo "Selecting $CANDIDATE"
sudo xcode-select -s "$CANDIDATE"
xcodebuild -version
- name: Show Swift version
run: swift --version
- name: Install tooling
run: |
brew update
brew install swiftlint swiftformat
- name: Format check
run: |
./scripts/format.sh
git diff --exit-code
- name: Lint
run: ./scripts/lint.sh
- name: Test
run: swift test --parallel
+33
View File
@@ -0,0 +1,33 @@
# macOS
.DS_Store
# SwiftPM / Build
/.build
/.swiftpm
/DerivedData
xcuserdata/
*.xcuserstate
# Editors
/.vscode
.idea/
# Xcode artifacts
*.hmap
*.ipa
*.dSYM.zip
*.dSYM
# Playgrounds
*.xcplayground
playground.xcworkspace
timeline.xctimeline
# Carthage
Carthage/Build/
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
+8
View File
@@ -0,0 +1,8 @@
--swiftversion 6.2
--indent 4
--maxwidth 120
--wraparguments before-first
--wrapcollections before-first
--stripunusedargs closure-only
--self remove
--header ""
+43
View File
@@ -0,0 +1,43 @@
# SwiftLint for swabble
included:
- Sources
excluded:
- .build
- DerivedData
- "**/.swiftpm"
- "**/.build"
- "**/DerivedData"
- "**/.DS_Store"
opt_in_rules:
- array_init
- closure_spacing
- explicit_init
- fatal_error_message
- first_where
- joined_default_parameter
- last_where
- literal_expression_end_indentation
- multiline_arguments
- multiline_parameters
- operator_usage_whitespace
- redundant_nil_coalescing
- sorted_first_last
- switch_case_alignment
- vertical_parameter_alignment_on_call
- vertical_whitespace_opening_braces
- vertical_whitespace_closing_braces
disabled_rules:
- trailing_whitespace
- trailing_newline
- indentation_width
- identifier_name
- explicit_self
- file_header
- todo
line_length:
warning: 140
error: 180
reporter: "xcode"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Peter Steinberger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+33
View File
@@ -0,0 +1,33 @@
{
"originHash" : "3018b2c8c183d55b57ad0c4526b2380ac3b957d13a3a86e1b2845e81323c443a",
"pins" : [
{
"identity" : "commander",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander.git",
"state" : {
"revision" : "8b8cb4f34315ce9e5307b3a2bcd77ff73f586a02",
"version" : "0.2.0"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax.git",
"state" : {
"revision" : "0687f71944021d616d34d922343dcef086855920",
"version" : "600.0.1"
}
},
{
"identity" : "swift-testing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-testing",
"state" : {
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
"version" : "0.99.0"
}
}
],
"version" : 3
}
+36
View File
@@ -0,0 +1,36 @@
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "swabble",
platforms: [
.macOS(.v26),
],
products: [
.library(name: "Swabble", targets: ["Swabble"]),
.executable(name: "swabble", targets: ["SwabbleCLI"]),
],
dependencies: [
.package(url: "https://github.com/steipete/Commander.git", from: "0.2.0"),
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
],
targets: [
.target(
name: "Swabble",
path: "Sources/SwabbleCore",
swiftSettings: []),
.executableTarget(
name: "SwabbleCLI",
dependencies: [
"Swabble",
.product(name: "Commander", package: "Commander"),
],
path: "Sources/swabble"),
.testTarget(
name: "swabbleTests",
dependencies: [
"Swabble",
.product(name: "Testing", package: "swift-testing"),
]),
],
swiftLanguageModes: [.v6])
+107
View File
@@ -0,0 +1,107 @@
# 🎙️ swabble — Speech.framework wake-word hook daemon (macOS 26)
swabble is a Swift 6.2, macOS 26-only rewrite of the brabble voice daemon. It listens on your mic, gates on a wake word, transcribes locally using Apple's new SpeechAnalyzer + SpeechTranscriber, then fires a shell hook with the transcript. No cloud calls, no Whisper binaries.
- **Local-only**: Speech.framework on-device models; zero network usage.
- **Wake word**: Default `clawd` (aliases `claude`), optional `--no-wake` bypass.
- **Hooks**: Run any command with prefix/env, cooldown, min_chars, timeout.
- **Services**: launchd helper stubs for start/stop/install.
- **File transcribe**: TXT or SRT with time ranges (using AttributedString splits).
## Quick start
```bash
# Install deps
brew install swiftformat swiftlint
# Build
swift build
# Write default config (~/.config/swabble/config.json)
swift run swabble setup
# Run foreground daemon
swift run swabble serve
# Test your hook
swift run swabble test-hook "hello world"
# Transcribe a file to SRT
swift run swabble transcribe /path/to/audio.m4a --format srt --output out.srt
```
## Use as a library
Add swabble as a SwiftPM dependency and import the `Swabble` product to reuse the Speech pipeline, config loader, hook executor, and transcript store in your own app:
```swift
// Package.swift
dependencies: [
.package(url: "https://github.com/steipete/swabble.git", branch: "main"),
],
targets: [
.target(name: "MyApp", dependencies: [.product(name: "Swabble", package: "swabble")]),
]
```
## CLI
- `serve` — foreground loop (mic → wake → hook)
- `transcribe <file>` — offline transcription (txt|srt)
- `test-hook "text"` — invoke configured hook
- `mic list|set <index>` — enumerate/select input device
- `setup` — write default config JSON
- `doctor` — check Speech auth & device availability
- `health` — prints `ok`
- `tail-log` — last 10 transcripts
- `status` — show wake state + recent transcripts
- `service install|uninstall|status` — user launchd plist (stub: prints launchctl commands)
- `start|stop|restart` — placeholders until full launchd wiring
All commands accept Commander runtime flags (`-v/--verbose`, `--json-output`, `--log-level`), plus `--config` where applicable.
## Config
`~/.config/swabble/config.json` (auto-created by `setup`):
```json
{
"audio": {"deviceName": "", "deviceIndex": -1, "sampleRate": 16000, "channels": 1},
"wake": {"enabled": true, "word": "clawd", "aliases": ["claude"]},
"hook": {
"command": "",
"args": [],
"prefix": "Voice swabble from ${hostname}: ",
"cooldownSeconds": 1,
"minCharacters": 24,
"timeoutSeconds": 5,
"env": {}
},
"logging": {"level": "info", "format": "text"},
"transcripts": {"enabled": true, "maxEntries": 50},
"speech": {"localeIdentifier": "en_US", "etiquetteReplacements": false}
}
```
- Config path override: `--config /path/to/config.json` on relevant commands.
- Transcripts persist to `~/Library/Application Support/swabble/transcripts.log`.
## Hook protocol
When a wake-gated transcript passes min_chars & cooldown, swabble runs:
```
<command> <args...> "<prefix><text>"
```
Environment variables:
- `SWABBLE_TEXT` — stripped transcript (wake word removed)
- `SWABBLE_PREFIX` — rendered prefix (hostname substituted)
- plus any `hook.env` key/values
## Speech pipeline
- `AVAudioEngine` tap → `BufferConverter` → `AnalyzerInput` → `SpeechAnalyzer` with a `SpeechTranscriber` module.
- Requests volatile + final results; wake gating is string match on partial/final.
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
## Development
- Format: `./scripts/format.sh` (uses ../peekaboo/.swiftformat if present)
- Lint: `./scripts/lint.sh` (uses ../peekaboo/.swiftlint.yml if present)
- Tests: `swift test` (uses swift-testing package)
## Roadmap
- launchd control (load/bootout, PID + status socket)
- JSON logging + PII redaction toggle
- Stronger wake-word detection and control socket status/health
@@ -0,0 +1,77 @@
import Foundation
public struct SwabbleConfig: Codable, Sendable {
public struct Audio: Codable, Sendable {
public var deviceName: String = ""
public var deviceIndex: Int = -1
public var sampleRate: Double = 16000
public var channels: Int = 1
}
public struct Wake: Codable, Sendable {
public var enabled: Bool = true
public var word: String = "clawd"
public var aliases: [String] = ["claude"]
}
public struct Hook: Codable, Sendable {
public var command: String = ""
public var args: [String] = []
public var prefix: String = "Voice swabble from ${hostname}: "
public var cooldownSeconds: Double = 1
public var minCharacters: Int = 24
public var timeoutSeconds: Double = 5
public var env: [String: String] = [:]
}
public struct Logging: Codable, Sendable {
public var level: String = "info"
public var format: String = "text" // text|json placeholder
}
public struct Transcripts: Codable, Sendable {
public var enabled: Bool = true
public var maxEntries: Int = 50
}
public struct Speech: Codable, Sendable {
public var localeIdentifier: String = Locale.current.identifier
public var etiquetteReplacements: Bool = false
}
public var audio = Audio()
public var wake = Wake()
public var hook = Hook()
public var logging = Logging()
public var transcripts = Transcripts()
public var speech = Speech()
public static let defaultPath = FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent(".config/swabble/config.json")
public init() {}
}
public enum ConfigError: Error {
case missingConfig
}
public enum ConfigLoader {
public static func load(at path: URL?) throws -> SwabbleConfig {
let url = path ?? SwabbleConfig.defaultPath
if !FileManager.default.fileExists(atPath: url.path) {
throw ConfigError.missingConfig
}
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(SwabbleConfig.self, from: data)
}
public static func save(_ config: SwabbleConfig, at path: URL?) throws {
let url = path ?? SwabbleConfig.defaultPath
let dir = url.deletingLastPathComponent()
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let data = try JSONEncoder().encode(config)
try data.write(to: url)
}
}
@@ -0,0 +1,75 @@
import Foundation
public struct HookJob: Sendable {
public let text: String
public let timestamp: Date
public init(text: String, timestamp: Date) {
self.text = text
self.timestamp = timestamp
}
}
public actor HookExecutor {
private let config: SwabbleConfig
private var lastRun: Date?
private let hostname: String
public init(config: SwabbleConfig) {
self.config = config
hostname = Host.current().localizedName ?? "host"
}
public func shouldRun() -> Bool {
guard config.hook.cooldownSeconds > 0 else { return true }
if let lastRun, Date().timeIntervalSince(lastRun) < config.hook.cooldownSeconds {
return false
}
return true
}
public func run(job: HookJob) async throws {
guard shouldRun() else { return }
guard !config.hook.command.isEmpty else { throw NSError(
domain: "Hook",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "hook command not set"]) }
let prefix = config.hook.prefix.replacingOccurrences(of: "${hostname}", with: hostname)
let payload = prefix + job.text
let process = Process()
process.executableURL = URL(fileURLWithPath: config.hook.command)
process.arguments = config.hook.args + [payload]
var env = ProcessInfo.processInfo.environment
env["SWABBLE_TEXT"] = job.text
env["SWABBLE_PREFIX"] = prefix
for (k, v) in config.hook.env {
env[k] = v
}
process.environment = env
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
try process.run()
let timeoutNanos = UInt64(max(config.hook.timeoutSeconds, 0.1) * 1_000_000_000)
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
process.waitUntilExit()
}
group.addTask {
try await Task.sleep(nanoseconds: timeoutNanos)
if process.isRunning {
process.terminate()
}
}
try await group.next()
group.cancelAll()
}
lastRun = Date()
}
}
@@ -0,0 +1,50 @@
@preconcurrency import AVFoundation
import Foundation
final class BufferConverter {
private final class Box<T>: @unchecked Sendable { var value: T; init(_ value: T) { self.value = value } }
enum ConverterError: Swift.Error {
case failedToCreateConverter
case failedToCreateConversionBuffer
case conversionFailed(NSError?)
}
private var converter: AVAudioConverter?
func convert(_ buffer: AVAudioPCMBuffer, to format: AVAudioFormat) throws -> AVAudioPCMBuffer {
let inputFormat = buffer.format
if inputFormat == format {
return buffer
}
if converter == nil || converter?.outputFormat != format {
converter = AVAudioConverter(from: inputFormat, to: format)
converter?.primeMethod = .none
}
guard let converter else { throw ConverterError.failedToCreateConverter }
let sampleRateRatio = converter.outputFormat.sampleRate / converter.inputFormat.sampleRate
let scaledInputFrameLength = Double(buffer.frameLength) * sampleRateRatio
let frameCapacity = AVAudioFrameCount(scaledInputFrameLength.rounded(.up))
guard let conversionBuffer = AVAudioPCMBuffer(pcmFormat: converter.outputFormat, frameCapacity: frameCapacity)
else {
throw ConverterError.failedToCreateConversionBuffer
}
var nsError: NSError?
let consumed = Box(false)
let inputBuffer = buffer
let status = converter.convert(to: conversionBuffer, error: &nsError) { _, statusPtr in
if consumed.value {
statusPtr.pointee = .noDataNow
return nil
}
consumed.value = true
statusPtr.pointee = .haveData
return inputBuffer
}
if status == .error {
throw ConverterError.conversionFailed(nsError)
}
return conversionBuffer
}
}
@@ -0,0 +1,111 @@
import AVFoundation
import Foundation
import Speech
public struct SpeechSegment: Sendable {
public let text: String
public let isFinal: Bool
}
public enum SpeechPipelineError: Error {
case authorizationDenied
case analyzerFormatUnavailable
case transcriberUnavailable
}
/// Live microphone → SpeechAnalyzer → SpeechTranscriber pipeline.
public actor SpeechPipeline {
private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer }
private var engine = AVAudioEngine()
private var transcriber: SpeechTranscriber?
private var analyzer: SpeechAnalyzer?
private var inputContinuation: AsyncStream<AnalyzerInput>.Continuation?
private var resultTask: Task<Void, Never>?
private let converter = BufferConverter()
public init() {}
public func start(localeIdentifier: String, etiquette: Bool) async throws -> AsyncStream<SpeechSegment> {
let auth = await requestAuthorizationIfNeeded()
guard auth == .authorized else { throw SpeechPipelineError.authorizationDenied }
let transcriberModule = SpeechTranscriber(
locale: Locale(identifier: localeIdentifier),
transcriptionOptions: etiquette ? [.etiquetteReplacements] : [],
reportingOptions: [.volatileResults],
attributeOptions: [])
transcriber = transcriberModule
guard let analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriberModule])
else {
throw SpeechPipelineError.analyzerFormatUnavailable
}
analyzer = SpeechAnalyzer(modules: [transcriberModule])
let (stream, continuation) = AsyncStream<AnalyzerInput>.makeStream()
inputContinuation = continuation
let inputNode = engine.inputNode
let inputFormat = inputNode.outputFormat(forBus: 0)
inputNode.removeTap(onBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 2048, format: inputFormat) { [weak self] buffer, _ in
guard let self else { return }
let boxed = UnsafeBuffer(buffer: buffer)
Task { await self.handleBuffer(boxed.buffer, targetFormat: analyzerFormat) }
}
engine.prepare()
try engine.start()
try await analyzer?.start(inputSequence: stream)
guard let transcriberForStream = transcriber else {
throw SpeechPipelineError.transcriberUnavailable
}
return AsyncStream { continuation in
self.resultTask = Task {
do {
for try await result in transcriberForStream.results {
let seg = SpeechSegment(text: String(result.text.characters), isFinal: result.isFinal)
continuation.yield(seg)
}
} catch {
// swallow errors and finish
}
continuation.finish()
}
continuation.onTermination = { _ in
Task { await self.stop() }
}
}
}
public func stop() async {
resultTask?.cancel()
inputContinuation?.finish()
engine.inputNode.removeTap(onBus: 0)
engine.stop()
try? await analyzer?.finalizeAndFinishThroughEndOfInput()
}
private func handleBuffer(_ buffer: AVAudioPCMBuffer, targetFormat: AVAudioFormat) async {
do {
let converted = try converter.convert(buffer, to: targetFormat)
let input = AnalyzerInput(buffer: converted)
inputContinuation?.yield(input)
} catch {
// drop on conversion failure
}
}
private func requestAuthorizationIfNeeded() async -> SFSpeechRecognizerAuthorizationStatus {
let current = SFSpeechRecognizer.authorizationStatus()
guard current == .notDetermined else { return current }
return await withCheckedContinuation { continuation in
SFSpeechRecognizer.requestAuthorization { status in
continuation.resume(returning: status)
}
}
}
}
@@ -0,0 +1,63 @@
import CoreMedia
import Foundation
import NaturalLanguage
extension AttributedString {
public func sentences(maxLength: Int? = nil) -> [AttributedString] {
let tokenizer = NLTokenizer(unit: .sentence)
let string = String(characters)
tokenizer.string = string
let sentenceRanges = tokenizer.tokens(for: string.startIndex..<string.endIndex).map {
(
$0,
AttributedString.Index($0.lowerBound, within: self)!
..<
AttributedString.Index($0.upperBound, within: self)!)
}
let ranges = sentenceRanges.flatMap { sentenceStringRange, sentenceRange in
let sentence = self[sentenceRange]
guard let maxLength, sentence.characters.count > maxLength else {
return [sentenceRange]
}
let wordTokenizer = NLTokenizer(unit: .word)
wordTokenizer.string = string
var wordRanges = wordTokenizer.tokens(for: sentenceStringRange).map {
AttributedString.Index($0.lowerBound, within: self)!
..<
AttributedString.Index($0.upperBound, within: self)!
}
guard !wordRanges.isEmpty else { return [sentenceRange] }
wordRanges[0] = sentenceRange.lowerBound..<wordRanges[0].upperBound
wordRanges[wordRanges.count - 1] = wordRanges[wordRanges.count - 1].lowerBound..<sentenceRange.upperBound
var ranges: [Range<AttributedString.Index>] = []
for wordRange in wordRanges {
if let lastRange = ranges.last,
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength
{
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
} else {
ranges.append(wordRange)
}
}
return ranges
}
return ranges.compactMap { range in
let audioTimeRanges = self[range].runs.filter {
!String(self[$0.range].characters)
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}.compactMap(\.audioTimeRange)
guard !audioTimeRanges.isEmpty else { return nil }
let start = audioTimeRanges.first!.start
let end = audioTimeRanges.last!.end
var attributes = AttributeContainer()
attributes[AttributeScopes.SpeechAttributes.TimeRangeAttribute.self] = CMTimeRange(
start: start,
end: end)
return AttributedString(self[range].characters, attributes: attributes)
}
}
}
@@ -0,0 +1,41 @@
import Foundation
public enum LogLevel: String, Comparable, CaseIterable, Sendable {
case trace, debug, info, warn, error
var rank: Int {
switch self {
case .trace: 0
case .debug: 1
case .info: 2
case .warn: 3
case .error: 4
}
}
public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { lhs.rank < rhs.rank }
}
public struct Logger: Sendable {
public let level: LogLevel
public init(level: LogLevel) { self.level = level }
public func log(_ level: LogLevel, _ message: String) {
guard level >= self.level else { return }
let ts = ISO8601DateFormatter().string(from: Date())
print("[\(level.rawValue.uppercased())] \(ts) | \(message)")
}
public func trace(_ msg: String) { log(.trace, msg) }
public func debug(_ msg: String) { log(.debug, msg) }
public func info(_ msg: String) { log(.info, msg) }
public func warn(_ msg: String) { log(.warn, msg) }
public func error(_ msg: String) { log(.error, msg) }
}
extension LogLevel {
public init?(configValue: String) {
self.init(rawValue: configValue.lowercased())
}
}
@@ -0,0 +1,45 @@
import CoreMedia
import Foundation
public enum OutputFormat: String {
case txt
case srt
public var needsAudioTimeRange: Bool {
switch self {
case .srt: true
default: false
}
}
public func text(for transcript: AttributedString, maxLength: Int) -> String {
switch self {
case .txt:
return String(transcript.characters)
case .srt:
func format(_ timeInterval: TimeInterval) -> String {
let ms = Int(timeInterval.truncatingRemainder(dividingBy: 1) * 1000)
let s = Int(timeInterval) % 60
let m = (Int(timeInterval) / 60) % 60
let h = Int(timeInterval) / 60 / 60
return String(format: "%0.2d:%0.2d:%0.2d,%0.3d", h, m, s, ms)
}
return transcript.sentences(maxLength: maxLength).compactMap { (sentence: AttributedString) -> (
CMTimeRange,
String)? in
guard let timeRange = sentence.audioTimeRange else { return nil }
return (timeRange, String(sentence.characters))
}.enumerated().map { index, run in
let (timeRange, text) = run
return """
\(index + 1)
\(format(timeRange.start.seconds)) --> \(format(timeRange.end.seconds))
\(text.trimmingCharacters(in: .whitespacesAndNewlines))
"""
}.joined().trimmingCharacters(in: .whitespacesAndNewlines)
}
}
}
@@ -0,0 +1,46 @@
import Foundation
public actor TranscriptsStore {
public static let shared = TranscriptsStore()
private var entries: [String] = []
private let limit = 100
private let fileURL: URL
public init() {
let dir = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/swabble", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
fileURL = dir.appendingPathComponent("transcripts.log")
if let data = try? Data(contentsOf: fileURL),
let text = String(data: data, encoding: .utf8)
{
entries = text.split(separator: "\n").map(String.init).suffix(limit)
}
}
public func append(text: String) {
entries.append(text)
if entries.count > limit {
entries.removeFirst(entries.count - limit)
}
let body = entries.joined(separator: "\n")
try? body.write(to: fileURL, atomically: false, encoding: .utf8)
}
public func latest() -> [String] { entries }
}
extension String {
private func appendLine(to url: URL) throws {
let data = (self + "\n").data(using: .utf8) ?? Data()
if FileManager.default.fileExists(atPath: url.path) {
let handle = try FileHandle(forWritingTo: url)
try handle.seekToEnd()
try handle.write(contentsOf: data)
try handle.close()
} else {
try data.write(to: url)
}
}
}
@@ -0,0 +1,70 @@
import Commander
import Foundation
@MainActor
enum CLIRegistry {
static var descriptors: [CommandDescriptor] {
let serveDesc = descriptor(for: ServeCommand.self)
let transcribeDesc = descriptor(for: TranscribeCommand.self)
let testHookDesc = descriptor(for: TestHookCommand.self)
let micList = descriptor(for: MicList.self)
let micSet = descriptor(for: MicSet.self)
let micRoot = CommandDescriptor(
name: "mic",
abstract: "Microphone management",
discussion: nil,
signature: CommandSignature(),
subcommands: [micList, micSet])
let serviceRoot = CommandDescriptor(
name: "service",
abstract: "launchd helper",
discussion: nil,
signature: CommandSignature(),
subcommands: [
descriptor(for: ServiceInstall.self),
descriptor(for: ServiceUninstall.self),
descriptor(for: ServiceStatus.self),
])
let doctorDesc = descriptor(for: DoctorCommand.self)
let setupDesc = descriptor(for: SetupCommand.self)
let healthDesc = descriptor(for: HealthCommand.self)
let tailLogDesc = descriptor(for: TailLogCommand.self)
let startDesc = descriptor(for: StartCommand.self)
let stopDesc = descriptor(for: StopCommand.self)
let restartDesc = descriptor(for: RestartCommand.self)
let statusDesc = descriptor(for: StatusCommand.self)
let rootSignature = CommandSignature().withStandardRuntimeFlags()
let root = CommandDescriptor(
name: "swabble",
abstract: "Speech hook daemon",
discussion: "Local wake-word → SpeechTranscriber → hook",
signature: rootSignature,
subcommands: [
serveDesc,
transcribeDesc,
testHookDesc,
micRoot,
serviceRoot,
doctorDesc,
setupDesc,
healthDesc,
tailLogDesc,
startDesc,
stopDesc,
restartDesc,
statusDesc,
])
return [root]
}
private static func descriptor(for type: any ParsableCommand.Type) -> CommandDescriptor {
let sig = CommandSignature.describe(type.init()).withStandardRuntimeFlags()
return CommandDescriptor(
name: type.commandDescription.commandName ?? "",
abstract: type.commandDescription.abstract,
discussion: type.commandDescription.discussion,
signature: sig,
subcommands: [])
}
}
@@ -0,0 +1,37 @@
import Commander
import Foundation
import Speech
import Swabble
@MainActor
struct DoctorCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "doctor", abstract: "Check Speech permission and config")
}
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
init() {}
init(parsed: ParsedValues) {
self.init()
if let cfg = parsed.options["config"]?.last { configPath = cfg }
}
mutating func run() async throws {
let auth = await SFSpeechRecognizer.authorizationStatus()
print("Speech auth: \(auth)")
do {
_ = try ConfigLoader.load(at: configURL)
print("Config: OK")
} catch {
print("Config missing or invalid; run setup")
}
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: [.microphone, .external],
mediaType: .audio,
position: .unspecified)
print("Mics found: \(session.devices.count)")
}
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
}
@@ -0,0 +1,16 @@
import Commander
import Foundation
@MainActor
struct HealthCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "health", abstract: "Health probe")
}
init() {}
init(parsed: ParsedValues) {}
mutating func run() async throws {
print("ok")
}
}
@@ -0,0 +1,62 @@
import AVFoundation
import Commander
import Foundation
import Swabble
@MainActor
struct MicCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(
commandName: "mic",
abstract: "Microphone management",
subcommands: [MicList.self, MicSet.self])
}
}
@MainActor
struct MicList: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "list", abstract: "List input devices")
}
init() {}
init(parsed: ParsedValues) {}
mutating func run() async throws {
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: [.microphone, .external],
mediaType: .audio,
position: .unspecified)
let devices = session.devices
if devices.isEmpty { print("no audio inputs found"); return }
for (idx, device) in devices.enumerated() {
print("[\(idx)] \(device.localizedName)")
}
}
}
@MainActor
struct MicSet: ParsableCommand {
@Argument(help: "Device index from list") var index: Int = 0
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
static var commandDescription: CommandDescription {
CommandDescription(commandName: "set", abstract: "Set default input device index")
}
init() {}
init(parsed: ParsedValues) {
self.init()
if let value = parsed.positional.first, let intVal = Int(value) { index = intVal }
if let cfg = parsed.options["config"]?.last { configPath = cfg }
}
mutating func run() async throws {
var cfg = try ConfigLoader.load(at: configURL)
cfg.audio.deviceIndex = index
try ConfigLoader.save(cfg, at: configURL)
print("saved device index \(index)")
}
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
}
@@ -0,0 +1,84 @@
import Commander
import Foundation
import Swabble
@MainActor
struct ServeCommand: ParsableCommand {
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
@Flag(name: .long("no-wake"), help: "Disable wake word") var noWake: Bool = false
static var commandDescription: CommandDescription {
CommandDescription(
commandName: "serve",
abstract: "Run swabble in the foreground")
}
init() {}
init(parsed: ParsedValues) {
self.init()
if parsed.flags.contains("noWake") { noWake = true }
if let cfg = parsed.options["config"]?.last { configPath = cfg }
}
mutating func run() async throws {
var cfg: SwabbleConfig
do {
cfg = try ConfigLoader.load(at: configURL)
} catch {
cfg = SwabbleConfig()
try ConfigLoader.save(cfg, at: configURL)
}
if noWake {
cfg.wake.enabled = false
}
let logger = Logger(level: LogLevel(configValue: cfg.logging.level) ?? .info)
logger.info("swabble serve starting (wake: \(cfg.wake.enabled ? cfg.wake.word : "disabled"))")
let pipeline = SpeechPipeline()
do {
let stream = try await pipeline.start(
localeIdentifier: cfg.speech.localeIdentifier,
etiquette: cfg.speech.etiquetteReplacements)
for await seg in stream {
if cfg.wake.enabled {
guard Self.matchesWake(text: seg.text, cfg: cfg) else { continue }
}
let stripped = Self.stripWake(text: seg.text, cfg: cfg)
let job = HookJob(text: stripped, timestamp: Date())
let executor = HookExecutor(config: cfg)
try await executor.run(job: job)
if cfg.transcripts.enabled {
await TranscriptsStore.shared.append(text: stripped)
}
if seg.isFinal {
logger.info("final: \(stripped)")
} else {
logger.debug("partial: \(stripped)")
}
}
} catch {
logger.error("serve error: \(error)")
throw error
}
}
private var configURL: URL? {
configPath.map { URL(fileURLWithPath: $0) }
}
private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool {
let lowered = text.lowercased()
if lowered.contains(cfg.wake.word.lowercased()) { return true }
return cfg.wake.aliases.contains(where: { lowered.contains($0.lowercased()) })
}
private static func stripWake(text: String, cfg: SwabbleConfig) -> String {
var out = text
out = out.replacingOccurrences(of: cfg.wake.word, with: "", options: [.caseInsensitive])
for alias in cfg.wake.aliases {
out = out.replacingOccurrences(of: alias, with: "", options: [.caseInsensitive])
}
return out.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
@@ -0,0 +1,77 @@
import Commander
import Foundation
@MainActor
struct ServiceRootCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(
commandName: "service",
abstract: "Manage launchd agent",
subcommands: [ServiceInstall.self, ServiceUninstall.self, ServiceStatus.self])
}
}
private enum LaunchdHelper {
static let label = "com.swabble.agent"
static var plistURL: URL {
FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(label).plist")
}
static func writePlist(executable: String) throws {
let plist: [String: Any] = [
"Label": label,
"ProgramArguments": [executable, "serve"],
"RunAtLoad": true,
"KeepAlive": true,
]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: plistURL)
}
static func removePlist() throws {
try? FileManager.default.removeItem(at: plistURL)
}
}
@MainActor
struct ServiceInstall: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "install", abstract: "Install user launch agent")
}
mutating func run() async throws {
let exe = CommandLine.arguments.first ?? "/usr/local/bin/swabble"
try LaunchdHelper.writePlist(executable: exe)
print("launchctl load -w \(LaunchdHelper.plistURL.path)")
}
}
@MainActor
struct ServiceUninstall: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "uninstall", abstract: "Remove launch agent")
}
mutating func run() async throws {
try LaunchdHelper.removePlist()
print("launchctl bootout gui/$(id -u)/\(LaunchdHelper.label)")
}
}
@MainActor
struct ServiceStatus: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "status", abstract: "Show launch agent status")
}
mutating func run() async throws {
if FileManager.default.fileExists(atPath: LaunchdHelper.plistURL.path) {
print("plist present at \(LaunchdHelper.plistURL.path)")
} else {
print("launchd plist not installed")
}
}
}
@@ -0,0 +1,26 @@
import Commander
import Foundation
import Swabble
@MainActor
struct SetupCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "setup", abstract: "Write default config")
}
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
init() {}
init(parsed: ParsedValues) {
self.init()
if let cfg = parsed.options["config"]?.last { configPath = cfg }
}
mutating func run() async throws {
let cfg = SwabbleConfig()
try ConfigLoader.save(cfg, at: configURL)
print("wrote config to \(configURL?.path ?? SwabbleConfig.defaultPath.path)")
}
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
}
@@ -0,0 +1,35 @@
import Commander
import Foundation
@MainActor
struct StartCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "start", abstract: "Start swabble (foreground placeholder)")
}
mutating func run() async throws {
print("start: launchd helper not implemented; run 'swabble serve' instead")
}
}
@MainActor
struct StopCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "stop", abstract: "Stop swabble (placeholder)")
}
mutating func run() async throws {
print("stop: launchd helper not implemented yet")
}
}
@MainActor
struct RestartCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "restart", abstract: "Restart swabble (placeholder)")
}
mutating func run() async throws {
print("restart: launchd helper not implemented yet")
}
}
@@ -0,0 +1,34 @@
import Commander
import Foundation
import Swabble
@MainActor
struct StatusCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "status", abstract: "Show daemon state")
}
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
init() {}
init(parsed: ParsedValues) {
self.init()
if let cfg = parsed.options["config"]?.last { configPath = cfg }
}
mutating func run() async throws {
let cfg = try? ConfigLoader.load(at: configURL)
let wake = cfg?.wake.word ?? "clawd"
let wakeEnabled = cfg?.wake.enabled ?? false
let latest = await TranscriptsStore.shared.latest().suffix(3)
print("wake: \(wakeEnabled ? wake : "disabled")")
if latest.isEmpty {
print("transcripts: (none yet)")
} else {
print("last transcripts:")
latest.forEach { print("- \($0)") }
}
}
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
}
@@ -0,0 +1,20 @@
import Commander
import Foundation
import Swabble
@MainActor
struct TailLogCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "tail-log", abstract: "Tail recent transcripts")
}
init() {}
init(parsed: ParsedValues) {}
mutating func run() async throws {
let latest = await TranscriptsStore.shared.latest()
for line in latest.suffix(10) {
print(line)
}
}
}
@@ -0,0 +1,30 @@
import Commander
import Foundation
import Swabble
@MainActor
struct TestHookCommand: ParsableCommand {
@Argument(help: "Text to send to hook") var text: String
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
static var commandDescription: CommandDescription {
CommandDescription(commandName: "test-hook", abstract: "Invoke the configured hook with text")
}
init() {}
init(parsed: ParsedValues) {
self.init()
if let positional = parsed.positional.first { text = positional }
if let cfg = parsed.options["config"]?.last { configPath = cfg }
}
mutating func run() async throws {
let cfg = try ConfigLoader.load(at: configURL)
let executor = HookExecutor(config: cfg)
try await executor.run(job: HookJob(text: text, timestamp: Date()))
print("hook invoked")
}
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
}
@@ -0,0 +1,61 @@
import AVFoundation
import Commander
import Foundation
import Speech
import Swabble
@MainActor
struct TranscribeCommand: ParsableCommand {
@Argument(help: "Path to audio/video file") var inputFile: String = ""
@Option(name: .long("locale"), help: "Locale identifier", parsing: .singleValue) var locale: String = Locale.current
.identifier
@Flag(help: "Censor etiquette-sensitive content") var censor: Bool = false
@Option(name: .long("output"), help: "Output file path") var outputFile: String?
@Option(name: .long("format"), help: "Output format txt|srt") var format: String = "txt"
@Option(name: .long("max-length"), help: "Max sentence length for srt") var maxLength: Int = 40
static var commandDescription: CommandDescription {
CommandDescription(
commandName: "transcribe",
abstract: "Transcribe a media file locally")
}
init() {}
init(parsed: ParsedValues) {
self.init()
if let positional = parsed.positional.first { inputFile = positional }
if let loc = parsed.options["locale"]?.last { locale = loc }
if parsed.flags.contains("censor") { censor = true }
if let out = parsed.options["output"]?.last { outputFile = out }
if let fmt = parsed.options["format"]?.last { format = fmt }
if let len = parsed.options["maxLength"]?.last, let intVal = Int(len) { maxLength = intVal }
}
mutating func run() async throws {
let fileURL = URL(fileURLWithPath: inputFile)
let audioFile = try AVAudioFile(forReading: fileURL)
let outputFormat = OutputFormat(rawValue: format) ?? .txt
let transcriber = SpeechTranscriber(
locale: Locale(identifier: locale),
transcriptionOptions: censor ? [.etiquetteReplacements] : [],
reportingOptions: [],
attributeOptions: outputFormat.needsAudioTimeRange ? [.audioTimeRange] : [])
let analyzer = SpeechAnalyzer(modules: [transcriber])
try await analyzer.start(inputAudioFile: audioFile, finishAfterFile: true)
var transcript: AttributedString = ""
for try await result in transcriber.results {
transcript += result.text
}
let output = outputFormat.text(for: transcript, maxLength: maxLength)
if let path = outputFile {
try output.write(to: URL(fileURLWithPath: path), atomically: false, encoding: .utf8)
} else {
print(output)
}
}
}
+99
View File
@@ -0,0 +1,99 @@
import Commander
import Foundation
@MainActor
private func runCLI() async -> Int32 {
do {
let descriptors = CLIRegistry.descriptors
let program = Program(descriptors: descriptors)
let invocation = try program.resolve(argv: CommandLine.arguments)
try await dispatch(invocation: invocation)
return 0
} catch {
fputs("error: \(error)\n", stderr)
return 1
}
}
@MainActor
private func dispatch(invocation: CommandInvocation) async throws {
let parsed = invocation.parsedValues
let path = invocation.path
guard let first = path.first else { throw CommanderProgramError.missingCommand }
switch first {
case "swabble":
guard path.count >= 2 else { throw CommanderProgramError.missingSubcommand(command: "swabble") }
let sub = path[1]
switch sub {
case "serve":
var cmd = ServeCommand(parsed: parsed)
try await cmd.run()
case "transcribe":
var cmd = TranscribeCommand(parsed: parsed)
try await cmd.run()
case "test-hook":
var cmd = TestHookCommand(parsed: parsed)
try await cmd.run()
case "mic":
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "mic") }
let micSub = path[2]
if micSub == "list" {
var cmd = MicList(parsed: parsed)
try await cmd.run()
} else if micSub == "set" {
var cmd = MicSet(parsed: parsed)
try await cmd.run()
} else {
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
}
case "service":
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "service") }
let svcSub = path[2]
switch svcSub {
case "install":
var cmd = ServiceInstall()
try await cmd.run()
case "uninstall":
var cmd = ServiceUninstall()
try await cmd.run()
case "status":
var cmd = ServiceStatus()
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
}
case "doctor":
var cmd = DoctorCommand(parsed: parsed)
try await cmd.run()
case "setup":
var cmd = SetupCommand(parsed: parsed)
try await cmd.run()
case "health":
var cmd = HealthCommand(parsed: parsed)
try await cmd.run()
case "tail-log":
var cmd = TailLogCommand(parsed: parsed)
try await cmd.run()
case "start":
var cmd = StartCommand()
try await cmd.run()
case "stop":
var cmd = StopCommand()
try await cmd.run()
case "restart":
var cmd = RestartCommand()
try await cmd.run()
case "status":
var cmd = StatusCommand()
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
}
default:
throw CommanderProgramError.unknownCommand(first)
}
}
let exitCode = await runCLI()
exit(exitCode)
@@ -0,0 +1,23 @@
import Foundation
import Testing
@testable import Swabble
@Test
func configRoundTrip() throws {
var cfg = SwabbleConfig()
cfg.wake.word = "robot"
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".json")
defer { try? FileManager.default.removeItem(at: url) }
try ConfigLoader.save(cfg, at: url)
let loaded = try ConfigLoader.load(at: url)
#expect(loaded.wake.word == "robot")
#expect(loaded.hook.prefix.contains("Voice swabble"))
}
@Test
func configMissingThrows() {
#expect(throws: ConfigError.missingConfig) {
_ = try ConfigLoader.load(at: FileManager.default.temporaryDirectory.appendingPathComponent("nope.json"))
}
}
+32
View File
@@ -0,0 +1,32 @@
# swabble — macOS 26 speech hook daemon (Swift 6.2)
Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript.
## Requirements
- macOS 26+, Swift 6.2, Speech.framework with on-device assets.
- Local only; no network calls during transcription.
- Wake word gating (default "clawd" plus aliases) with bypass flag `--no-wake`.
- Hook execution with cooldown, min_chars, timeout, prefix, env vars.
- Simple config at `~/.config/swabble/config.json` (JSON, Codable) — no TOML.
- CLI implemented with Commander (SwiftPM package `steipete/Commander`); core types are available via the SwiftPM library product `Swabble` for embedding.
- Foreground `serve`; later launchd helper for start/stop/restart.
- File transcription command emitting txt or srt.
- Basic status/health surfaces and mic selection stubs.
## Architecture
- **CLI layer (Commander)**: Root command `swabble` with subcommands `serve`, `transcribe`, `test-hook`, `mic list|set`, `doctor`, `health`, `tail-log`. Runtime flags from Commander (`-v/--verbose`, `--json-output`, `--log-level`). Custom `--config` path applies everywhere.
- **Config**: `SwabbleConfig` Codable. Fields: audio device name/index, wake (enabled/word/aliases/sensitivity placeholder), hook (command/args/prefix/cooldown/min_chars/timeout/env), logging (level, format), transcripts (enabled, max kept), speech (locale, enableEtiquetteReplacements flag). Stored JSON; default written by `setup`.
- **Audio + Speech pipeline**: `SpeechPipeline` wraps `AVAudioEngine` input → `SpeechAnalyzer` with `SpeechTranscriber` module. Emits partial/final transcripts via async stream. Requests `.audioTimeRange` when transcripts enabled. Handles Speech permission and asset download prompts ahead of capture.
- **Wake gate**: text-based keyword match against latest partial/final; strips wake term before hook dispatch. `--no-wake` disables.
- **Hook executor**: async `HookExecutor` spawns `Process` with configured args, prefix substitution `${hostname}`. Enforces cooldown + timeout; injects env `SWABBLE_TEXT`, `SWABBLE_PREFIX` plus user env map.
- **Transcripts store**: in-memory ring buffer; optional persisted JSON lines under `~/Library/Application Support/swabble/transcripts.log`.
- **Logging**: simple structured logger to stderr; respects log level.
## Out of scope (initial cut)
- Model management (Speech handles assets).
- Launchd helper (planned follow-up).
- Advanced wake-word detector (text match only for now).
## Open decisions
- Whether to expose a UNIX control socket for `status`/`health` (currently planned as stdin/out direct calls).
- Hook redaction (PII) parity with brabble — placeholder boolean, no implementation yet.
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PEEKABOO_ROOT="${ROOT}/../peekaboo"
if [ -f "${PEEKABOO_ROOT}/.swiftformat" ]; then
CONFIG="${PEEKABOO_ROOT}/.swiftformat"
else
CONFIG="${ROOT}/.swiftformat"
fi
swiftformat --config "$CONFIG" "$ROOT/Sources"
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PEEKABOO_ROOT="${ROOT}/../peekaboo"
if [ -f "${PEEKABOO_ROOT}/.swiftlint.yml" ]; then
CONFIG="${PEEKABOO_ROOT}/.swiftlint.yml"
else
CONFIG="$ROOT/.swiftlint.yml"
fi
if ! command -v swiftlint >/dev/null; then
echo "swiftlint not installed" >&2
exit 1
fi
swiftlint --config "$CONFIG"
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"
xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>Clawdis Updates</title>
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
<description>Signed update feed for the Clawdis macOS companion app.</description>
</channel>
</rss>
+6
View File
@@ -0,0 +1,6 @@
parent_config: ../../.swiftlint.yml
included:
- Sources
- ../shared/ClawdisNodeKit/Sources
+18
View File
@@ -0,0 +1,18 @@
# ClawdisNode (iOS)
Internal-only SwiftUI app scaffold.
## Lint/format (required)
```bash
brew install swiftformat swiftlint
```
## Generate the Xcode project
```bash
cd apps/ios
xcodegen generate
open ClawdisNode.xcodeproj
```
## Shared packages
- `../shared/ClawdisKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing).
+121
View File
@@ -0,0 +1,121 @@
import ClawdisKit
import Foundation
import Network
actor BridgeClient {
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
func pairAndHello(
endpoint: NWEndpoint,
nodeId: String,
displayName: String?,
platform: String,
version: String,
existingToken: String?) async throws -> String
{
let connection = NWConnection(to: endpoint, using: .tcp)
let queue = DispatchQueue(label: "com.steipete.clawdis.ios.bridge-client")
connection.start(queue: queue)
let token = existingToken
try await self.send(
BridgeHello(
nodeId: nodeId,
displayName: displayName,
token: token,
platform: platform,
version: version),
over: connection)
if let line = try await self.receiveLine(over: connection),
let data = line.data(using: .utf8),
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
{
if base.type == "hello-ok" {
connection.cancel()
return existingToken ?? ""
}
if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
if err.code == "NOT_PAIRED" || err.code == "UNAUTHORIZED" {
try await self.send(
BridgePairRequest(
nodeId: nodeId,
displayName: displayName,
platform: platform,
version: version),
over: connection)
while let next = try await self.receiveLine(over: connection) {
guard let nextData = next.data(using: .utf8) else { continue }
let nextBase = try self.decoder.decode(BridgeBaseFrame.self, from: nextData)
if nextBase.type == "pair-ok" {
let ok = try self.decoder.decode(BridgePairOk.self, from: nextData)
connection.cancel()
return ok.token
}
if nextBase.type == "error" {
let e = try self.decoder.decode(BridgeErrorFrame.self, from: nextData)
connection.cancel()
throw NSError(domain: "Bridge", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
])
}
}
}
connection.cancel()
throw NSError(domain: "Bridge", code: 1, userInfo: [
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
])
}
}
connection.cancel()
throw NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response",
])
}
private func send(_ obj: some Encodable, over connection: NWConnection) async throws {
let data = try self.encoder.encode(obj)
var line = Data()
line.append(data)
line.append(0x0A)
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
connection.send(content: line, completion: .contentProcessed { err in
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
})
}
}
private func receiveLine(over connection: NWConnection) async throws -> String? {
var buffer = Data()
while true {
if let idx = buffer.firstIndex(of: 0x0A) {
let lineData = buffer.prefix(upTo: idx)
return String(data: lineData, encoding: .utf8)
}
let chunk = try await self.receiveChunk(over: connection)
if chunk.isEmpty { return nil }
buffer.append(chunk)
}
}
private func receiveChunk(over connection: NWConnection) async throws -> Data {
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Data, Error>) in
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
if let error {
cont.resume(throwing: error)
return
}
if isComplete {
cont.resume(returning: Data())
return
}
cont.resume(returning: data ?? Data())
}
}
}
}
@@ -0,0 +1,108 @@
import ClawdisKit
import Foundation
import Network
@MainActor
final class BridgeDiscoveryModel: ObservableObject {
struct DiscoveredBridge: Identifiable, Equatable {
var id: String { self.debugID }
var name: String
var endpoint: NWEndpoint
var debugID: String
}
@Published var bridges: [DiscoveredBridge] = []
@Published var statusText: String = "Idle"
private var browser: NWBrowser?
func start() {
if self.browser != nil { return }
let params = NWParameters.tcp
params.includePeerToPeer = true
let browser = NWBrowser(
for: .bonjour(type: ClawdisBonjour.bridgeServiceType, domain: ClawdisBonjour.bridgeServiceDomain),
using: params)
browser.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
guard let self else { return }
switch state {
case .setup:
self.statusText = "Setup"
case .ready:
self.statusText = "Searching…"
case let .failed(err):
self.statusText = "Failed: \(err)"
case .cancelled:
self.statusText = "Stopped"
case let .waiting(err):
self.statusText = "Waiting: \(err)"
@unknown default:
self.statusText = "Unknown"
}
}
}
browser.browseResultsChangedHandler = { [weak self] results, _ in
Task { @MainActor in
guard let self else { return }
self.bridges = results.compactMap { result -> DiscoveredBridge? in
switch result.endpoint {
case let .service(name, _, _, _):
return DiscoveredBridge(
name: name,
endpoint: result.endpoint,
debugID: Self.prettyEndpointDebugID(result.endpoint))
default:
return nil
}
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
}
self.browser = browser
browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.ios.bridge-discovery"))
}
func stop() {
self.browser?.cancel()
self.browser = nil
self.bridges = []
self.statusText = "Stopped"
}
private static func prettyEndpointDebugID(_ endpoint: NWEndpoint) -> String {
self.decodeBonjourEscapes(String(describing: endpoint))
}
private static func decodeBonjourEscapes(_ input: String) -> String {
// mDNS / DNS-SD commonly escapes spaces as `\\032` (decimal byte value 32). Make this human-friendly for UI.
var out = ""
var i = input.startIndex
while i < input.endIndex {
if input[i] == "\\",
let d0 = input.index(i, offsetBy: 1, limitedBy: input.index(before: input.endIndex)),
let d1 = input.index(i, offsetBy: 2, limitedBy: input.index(before: input.endIndex)),
let d2 = input.index(i, offsetBy: 3, limitedBy: input.index(before: input.endIndex)),
input[d0].isNumber,
input[d1].isNumber,
input[d2].isNumber
{
let digits = String(input[d0...d2])
if let value = Int(digits),
let scalar = UnicodeScalar(value)
{
out.append(Character(scalar))
i = input.index(i, offsetBy: 4)
continue
}
}
out.append(input[i])
i = input.index(after: i)
}
return out
}
}
+151
View File
@@ -0,0 +1,151 @@
import ClawdisKit
import Foundation
import Network
actor BridgeSession {
enum State: Sendable, Equatable {
case idle
case connecting
case connected(serverName: String)
case failed(message: String)
}
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private var connection: NWConnection?
private var queue: DispatchQueue?
private var buffer = Data()
private(set) var state: State = .idle
func connect(
endpoint: NWEndpoint,
hello: BridgeHello,
onConnected: (@Sendable (String) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
async throws
{
await self.disconnect()
self.state = .connecting
let connection = NWConnection(to: endpoint, using: .tcp)
let queue = DispatchQueue(label: "com.steipete.clawdis.ios.bridge-session")
self.connection = connection
self.queue = queue
connection.start(queue: queue)
try await self.send(hello)
guard let line = try await self.receiveLine(),
let data = line.data(using: .utf8),
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
else {
await self.disconnect()
throw NSError(domain: "Bridge", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response",
])
}
if base.type == "hello-ok" {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName)
await onConnected?(ok.serverName)
} else if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
self.state = .failed(message: "\(err.code): \(err.message)")
await self.disconnect()
throw NSError(domain: "Bridge", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
])
} else {
self.state = .failed(message: "Unexpected bridge response")
await self.disconnect()
throw NSError(domain: "Bridge", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response",
])
}
while true {
guard let next = try await self.receiveLine() else { break }
guard let nextData = next.data(using: .utf8) else { continue }
guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue }
switch nextBase.type {
case "ping":
let ping = try self.decoder.decode(BridgePing.self, from: nextData)
try await self.send(BridgePong(type: "pong", id: ping.id))
case "invoke":
let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData)
let res = await onInvoke(req)
try await self.send(res)
default:
continue
}
}
await self.disconnect()
}
func sendEvent(event: String, payloadJSON: String?) async throws {
try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON))
}
func disconnect() async {
self.connection?.cancel()
self.connection = nil
self.queue = nil
self.buffer = Data()
self.state = .idle
}
private func send(_ obj: some Encodable) async throws {
guard let connection = self.connection else {
throw NSError(domain: "Bridge", code: 10, userInfo: [
NSLocalizedDescriptionKey: "not connected",
])
}
let data = try self.encoder.encode(obj)
var line = Data()
line.append(data)
line.append(0x0A)
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
connection.send(content: line, completion: .contentProcessed { err in
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
})
}
}
private func receiveLine() async throws -> String? {
while true {
if let idx = self.buffer.firstIndex(of: 0x0A) {
let lineData = self.buffer.prefix(upTo: idx)
self.buffer.removeSubrange(...idx)
return String(data: lineData, encoding: .utf8)
}
let chunk = try await self.receiveChunk()
if chunk.isEmpty { return nil }
self.buffer.append(chunk)
}
}
private func receiveChunk() async throws -> Data {
guard let connection = self.connection else { return Data() }
return try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Data, Error>) in
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
if let error {
cont.resume(throwing: error)
return
}
if isComplete {
cont.resume(returning: Data())
return
}
cont.resume(returning: data ?? Data())
}
}
}
}
@@ -0,0 +1,49 @@
import Foundation
import Security
enum KeychainStore {
static func loadString(service: String, account: String) -> String? {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
static func saveString(_ value: String, service: String, account: String) -> Bool {
let data = Data(value.utf8)
let base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
let update: [String: Any] = [kSecValueData as String: data]
let status = SecItemUpdate(base as CFDictionary, update as CFDictionary)
if status == errSecSuccess { return true }
if status != errSecItemNotFound { return false }
var insert = base
insert[kSecValueData as String] = data
return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess
}
static func delete(service: String, account: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
}
+21
View File
@@ -0,0 +1,21 @@
import SwiftUI
@main
struct ClawdisNodeApp: App {
@StateObject private var appModel = NodeAppModel()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
RootCanvas()
.environmentObject(self.appModel)
.environmentObject(self.appModel.voiceWake)
.onOpenURL { url in
Task { await self.appModel.handleDeepLink(url: url) }
}
.onChange(of: self.scenePhase) { _, newValue in
self.appModel.setScenePhase(newValue)
}
}
}
}
+45
View File
@@ -0,0 +1,45 @@
<?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>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Clawdis Node</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSBonjourServices</key>
<array>
<string>_clawdis-bridge._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>Clawdis Node discovers and connects to your Clawdis bridge on the local network.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Clawdis Node needs microphone access for voice wake.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Clawdis Node uses on-device speech recognition for voice wake.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchScreen</key>
<dict/>
</dict>
</plist>
+235
View File
@@ -0,0 +1,235 @@
import ClawdisKit
import Network
import SwiftUI
@MainActor
final class NodeAppModel: ObservableObject {
@Published var isBackgrounded: Bool = false
let screen = ScreenController()
@Published var bridgeStatusText: String = "Not connected"
@Published var bridgeServerName: String?
private let bridge = BridgeSession()
private var bridgeTask: Task<Void, Never>?
let voiceWake = VoiceWakeManager()
init() {
self.voiceWake.configure { [weak self] cmd in
guard let self else { return }
let nodeId = UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node"
let sessionKey = "node-\(nodeId)"
do {
try await self.sendVoiceTranscript(text: cmd, sessionKey: sessionKey)
} catch {
// Best-effort only.
}
}
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
self.voiceWake.setEnabled(enabled)
}
func setScenePhase(_ phase: ScenePhase) {
switch phase {
case .background:
self.isBackgrounded = true
case .active, .inactive:
self.isBackgrounded = false
@unknown default:
self.isBackgrounded = false
}
}
func setVoiceWakeEnabled(_ enabled: Bool) {
self.voiceWake.setEnabled(enabled)
}
func connectToBridge(
endpoint: NWEndpoint,
token: String,
nodeId: String,
displayName: String?,
platform: String,
version: String)
{
self.bridgeTask?.cancel()
self.bridgeStatusText = "Connecting…"
self.bridgeServerName = nil
self.bridgeTask = Task {
do {
try await self.bridge.connect(
endpoint: endpoint,
hello: BridgeHello(
nodeId: nodeId,
displayName: displayName,
token: token,
platform: platform,
version: version),
onConnected: { [weak self] serverName in
await MainActor.run {
self?.bridgeStatusText = "Connected"
self?.bridgeServerName = serverName
}
},
onInvoke: { [weak self] req in
guard let self else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(code: .unavailable, message: "UNAVAILABLE: node not ready"))
}
return await self.handleInvoke(req)
})
await MainActor.run {
self.bridgeStatusText = "Disconnected"
self.bridgeServerName = nil
}
} catch {
await MainActor.run {
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
self.bridgeServerName = nil
}
}
}
}
func disconnectBridge() {
self.bridgeTask?.cancel()
self.bridgeTask = nil
Task { await self.bridge.disconnect() }
self.bridgeStatusText = "Disconnected"
self.bridgeServerName = nil
}
func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
struct Payload: Codable {
var text: String
var sessionKey: String?
}
let payload = Payload(text: text, sessionKey: sessionKey)
let data = try JSONEncoder().encode(payload)
let json = String(decoding: data, as: UTF8.self)
try await self.bridge.sendEvent(event: "voice.transcript", payloadJSON: json)
}
func handleDeepLink(url: URL) async {
guard let route = DeepLinkParser.parse(url) else { return }
switch route {
case let .agent(link):
await self.handleAgentDeepLink(link, originalURL: url)
}
}
private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async {
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !message.isEmpty else { return }
if message.count > 20000 {
self.screen.errorText = "Deep link too large (message exceeds 20,000 characters)."
return
}
guard await self.isBridgeConnected() else {
self.screen.errorText = "Bridge not connected (cannot forward deep link)."
return
}
do {
try await self.sendAgentRequest(link: link)
self.screen.errorText = nil
} catch {
self.screen.errorText = "Agent request failed: \(error.localizedDescription)"
}
}
private func sendAgentRequest(link: AgentDeepLink) async throws {
if link.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
throw NSError(domain: "DeepLink", code: 1, userInfo: [
NSLocalizedDescriptionKey: "invalid agent message",
])
}
// iOS bridge forwards to the gateway; no local auth prompts here.
// (Key-based unattended auth is handled on macOS for clawdis:// links.)
let data = try JSONEncoder().encode(link)
let json = String(decoding: data, as: UTF8.self)
try await self.bridge.sendEvent(event: "agent.request", payloadJSON: json)
}
private func isBridgeConnected() async -> Bool {
if case .connected = await self.bridge.state { return true }
return false
}
private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
if req.command.hasPrefix("screen."), self.isBackgrounded {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .backgroundUnavailable,
message: "NODE_BACKGROUND_UNAVAILABLE: screen commands require foreground"))
}
do {
switch req.command {
case ClawdisScreenCommand.show.rawValue:
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdisScreenCommand.hide.rawValue:
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdisScreenCommand.setMode.rawValue:
let params = try Self.decodeParams(ClawdisScreenSetModeParams.self, from: req.paramsJSON)
self.screen.setMode(params.mode)
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdisScreenCommand.navigate.rawValue:
let params = try Self.decodeParams(ClawdisScreenNavigateParams.self, from: req.paramsJSON)
self.screen.navigate(to: params.url)
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdisScreenCommand.evalJS.rawValue:
let params = try Self.decodeParams(ClawdisScreenEvalParams.self, from: req.paramsJSON)
let result = try await self.screen.eval(javaScript: params.javaScript)
let payload = try Self.encodePayload(["result": result])
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdisScreenCommand.snapshot.rawValue:
let params = try? Self.decodeParams(ClawdisScreenSnapshotParams.self, from: req.paramsJSON)
let maxWidth = params?.maxWidth.map { CGFloat($0) }
let base64 = try await self.screen.snapshotPNGBase64(maxWidth: maxWidth)
let payload = try Self.encodePayload(["format": "png", "base64": base64])
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
}
} catch {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(code: .unavailable, message: error.localizedDescription))
}
}
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: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
])
}
return try JSONDecoder().decode(type, from: data)
}
private static func encodePayload(_ obj: some Encodable) throws -> String {
let data = try JSONEncoder().encode(obj)
return String(decoding: data, as: UTF8.self)
}
}
+49
View File
@@ -0,0 +1,49 @@
import SwiftUI
struct RootCanvas: View {
@State private var isShowingSettings = false
var body: some View {
ZStack(alignment: .topTrailing) {
ScreenTab()
Button {
self.isShowingSettings = true
} label: {
Image(systemName: "gearshape.fill")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.primary)
.padding(10)
.background {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(
LinearGradient(
colors: [
.white.opacity(0.18),
.white.opacity(0.04),
.clear,
],
startPoint: .topLeading,
endPoint: .bottomTrailing))
.blendMode(.overlay)
}
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(.white.opacity(0.18), lineWidth: 0.5)
}
.shadow(color: .black.opacity(0.35), radius: 12, y: 6)
}
}
.buttonStyle(.plain)
.padding(.top, 10)
.padding(.trailing, 10)
.accessibilityLabel("Settings")
}
.sheet(isPresented: self.$isShowingSettings) {
SettingsTab()
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import SwiftUI
struct RootTabs: View {
var body: some View {
TabView {
ScreenTab()
.tabItem { Label("Screen", systemImage: "rectangle.and.hand.point.up.left") }
VoiceTab()
.tabItem { Label("Voice", systemImage: "mic") }
SettingsTab()
.tabItem { Label("Settings", systemImage: "gearshape") }
}
}
}
@@ -0,0 +1,198 @@
import ClawdisKit
import SwiftUI
import WebKit
@MainActor
final class ScreenController: ObservableObject {
let webView: WKWebView
@Published var mode: ClawdisScreenMode = .canvas
@Published var urlString: String = ""
@Published var errorText: String?
init() {
let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent()
self.webView = WKWebView(frame: .zero, configuration: config)
self.webView.scrollView.contentInsetAdjustmentBehavior = .never
self.webView.scrollView.contentInset = .zero
self.webView.scrollView.scrollIndicatorInsets = .zero
self.webView.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
self.reload()
}
func setMode(_ mode: ClawdisScreenMode) {
self.mode = mode
self.reload()
}
func navigate(to urlString: String) {
self.urlString = urlString
self.reload()
}
func reload() {
switch self.mode {
case .web:
guard let url = URL(string: self.urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else { return }
self.webView.load(URLRequest(url: url))
case .canvas:
self.webView.loadHTMLString(Self.canvasScaffoldHTML, baseURL: nil)
}
}
func eval(javaScript: String) async throws -> String {
try await withCheckedThrowingContinuation { cont in
self.webView.evaluateJavaScript(javaScript) { result, error in
if let error {
cont.resume(throwing: error)
return
}
if let result {
cont.resume(returning: String(describing: result))
} else {
cont.resume(returning: "")
}
}
}
}
func snapshotPNGBase64(maxWidth: CGFloat? = nil) async throws -> String {
let config = WKSnapshotConfiguration()
if let maxWidth {
config.snapshotWidth = NSNumber(value: Double(maxWidth))
}
let image: UIImage = try await withCheckedThrowingContinuation { cont in
self.webView.takeSnapshot(with: config) { image, error in
if let error {
cont.resume(throwing: error)
return
}
guard let image else {
cont.resume(throwing: NSError(domain: "Screen", code: 2, userInfo: [
NSLocalizedDescriptionKey: "snapshot failed",
]))
return
}
cont.resume(returning: image)
}
}
guard let data = image.pngData() else {
throw NSError(domain: "Screen", code: 1, userInfo: [
NSLocalizedDescriptionKey: "snapshot encode failed",
])
}
return data.base64EncodedString()
}
private static let canvasScaffoldHTML = """
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Canvas</title>
<style>
:root { color-scheme: dark; }
html,body { height:100%; margin:0; }
body {
background: radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.18), rgba(0,0,0,0) 55%),
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.14), rgba(0,0,0,0) 60%),
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.10), rgba(0,0,0,0) 60%),
#000;
overflow: hidden;
}
body::before {
content:"";
position: fixed;
inset: -20%;
background:
repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px, transparent 1px, transparent 48px),
repeating-linear-gradient(90deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px, transparent 1px, transparent 48px);
transform: rotate(-7deg);
opacity: 0.55;
pointer-events: none;
}
canvas {
display:block;
width:100vw;
height:100vh;
touch-action: none;
}
#clawdis-status {
position: fixed;
inset: 0;
display: grid;
place-items: center;
pointer-events: none;
}
#clawdis-status .card {
text-align: center;
padding: 16px 18px;
border-radius: 14px;
background: rgba(18, 18, 22, 0.42);
border: 1px solid rgba(255,255,255,0.08);
box-shadow: 0 18px 60px rgba(0,0,0,0.55);
backdrop-filter: blur(14px);
}
#clawdis-status .title {
font: 600 20px -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif;
letter-spacing: 0.2px;
color: rgba(255,255,255,0.92);
text-shadow: 0 0 22px rgba(42, 113, 255, 0.35);
}
#clawdis-status .subtitle {
margin-top: 6px;
font: 500 12px -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
color: rgba(255,255,255,0.58);
}
</style>
</head>
<body>
<canvas id="clawdis-canvas"></canvas>
<div id="clawdis-status">
<div class="card">
<div class="title" id="clawdis-status-title">Ready</div>
<div class="subtitle" id="clawdis-status-subtitle">Waiting for agent</div>
</div>
</div>
<script>
(() => {
const canvas = document.getElementById('clawdis-canvas');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('clawdis-status');
const titleEl = document.getElementById('clawdis-status-title');
const subtitleEl = document.getElementById('clawdis-status-subtitle');
function resize() {
const dpr = window.devicePixelRatio || 1;
const w = Math.max(1, Math.floor(window.innerWidth * dpr));
const h = Math.max(1, Math.floor(window.innerHeight * dpr));
canvas.width = w;
canvas.height = h;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
resize();
window.__clawdis = {
canvas,
ctx,
setStatus: (title, subtitle) => {
if (!statusEl) return;
if (!title && !subtitle) {
statusEl.style.display = 'none';
return;
}
statusEl.style.display = 'grid';
if (titleEl && typeof title === 'string') titleEl.textContent = title;
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
}
};
})();
</script>
</body>
</html>
"""
}
+25
View File
@@ -0,0 +1,25 @@
import ClawdisKit
import SwiftUI
struct ScreenTab: View {
@EnvironmentObject private var appModel: NodeAppModel
var body: some View {
ZStack(alignment: .top) {
ScreenWebView(controller: self.appModel.screen)
.ignoresSafeArea()
.overlay(alignment: .top) {
if let errorText = self.appModel.screen.errorText {
Text(errorText)
.font(.footnote)
.padding(10)
.background(.thinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding()
}
}
}
}
// Navigation/mode selection is agent-driven; no local controls here.
}
@@ -0,0 +1,15 @@
import ClawdisKit
import SwiftUI
import WebKit
struct ScreenWebView: UIViewRepresentable {
@ObservedObject var controller: ScreenController
func makeUIView(context: Context) -> WKWebView {
self.controller.webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
// State changes are driven by ScreenController.
}
}
+196
View File
@@ -0,0 +1,196 @@
import SwiftUI
struct SettingsTab: View {
@EnvironmentObject private var appModel: NodeAppModel
@Environment(\.dismiss) private var dismiss
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
@StateObject private var discovery = BridgeDiscoveryModel()
@State private var connectStatus: String?
@State private var isConnecting = false
@State private var didAutoConnect = false
@State private var isShowingBridgeList = false
var body: some View {
NavigationStack {
Form {
Section("Node") {
TextField("Name", text: self.$displayName)
Text(self.instanceId)
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("Voice") {
Toggle("Voice Wake", isOn: self.$voiceWakeEnabled)
.onChange(of: self.voiceWakeEnabled) { _, newValue in
self.appModel.setVoiceWakeEnabled(newValue)
}
}
Section("Bridge") {
LabeledContent("Discovery", value: self.discovery.statusText)
LabeledContent("Status", value: self.appModel.bridgeStatusText)
if let serverName = self.appModel.bridgeServerName {
LabeledContent("Server", value: serverName)
Button("Disconnect", role: .destructive) {
self.appModel.disconnectBridge()
}
DisclosureGroup("Switch bridge", isExpanded: self.$isShowingBridgeList) {
self.bridgeList(showConnectedRow: true)
}
} else {
self.bridgeList(showConnectedRow: false)
}
if let connectStatus {
Text(connectStatus)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
self.dismiss()
} label: {
Image(systemName: "xmark")
}
.accessibilityLabel("Close")
}
}
.onAppear { self.discovery.start() }
.onDisappear { self.discovery.stop() }
.onChange(of: self.discovery.bridges) { _, newValue in
if self.didAutoConnect { return }
if self.appModel.bridgeServerName != nil { return }
let existing = KeychainStore.loadString(
service: "com.steipete.clawdis.bridge",
account: self.keychainAccount())
guard let existing, !existing.isEmpty else { return }
guard let first = newValue.first else { return }
self.didAutoConnect = true
self.appModel.connectToBridge(
endpoint: first.endpoint,
token: existing,
nodeId: self.instanceId,
displayName: self.displayName,
platform: self.platformString(),
version: self.appVersion())
self.connectStatus = nil
}
.onChange(of: self.appModel.bridgeServerName) { _, _ in
self.connectStatus = nil
self.isShowingBridgeList = false
}
}
}
@ViewBuilder
private func bridgeList(showConnectedRow: Bool) -> some View {
if self.discovery.bridges.isEmpty {
Text("No bridges found yet.")
.foregroundStyle(.secondary)
} else {
ForEach(self.discovery.bridges) { bridge in
let isConnected = self.isConnectedBridge(bridge)
if isConnected, !showConnectedRow {
EmptyView()
} else {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(bridge.name)
Text(bridge.debugID)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
if isConnected {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.accessibilityLabel("Connected")
} else {
Button(self.isConnecting ? "…" : "Connect") {
Task { await self.connect(bridge) }
}
.disabled(self.isConnecting)
}
}
}
}
}
}
private func isConnectedBridge(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) -> Bool {
guard let serverName = self.appModel.bridgeServerName?.trimmingCharacters(in: .whitespacesAndNewlines),
!serverName.isEmpty
else {
return false
}
return bridge.name.localizedCaseInsensitiveContains(serverName)
}
private func keychainAccount() -> String {
"bridge-token.\(self.instanceId)"
}
private func platformString() -> String {
let v = ProcessInfo.processInfo.operatingSystemVersion
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
}
private func appVersion() -> String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
}
private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async {
self.isConnecting = true
defer { self.isConnecting = false }
let existing = KeychainStore.loadString(service: "com.steipete.clawdis.bridge", account: self.keychainAccount())
do {
let token: String
if let existing, !existing.isEmpty {
token = existing
} else {
let newToken = try await BridgeClient().pairAndHello(
endpoint: bridge.endpoint,
nodeId: self.instanceId,
displayName: self.displayName,
platform: self.platformString(),
version: self.appVersion(),
existingToken: nil)
guard !newToken.isEmpty else {
self.connectStatus = "Pairing failed: empty token"
return
}
_ = KeychainStore.saveString(
newToken,
service: "com.steipete.clawdis.bridge",
account: self.keychainAccount())
token = newToken
}
self.appModel.connectToBridge(
endpoint: bridge.endpoint,
token: token,
nodeId: self.instanceId,
displayName: self.displayName,
platform: self.platformString(),
version: self.appVersion())
self.connectStatus = "Connected"
} catch {
self.connectStatus = "Failed: \(error.localizedDescription)"
}
}
}
+30
View File
@@ -0,0 +1,30 @@
import SwiftUI
struct VoiceTab: View {
@EnvironmentObject private var appModel: NodeAppModel
@EnvironmentObject private var voiceWake: VoiceWakeManager
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
var body: some View {
NavigationStack {
List {
Section("Status") {
LabeledContent("Voice Wake", value: self.voiceWakeEnabled ? "Enabled" : "Disabled")
LabeledContent("Listener", value: self.voiceWake.isListening ? "Listening" : "Idle")
Text(self.voiceWake.statusText)
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("Notes") {
Text("Say “clawdis …” to trigger.")
.foregroundStyle(.secondary)
}
}
.navigationTitle("Voice")
.onChange(of: self.voiceWakeEnabled) { _, newValue in
self.appModel.setVoiceWakeEnabled(newValue)
}
}
}
}
@@ -0,0 +1,216 @@
import AVFAudio
import Foundation
import Speech
enum SpeechAudioTapFactory {
static func makeAppendTap(requestBox: SpeechRequestBox) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
{ buffer, _ in
requestBox.append(buffer)
}
}
}
final class SpeechRequestBox: @unchecked Sendable {
let request: SFSpeechAudioBufferRecognitionRequest
init(request: SFSpeechAudioBufferRecognitionRequest) {
self.request = request
}
func append(_ buffer: AVAudioPCMBuffer) {
self.request.append(buffer)
}
}
@MainActor
final class VoiceWakeManager: NSObject, ObservableObject {
@Published var isEnabled: Bool = false
@Published var isListening: Bool = false
@Published var statusText: String = "Off"
private let audioEngine = AVAudioEngine()
private var speechRecognizer: SFSpeechRecognizer?
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
private var recognitionTask: SFSpeechRecognitionTask?
private var lastDispatched: String?
private var onCommand: (@Sendable (String) async -> Void)?
func configure(onCommand: @escaping @Sendable (String) async -> Void) {
self.onCommand = onCommand
}
func setEnabled(_ enabled: Bool) {
self.isEnabled = enabled
if enabled {
Task { await self.start() }
} else {
self.stop()
}
}
func start() async {
guard self.isEnabled else { return }
if self.isListening { return }
self.statusText = "Requesting permissions…"
let micOk = await Self.requestMicrophonePermission()
guard micOk else {
self.statusText = "Microphone permission denied"
self.isListening = false
return
}
let speechOk = await Self.requestSpeechPermission()
guard speechOk else {
self.statusText = "Speech recognition permission denied"
self.isListening = false
return
}
self.speechRecognizer = SFSpeechRecognizer()
guard self.speechRecognizer != nil else {
self.statusText = "Speech recognizer unavailable"
self.isListening = false
return
}
do {
try Self.configureAudioSession()
try self.startRecognition()
self.isListening = true
self.statusText = "Listening"
} catch {
self.isListening = false
self.statusText = "Start failed: \(error.localizedDescription)"
}
}
func stop() {
self.isEnabled = false
self.isListening = false
self.statusText = "Off"
self.recognitionTask?.cancel()
self.recognitionTask = nil
self.recognitionRequest = nil
if self.audioEngine.isRunning {
self.audioEngine.stop()
self.audioEngine.inputNode.removeTap(onBus: 0)
}
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
private func startRecognition() throws {
self.recognitionTask?.cancel()
self.recognitionTask = nil
let request = SFSpeechAudioBufferRecognitionRequest()
request.shouldReportPartialResults = true
self.recognitionRequest = request
let inputNode = self.audioEngine.inputNode
inputNode.removeTap(onBus: 0)
let requestBox = SpeechRequestBox(request: request)
let recordingFormat = inputNode.outputFormat(forBus: 0)
let tap = SpeechAudioTapFactory.makeAppendTap(requestBox: requestBox)
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat, block: tap)
self.audioEngine.prepare()
try self.audioEngine.start()
let handler = self.makeRecognitionResultHandler()
self.recognitionTask = self.speechRecognizer?.recognitionTask(with: request, resultHandler: handler)
}
private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void {
{ [weak self] result, error in
let transcript = result?.bestTranscription.formattedString
let errorText = error?.localizedDescription
Task { @MainActor in
self?.handleRecognitionCallback(transcript: transcript, errorText: errorText)
}
}
}
private func handleRecognitionCallback(transcript: String?, errorText: String?) {
if let errorText {
self.statusText = "Recognizer error: \(errorText)"
self.isListening = false
let shouldRestart = self.isEnabled
if shouldRestart {
Task {
try? await Task.sleep(nanoseconds: 700_000_000)
await self.start()
}
}
return
}
guard let transcript else { return }
guard let cmd = self.extractCommand(from: transcript) else { return }
if cmd == self.lastDispatched { return }
self.lastDispatched = cmd
self.statusText = "Triggered"
Task { [weak self] in
guard let self else { return }
await self.onCommand?(cmd)
await self.startIfEnabled()
}
}
private func startIfEnabled() async {
let shouldRestart = self.isEnabled
if shouldRestart {
await self.start()
}
}
private func extractCommand(from transcript: String) -> String? {
let lower = transcript.lowercased()
guard let range = lower.range(of: "clawdis", options: .backwards) else { return nil }
let after = lower[range.upperBound...]
let trimmed = after.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
return trimmed
}
private static func configureAudioSession() throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .measurement, options: [
.duckOthers,
.mixWithOthers,
.allowBluetoothHFP,
.defaultToSpeaker,
])
try session.setActive(true, options: [])
}
private nonisolated static func requestMicrophonePermission() async -> Bool {
await withCheckedContinuation { cont in
AVAudioApplication.requestRecordPermission { ok in
Task { @MainActor in
cont.resume(returning: ok)
}
}
}
}
private nonisolated static func requestSpeechPermission() async -> Bool {
await withCheckedContinuation { cont in
SFSpeechRecognizer.requestAuthorization { status in
Task { @MainActor in
cont.resume(returning: status == .authorized)
}
}
}
}
}
+56
View File
@@ -0,0 +1,56 @@
name: ClawdisNode
options:
bundleIdPrefix: com.steipete.clawdis
deploymentTarget:
iOS: "17.0"
xcodeVersion: "16.0"
packages:
ClawdisKit:
path: ../shared/ClawdisKit
targets:
ClawdisNode:
type: application
platform: iOS
sources:
- path: Sources
dependencies:
- package: ClawdisKit
preBuildScripts:
- name: SwiftFormat (lint)
script: |
set -euo pipefail
if ! command -v swiftformat >/dev/null 2>&1; then
echo "error: swiftformat not found (brew install swiftformat)" >&2
exit 1
fi
swiftformat --lint --config "$SRCROOT/../../.swiftformat" \
"$SRCROOT/Sources" \
"$SRCROOT/../shared/ClawdisKit/Sources"
- name: SwiftLint
script: |
set -euo pipefail
if ! command -v swiftlint >/dev/null 2>&1; then
echo "error: swiftlint not found (brew install swiftlint)" >&2
exit 1
fi
swiftlint lint --config "$SRCROOT/.swiftlint.yml"
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.node
SWIFT_VERSION: "6.0"
info:
path: Sources/Info.plist
properties:
CFBundleDisplayName: Clawdis Node
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
UIBackgroundModes:
- audio
NSLocalNetworkUsageDescription: Clawdis Node discovers and connects to your Clawdis bridge on the local network.
NSBonjourServices:
- _clawdis-bridge._tcp
NSMicrophoneUsageDescription: Clawdis Node needs microphone access for voice wake.
NSSpeechRecognitionUsageDescription: Clawdis Node uses on-device speech recognition for voice wake.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+36
View File
@@ -0,0 +1,36 @@
{
"fill" : {
"automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"image-name" : "clawdis-mac.png",
"name" : "clawdis-mac",
"position" : {
"scale" : 1.07,
"translation-in-points" : [
-2,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"originHash" : "ee7127ff91914397f9991e22a0b06ab0bca0d83582adeed6011198c49167631b",
"pins" : [
{
"identity" : "menubarextraaccess",
"kind" : "remoteSourceControl",
"location" : "https://github.com/orchetect/MenuBarExtraAccess",
"state" : {
"revision" : "707dff6f55217b3ef5b6be84ced3e83511d4df5c",
"version" : "1.2.2"
}
},
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "5581748cef2bae787496fe6d61139aebe0a451f6",
"version" : "2.8.1"
}
},
{
"identity" : "swift-subprocess",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-subprocess.git",
"state" : {
"revision" : "44922dfe46380cd354ca4b0208e717a3e92b13dd",
"version" : "0.2.1"
}
},
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system",
"state" : {
"revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
"version" : "1.6.3"
}
}
],
"version" : 3
}
+69
View File
@@ -0,0 +1,69 @@
// swift-tools-version: 6.2
// Package manifest for the Clawdis macOS companion (menu bar app + CLI + IPC library).
import PackageDescription
let package = Package(
name: "Clawdis",
platforms: [
.macOS(.v15),
],
products: [
.library(name: "ClawdisIPC", targets: ["ClawdisIPC"]),
.executable(name: "Clawdis", targets: ["Clawdis"]),
.executable(name: "ClawdisCLI", targets: ["ClawdisCLI"]),
],
dependencies: [
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
.package(path: "../shared/ClawdisKit"),
],
targets: [
.target(
name: "ClawdisProtocol",
dependencies: [],
path: "Sources/ClawdisProtocol",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.target(
name: "ClawdisIPC",
dependencies: [],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "Clawdis",
dependencies: [
"ClawdisIPC",
"ClawdisProtocol",
.product(name: "ClawdisKit", package: "ClawdisKit"),
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
.product(name: "Subprocess", package: "swift-subprocess"),
.product(name: "Sparkle", package: "Sparkle"),
],
resources: [
.copy("Resources/Clawdis.icns"),
.copy("Resources/WebChat"),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "ClawdisCLI",
dependencies: [
"ClawdisIPC",
"ClawdisProtocol",
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.testTarget(
name: "ClawdisIPCTests",
dependencies: ["ClawdisIPC", "Clawdis", "ClawdisProtocol"],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
]),
])
@@ -0,0 +1,189 @@
import SwiftUI
struct AboutSettings: View {
weak var updater: UpdaterProviding?
@State private var iconHover = false
@AppStorage("autoUpdateEnabled") private var autoCheckEnabled = true
@State private var didLoadUpdaterState = false
var body: some View {
VStack(spacing: 8) {
let appIcon = NSApplication.shared.applicationIconImage ?? CritterIconRenderer.makeIcon(blink: 0)
Button {
if let url = URL(string: "https://github.com/steipete/clawdis") {
NSWorkspace.shared.open(url)
}
} label: {
Image(nsImage: appIcon)
.resizable()
.frame(width: 160, height: 160)
.cornerRadius(24)
.shadow(color: self.iconHover ? .accentColor.opacity(0.25) : .clear, radius: 10)
.scaleEffect(self.iconHover ? 1.05 : 1.0)
}
.buttonStyle(.plain)
.focusable(false)
.onHover { hover in
withAnimation(.spring(response: 0.3, dampingFraction: 0.72)) { self.iconHover = hover }
}
VStack(spacing: 3) {
Text("Clawdis")
.font(.title3.bold())
Text("Version \(self.versionString)")
.foregroundStyle(.secondary)
if let buildTimestamp {
Text("Built \(buildTimestamp)\(self.buildSuffix)")
.font(.footnote)
.foregroundStyle(.secondary)
}
Text("Menu bar companion for notifications, screenshots, and privileged agent actions.")
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 18)
}
VStack(alignment: .center, spacing: 6) {
AboutLinkRow(
icon: "chevron.left.slash.chevron.right",
title: "GitHub",
url: "https://github.com/steipete/clawdis")
AboutLinkRow(icon: "globe", title: "Website", url: "https://steipete.me")
AboutLinkRow(icon: "bird", title: "Twitter", url: "https://twitter.com/steipete")
AboutLinkRow(icon: "envelope", title: "Email", url: "mailto:peter@steipete.me")
}
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
.padding(.vertical, 10)
if let updater {
Divider()
.padding(.vertical, 8)
if updater.isAvailable {
VStack(spacing: 10) {
Toggle("Check for updates automatically", isOn: self.$autoCheckEnabled)
.toggleStyle(.checkbox)
.frame(maxWidth: .infinity, alignment: .center)
Button("Check for Updates…") { updater.checkForUpdates(nil) }
}
} else {
Text("Updates unavailable in this build.")
.foregroundStyle(.secondary)
.padding(.top, 4)
}
}
Text("© 2025 Peter Steinberger — MIT License.")
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.top, 4)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, 4)
.padding(.horizontal, 24)
.padding(.bottom, 24)
.onAppear {
guard let updater, !self.didLoadUpdaterState else { return }
// Keep Sparkle’s auto-check setting in sync with the persisted toggle.
updater.automaticallyChecksForUpdates = self.autoCheckEnabled
self.didLoadUpdaterState = true
}
.onChange(of: self.autoCheckEnabled) { _, newValue in
self.updater?.automaticallyChecksForUpdates = newValue
}
}
private var versionString: String {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev"
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
return build.map { "\(version) (\($0))" } ?? version
}
private var buildTimestamp: String? {
guard let raw = Bundle.main.object(forInfoDictionaryKey: "ClawdisBuildTimestamp") as? String else { return nil }
let parser = ISO8601DateFormatter()
parser.formatOptions = [.withInternetDateTime]
guard let date = parser.date(from: raw) else { return raw }
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
formatter.locale = .current
return formatter.string(from: date)
}
private var gitCommit: String {
Bundle.main.object(forInfoDictionaryKey: "ClawdisGitCommit") as? String ?? "unknown"
}
private var bundleID: String {
Bundle.main.bundleIdentifier ?? "unknown"
}
private var buildSuffix: String {
let git = self.gitCommit
guard !git.isEmpty, git != "unknown" else { return "" }
var suffix = " (\(git)"
#if DEBUG
suffix += " DEBUG"
#endif
suffix += ")"
return suffix
}
}
@MainActor
private struct AboutLinkRow: View {
let icon: String
let title: String
let url: String
@State private var hovering = false
var body: some View {
Button {
if let url = URL(string: url) { NSWorkspace.shared.open(url) }
} label: {
HStack(spacing: 6) {
Image(systemName: self.icon)
Text(self.title)
.underline(self.hovering, color: .accentColor)
}
.foregroundColor(.accentColor)
}
.buttonStyle(.plain)
.onHover { self.hovering = $0 }
}
}
private struct AboutMetaRow: View {
let label: String
let value: String
var body: some View {
HStack {
Text(self.label)
.foregroundStyle(.secondary)
Spacer()
Text(self.value)
.font(.caption.monospaced())
.foregroundStyle(.primary)
}
}
}
#if DEBUG
struct AboutSettings_Previews: PreviewProvider {
private static let updater = DisabledUpdaterController()
static var previews: some View {
AboutSettings(updater: updater)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}
#endif
@@ -0,0 +1,20 @@
import Foundation
@MainActor
final class AgentEventStore: ObservableObject {
static let shared = AgentEventStore()
@Published private(set) var events: [ControlAgentEvent] = []
private let maxEvents = 400
func append(_ event: ControlAgentEvent) {
self.events.append(event)
if self.events.count > self.maxEvents {
self.events.removeFirst(self.events.count - self.maxEvents)
}
}
func clear() {
self.events.removeAll()
}
}
@@ -0,0 +1,105 @@
import SwiftUI
@MainActor
struct AgentEventsWindow: View {
@ObservedObject private var store = AgentEventStore.shared
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Agent Events")
.font(.title3.weight(.semibold))
Spacer()
Button("Clear") { self.store.clear() }
.buttonStyle(.bordered)
}
.padding(.bottom, 4)
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(self.store.events.reversed(), id: \.seq) { evt in
EventRow(event: evt)
}
}
}
}
.padding(12)
.frame(minWidth: 520, minHeight: 360)
}
}
private struct EventRow: View {
let event: ControlAgentEvent
var body: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(self.event.stream.uppercased())
.font(.caption2.weight(.bold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(self.tint)
.foregroundStyle(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
Text("run " + self.event.runId)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
Spacer()
Text(self.formattedTs)
.font(.caption2)
.foregroundStyle(.secondary)
}
if let json = self.prettyJSON(event.data) {
Text(json)
.font(.caption.monospaced())
.foregroundStyle(.primary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 2)
}
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color.primary.opacity(0.04)))
}
private var tint: Color {
switch self.event.stream {
case "job": .blue
case "tool": .orange
case "assistant": .green
default: .gray
}
}
private var formattedTs: String {
let date = Date(timeIntervalSince1970: event.ts / 1000)
let f = DateFormatter()
f.dateFormat = "HH:mm:ss.SSS"
return f.string(from: date)
}
private func prettyJSON(_ dict: [String: AnyCodable]) -> String? {
let normalized = dict.mapValues { $0.value }
guard JSONSerialization.isValidJSONObject(normalized),
let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.prettyPrinted]),
let str = String(data: data, encoding: .utf8)
else { return nil }
return str
}
}
struct AgentEventsWindow_Previews: PreviewProvider {
static var previews: some View {
let sample = ControlAgentEvent(
runId: "abc",
seq: 1,
stream: "tool",
ts: Date().timeIntervalSince1970 * 1000,
data: ["phase": AnyCodable("start"), "name": AnyCodable("bash")],
summary: nil)
AgentEventStore.shared.append(sample)
return AgentEventsWindow()
}
}
+74
View File
@@ -0,0 +1,74 @@
import Foundation
import OSLog
struct ControlRequestParams: @unchecked Sendable {
/// Heterogeneous JSON-ish params (Bool/String/Int/Double/[...]/[String:...]).
/// `@unchecked Sendable` is intentional: values are treated as immutable payloads.
let raw: [String: Any]
}
actor AgentRPC {
static let shared = AgentRPC()
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "agent.rpc")
func shutdown() async {
// no-op; socket managed by GatewayConnection
}
func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool {
do {
_ = try await self.controlRequest(
method: "set-heartbeats",
params: ControlRequestParams(raw: ["enabled": enabled]))
return true
} catch {
self.logger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)")
return false
}
}
func status() async -> (ok: Bool, error: String?) {
do {
let data = try await controlRequest(method: "status")
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["ok"] as? Bool) ?? true
{
return (true, nil)
}
return (false, "status error")
} catch {
return (false, error.localizedDescription)
}
}
func send(
text: String,
thinking: String?,
sessionKey: String,
deliver: Bool,
to: String?,
channel: String? = nil) async -> (ok: Bool, text: String?, error: String?)
{
do {
let params: [String: Any] = [
"message": text,
"sessionKey": sessionKey,
"thinking": thinking ?? "default",
"deliver": deliver,
"to": to ?? "",
"channel": channel ?? "",
"idempotencyKey": UUID().uuidString,
]
_ = try await self.controlRequest(method: "agent", params: ControlRequestParams(raw: params))
return (true, nil, nil)
} catch {
return (false, nil, error.localizedDescription)
}
}
func controlRequest(method: String, params: ControlRequestParams? = nil) async throws -> Data {
let rawParams = params?.raw.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) }
return try await GatewayConnection.shared.request(method: method, params: rawParams)
}
}
@@ -0,0 +1,54 @@
import Foundation
/// Lightweight `Codable` wrapper that round-trips heterogeneous JSON payloads.
/// Marked `@unchecked Sendable` because it can hold reference types.
struct AnyCodable: Codable, @unchecked Sendable {
let value: Any
init(_ value: Any) { self.value = value }
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
if container.decodeNil() { self.value = NSNull(); return }
if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Unsupported type")
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self.value {
case let intVal as Int: try container.encode(intVal)
case let doubleVal as Double: try container.encode(doubleVal)
case let boolVal as Bool: try container.encode(boolVal)
case let stringVal as String: try container.encode(stringVal)
case is NSNull: try container.encodeNil()
case let dict as [String: AnyCodable]: try container.encode(dict)
case let array as [AnyCodable]: try container.encode(array)
case let dict as [String: Any]:
try container.encode(dict.mapValues { AnyCodable($0) })
case let array as [Any]:
try container.encode(array.map { AnyCodable($0) })
case let dict as NSDictionary:
var converted: [String: AnyCodable] = [:]
for (k, v) in dict {
guard let key = k as? String else { continue }
converted[key] = AnyCodable(v)
}
try container.encode(converted)
case let array as NSArray:
try container.encode(array.map { AnyCodable($0) })
default:
let context = EncodingError.Context(
codingPath: encoder.codingPath,
debugDescription: "Unsupported type")
throw EncodingError.invalidValue(self.value, context)
}
}
}
+390
View File
@@ -0,0 +1,390 @@
import AppKit
import Foundation
import ServiceManagement
import SwiftUI
@MainActor
final class AppState: ObservableObject {
private let isPreview: Bool
private func ifNotPreview(_ action: () -> Void) {
guard !self.isPreview else { return }
action()
}
enum ConnectionMode: String {
case local
case remote
}
@Published var isPaused: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
}
@Published var launchAtLogin: Bool {
didSet { self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } } }
}
@Published var onboardingSeen: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.onboardingSeen, forKey: "clawdis.onboardingSeen") }
}
}
@Published var debugPaneEnabled: Bool {
didSet {
self.ifNotPreview { UserDefaults.standard.set(self.debugPaneEnabled, forKey: "clawdis.debugPaneEnabled") }
}
}
@Published var swabbleEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey)
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
}
}
@Published var swabbleTriggerWords: [String] {
didSet {
// Preserve the raw editing state; sanitization happens when we actually use the triggers.
self.ifNotPreview {
UserDefaults.standard.set(self.swabbleTriggerWords, forKey: swabbleTriggersKey)
if self.swabbleEnabled {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
}
}
}
@Published var voiceWakeTriggerChime: VoiceWakeChime {
didSet { self.ifNotPreview { self.storeChime(self.voiceWakeTriggerChime, key: voiceWakeTriggerChimeKey) } }
}
@Published var voiceWakeSendChime: VoiceWakeChime {
didSet { self.ifNotPreview { self.storeChime(self.voiceWakeSendChime, key: voiceWakeSendChimeKey) } }
}
@Published var iconAnimationsEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(
self.iconAnimationsEnabled,
forKey: iconAnimationsEnabledKey) } }
}
@Published var showDockIcon: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey)
AppActivationPolicy.apply(showDockIcon: self.showDockIcon)
}
}
}
@Published var voiceWakeMicID: String {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey)
if self.swabbleEnabled {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
}
}
}
@Published var voiceWakeLocaleID: String {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey)
if self.swabbleEnabled {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
}
}
}
@Published var voiceWakeAdditionalLocaleIDs: [String] {
didSet { self.ifNotPreview { UserDefaults.standard.set(
self.voiceWakeAdditionalLocaleIDs,
forKey: voiceWakeAdditionalLocalesKey) } }
}
@Published var voicePushToTalkEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(
self.voicePushToTalkEnabled,
forKey: voicePushToTalkEnabledKey) } }
}
@Published var iconOverride: IconOverrideSelection {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } }
}
@Published var isWorking: Bool = false
@Published var earBoostActive: Bool = false
@Published var blinkTick: Int = 0
@Published var sendCelebrationTick: Int = 0
@Published var heartbeatsEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey)
Task { _ = await AgentRPC.shared.setHeartbeatsEnabled(self.heartbeatsEnabled) }
}
}
}
@Published var connectionMode: ConnectionMode {
didSet {
self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
}
}
@Published var webChatEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatEnabled, forKey: webChatEnabledKey) } }
}
@Published var webChatSwiftUIEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatSwiftUIEnabled, forKey: webChatSwiftUIEnabledKey) } }
}
@Published var webChatPort: Int {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatPort, forKey: webChatPortKey) } }
}
@Published var canvasEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
}
@Published var attachExistingGatewayOnly: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.attachExistingGatewayOnly, forKey: attachExistingGatewayOnlyKey)
}
}
}
@Published var remoteTarget: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } }
}
@Published var remoteIdentity: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
}
@Published var remoteProjectRoot: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } }
}
private var earBoostTask: Task<Void, Never>?
init(preview: Bool = false) {
self.isPreview = preview
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
self.launchAtLogin = false
self.onboardingSeen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen")
self.debugPaneEnabled = UserDefaults.standard.bool(forKey: "clawdis.debugPaneEnabled")
let savedVoiceWake = UserDefaults.standard.bool(forKey: swabbleEnabledKey)
self.swabbleEnabled = voiceWakeSupported ? savedVoiceWake : false
self.swabbleTriggerWords = UserDefaults.standard
.stringArray(forKey: swabbleTriggersKey) ?? defaultVoiceWakeTriggers
self.voiceWakeTriggerChime = Self.loadChime(
key: voiceWakeTriggerChimeKey,
fallback: .system(name: "Glass"))
self.voiceWakeSendChime = Self.loadChime(
key: voiceWakeSendChimeKey,
fallback: .system(name: "Glass"))
if let storedIconAnimations = UserDefaults.standard.object(forKey: iconAnimationsEnabledKey) as? Bool {
self.iconAnimationsEnabled = storedIconAnimations
} else {
self.iconAnimationsEnabled = true
UserDefaults.standard.set(true, forKey: iconAnimationsEnabledKey)
}
self.showDockIcon = UserDefaults.standard.bool(forKey: showDockIconKey)
self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? ""
self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier
self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard
.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? []
self.voicePushToTalkEnabled = UserDefaults.standard
.object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false
if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {
self.heartbeatsEnabled = storedHeartbeats
} else {
self.heartbeatsEnabled = true
UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey)
}
if let storedOverride = UserDefaults.standard.string(forKey: iconOverrideKey),
let selection = IconOverrideSelection(rawValue: storedOverride)
{
self.iconOverride = selection
} else {
self.iconOverride = .system
UserDefaults.standard.set(IconOverrideSelection.system.rawValue, forKey: iconOverrideKey)
}
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
self.connectionMode = ConnectionMode(rawValue: storedMode ?? "local") ?? .local
self.remoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
self.webChatEnabled = UserDefaults.standard.object(forKey: webChatEnabledKey) as? Bool ?? true
self.webChatSwiftUIEnabled = UserDefaults.standard.object(forKey: webChatSwiftUIEnabledKey) as? Bool ?? false
let storedPort = UserDefaults.standard.integer(forKey: webChatPortKey)
self.webChatPort = storedPort > 0 ? storedPort : 18788
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
self.attachExistingGatewayOnly = UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
if !self.isPreview {
Task.detached(priority: .utility) { [weak self] in
let current = await LaunchAgentManager.status()
await MainActor.run { [weak self] in self?.launchAtLogin = current }
}
}
if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() {
self.swabbleEnabled = false
}
if !self.isPreview {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
}
func triggerVoiceEars(ttl: TimeInterval? = 5) {
self.earBoostTask?.cancel()
self.earBoostActive = true
guard let ttl else { return }
self.earBoostTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(ttl * 1_000_000_000))
await MainActor.run { [weak self] in self?.earBoostActive = false }
}
}
func stopVoiceEars() {
self.earBoostTask?.cancel()
self.earBoostTask = nil
self.earBoostActive = false
}
func blinkOnce() {
self.blinkTick &+= 1
}
func celebrateSend() {
self.sendCelebrationTick &+= 1
}
func setVoiceWakeEnabled(_ enabled: Bool) async {
guard voiceWakeSupported else {
self.swabbleEnabled = false
return
}
self.swabbleEnabled = enabled
guard !self.isPreview else { return }
if !enabled {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
return
}
if PermissionManager.voiceWakePermissionsGranted() {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
return
}
let granted = await PermissionManager.ensureVoiceWakePermissions(interactive: true)
self.swabbleEnabled = granted
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
func setWorking(_ working: Bool) {
self.isWorking = working
}
// MARK: - Chime persistence
private static func loadChime(key: String, fallback: VoiceWakeChime) -> VoiceWakeChime {
guard let data = UserDefaults.standard.data(forKey: key) else { return fallback }
if let decoded = try? JSONDecoder().decode(VoiceWakeChime.self, from: data) {
return decoded
}
return fallback
}
private func storeChime(_ chime: VoiceWakeChime, key: String) {
guard let data = try? JSONEncoder().encode(chime) else { return }
UserDefaults.standard.set(data, forKey: key)
}
}
extension AppState {
static var preview: AppState {
let state = AppState(preview: true)
state.isPaused = false
state.launchAtLogin = true
state.onboardingSeen = true
state.debugPaneEnabled = true
state.swabbleEnabled = true
state.swabbleTriggerWords = ["Claude", "Computer", "Jarvis"]
state.voiceWakeTriggerChime = .system(name: "Glass")
state.voiceWakeSendChime = .system(name: "Ping")
state.iconAnimationsEnabled = true
state.showDockIcon = true
state.voiceWakeMicID = "BuiltInMic"
state.voiceWakeLocaleID = Locale.current.identifier
state.voiceWakeAdditionalLocaleIDs = ["en-US", "de-DE"]
state.voicePushToTalkEnabled = false
state.iconOverride = .system
state.heartbeatsEnabled = true
state.connectionMode = .local
state.webChatEnabled = true
state.webChatSwiftUIEnabled = false
state.webChatPort = 18788
state.canvasEnabled = true
state.remoteTarget = "user@example.com"
state.remoteIdentity = "~/.ssh/id_ed25519"
state.remoteProjectRoot = "~/Projects/clawdis"
state.attachExistingGatewayOnly = false
return state
}
}
@MainActor
enum AppStateStore {
static let shared = AppState()
static var isPausedFlag: Bool { UserDefaults.standard.bool(forKey: pauseDefaultsKey) }
static func updateLaunchAtLogin(enabled: Bool) {
Task.detached(priority: .utility) {
await LaunchAgentManager.set(enabled: enabled, bundlePath: Bundle.main.bundlePath)
}
}
static var webChatEnabled: Bool {
UserDefaults.standard.object(forKey: webChatEnabledKey) as? Bool ?? true
}
static var webChatSwiftUIEnabled: Bool {
UserDefaults.standard.object(forKey: webChatSwiftUIEnabledKey) as? Bool ?? false
}
static var webChatPort: Int {
let stored = UserDefaults.standard.integer(forKey: webChatPortKey)
return stored > 0 ? stored : 18788
}
static var canvasEnabled: Bool {
UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
}
static var attachExistingGatewayOnly: Bool {
UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
}
}
@MainActor
enum AppActivationPolicy {
static func apply(showDockIcon: Bool) {
NSApp.setActivationPolicy(showDockIcon ? .regular : .accessory)
}
}
@@ -0,0 +1,294 @@
import ClawdisKit
import Foundation
import Network
import OSLog
actor BridgeConnectionHandler {
private let connection: NWConnection
private let logger: Logger
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private let queue = DispatchQueue(label: "com.steipete.clawdis.bridge.connection")
private var buffer = Data()
private var isAuthenticated = false
private var nodeId: String?
private var pendingInvokes: [String: CheckedContinuation<BridgeInvokeResponse, Error>] = [:]
private var isClosed = false
init(connection: NWConnection, logger: Logger) {
self.connection = connection
self.logger = logger
}
enum AuthResult: Sendable {
case ok
case notPaired
case unauthorized
case error(code: String, message: String)
}
enum PairResult: Sendable {
case ok(token: String)
case rejected
case error(code: String, message: String)
}
func run(
resolveAuth: @escaping @Sendable (BridgeHello) async -> AuthResult,
handlePair: @escaping @Sendable (BridgePairRequest) async -> PairResult,
onAuthenticated: (@Sendable (String) async -> Void)? = nil,
onDisconnected: (@Sendable (String) async -> Void)? = nil,
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)? = nil) async
{
self.connection.stateUpdateHandler = { [logger] state in
switch state {
case .ready:
logger.debug("bridge conn ready")
case let .failed(err):
logger.error("bridge conn failed: \(err.localizedDescription, privacy: .public)")
default:
break
}
}
self.connection.start(queue: self.queue)
while true {
do {
guard let line = try await self.receiveLine() else { break }
guard let data = line.data(using: .utf8) else { continue }
let base = try self.decoder.decode(BridgeBaseFrame.self, from: data)
switch base.type {
case "hello":
let hello = try self.decoder.decode(BridgeHello.self, from: data)
self.nodeId = hello.nodeId
let result = await resolveAuth(hello)
await self.handleAuthResult(
result,
serverName: Host.current().localizedName ?? ProcessInfo.processInfo.hostName)
if case .ok = result, let nodeId = self.nodeId {
await onAuthenticated?(nodeId)
}
case "pair-request":
let req = try self.decoder.decode(BridgePairRequest.self, from: data)
self.nodeId = req.nodeId
let enriched = BridgePairRequest(
type: req.type,
nodeId: req.nodeId,
displayName: req.displayName,
platform: req.platform,
version: req.version,
remoteAddress: self.remoteAddressString())
let result = await handlePair(enriched)
await self.handlePairResult(
result,
serverName: Host.current().localizedName ?? ProcessInfo.processInfo.hostName)
if case .ok = result, let nodeId = self.nodeId {
await onAuthenticated?(nodeId)
}
case "event":
guard self.isAuthenticated, let nodeId = self.nodeId else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
continue
}
let evt = try self.decoder.decode(BridgeEventFrame.self, from: data)
await onEvent?(nodeId, evt)
case "ping":
if !self.isAuthenticated {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
continue
}
let ping = try self.decoder.decode(BridgePing.self, from: data)
try await self.send(BridgePong(type: "pong", id: ping.id))
case "invoke-res":
guard self.isAuthenticated else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
continue
}
let res = try self.decoder.decode(BridgeInvokeResponse.self, from: data)
if let cont = self.pendingInvokes.removeValue(forKey: res.id) {
cont.resume(returning: res)
}
default:
await self.sendError(code: "INVALID_REQUEST", message: "unknown type")
}
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
await self.close(with: onDisconnected)
}
private func remoteAddressString() -> String? {
switch self.connection.endpoint {
case let .hostPort(host: host, port: _):
let value = String(describing: host)
return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : value
default:
return nil
}
}
func remoteAddress() -> String? {
self.remoteAddressString()
}
private func handlePairResult(_ result: PairResult, serverName: String) async {
switch result {
case let .ok(token):
do {
try await self.send(BridgePairOk(type: "pair-ok", token: token))
self.isAuthenticated = true
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
} catch {
self.logger.error("bridge send pair-ok failed: \(error.localizedDescription, privacy: .public)")
}
case .rejected:
await self.sendError(code: "UNAUTHORIZED", message: "pairing rejected")
case let .error(code, message):
await self.sendError(code: code, message: message)
}
}
private func handleAuthResult(_ result: AuthResult, serverName: String) async {
switch result {
case .ok:
self.isAuthenticated = true
do {
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
} catch {
self.logger.error("bridge send hello-ok failed: \(error.localizedDescription, privacy: .public)")
}
case .notPaired:
await self.sendError(code: "NOT_PAIRED", message: "pairing required")
case .unauthorized:
await self.sendError(code: "UNAUTHORIZED", message: "invalid token")
case let .error(code, message):
await self.sendError(code: code, message: message)
}
}
private func sendError(code: String, message: String) async {
do {
try await self.send(BridgeErrorFrame(type: "error", code: code, message: message))
} catch {
self.logger.error("bridge send error failed: \(error.localizedDescription, privacy: .public)")
}
}
func invoke(command: String, paramsJSON: String?) async throws -> BridgeInvokeResponse {
guard self.isAuthenticated else {
throw NSError(domain: "Bridge", code: 1, userInfo: [
NSLocalizedDescriptionKey: "UNAUTHORIZED: not authenticated",
])
}
let id = UUID().uuidString
let req = BridgeInvokeRequest(type: "invoke", id: id, command: command, paramsJSON: paramsJSON)
let timeoutTask = Task {
try await Task.sleep(nanoseconds: 15 * 1_000_000_000)
await self.timeoutInvoke(id: id)
}
defer { timeoutTask.cancel() }
return try await withCheckedThrowingContinuation { cont in
Task { [weak self] in
guard let self else { return }
await self.beginInvoke(id: id, request: req, continuation: cont)
}
}
}
private func beginInvoke(
id: String,
request: BridgeInvokeRequest,
continuation: CheckedContinuation<BridgeInvokeResponse, Error>) async
{
self.pendingInvokes[id] = continuation
do {
try await self.send(request)
} catch {
await self.failInvoke(id: id, error: error)
}
}
private func timeoutInvoke(id: String) async {
guard let cont = self.pendingInvokes.removeValue(forKey: id) else { return }
cont.resume(throwing: NSError(domain: "Bridge", code: 3, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: invoke timeout",
]))
}
private func failInvoke(id: String, error: Error) async {
guard let cont = self.pendingInvokes.removeValue(forKey: id) else { return }
cont.resume(throwing: error)
}
private func send(_ obj: some Encodable) async throws {
let data = try self.encoder.encode(obj)
var line = Data()
line.append(data)
line.append(0x0A) // \n
let _: Void = try await withCheckedThrowingContinuation { cont in
self.connection.send(content: line, completion: .contentProcessed { err in
if let err {
cont.resume(throwing: err)
} else {
cont.resume(returning: ())
}
})
}
}
private func receiveLine() async throws -> String? {
while true {
if let idx = self.buffer.firstIndex(of: 0x0A) {
let lineData = self.buffer.prefix(upTo: idx)
self.buffer.removeSubrange(...idx)
return String(data: lineData, encoding: .utf8)
}
let chunk = try await self.receiveChunk()
if chunk.isEmpty { return nil }
self.buffer.append(chunk)
}
}
private func receiveChunk() async throws -> Data {
try await withCheckedThrowingContinuation { cont in
self.connection
.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
if let error {
cont.resume(throwing: error)
return
}
if isComplete {
cont.resume(returning: Data())
return
}
cont.resume(returning: data ?? Data())
}
}
}
private func close(with onDisconnected: (@Sendable (String) async -> Void)? = nil) async {
if self.isClosed { return }
self.isClosed = true
let nodeId = self.nodeId
let pending = self.pendingInvokes.values
self.pendingInvokes.removeAll()
for cont in pending {
cont.resume(throwing: NSError(domain: "Bridge", code: 4, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: connection closed",
]))
}
self.connection.cancel()
if let nodeId {
await onDisconnected?(nodeId)
}
}
}
@@ -0,0 +1,328 @@
import AppKit
import ClawdisKit
import Foundation
import Network
import OSLog
actor BridgeServer {
static let shared = BridgeServer()
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "bridge")
private var listener: NWListener?
private var isRunning = false
private var store: PairedNodesStore?
private var connections: [String: BridgeConnectionHandler] = [:]
private var presenceTasks: [String: Task<Void, Never>] = [:]
func start() async {
if self.isRunning { return }
self.isRunning = true
do {
let storeURL = try Self.defaultStoreURL()
let store = PairedNodesStore(fileURL: storeURL)
await store.load()
self.store = store
let params = NWParameters.tcp
params.includePeerToPeer = true
let listener = try NWListener(using: params, on: .any)
let name = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
listener.service = NWListener.Service(
name: "\(name) (Clawdis)",
type: ClawdisBonjour.bridgeServiceType,
domain: ClawdisBonjour.bridgeServiceDomain,
txtRecord: nil)
listener.newConnectionHandler = { [weak self] connection in
guard let self else { return }
Task { await self.handle(connection: connection) }
}
listener.stateUpdateHandler = { [weak self] state in
guard let self else { return }
Task { await self.handleListenerState(state) }
}
listener.start(queue: DispatchQueue(label: "com.steipete.clawdis.bridge"))
self.listener = listener
} catch {
self.logger.error("bridge start failed: \(error.localizedDescription, privacy: .public)")
self.isRunning = false
}
}
func stop() async {
self.isRunning = false
self.listener?.cancel()
self.listener = nil
}
private func handleListenerState(_ state: NWListener.State) {
switch state {
case .ready:
self.logger.info("bridge listening")
case let .failed(err):
self.logger.error("bridge listener failed: \(err.localizedDescription, privacy: .public)")
case .cancelled:
self.logger.info("bridge listener cancelled")
case .waiting:
self.logger.info("bridge listener waiting")
case .setup:
break
@unknown default:
break
}
}
private func handle(connection: NWConnection) async {
let handler = BridgeConnectionHandler(connection: connection, logger: self.logger)
await handler.run(
resolveAuth: { [weak self] hello in
await self?.authorize(hello: hello) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable")
},
handlePair: { [weak self] request in
await self?.pair(request: request) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable")
},
onAuthenticated: { [weak self] nodeId in
await self?.registerConnection(handler: handler, nodeId: nodeId)
},
onDisconnected: { [weak self] nodeId in
await self?.unregisterConnection(nodeId: nodeId)
},
onEvent: { [weak self] nodeId, evt in
await self?.handleEvent(nodeId: nodeId, evt: evt)
})
}
func invoke(nodeId: String, command: String, paramsJSON: String?) async throws -> BridgeInvokeResponse {
guard let handler = self.connections[nodeId] else {
throw NSError(domain: "Bridge", code: 10, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: node not connected",
])
}
return try await handler.invoke(command: command, paramsJSON: paramsJSON)
}
func connectedNodeIds() -> [String] {
Array(self.connections.keys).sorted()
}
private func registerConnection(handler: BridgeConnectionHandler, nodeId: String) async {
self.connections[nodeId] = handler
await self.beaconPresence(nodeId: nodeId, reason: "connect")
self.startPresenceTask(nodeId: nodeId)
}
private func unregisterConnection(nodeId: String) async {
await self.beaconPresence(nodeId: nodeId, reason: "disconnect")
self.stopPresenceTask(nodeId: nodeId)
self.connections.removeValue(forKey: nodeId)
}
private struct VoiceTranscriptPayload: Codable, Sendable {
var text: String
var sessionKey: String?
}
private func handleEvent(nodeId: String, evt: BridgeEventFrame) async {
switch evt.event {
case "voice.transcript":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
return
}
guard let payload = try? JSONDecoder().decode(VoiceTranscriptPayload.self, from: data) else {
return
}
let text = payload.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
let sessionKey = payload.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? "node-\(nodeId)"
_ = await AgentRPC.shared.send(
text: text,
thinking: "low",
sessionKey: sessionKey,
deliver: false,
to: nil,
channel: "last")
case "agent.request":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
return
}
guard let link = try? JSONDecoder().decode(AgentDeepLink.self, from: data) else {
return
}
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !message.isEmpty else { return }
guard message.count <= 20000 else { return }
let sessionKey = link.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? "node-\(nodeId)"
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let channel = link.channel?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
_ = await AgentRPC.shared.send(
text: message,
thinking: thinking,
sessionKey: sessionKey,
deliver: link.deliver,
to: to,
channel: channel ?? "last")
default:
break
}
}
private func beaconPresence(nodeId: String, reason: String) async {
do {
let paired = await self.store?.find(nodeId: nodeId)
let host = paired?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? nodeId
let version = paired?.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let platform = paired?.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let ip = await self.connections[nodeId]?.remoteAddress()
var tags: [String] = ["node", "ios"]
if let platform { tags.append(platform) }
let summary = [
"Node: \(host)\(ip.map { " (\($0))" } ?? "")",
platform.map { "platform \($0)" },
version.map { "app \($0)" },
"mode node",
"reason \(reason)",
].compactMap(\.self).joined(separator: " · ")
var params: [String: Any] = [
"text": summary,
"instanceId": nodeId,
"host": host,
"mode": "node",
"reason": reason,
"tags": tags,
]
if let ip { params["ip"] = ip }
if let version { params["version"] = version }
_ = try await AgentRPC.shared.controlRequest(
method: "system-event",
params: ControlRequestParams(raw: params))
} catch {
// Best-effort only.
}
}
private func startPresenceTask(nodeId: String) {
self.presenceTasks[nodeId]?.cancel()
self.presenceTasks[nodeId] = Task.detached { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 180 * 1_000_000_000)
if Task.isCancelled { return }
await self?.beaconPresence(nodeId: nodeId, reason: "periodic")
}
}
}
private func stopPresenceTask(nodeId: String) {
self.presenceTasks[nodeId]?.cancel()
self.presenceTasks.removeValue(forKey: nodeId)
}
private func authorize(hello: BridgeHello) async -> BridgeConnectionHandler.AuthResult {
let nodeId = hello.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
if nodeId.isEmpty {
return .error(code: "INVALID_REQUEST", message: "nodeId required")
}
guard let store = self.store else {
return .error(code: "UNAVAILABLE", message: "store unavailable")
}
guard let paired = await store.find(nodeId: nodeId) else {
return .notPaired
}
guard let token = hello.token, token == paired.token else {
return .unauthorized
}
do { try await store.touchSeen(nodeId: nodeId) } catch { /* ignore */ }
return .ok
}
private func pair(request: BridgePairRequest) async -> BridgeConnectionHandler.PairResult {
let nodeId = request.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
if nodeId.isEmpty {
return .error(code: "INVALID_REQUEST", message: "nodeId required")
}
guard let store = self.store else {
return .error(code: "UNAVAILABLE", message: "store unavailable")
}
let existing = await store.find(nodeId: nodeId)
let approved = await BridgePairingApprover.approve(request: request, isRepair: existing != nil)
if !approved {
return .rejected
}
let token = UUID().uuidString.replacingOccurrences(of: "-", with: "")
let nowMs = Int(Date().timeIntervalSince1970 * 1000)
let node = PairedNode(
nodeId: nodeId,
displayName: request.displayName,
platform: request.platform,
version: request.version,
token: token,
createdAtMs: nowMs,
lastSeenAtMs: nowMs)
do {
try await store.upsert(node)
return .ok(token: token)
} catch {
return .error(code: "UNAVAILABLE", message: "failed to persist pairing")
}
}
private static func defaultStoreURL() throws -> URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
guard let base else {
throw NSError(
domain: "Bridge",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Application Support unavailable"])
}
return base
.appendingPathComponent("Clawdis", isDirectory: true)
.appendingPathComponent("bridge", isDirectory: true)
.appendingPathComponent("paired-nodes.json", isDirectory: false)
}
}
@MainActor
enum BridgePairingApprover {
static func approve(request: BridgePairRequest, isRepair: Bool) async -> Bool {
await withCheckedContinuation { cont in
let name = request.displayName ?? request.nodeId
let remote = request.remoteAddress?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let alert = NSAlert()
alert.messageText = isRepair ? "Re-pair Clawdis Node?" : "Pair Clawdis Node?"
alert.informativeText = """
Node: \(name)
IP: \(remote ?? "unknown")
Platform: \(request.platform ?? "unknown")
Version: \(request.version ?? "unknown")
"""
alert.addButton(withTitle: "Approve")
alert.addButton(withTitle: "Reject")
let resp = alert.runModal()
cont.resume(returning: resp == .alertFirstButtonReturn)
}
}
}
extension String {
fileprivate var nonEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
@@ -0,0 +1,57 @@
import Foundation
struct PairedNode: Codable, Equatable {
var nodeId: String
var displayName: String?
var platform: String?
var version: String?
var token: String
var createdAtMs: Int
var lastSeenAtMs: Int?
}
actor PairedNodesStore {
private let fileURL: URL
private var nodes: [String: PairedNode] = [:]
init(fileURL: URL) {
self.fileURL = fileURL
}
func load() {
do {
let data = try Data(contentsOf: self.fileURL)
let decoded = try JSONDecoder().decode([String: PairedNode].self, from: data)
self.nodes = decoded
} catch {
self.nodes = [:]
}
}
func all() -> [PairedNode] {
self.nodes.values.sorted { a, b in (a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId) }
}
func find(nodeId: String) -> PairedNode? {
self.nodes[nodeId]
}
func upsert(_ node: PairedNode) async throws {
self.nodes[node.nodeId] = node
try await self.persist()
}
func touchSeen(nodeId: String) async throws {
guard var node = self.nodes[nodeId] else { return }
node.lastSeenAtMs = Int(Date().timeIntervalSince1970 * 1000)
self.nodes[nodeId] = node
try await self.persist()
}
private func persist() async throws {
let dir = self.fileURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let data = try JSONEncoder().encode(self.nodes)
try data.write(to: self.fileURL, options: [.atomic])
}
}
@@ -0,0 +1,94 @@
import Foundation
import CoreServices
final class CanvasFileWatcher: @unchecked Sendable {
private let url: URL
private let queue: DispatchQueue
private var stream: FSEventStreamRef?
private var pending = false
private let onChange: () -> Void
init(url: URL, onChange: @escaping () -> Void) {
self.url = url
self.queue = DispatchQueue(label: "com.steipete.clawdis.canvaswatcher")
self.onChange = onChange
}
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<CanvasFileWatcher>.fromOpaque(pointer).release()
},
copyDescription: nil)
let paths = [self.url.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 CanvasFileWatcher {
private static let callback: FSEventStreamCallback = { _, info, numEvents, _, eventFlags, _ in
guard let info else { return }
let watcher = Unmanaged<CanvasFileWatcher>.fromOpaque(info).takeUnretainedValue()
watcher.handleEvents(numEvents: numEvents, eventFlags: eventFlags)
}
private func handleEvents(numEvents: Int, eventFlags: UnsafePointer<FSEventStreamEventFlags>?) {
guard numEvents > 0 else { return }
guard eventFlags != nil else { return }
// Coalesce rapid changes (common during builds/atomic saves).
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()
}
}
}
@@ -0,0 +1,82 @@
import AppKit
import ClawdisIPC
import Foundation
@MainActor
final class CanvasManager {
static let shared = CanvasManager()
private var panelController: CanvasWindowController?
private var panelSessionKey: String?
/// Optional anchor provider (e.g. menu bar status item). If nil, Canvas anchors to the mouse cursor.
var defaultAnchorProvider: (() -> NSRect?)?
private nonisolated static let canvasRoot: URL = {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("Clawdis/canvas", isDirectory: true)
}()
func show(sessionKey: String, path: String? = nil, placement: CanvasPlacement? = nil) throws -> String {
let anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider
let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
if let controller = self.panelController, self.panelSessionKey == session {
controller.presentAnchoredPanel(anchorProvider: anchorProvider)
controller.applyPreferredPlacement(placement)
controller.goto(path: path ?? "/")
return controller.directoryPath
}
self.panelController?.close()
self.panelController = nil
self.panelSessionKey = nil
try FileManager.default.createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true)
let controller = try CanvasWindowController(
sessionKey: session,
root: Self.canvasRoot,
presentation: .panel(anchorProvider: anchorProvider))
self.panelController = controller
self.panelSessionKey = session
controller.applyPreferredPlacement(placement)
controller.showCanvas(path: path ?? "/")
return controller.directoryPath
}
func hide(sessionKey: String) {
let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard self.panelSessionKey == session else { return }
self.panelController?.hideCanvas()
}
func hideAll() {
self.panelController?.hideCanvas()
}
func goto(sessionKey: String, path: String, placement: CanvasPlacement? = nil) throws {
_ = try self.show(sessionKey: sessionKey, path: path, placement: placement)
}
func eval(sessionKey: String, javaScript: String) async throws -> String {
_ = try self.show(sessionKey: sessionKey, path: nil)
guard let controller = self.panelController else { return "" }
return await controller.eval(javaScript: javaScript)
}
func snapshot(sessionKey: String, outPath: String?) async throws -> String {
_ = try self.show(sessionKey: sessionKey, path: nil)
guard let controller = self.panelController else {
throw NSError(domain: "Canvas", code: 21, userInfo: [NSLocalizedDescriptionKey: "canvas not available"])
}
return try await controller.snapshot(to: outPath)
}
// MARK: - Anchoring
private static func mouseAnchorProvider() -> NSRect? {
let pt = NSEvent.mouseLocation
return NSRect(x: pt.x, y: pt.y, width: 1, height: 1)
}
// placement interpretation is handled by the window controller.
}
@@ -0,0 +1,41 @@
import Foundation
enum CanvasScheme {
static let scheme = "clawdis-canvas"
static func makeURL(session: String, path: String? = nil) -> URL? {
var comps = URLComponents()
comps.scheme = Self.scheme
comps.host = session
let p = (path ?? "/").trimmingCharacters(in: .whitespacesAndNewlines)
if p.isEmpty || p == "/" {
comps.path = "/"
} else if p.hasPrefix("/") {
comps.path = p
} else {
comps.path = "/" + p
}
return comps.url
}
static func mimeType(forExtension ext: String) -> String {
switch ext.lowercased() {
// Note: WKURLSchemeHandler uses URLResponse(mimeType:), which expects a bare MIME type
// (no `; charset=...`). Encoding is provided via URLResponse(textEncodingName:).
case "html", "htm": "text/html"
case "js", "mjs": "application/javascript"
case "css": "text/css"
case "json", "map": "application/json"
case "svg": "image/svg+xml"
case "png": "image/png"
case "jpg", "jpeg": "image/jpeg"
case "gif": "image/gif"
case "ico": "image/x-icon"
case "woff2": "font/woff2"
case "woff": "font/woff"
case "ttf": "font/ttf"
case "wasm": "application/wasm"
default: "application/octet-stream"
}
}
}
@@ -0,0 +1,204 @@
import Foundation
import OSLog
import WebKit
private let canvasLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas")
final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
private let root: URL
init(root: URL) {
self.root = root
}
func webView(_: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
guard let url = urlSchemeTask.request.url else {
urlSchemeTask.didFailWithError(NSError(domain: "Canvas", code: 1, userInfo: [
NSLocalizedDescriptionKey: "missing url",
]))
return
}
let response = self.response(for: url)
let mime = response.mime
let data = response.data
let encoding = self.textEncodingName(forMimeType: mime)
let urlResponse = URLResponse(
url: url,
mimeType: mime,
expectedContentLength: data.count,
textEncodingName: encoding)
urlSchemeTask.didReceive(urlResponse)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
}
func webView(_: WKWebView, stop _: WKURLSchemeTask) {
// no-op
}
private struct CanvasResponse {
let mime: String
let data: Data
}
private func response(for url: URL) -> CanvasResponse {
guard url.scheme == CanvasScheme.scheme else {
return self.html("Invalid scheme.")
}
guard let session = url.host, !session.isEmpty else {
return self.html("Missing session.")
}
// Keep session component safe; don't allow slashes or traversal.
if session.contains("/") || session.contains("..") {
return self.html("Invalid session.")
}
let sessionRoot = self.root.appendingPathComponent(session, isDirectory: true)
// Path mapping: request path maps directly into the session dir.
var path = url.path
if let qIdx = path.firstIndex(of: "?") { path = String(path[..<qIdx]) }
if path.hasPrefix("/") { path.removeFirst() }
path = path.removingPercentEncoding ?? path
// Special-case: welcome page when root index is missing.
if path.isEmpty {
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
let indexB = sessionRoot.appendingPathComponent("index.htm", isDirectory: false)
if !FileManager.default.fileExists(atPath: indexA.path),
!FileManager.default.fileExists(atPath: indexB.path)
{
return self.welcomePage(sessionRoot: sessionRoot)
}
}
let resolved = self.resolveFileURL(sessionRoot: sessionRoot, requestPath: path)
guard let fileURL = resolved else {
return self.html("Not Found", title: "Canvas: 404")
}
// Directory traversal guard: served files must live under the session root.
let standardizedRoot = sessionRoot.standardizedFileURL
let standardizedFile = fileURL.standardizedFileURL
guard standardizedFile.path.hasPrefix(standardizedRoot.path) else {
return self.html("Forbidden", title: "Canvas: 403")
}
do {
let data = try Data(contentsOf: standardizedFile)
let mime = CanvasScheme.mimeType(forExtension: standardizedFile.pathExtension)
canvasLogger.debug(
"served \(session, privacy: .public)/\(path, privacy: .public) -> \(standardizedFile.path, privacy: .public)")
return CanvasResponse(mime: mime, data: data)
} catch {
canvasLogger.error("failed reading \(standardizedFile.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
return self.html("Failed to read file.", title: "Canvas error")
}
}
private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
let fm = FileManager.default
var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false)
var isDir: ObjCBool = false
if fm.fileExists(atPath: candidate.path, isDirectory: &isDir) {
if isDir.boolValue {
if let idx = self.resolveIndex(in: candidate) { return idx }
return nil
}
return candidate
}
// Directory index behavior:
// - "/yolo" serves "<yolo>/index.html" if that directory exists.
if !requestPath.isEmpty, !requestPath.hasSuffix("/") {
candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: true)
if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue {
if let idx = self.resolveIndex(in: candidate) { return idx }
}
}
// Root fallback:
// - "/" serves "<sessionRoot>/index.html" if present.
if requestPath.isEmpty {
return self.resolveIndex(in: sessionRoot)
}
return nil
}
private func resolveIndex(in dir: URL) -> URL? {
let fm = FileManager.default
let a = dir.appendingPathComponent("index.html", isDirectory: false)
if fm.fileExists(atPath: a.path) { return a }
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
if fm.fileExists(atPath: b.path) { return b }
return nil
}
private func html(_ body: String, title: String = "Canvas") -> CanvasResponse {
let html = """
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>\(title)</title>
<style>
:root { color-scheme: light; }
html,body { height:100%; margin:0; }
body {
font: 13px -apple-system, system-ui;
display:flex;
align-items:center;
justify-content:center;
background: #fff;
color:#111827;
}
.card {
max-width: 520px;
padding: 18px 18px;
border-radius: 12px;
border: 1px solid rgba(0,0,0,.08);
box-shadow: 0 10px 30px rgba(0,0,0,.08);
}
.muted { color:#6b7280; margin-top:8px; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
</style>
</head>
<body>
<div class="card">
<div>\(body)</div>
</div>
</body>
</html>
"""
return CanvasResponse(mime: "text/html", data: Data(html.utf8))
}
private func welcomePage(sessionRoot: URL) -> CanvasResponse {
let escaped = sessionRoot.path
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
let body = """
<div style="font-weight:600; font-size:14px;">Canvas is ready.</div>
<div class="muted">Create <code>index.html</code> in:</div>
<div style="margin-top:10px;"><code>\(escaped)</code></div>
"""
return self.html(body, title: "Canvas")
}
private func textEncodingName(forMimeType mimeType: String) -> String? {
if mimeType.hasPrefix("text/") { return "utf-8" }
switch mimeType {
case "application/javascript", "application/json", "image/svg+xml":
return "utf-8"
default:
return nil
}
}
}
@@ -0,0 +1,580 @@
import AppKit
import ClawdisIPC
import Foundation
import OSLog
import WebKit
import QuartzCore
private let canvasWindowLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas")
private enum CanvasLayout {
static let panelSize = NSSize(width: 520, height: 680)
static let windowSize = NSSize(width: 1120, height: 840)
static let anchorPadding: CGFloat = 8
static let defaultPadding: CGFloat = 10
static let minPanelSize = NSSize(width: 360, height: 360)
}
final class CanvasPanel: NSPanel {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true }
}
enum CanvasPresentation {
case window
case panel(anchorProvider: () -> NSRect?)
var isPanel: Bool {
if case .panel = self { return true }
return false
}
}
@MainActor
final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
private let sessionKey: String
private let root: URL
private let sessionDir: URL
private let schemeHandler: CanvasSchemeHandler
private let webView: WKWebView
private let watcher: CanvasFileWatcher
private let container: HoverChromeContainerView
let presentation: CanvasPresentation
private var preferredPlacement: CanvasPlacement?
var onVisibilityChanged: ((Bool) -> Void)?
init(sessionKey: String, root: URL, presentation: CanvasPresentation) throws {
self.sessionKey = sessionKey
self.root = root
self.presentation = presentation
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
try FileManager.default.createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
self.schemeHandler = CanvasSchemeHandler(root: root)
let config = WKWebViewConfiguration()
config.userContentController = WKUserContentController()
config.preferences.isElementFullscreenEnabled = true
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: CanvasScheme.scheme)
self.webView = WKWebView(frame: .zero, configuration: config)
self.webView.setValue(false, forKey: "drawsBackground")
self.watcher = CanvasFileWatcher(url: self.sessionDir) { [weak webView] in
Task { @MainActor in
// Only auto-reload when we are showing local canvas content.
guard webView?.url?.scheme == CanvasScheme.scheme else { return }
webView?.reload()
}
}
self.container = HoverChromeContainerView(containing: self.webView)
let window = Self.makeWindow(for: presentation, contentView: self.container)
super.init(window: window)
self.webView.navigationDelegate = self
self.window?.delegate = self
self.container.onClose = { [weak self] in
self?.hideCanvas()
}
self.watcher.start()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
@MainActor deinit {
self.watcher.stop()
}
func applyPreferredPlacement(_ placement: CanvasPlacement?) {
self.preferredPlacement = placement
}
func showCanvas(path: String? = nil) {
if case .panel(let anchorProvider) = self.presentation {
self.presentAnchoredPanel(anchorProvider: anchorProvider)
if let path {
self.goto(path: path)
} else {
self.goto(path: "/")
}
return
}
self.showWindow(nil)
self.window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
if let path {
self.goto(path: path)
} else {
self.goto(path: "/")
}
self.onVisibilityChanged?(true)
}
func hideCanvas() {
if case .panel = self.presentation {
self.persistFrameIfPanel()
self.window?.orderOut(nil)
} else {
self.close()
}
self.onVisibilityChanged?(false)
}
func goto(path: String) {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http" {
canvasWindowLogger.debug("canvas goto web \(url.absoluteString, privacy: .public)")
self.webView.load(URLRequest(url: url))
return
}
guard let url = CanvasScheme.makeURL(session: CanvasWindowController.sanitizeSessionKey(self.sessionKey), path: trimmed) else {
canvasWindowLogger.error("invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)")
return
}
canvasWindowLogger.debug("canvas goto canvas \(url.absoluteString, privacy: .public)")
self.webView.load(URLRequest(url: url))
}
func eval(javaScript: String) async -> String {
await withCheckedContinuation { cont in
self.webView.evaluateJavaScript(javaScript) { result, error in
if let error {
cont.resume(returning: "error: \(error.localizedDescription)")
return
}
if let result {
cont.resume(returning: String(describing: result))
} else {
cont.resume(returning: "")
}
}
}
}
func snapshot(to outPath: String?) async throws -> String {
let image: NSImage = try await withCheckedThrowingContinuation { cont in
self.webView.takeSnapshot(with: nil) { image, error in
if let error {
cont.resume(throwing: error)
return
}
guard let image else {
cont.resume(throwing: NSError(domain: "Canvas", code: 11, userInfo: [
NSLocalizedDescriptionKey: "snapshot returned nil image",
]))
return
}
cont.resume(returning: image)
}
}
guard let tiff = image.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff),
let png = rep.representation(using: .png, properties: [:])
else {
throw NSError(domain: "Canvas", code: 12, userInfo: [
NSLocalizedDescriptionKey: "failed to encode png",
])
}
let path: String
if let outPath, !outPath.isEmpty {
path = outPath
} else {
let ts = Int(Date().timeIntervalSince1970)
path = "/tmp/clawdis-canvas-\(CanvasWindowController.sanitizeSessionKey(self.sessionKey))-\(ts).png"
}
try png.write(to: URL(fileURLWithPath: path), options: [.atomic])
return path
}
var directoryPath: String {
self.sessionDir.path
}
// MARK: - Window
private static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow {
switch presentation {
case .window:
let window = NSWindow(
contentRect: NSRect(origin: .zero, size: CanvasLayout.windowSize),
styleMask: [.titled, .closable, .resizable, .miniaturizable],
backing: .buffered,
defer: false)
window.title = "Clawdis Canvas"
window.contentView = contentView
window.center()
window.minSize = NSSize(width: 880, height: 680)
return window
case .panel:
let panel = CanvasPanel(
contentRect: NSRect(origin: .zero, size: CanvasLayout.panelSize),
styleMask: [.borderless, .resizable],
backing: .buffered,
defer: false)
// Keep Canvas below the Voice Wake overlay panel.
panel.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue - 1)
panel.hasShadow = true
panel.isMovable = false
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.titleVisibility = .hidden
panel.titlebarAppearsTransparent = true
panel.backgroundColor = .clear
panel.isOpaque = false
panel.contentView = contentView
panel.becomesKeyOnlyIfNeeded = true
panel.hidesOnDeactivate = false
panel.minSize = CanvasLayout.minPanelSize
return panel
}
}
func presentAnchoredPanel(anchorProvider: @escaping () -> NSRect?) {
guard case .panel = self.presentation, let window else { return }
self.repositionPanel(using: anchorProvider)
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
window.makeFirstResponder(self.webView)
VoiceWakeOverlayController.shared.bringToFrontIfVisible()
self.onVisibilityChanged?(true)
}
private func repositionPanel(using anchorProvider: () -> NSRect?) {
guard let panel = self.window else { return }
let anchor = anchorProvider()
let screen = NSScreen.screens.first { screen in
guard let anchor else { return false }
return screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY))
} ?? NSScreen.main
// Base frame: restored frame (preferred), otherwise default top-right.
var frame = Self.loadRestoredFrame(sessionKey: self.sessionKey) ?? Self.defaultTopRightFrame(panel: panel, screen: screen)
// Apply agent placement as partial overrides:
// - If agent provides x/y, override origin.
// - If agent provides width/height, override size.
// - If agent provides only size, keep the remembered origin.
if let placement = self.preferredPlacement {
if let x = placement.x { frame.origin.x = x }
if let y = placement.y { frame.origin.y = y }
if let w = placement.width { frame.size.width = max(CanvasLayout.minPanelSize.width, CGFloat(w)) }
if let h = placement.height { frame.size.height = max(CanvasLayout.minPanelSize.height, CGFloat(h)) }
}
self.setPanelFrame(frame, on: screen)
}
private static func defaultTopRightFrame(panel: NSWindow, screen: NSScreen?) -> NSRect {
let visible = (screen?.visibleFrame ?? NSScreen.main?.visibleFrame) ?? panel.frame
let w = max(CanvasLayout.minPanelSize.width, panel.frame.width)
let h = max(CanvasLayout.minPanelSize.height, panel.frame.height)
let x = visible.maxX - w - CanvasLayout.defaultPadding
let y = visible.maxY - h - CanvasLayout.defaultPadding
return NSRect(x: x, y: y, width: w, height: h)
}
private func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) {
guard let panel = self.window else { return }
let s = screen ?? panel.screen ?? NSScreen.main
let constrained: NSRect
if let s {
constrained = panel.constrainFrameRect(frame, to: s)
} else {
constrained = frame
}
panel.setFrame(constrained, display: false)
self.persistFrameIfPanel()
}
// MARK: - WKNavigationDelegate
@MainActor
func webView(
_: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void)
{
guard let url = navigationAction.request.url else {
decisionHandler(.cancel)
return
}
let scheme = url.scheme?.lowercased()
// Keep web content inside the panel when reasonable.
// `about:blank` and friends are common internal navigations for WKWebView; never send them to NSWorkspace.
if scheme == CanvasScheme.scheme
|| scheme == "https"
|| scheme == "http"
|| scheme == "about"
|| scheme == "blob"
|| scheme == "data"
|| scheme == "javascript"
{
decisionHandler(.allow)
return
}
// Only open external URLs when there is a registered handler, otherwise macOS will show a confusing
// "There is no application set to open the URL ..." alert (e.g. for about:blank).
if let appURL = NSWorkspace.shared.urlForApplication(toOpen: url) {
NSWorkspace.shared.open(
[url],
withApplicationAt: appURL,
configuration: NSWorkspace.OpenConfiguration(),
completionHandler: nil)
} else {
canvasWindowLogger.debug("no application to open url \(url.absoluteString, privacy: .public)")
}
decisionHandler(.cancel)
}
// MARK: - NSWindowDelegate
func windowWillClose(_: Notification) {
self.onVisibilityChanged?(false)
}
func windowDidMove(_: Notification) {
self.persistFrameIfPanel()
}
func windowDidEndLiveResize(_: Notification) {
self.persistFrameIfPanel()
}
private func persistFrameIfPanel() {
guard case .panel = self.presentation, let window else { return }
Self.storeRestoredFrame(window.frame, sessionKey: self.sessionKey)
}
// MARK: - Helpers
private static func sanitizeSessionKey(_ key: String) -> String {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return "main" }
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-+")
let scalars = trimmed.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
return String(scalars)
}
private static func storedFrameDefaultsKey(sessionKey: String) -> String {
"clawdis.canvas.frame.\(sanitizeSessionKey(sessionKey))"
}
private static func loadRestoredFrame(sessionKey: String) -> NSRect? {
let key = storedFrameDefaultsKey(sessionKey: sessionKey)
guard let arr = UserDefaults.standard.array(forKey: key) as? [Double], arr.count == 4 else { return nil }
let rect = NSRect(x: arr[0], y: arr[1], width: arr[2], height: arr[3])
if rect.width < CanvasLayout.minPanelSize.width || rect.height < CanvasLayout.minPanelSize.height { return nil }
return rect
}
private static func storeRestoredFrame(_ frame: NSRect, sessionKey: String) {
let key = storedFrameDefaultsKey(sessionKey: sessionKey)
UserDefaults.standard.set([Double(frame.origin.x), Double(frame.origin.y), Double(frame.size.width), Double(frame.size.height)], forKey: key)
}
}
// MARK: - Hover chrome container
private final class HoverChromeContainerView: NSView {
private let content: NSView
private let chrome: CanvasChromeOverlayView
private var tracking: NSTrackingArea?
var onClose: (() -> Void)?
init(containing content: NSView) {
self.content = content
self.chrome = CanvasChromeOverlayView(frame: .zero)
super.init(frame: .zero)
self.wantsLayer = true
self.layer?.cornerRadius = 12
self.layer?.masksToBounds = true
self.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
self.content.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(self.content)
self.chrome.translatesAutoresizingMaskIntoConstraints = false
self.chrome.alphaValue = 0
self.chrome.onClose = { [weak self] in self?.onClose?() }
self.addSubview(self.chrome)
NSLayoutConstraint.activate([
self.content.leadingAnchor.constraint(equalTo: self.leadingAnchor),
self.content.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.content.topAnchor.constraint(equalTo: self.topAnchor),
self.content.bottomAnchor.constraint(equalTo: self.bottomAnchor),
self.chrome.leadingAnchor.constraint(equalTo: self.leadingAnchor),
self.chrome.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.chrome.topAnchor.constraint(equalTo: self.topAnchor),
self.chrome.bottomAnchor.constraint(equalTo: self.bottomAnchor),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let tracking {
self.removeTrackingArea(tracking)
}
let area = NSTrackingArea(
rect: self.bounds,
options: [.activeAlways, .mouseEnteredAndExited, .inVisibleRect],
owner: self,
userInfo: nil)
self.addTrackingArea(area)
self.tracking = area
}
private final class CanvasDragHandleView: NSView {
override func mouseDown(with event: NSEvent) {
self.window?.performDrag(with: event)
}
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
}
private final class CanvasResizeHandleView: NSView {
private var startPoint: NSPoint = .zero
private var startFrame: NSRect = .zero
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
override func mouseDown(with event: NSEvent) {
guard let window else { return }
_ = window.makeFirstResponder(self)
self.startPoint = NSEvent.mouseLocation
self.startFrame = window.frame
super.mouseDown(with: event)
}
override func mouseDragged(with _: NSEvent) {
guard let window else { return }
let current = NSEvent.mouseLocation
let dx = current.x - self.startPoint.x
let dy = current.y - self.startPoint.y
var frame = self.startFrame
frame.size.width = max(CanvasLayout.minPanelSize.width, frame.size.width + dx)
frame.origin.y += dy
frame.size.height = max(CanvasLayout.minPanelSize.height, frame.size.height - dy)
if let screen = window.screen {
frame = window.constrainFrameRect(frame, to: screen)
}
window.setFrame(frame, display: true)
}
}
private final class CanvasChromeOverlayView: NSView {
var onClose: (() -> Void)?
private let dragHandle = CanvasDragHandleView(frame: .zero)
private let resizeHandle = CanvasResizeHandleView(frame: .zero)
private let closeButton: NSButton = {
let img = NSImage(systemSymbolName: "xmark.circle.fill", accessibilityDescription: "Close")
?? NSImage(size: NSSize(width: 18, height: 18))
let btn = NSButton(image: img, target: nil, action: nil)
btn.isBordered = false
btn.bezelStyle = .regularSquare
btn.imageScaling = .scaleProportionallyDown
btn.contentTintColor = NSColor.secondaryLabelColor
btn.toolTip = "Close"
return btn
}()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
self.wantsLayer = true
self.layer?.cornerRadius = 12
self.layer?.masksToBounds = true
self.layer?.borderWidth = 1
self.layer?.borderColor = NSColor.black.withAlphaComponent(0.18).cgColor
self.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.02).cgColor
self.dragHandle.translatesAutoresizingMaskIntoConstraints = false
self.dragHandle.wantsLayer = true
self.dragHandle.layer?.backgroundColor = NSColor.clear.cgColor
self.addSubview(self.dragHandle)
self.resizeHandle.translatesAutoresizingMaskIntoConstraints = false
self.resizeHandle.wantsLayer = true
self.resizeHandle.layer?.backgroundColor = NSColor.clear.cgColor
self.addSubview(self.resizeHandle)
self.closeButton.translatesAutoresizingMaskIntoConstraints = false
self.closeButton.target = self
self.closeButton.action = #selector(self.handleClose)
self.addSubview(self.closeButton)
NSLayoutConstraint.activate([
self.dragHandle.leadingAnchor.constraint(equalTo: self.leadingAnchor),
self.dragHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.dragHandle.topAnchor.constraint(equalTo: self.topAnchor),
self.dragHandle.heightAnchor.constraint(equalToConstant: 30),
self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8),
self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
self.closeButton.widthAnchor.constraint(equalToConstant: 18),
self.closeButton.heightAnchor.constraint(equalToConstant: 18),
self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor),
self.resizeHandle.widthAnchor.constraint(equalToConstant: 18),
self.resizeHandle.heightAnchor.constraint(equalToConstant: 18),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func hitTest(_ point: NSPoint) -> NSView? {
// When the chrome is hidden, do not intercept any mouse events (let the WKWebView receive them).
guard self.alphaValue > 0.02 else { return nil }
if self.closeButton.frame.contains(point) { return self.closeButton }
if self.dragHandle.frame.contains(point) { return self.dragHandle }
if self.resizeHandle.frame.contains(point) { return self.resizeHandle }
return nil
}
@objc private func handleClose() {
self.onClose?()
}
}
override func mouseEntered(with _: NSEvent) {
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.12
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
self.chrome.animator().alphaValue = 1
}
}
override func mouseExited(with _: NSEvent) {
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.16
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
self.chrome.animator().alphaValue = 0
}
}
}
@@ -0,0 +1,260 @@
import SwiftUI
@MainActor
struct ConfigSettings: View {
private let isPreview = ProcessInfo.processInfo.isPreview
@State private var configModel: String = ""
@State private var customModel: String = ""
@State private var configSaving = false
@State private var hasLoaded = false
@State private var models: [ModelChoice] = []
@State private var modelsLoading = false
@State private var modelError: String?
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
@State private var allowAutosave = false
@State private var heartbeatMinutes: Int?
@State private var heartbeatBody: String = "HEARTBEAT"
@AppStorage(webChatEnabledKey) private var webChatEnabled: Bool = true
@AppStorage(webChatPortKey) private var webChatPort: Int = 18788
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Clawdis CLI config")
.font(.title3.weight(.semibold))
Text("Edit ~/.clawdis/clawdis.json (inbound.reply.agent/session).")
.font(.callout)
.foregroundStyle(.secondary)
LabeledContent("Model") {
VStack(alignment: .leading, spacing: 6) {
Picker("Model", selection: self.$configModel) {
ForEach(self.models) { choice in
Text(
"\(choice.name) — \(choice.provider.uppercased())")
.tag(choice.id)
}
Text("Manual entry…").tag("__custom__")
}
.labelsHidden()
.frame(width: 360)
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
.onChange(of: self.configModel) { _, _ in
self.autosaveConfig()
}
if self.configModel == "__custom__" {
TextField("Enter model ID", text: self.$customModel)
.textFieldStyle(.roundedBorder)
.frame(width: 320)
.onChange(of: self.customModel) { _, newValue in
self.configModel = newValue
self.autosaveConfig()
}
}
if let contextLabel = self.selectedContextLabel {
Text(contextLabel)
.font(.footnote)
.foregroundStyle(.secondary)
}
if let modelError {
Text(modelError)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
LabeledContent("Heartbeat") {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 12) {
Stepper(
value: Binding(
get: { self.heartbeatMinutes ?? 10 },
set: { self.heartbeatMinutes = $0; self.autosaveConfig() }),
in: 0...720)
{
Text("Every \(self.heartbeatMinutes ?? 10) min")
}
.help("Set to 0 to disable automatic heartbeats")
TextField("HEARTBEAT", text: self.$heartbeatBody)
.textFieldStyle(.roundedBorder)
.frame(width: 240)
.onChange(of: self.heartbeatBody) { _, _ in
self.autosaveConfig()
}
.help("Message body sent on each heartbeat")
}
Text("Heartbeats keep Pi sessions warm; 0 minutes disables them.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Divider().padding(.vertical, 4)
LabeledContent("Web chat") {
VStack(alignment: .leading, spacing: 6) {
Toggle("Enable embedded web chat (loopback only)", isOn: self.$webChatEnabled)
.toggleStyle(.switch)
.frame(width: 320, alignment: .leading)
HStack(spacing: 8) {
Text("Port")
TextField("18788", value: self.$webChatPort, formatter: NumberFormatter())
.textFieldStyle(.roundedBorder)
.frame(width: 100)
.disabled(!self.webChatEnabled)
}
Text(
"""
Mac app connects to the gateway’s loopback web chat on this port.
Remote mode uses SSH -L to forward it.
""")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: 480, alignment: .leading)
}
}
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.onChange(of: self.modelCatalogPath) { _, _ in
Task { await self.loadModels() }
}
.onChange(of: self.modelCatalogReloadBump) { _, _ in
Task { await self.loadModels() }
}
.task {
guard !self.hasLoaded else { return }
guard !self.isPreview else { return }
self.hasLoaded = true
self.loadConfig()
await self.loadModels()
self.allowAutosave = true
}
}
private func configURL() -> URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdis")
.appendingPathComponent("clawdis.json")
}
private func loadConfig() {
let parsed = self.loadConfigDict()
let inbound = parsed["inbound"] as? [String: Any]
let reply = inbound?["reply"] as? [String: Any]
let agent = reply?["agent"] as? [String: Any]
let heartbeatMinutes = reply?["heartbeatMinutes"] as? Int
let heartbeatBody = reply?["heartbeatBody"] as? String
let loadedModel = (agent?["model"] as? String) ?? ""
if !loadedModel.isEmpty {
self.configModel = loadedModel
self.customModel = loadedModel
} else {
self.configModel = SessionLoader.fallbackModel
self.customModel = SessionLoader.fallbackModel
}
if let heartbeatMinutes { self.heartbeatMinutes = heartbeatMinutes }
if let heartbeatBody, !heartbeatBody.isEmpty { self.heartbeatBody = heartbeatBody }
}
private func autosaveConfig() {
guard self.allowAutosave else { return }
Task { await self.saveConfig() }
}
private func saveConfig() async {
guard !self.configSaving else { return }
self.configSaving = true
defer { self.configSaving = false }
var root = self.loadConfigDict()
var inbound = root["inbound"] as? [String: Any] ?? [:]
var reply = inbound["reply"] as? [String: Any] ?? [:]
var agent = reply["agent"] as? [String: Any] ?? [:]
let chosenModel = (self.configModel == "__custom__" ? self.customModel : self.configModel)
.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedModel = chosenModel
if !trimmedModel.isEmpty { agent["model"] = trimmedModel }
reply["agent"] = agent
if let heartbeatMinutes {
reply["heartbeatMinutes"] = heartbeatMinutes
}
let trimmedBody = self.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedBody.isEmpty {
reply["heartbeatBody"] = trimmedBody
}
inbound["reply"] = reply
root["inbound"] = inbound
do {
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
let url = self.configURL()
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
} catch {}
}
private func loadConfigDict() -> [String: Any] {
let url = self.configURL()
guard let data = try? Data(contentsOf: url) else { return [:] }
return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]
}
private func loadModels() async {
guard !self.modelsLoading else { return }
self.modelsLoading = true
self.modelError = nil
do {
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
self.models = loaded
if !self.configModel.isEmpty, !loaded.contains(where: { $0.id == self.configModel }) {
self.customModel = self.configModel
self.configModel = "__custom__"
}
} catch {
self.modelError = error.localizedDescription
self.models = []
}
self.modelsLoading = false
}
private var selectedContextLabel: String? {
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
guard
!chosenId.isEmpty,
let choice = self.models.first(where: { $0.id == chosenId }),
let context = choice.contextWindow
else {
return nil
}
let human = context >= 1000 ? "\(context / 1000)k" : "\(context)"
return "Context window: \(human) tokens"
}
}
#if DEBUG
struct ConfigSettings_Previews: PreviewProvider {
static var previews: some View {
ConfigSettings()
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}
#endif
@@ -0,0 +1,49 @@
import Foundation
import OSLog
@MainActor
final class ConnectionModeCoordinator {
static let shared = ConnectionModeCoordinator()
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "connection")
/// Apply the requested connection mode by starting/stopping local gateway,
/// managing the control-channel SSH tunnel, and cleaning up WebChat tunnels.
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
switch mode {
case .local:
await RemoteTunnelManager.shared.stopAll()
WebChatManager.shared.resetTunnels()
do {
try await ControlChannel.shared.configure(mode: .local)
} catch {
// Control channel will mark itself degraded; nothing else to do here.
self.logger.error(
"control channel local configure failed: \(error.localizedDescription, privacy: .public)")
}
if paused {
GatewayProcessManager.shared.stop()
} else {
GatewayProcessManager.shared.setActive(true)
}
Task.detached { await PortGuardian.shared.sweep(mode: .local) }
case .remote:
// Never run a local gateway in remote mode.
GatewayProcessManager.shared.stop()
WebChatManager.shared.resetTunnels()
do {
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote(
target: settings.target,
identity: settings.identity))
} catch {
self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)")
}
Task.detached { await PortGuardian.shared.sweep(mode: .remote) }
}
}
}
@@ -0,0 +1,35 @@
import Foundation
let launchdLabel = "com.steipete.clawdis"
let onboardingVersionKey = "clawdis.onboardingVersion"
let currentOnboardingVersion = 3
let pauseDefaultsKey = "clawdis.pauseEnabled"
let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled"
let swabbleEnabledKey = "clawdis.swabbleEnabled"
let swabbleTriggersKey = "clawdis.swabbleTriggers"
let voiceWakeTriggerChimeKey = "clawdis.voiceWakeTriggerChime"
let voiceWakeSendChimeKey = "clawdis.voiceWakeSendChime"
let showDockIconKey = "clawdis.showDockIcon"
let defaultVoiceWakeTriggers = ["clawd", "claude"]
let voiceWakeMicKey = "clawdis.voiceWakeMicID"
let voiceWakeLocaleKey = "clawdis.voiceWakeLocaleID"
let voiceWakeAdditionalLocalesKey = "clawdis.voiceWakeAdditionalLocaleIDs"
let voicePushToTalkEnabledKey = "clawdis.voicePushToTalkEnabled"
let iconOverrideKey = "clawdis.iconOverride"
let connectionModeKey = "clawdis.connectionMode"
let remoteTargetKey = "clawdis.remoteTarget"
let remoteIdentityKey = "clawdis.remoteIdentity"
let remoteProjectRootKey = "clawdis.remoteProjectRoot"
let webChatEnabledKey = "clawdis.webChatEnabled"
let webChatSwiftUIEnabledKey = "clawdis.webChatSwiftUIEnabled"
let webChatPortKey = "clawdis.webChatPort"
let canvasEnabledKey = "clawdis.canvasEnabled"
let deepLinkAgentEnabledKey = "clawdis.deepLinkAgentEnabled"
let deepLinkKeyKey = "clawdis.deepLinkKey"
let modelCatalogPathKey = "clawdis.modelCatalogPath"
let modelCatalogReloadKey = "clawdis.modelCatalogReload"
let attachExistingGatewayOnlyKey = "clawdis.gateway.attachExistingOnly"
let heartbeatsEnabledKey = "clawdis.heartbeatsEnabled"
let debugFileLogEnabledKey = "clawdis.debug.fileLogEnabled"
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
let cliHelperSearchPaths = ["/usr/local/bin", "/opt/homebrew/bin"]
@@ -0,0 +1,131 @@
import Foundation
import SwiftUI
/// Context usage card shown at the top of the menubar menu.
struct ContextMenuCardView: View {
private let width: CGFloat
private let padding: CGFloat = 10
private let barHeight: CGFloat = 4
@State private var rows: [SessionRow] = []
@State private var activeCount: Int = 0
private let activeWindowSeconds: TimeInterval = 24 * 60 * 60
init(width: CGFloat) {
self.width = width
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline) {
Text("Context")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer(minLength: 10)
Text(self.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
if self.rows.isEmpty {
Text("No active sessions")
.font(.caption)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(self.rows) { row in
self.sessionRow(row)
}
}
}
}
.padding(self.padding)
.frame(width: self.width, alignment: .leading)
.background {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.white.opacity(0.04))
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.06), lineWidth: 1)
}
}
.task { await self.reload() }
}
private var subtitle: String {
let count = self.activeCount
if count == 1 { return "1 session · 24h" }
return "\(count) sessions · 24h"
}
private var contentWidth: CGFloat {
max(1, self.width - (self.padding * 2))
}
@ViewBuilder
private func sessionRow(_ row: SessionRow) -> some View {
let width = self.contentWidth
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(row.key)
.font(.caption.weight(row.key == "main" ? .semibold : .regular))
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer(minLength: 8)
Text(row.tokens.contextSummaryShort)
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(2)
}
.frame(width: width)
ContextUsageBar(
usedTokens: row.tokens.total,
contextTokens: row.tokens.contextTokens,
width: width,
height: self.barHeight)
}
.frame(width: width)
}
@MainActor
private func reload() async {
let hints = SessionLoader.configHints()
let store = SessionLoader.resolveStorePath(override: hints.storePath)
let defaults = SessionDefaults(
model: hints.model ?? SessionLoader.fallbackModel,
contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens)
guard let loaded = try? await SessionLoader.loadRows(at: store, defaults: defaults) else {
self.rows = []
self.activeCount = 0
return
}
let now = Date()
let active = loaded.filter { row in
guard let updatedAt = row.updatedAt else { return false }
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
}
let main = loaded.first(where: { $0.key == "main" })
var merged = active
if let main, !merged.contains(where: { $0.key == "main" }) {
merged.insert(main, at: 0)
}
merged.sort { lhs, rhs in
if lhs.key == "main" { return true }
if rhs.key == "main" { return false }
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
}
self.rows = merged
self.activeCount = active.count
}
}
@@ -0,0 +1,74 @@
import SwiftUI
struct ContextUsageBar: View {
let usedTokens: Int
let contextTokens: Int
var width: CGFloat?
var height: CGFloat = 6
private var clampedFractionUsed: Double {
guard self.contextTokens > 0 else { return 0 }
return min(1, max(0, Double(self.usedTokens) / Double(self.contextTokens)))
}
private var percentUsed: Int? {
guard self.contextTokens > 0, self.usedTokens > 0 else { return nil }
return min(100, Int(round(self.clampedFractionUsed * 100)))
}
private var tint: Color {
guard let pct = self.percentUsed else { return .secondary }
if pct >= 95 { return Color(nsColor: .systemRed) }
if pct >= 80 { return Color(nsColor: .systemOrange) }
if pct >= 60 { return Color(nsColor: .systemYellow) }
return Color(nsColor: .systemGreen)
}
var body: some View {
let fraction = self.clampedFractionUsed
Group {
if let width = self.width, width > 0 {
self.barBody(width: width, fraction: fraction)
.frame(width: width, height: self.height)
} else {
GeometryReader { proxy in
self.barBody(width: proxy.size.width, fraction: fraction)
.frame(width: proxy.size.width, height: self.height)
}
.frame(height: self.height)
}
}
.accessibilityLabel("Context usage")
.accessibilityValue(self.accessibilityValue)
}
private var accessibilityValue: String {
if self.contextTokens <= 0 { return "Unknown context window" }
let pct = Int(round(self.clampedFractionUsed * 100))
return "\(pct) percent used"
}
@ViewBuilder
private func barBody(width: CGFloat, fraction: Double) -> some View {
let radius = self.height / 2
let trackFill = Color.white.opacity(0.12)
let trackStroke = Color.white.opacity(0.18)
let fillWidth = max(1, floor(width * CGFloat(fraction)))
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: radius, style: .continuous)
.fill(trackFill)
.overlay {
RoundedRectangle(cornerRadius: radius, style: .continuous)
.strokeBorder(trackStroke, lineWidth: 0.75)
}
RoundedRectangle(cornerRadius: radius, style: .continuous)
.fill(self.tint)
.frame(width: fillWidth)
.mask {
RoundedRectangle(cornerRadius: radius, style: .continuous)
}
}
}
}
@@ -0,0 +1,268 @@
import ClawdisProtocol
import Foundation
import OSLog
import SwiftUI
struct ControlHeartbeatEvent: Codable {
let ts: Double
let status: String
let to: String?
let preview: String?
let durationMs: Double?
let hasMedia: Bool?
let reason: String?
}
struct ControlAgentEvent: Codable, Sendable, Identifiable {
var id: String { "\(self.runId)-\(self.seq)" }
let runId: String
let seq: Int
let stream: String
let ts: Double
let data: [String: AnyCodable]
let summary: String?
}
enum ControlChannelError: Error, LocalizedError {
case disconnected
case badResponse(String)
var errorDescription: String? {
switch self {
case .disconnected: "Control channel disconnected"
case let .badResponse(msg): msg
}
}
}
@MainActor
final class ControlChannel: ObservableObject {
static let shared = ControlChannel()
enum Mode {
case local
case remote(target: String, identity: String)
}
enum ConnectionState: Equatable {
case disconnected
case connecting
case connected
case degraded(String)
}
@Published private(set) var state: ConnectionState = .disconnected
@Published private(set) var lastPingMs: Double?
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
private var eventTask: Task<Void, Never>?
private init() {
self.startEventStream()
}
func configure() async {
self.state = .connecting
do {
try await GatewayConnection.shared.refresh()
self.state = .connected
PresenceReporter.shared.sendImmediate(reason: "connect")
} catch {
let message = self.friendlyGatewayMessage(error)
self.state = .degraded(message)
}
}
func configure(mode: Mode = .local) async throws {
switch mode {
case .local:
await self.configure()
case let .remote(target, identity):
do {
_ = (target, identity)
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
await self.configure()
} catch {
self.state = .degraded(error.localizedDescription)
throw error
}
}
}
func health(timeout: TimeInterval? = nil) async throws -> Data {
do {
let start = Date()
var params: [String: AnyHashable]?
if let timeout {
params = ["timeout": AnyHashable(Int(timeout * 1000))]
}
let timeoutMs = (timeout ?? 15) * 1000
let payload = try await self.request(method: "health", params: params, timeoutMs: timeoutMs)
let ms = Date().timeIntervalSince(start) * 1000
self.lastPingMs = ms
self.state = .connected
return payload
} catch {
let message = self.friendlyGatewayMessage(error)
self.state = .degraded(message)
throw ControlChannelError.badResponse(message)
}
}
func lastHeartbeat() async throws -> ControlHeartbeatEvent? {
let data = try await self.request(method: "last-heartbeat")
return try JSONDecoder().decode(ControlHeartbeatEvent?.self, from: data)
}
func request(
method: String,
params: [String: AnyHashable]? = nil,
timeoutMs: Double? = nil) async throws -> Data
{
do {
let rawParams = params?.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) }
let data = try await GatewayConnection.shared.request(method: method, params: rawParams, timeoutMs: timeoutMs)
self.state = .connected
return data
} catch {
let message = self.friendlyGatewayMessage(error)
self.state = .degraded(message)
throw ControlChannelError.badResponse(message)
}
}
private func friendlyGatewayMessage(_ error: Error) -> String {
// Map URLSession/WS errors into user-facing, actionable text.
if let ctrlErr = error as? ControlChannelError, let desc = ctrlErr.errorDescription {
return desc
}
// If the gateway explicitly rejects the hello (e.g., auth/token mismatch), surface it.
if let urlErr = error as? URLError,
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
{
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
return "Gateway rejected token; set CLAWDIS_GATEWAY_TOKEN in the mac app environment or clear it on the gateway. Reason: \(reason)"
}
// Common misfire: we connected to localhost:18789 but the port is occupied
// by some other process (e.g. a local dev gateway or a stuck SSH forward).
// The gateway handshake returns something we can't parse, which currently
// surfaces as "hello failed (unexpected response)". Give the user a pointer
// to free the port instead of a vague message.
let nsError = error as NSError
if nsError.domain == "Gateway",
nsError.localizedDescription.contains("hello failed (unexpected response)")
{
let port = GatewayEnvironment.gatewayPort()
return """
Gateway handshake got non-gateway data on localhost:\(port).
Another process is using that port or the SSH forward failed.
Stop the local gateway/port-forward on \(port) and retry Remote mode.
"""
}
if let urlError = error as? URLError {
let port = GatewayEnvironment.gatewayPort()
switch urlError.code {
case .cancelled:
return "Gateway connection was closed; start the gateway (localhost:\(port)) and retry."
case .cannotFindHost, .cannotConnectToHost:
return "Cannot reach gateway at localhost:\(port); ensure the gateway is running."
case .networkConnectionLost:
return "Gateway connection dropped; gateway likely restarted—retry."
case .timedOut:
return "Gateway request timed out; check gateway on localhost:\(port)."
case .notConnectedToInternet:
return "No network connectivity; cannot reach gateway."
default:
break
}
}
if nsError.domain == "Gateway", nsError.code == 5 {
let port = GatewayEnvironment.gatewayPort()
return "Gateway request timed out; check the gateway process on localhost:\(port)."
}
let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription
return "Gateway error: \(detail)"
}
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
var merged = params
merged["text"] = AnyHashable(text)
_ = try await self.request(method: "system-event", params: merged)
}
private func startEventStream() {
self.eventTask?.cancel()
self.eventTask = Task { [weak self] in
guard let self else { return }
let stream = await GatewayConnection.shared.subscribe()
for await push in stream {
if Task.isCancelled { return }
await MainActor.run { [weak self] in
self?.handle(push: push)
}
}
}
}
private func handle(push: GatewayPush) {
switch push {
case let .event(evt) where evt.event == "agent":
if let payload = evt.payload,
let agent = try? GatewayPayloadDecoding.decode(payload, as: ControlAgentEvent.self)
{
AgentEventStore.shared.append(agent)
self.routeWorkActivity(from: agent)
}
case let .event(evt) where evt.event == "heartbeat":
if let payload = evt.payload,
let heartbeat = try? GatewayPayloadDecoding.decode(payload, as: ControlHeartbeatEvent.self),
let data = try? JSONEncoder().encode(heartbeat)
{
NotificationCenter.default.post(name: .controlHeartbeat, object: data)
}
case let .event(evt) where evt.event == "shutdown":
self.state = .degraded("gateway shutdown")
case .snapshot:
self.state = .connected
default:
break
}
}
private func routeWorkActivity(from event: ControlAgentEvent) {
// We currently treat VoiceWake as the "main" session for UI purposes.
// In the future, the gateway can include a sessionKey to distinguish runs.
let sessionKey = (event.data["sessionKey"]?.value as? String) ?? "main"
switch event.stream.lowercased() {
case "job":
if let state = event.data["state"]?.value as? String {
WorkActivityStore.shared.handleJob(sessionKey: sessionKey, state: state)
}
case "tool":
let phase = event.data["phase"]?.value as? String ?? ""
let name = event.data["name"]?.value as? String
let meta = event.data["meta"]?.value as? String
let args = event.data["args"]?.value as? [String: AnyCodable]
WorkActivityStore.shared.handleTool(
sessionKey: sessionKey,
phase: phase,
name: name,
meta: meta,
args: args)
default:
break
}
}
}
extension Notification.Name {
static let controlHeartbeat = Notification.Name("clawdis.control.heartbeat")
static let controlAgentEvent = Notification.Name("clawdis.control.agent")
}
@@ -0,0 +1,174 @@
import ClawdisIPC
import Foundation
import OSLog
enum ControlRequestHandler {
static func process(
request: Request,
notifier: NotificationManager = NotificationManager(),
logger: Logger = Logger(subsystem: "com.steipete.clawdis", category: "control")) async throws -> Response
{
// Keep `status` responsive even if the main actor is busy.
let paused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
if paused {
switch request {
case .status:
break
default:
return Response(ok: false, message: "clawdis paused")
}
}
switch request {
case let .notify(title, body, sound, priority, delivery):
let chosenSound = sound?.trimmingCharacters(in: .whitespacesAndNewlines)
let chosenDelivery = delivery ?? .system
switch chosenDelivery {
case .system:
let ok = await notifier.send(title: title, body: body, sound: chosenSound, priority: priority)
return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized")
case .overlay:
await MainActor.run {
NotifyOverlayController.shared.present(title: title, body: body)
}
return Response(ok: true)
case .auto:
let ok = await notifier.send(title: title, body: body, sound: chosenSound, priority: priority)
if ok { return Response(ok: true) }
await MainActor.run {
NotifyOverlayController.shared.present(title: title, body: body)
}
return Response(ok: true, message: "notification not authorized; used overlay")
}
case let .ensurePermissions(caps, interactive):
let statuses = await PermissionManager.ensure(caps, interactive: interactive)
let missing = statuses.filter { !$0.value }.map(\.key.rawValue)
let ok = missing.isEmpty
let msg = ok ? "all granted" : "missing: \(missing.joined(separator: ","))"
return Response(ok: ok, message: msg)
case .status:
return paused ? Response(ok: false, message: "clawdis paused") : Response(ok: true, message: "ready")
case .rpcStatus:
let result = await AgentRPC.shared.status()
return Response(ok: result.ok, message: result.error)
case let .screenshot(displayID, windowID, _):
let authorized = await PermissionManager
.ensure([.screenRecording], interactive: false)[.screenRecording] ?? false
guard authorized else { return Response(ok: false, message: "screen recording permission missing") }
if let data = await Screenshotter.capture(displayID: displayID, windowID: windowID) {
return Response(ok: true, payload: data)
}
return Response(ok: false, message: "screenshot failed")
case let .runShell(command, cwd, env, timeoutSec, needsSR):
if needsSR {
let authorized = await PermissionManager
.ensure([.screenRecording], interactive: false)[.screenRecording] ?? false
guard authorized else { return Response(ok: false, message: "screen recording permission missing") }
}
return await ShellExecutor.run(command: command, cwd: cwd, env: env, timeout: timeoutSec)
case let .agent(message, thinking, session, deliver, to):
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return Response(ok: false, message: "message empty") }
let sessionKey = session ?? "main"
let rpcResult = await AgentRPC.shared.send(
text: trimmed,
thinking: thinking,
sessionKey: sessionKey,
deliver: deliver,
to: to,
channel: nil)
return rpcResult.ok
? Response(ok: true, message: rpcResult.text ?? "sent")
: Response(ok: false, message: rpcResult.error ?? "failed to send")
case let .canvasShow(session, path, placement):
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
guard canvasEnabled else {
return Response(ok: false, message: "Canvas disabled by user")
}
do {
let dir = try await MainActor.run { try CanvasManager.shared.show(
sessionKey: session,
path: path,
placement: placement) }
return Response(ok: true, message: dir)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
case let .canvasHide(session):
await MainActor.run { CanvasManager.shared.hide(sessionKey: session) }
return Response(ok: true)
case let .canvasGoto(session, path, placement):
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
guard canvasEnabled else {
return Response(ok: false, message: "Canvas disabled by user")
}
do {
try await MainActor.run { try CanvasManager.shared.goto(
sessionKey: session,
path: path,
placement: placement) }
return Response(ok: true)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
case let .canvasEval(session, javaScript):
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
guard canvasEnabled else {
return Response(ok: false, message: "Canvas disabled by user")
}
do {
let result = try await CanvasManager.shared.eval(sessionKey: session, javaScript: javaScript)
return Response(ok: true, payload: Data(result.utf8))
} catch {
return Response(ok: false, message: error.localizedDescription)
}
case let .canvasSnapshot(session, outPath):
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
guard canvasEnabled else {
return Response(ok: false, message: "Canvas disabled by user")
}
do {
let path = try await CanvasManager.shared.snapshot(sessionKey: session, outPath: outPath)
return Response(ok: true, message: path)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
case .nodeList:
let ids = await BridgeServer.shared.connectedNodeIds()
let payload = (try? JSONSerialization.data(
withJSONObject: ["connectedNodeIds": ids],
options: [.prettyPrinted]))
.flatMap { String(data: $0, encoding: .utf8) }
?? "{}"
return Response(ok: true, payload: Data(payload.utf8))
case let .nodeInvoke(nodeId, command, paramsJSON):
do {
let res = try await BridgeServer.shared.invoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON)
if res.ok {
let payload = res.payloadJSON ?? ""
return Response(ok: true, payload: Data(payload.utf8))
}
let errText = res.error?.message ?? "node invoke failed"
return Response(ok: false, message: errText)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
}
}
}
@@ -0,0 +1,284 @@
import ClawdisIPC
import Foundation
import Darwin
import OSLog
/// Lightweight UNIX-domain socket server so `clawdis-mac` can talk to the app
/// without a launchd MachService. Listens on `controlSocketPath`.
final actor ControlSocketServer {
nonisolated private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "control.socket")
private var listenFD: Int32 = -1
private var acceptTask: Task<Void, Never>?
private let socketPath: String
private let maxRequestBytes: Int
private let allowedTeamIDs: Set<String>
private let requestTimeoutSec: TimeInterval
init(
socketPath: String = controlSocketPath,
maxRequestBytes: Int = 512 * 1024,
allowedTeamIDs: Set<String> = ["Y5PE65HELJ"],
requestTimeoutSec: TimeInterval = 5)
{
self.socketPath = socketPath
self.maxRequestBytes = maxRequestBytes
self.allowedTeamIDs = allowedTeamIDs
self.requestTimeoutSec = requestTimeoutSec
}
private static func disableSigPipe(fd: Int32) {
var one: Int32 = 1
_ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size(ofValue: one)))
}
func start() {
// Already running
guard self.listenFD == -1 else { return }
let path = self.socketPath
let fm = FileManager.default
// Ensure directory exists
let dir = (path as NSString).deletingLastPathComponent
try? fm.createDirectory(atPath: dir, withIntermediateDirectories: true)
// Remove stale socket
unlink(path)
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return }
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let capacity = MemoryLayout.size(ofValue: addr.sun_path)
let copied = path.withCString { cstr -> Int in
strlcpy(&addr.sun_path.0, cstr, capacity)
}
if copied >= capacity {
close(fd)
return
}
addr.sun_len = UInt8(MemoryLayout.size(ofValue: addr))
let len = socklen_t(MemoryLayout.size(ofValue: addr))
if bind(fd, withUnsafePointer(to: &addr, { UnsafePointer<sockaddr>(OpaquePointer($0)) }), len) != 0 {
close(fd)
return
}
// Restrict permissions: owner rw
chmod(path, S_IRUSR | S_IWUSR)
if listen(fd, SOMAXCONN) != 0 {
close(fd)
return
}
self.listenFD = fd
let allowedTeamIDs = self.allowedTeamIDs
let maxRequestBytes = self.maxRequestBytes
let requestTimeoutSec = self.requestTimeoutSec
self.acceptTask = Task.detached(priority: .utility) {
await Self.acceptLoop(
listenFD: fd,
allowedTeamIDs: allowedTeamIDs,
maxRequestBytes: maxRequestBytes,
requestTimeoutSec: requestTimeoutSec)
}
}
func stop() {
self.acceptTask?.cancel()
self.acceptTask = nil
if self.listenFD != -1 {
close(self.listenFD)
self.listenFD = -1
}
unlink(self.socketPath)
}
private nonisolated static func acceptLoop(
listenFD: Int32,
allowedTeamIDs: Set<String>,
maxRequestBytes: Int,
requestTimeoutSec: TimeInterval) async
{
while !Task.isCancelled {
var addr = sockaddr()
var len: socklen_t = socklen_t(MemoryLayout<sockaddr>.size)
let client = accept(listenFD, &addr, &len)
if client < 0 {
if errno == EINTR { continue }
// Socket was likely closed as part of stop().
if errno == EBADF || errno == EINVAL { return }
self.logger.error("accept failed: \(errno, privacy: .public)")
try? await Task.sleep(nanoseconds: 50_000_000)
continue
}
Self.disableSigPipe(fd: client)
Task.detached(priority: .utility) {
defer { close(client) }
await Self.handleClient(
fd: client,
allowedTeamIDs: allowedTeamIDs,
maxRequestBytes: maxRequestBytes,
requestTimeoutSec: requestTimeoutSec)
}
}
}
private nonisolated static func handleClient(
fd: Int32,
allowedTeamIDs: Set<String>,
maxRequestBytes: Int,
requestTimeoutSec: TimeInterval) async
{
guard self.isAllowed(fd: fd, allowedTeamIDs: allowedTeamIDs) else {
return
}
do {
guard let request = try self.readRequest(
fd: fd,
maxRequestBytes: maxRequestBytes,
timeoutSec: requestTimeoutSec)
else {
return
}
let response = try await ControlRequestHandler.process(request: request)
try self.writeResponse(fd: fd, response: response)
} catch {
self.logger.error("socket request failed: \(error.localizedDescription, privacy: .public)")
let resp = Response(ok: false, message: "socket error: \(error.localizedDescription)")
try? self.writeResponse(fd: fd, response: resp)
}
}
private nonisolated static func readRequest(
fd: Int32,
maxRequestBytes: Int,
timeoutSec: TimeInterval) throws -> Request?
{
let deadline = Date().addingTimeInterval(timeoutSec)
var data = Data()
var buffer = [UInt8](repeating: 0, count: 16 * 1024)
let bufferSize = buffer.count
let decoder = JSONDecoder()
while true {
let remaining = deadline.timeIntervalSinceNow
if remaining <= 0 {
throw POSIXError(.ETIMEDOUT)
}
var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
let sliceMs = max(1.0, min(remaining, 0.25) * 1000.0)
let polled = poll(&pfd, 1, Int32(sliceMs))
if polled == 0 { continue }
if polled < 0 {
if errno == EINTR { continue }
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
let n = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress!, bufferSize) }
if n > 0 {
data.append(buffer, count: n)
if data.count > maxRequestBytes {
throw POSIXError(.EMSGSIZE)
}
if let req = try? decoder.decode(Request.self, from: data) {
return req
}
continue
}
if n == 0 {
return data.isEmpty ? nil : try decoder.decode(Request.self, from: data)
}
if errno == EINTR { continue }
if errno == EAGAIN { continue }
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
}
private nonisolated static func writeResponse(fd: Int32, response: Response) throws {
let encoded = try JSONEncoder().encode(response)
try encoded.withUnsafeBytes { buf in
guard let base = buf.baseAddress else { return }
var written = 0
while written < encoded.count {
let n = write(fd, base.advanced(by: written), encoded.count - written)
if n > 0 {
written += n
continue
}
if n == -1, errno == EINTR { continue }
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
}
}
private nonisolated static func isAllowed(fd: Int32, allowedTeamIDs: Set<String>) -> Bool {
var pid: pid_t = 0
var pidSize = socklen_t(MemoryLayout<pid_t>.size)
let r = getsockopt(fd, SOL_LOCAL, LOCAL_PEERPID, &pid, &pidSize)
guard r == 0, pid > 0 else { return false }
// Always require a valid code signature match (TeamID).
// This prevents any same-UID process from driving the app's privileged surface.
if self.teamIDMatches(pid: pid, allowedTeamIDs: allowedTeamIDs) {
return true
}
#if DEBUG
// Debug-only escape hatch: allow unsigned/same-UID clients when explicitly opted in.
// This keeps local development workable (e.g. a SwiftPM-built `clawdis-mac` binary).
let env = ProcessInfo.processInfo.environment["CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS"]
if env == "1", let callerUID = self.uid(for: pid), callerUID == getuid() {
self.logger.warning(
"allowing unsigned same-UID socket client pid=\(pid, privacy: .public) due to CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS=1")
return true
}
#endif
if let callerUID = self.uid(for: pid) {
self.logger.error(
"socket client rejected pid=\(pid, privacy: .public) uid=\(callerUID, privacy: .public)")
} else {
self.logger.error("socket client rejected pid=\(pid, privacy: .public) (uid unknown)")
}
return false
}
private nonisolated static func uid(for pid: pid_t) -> uid_t? {
var info = kinfo_proc()
var size = MemoryLayout.size(ofValue: info)
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]
let ok = mib.withUnsafeMutableBufferPointer { mibPtr -> Bool in
return sysctl(mibPtr.baseAddress, u_int(mibPtr.count), &info, &size, nil, 0) == 0
}
return ok ? info.kp_eproc.e_ucred.cr_uid : nil
}
private nonisolated static func teamIDMatches(pid: pid_t, allowedTeamIDs: Set<String>) -> Bool {
let attrs: NSDictionary = [kSecGuestAttributePid: pid]
var secCode: SecCode?
guard SecCodeCopyGuestWithAttributes(nil, attrs, SecCSFlags(), &secCode) == errSecSuccess,
let code = secCode else { return false }
var staticCode: SecStaticCode?
guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess,
let sCode = staticCode else { return false }
var infoCF: CFDictionary?
guard SecCodeCopySigningInformation(sCode, SecCSFlags(), &infoCF) == errSecSuccess,
let info = infoCF as? [String: Any],
let teamID = info[kSecCodeInfoTeamIdentifier as String] as? String
else {
return false
}
return allowedTeamIDs.contains(teamID)
}
}
@@ -0,0 +1,460 @@
import AppKit
import SwiftUI
struct CritterStatusLabel: View {
var isPaused: Bool
var isWorking: Bool
var earBoostActive: Bool
var blinkTick: Int
var sendCelebrationTick: Int
var gatewayStatus: GatewayProcessManager.Status
var animationsEnabled: Bool
var iconState: IconState
@State private var blinkAmount: CGFloat = 0
@State private var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5))
@State private var wiggleAngle: Double = 0
@State private var wiggleOffset: CGFloat = 0
@State private var nextWiggle = Date().addingTimeInterval(Double.random(in: 6.5...14))
@State private var legWiggle: CGFloat = 0
@State private var nextLegWiggle = Date().addingTimeInterval(Double.random(in: 5.0...11.0))
@State private var earWiggle: CGFloat = 0
@State private var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0...14.0))
private let ticker = Timer.publish(every: 0.35, on: .main, in: .common).autoconnect()
private var isWorkingNow: Bool {
self.iconState.isWorking || self.isWorking
}
var body: some View {
ZStack(alignment: .topTrailing) {
self.iconImage
.frame(width: 18, height: 18)
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
.offset(x: self.wiggleOffset)
.onReceive(self.ticker) { now in
guard self.animationsEnabled, !self.earBoostActive else {
self.resetMotion()
return
}
if now >= self.nextBlink {
self.blink()
self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5))
}
if now >= self.nextWiggle {
self.wiggle()
self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14))
}
if now >= self.nextLegWiggle {
self.wiggleLegs()
self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0))
}
if now >= self.nextEarWiggle {
self.wiggleEars()
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
}
if self.isWorkingNow {
self.scurry()
}
}
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
.onChange(of: self.blinkTick) { _, _ in
guard !self.earBoostActive else { return }
self.blink()
}
.onChange(of: self.sendCelebrationTick) { _, _ in
guard !self.earBoostActive else { return }
self.wiggleLegs()
}
.onChange(of: self.animationsEnabled) { _, enabled in
if enabled {
self.scheduleRandomTimers(from: Date())
} else {
self.resetMotion()
}
}
.onChange(of: self.earBoostActive) { _, active in
if active {
self.resetMotion()
} else if self.animationsEnabled {
self.scheduleRandomTimers(from: Date())
}
}
if self.gatewayNeedsAttention {
Circle()
.fill(self.gatewayBadgeColor)
.frame(width: 6, height: 6)
.padding(1)
}
}
.frame(width: 18, height: 18)
}
private var iconImage: Image {
let badge: CritterIconRenderer.Badge? = if let prominence = self.iconState.badgeProminence, !self.isPaused {
CritterIconRenderer.Badge(
symbolName: self.iconState.badgeSymbolName,
prominence: prominence)
} else {
nil
}
if self.isPaused {
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, badge: nil))
}
return Image(nsImage: CritterIconRenderer.makeIcon(
blink: self.blinkAmount,
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
earWiggle: self.earWiggle,
earScale: self.earBoostActive ? 1.9 : 1.0,
earHoles: self.earBoostActive,
badge: badge))
}
private func resetMotion() {
self.blinkAmount = 0
self.wiggleAngle = 0
self.wiggleOffset = 0
self.legWiggle = 0
self.earWiggle = 0
}
private func blink() {
withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) {
withAnimation(.easeOut(duration: 0.12)) { self.blinkAmount = 0 }
}
}
private func wiggle() {
let targetAngle = Double.random(in: -4.5...4.5)
let targetOffset = CGFloat.random(in: -0.5...0.5)
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
self.wiggleAngle = targetAngle
self.wiggleOffset = targetOffset
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.36) {
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
self.wiggleAngle = 0
self.wiggleOffset = 0
}
}
}
private func wiggleLegs() {
let target = CGFloat.random(in: 0.35...0.9)
withAnimation(.easeInOut(duration: 0.14)) {
self.legWiggle = target
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 }
}
}
private func scurry() {
let target = CGFloat.random(in: 0.7...1.0)
withAnimation(.easeInOut(duration: 0.12)) {
self.legWiggle = target
self.wiggleOffset = CGFloat.random(in: -0.6...0.6)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
withAnimation(.easeOut(duration: 0.16)) {
self.legWiggle = 0.25
self.wiggleOffset = 0
}
}
}
private func wiggleEars() {
let target = CGFloat.random(in: -1.2...1.2)
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
self.earWiggle = target
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) {
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { self.earWiggle = 0 }
}
}
private func scheduleRandomTimers(from date: Date) {
self.nextBlink = date.addingTimeInterval(Double.random(in: 3.5...8.5))
self.nextWiggle = date.addingTimeInterval(Double.random(in: 6.5...14))
self.nextLegWiggle = date.addingTimeInterval(Double.random(in: 5.0...11.0))
self.nextEarWiggle = date.addingTimeInterval(Double.random(in: 7.0...14.0))
}
private var gatewayNeedsAttention: Bool {
switch self.gatewayStatus {
case .failed, .stopped:
!self.isPaused
case .starting, .restarting, .running, .attachedExisting:
false
}
}
private var gatewayBadgeColor: Color {
switch self.gatewayStatus {
case .failed: .red
case .stopped: .orange
default: .clear
}
}
}
enum CritterIconRenderer {
private static let size = NSSize(width: 18, height: 18)
struct Badge {
let symbolName: String
let prominence: IconState.BadgeProminence
}
private struct Canvas {
let w: CGFloat
let h: CGFloat
let snapX: (CGFloat) -> CGFloat
let snapY: (CGFloat) -> CGFloat
let context: CGContext
}
static func makeIcon(
blink: CGFloat,
legWiggle: CGFloat = 0,
earWiggle: CGFloat = 0,
earScale: CGFloat = 1,
earHoles: Bool = false,
badge: Badge? = nil) -> NSImage
{
// Force a 36×36px backing store (2× for the 18pt logical canvas) so the menu bar icon stays crisp on Retina.
let pixelsWide = 36
let pixelsHigh = 36
guard let rep = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: pixelsWide,
pixelsHigh: pixelsHigh,
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bitmapFormat: [],
bytesPerRow: 0,
bitsPerPixel: 0)
else {
return NSImage(size: self.size)
}
rep.size = self.size
NSGraphicsContext.saveGraphicsState()
if let context = NSGraphicsContext(bitmapImageRep: rep) {
NSGraphicsContext.current = context
context.imageInterpolation = .none
context.cgContext.setShouldAntialias(false)
defer { NSGraphicsContext.restoreGraphicsState() }
let stepX = self.size.width / max(CGFloat(rep.pixelsWide), 1)
let stepY = self.size.height / max(CGFloat(rep.pixelsHigh), 1)
let snapX: (CGFloat) -> CGFloat = { ($0 / stepX).rounded() * stepX }
let snapY: (CGFloat) -> CGFloat = { ($0 / stepY).rounded() * stepY }
let w = snapX(size.width)
let h = snapY(size.height)
let bodyW = snapX(w * 0.78)
let bodyH = snapY(h * 0.58)
let bodyX = snapX((w - bodyW) / 2)
let bodyY = snapY(h * 0.36)
let bodyCorner = snapX(w * 0.09)
let earW = snapX(w * 0.22)
let earH = snapY(bodyH * 0.54 * earScale * (1 - 0.08 * abs(earWiggle)))
let earCorner = snapX(earW * 0.24)
let leftEarRect = CGRect(
x: snapX(bodyX - earW * 0.55 + earWiggle),
y: snapY(bodyY + bodyH * 0.08 + earWiggle * 0.4),
width: earW,
height: earH)
let rightEarRect = CGRect(
x: snapX(bodyX + bodyW - earW * 0.45 - earWiggle),
y: snapY(bodyY + bodyH * 0.08 - earWiggle * 0.4),
width: earW,
height: earH)
let legW = snapX(w * 0.11)
let legH = snapY(h * 0.26)
let legSpacing = snapX(w * 0.085)
let legsWidth = snapX(4 * legW + 3 * legSpacing)
let legStartX = snapX((w - legsWidth) / 2)
let legLift = snapY(legH * 0.35 * legWiggle)
let legYBase = snapY(bodyY - legH + h * 0.05)
let eyeOpen = max(0.05, 1 - blink)
let eyeW = snapX(bodyW * 0.2)
let eyeH = snapY(bodyH * 0.26 * eyeOpen)
let eyeY = snapY(bodyY + bodyH * 0.56)
let eyeOffset = snapX(bodyW * 0.24)
context.cgContext.setFillColor(NSColor.labelColor.cgColor)
context.cgContext.addPath(CGPath(
roundedRect: CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH),
cornerWidth: bodyCorner,
cornerHeight: bodyCorner,
transform: nil))
context.cgContext.addPath(CGPath(
roundedRect: leftEarRect,
cornerWidth: earCorner,
cornerHeight: earCorner,
transform: nil))
context.cgContext.addPath(CGPath(
roundedRect: rightEarRect,
cornerWidth: earCorner,
cornerHeight: earCorner,
transform: nil))
for i in 0..<4 {
let x = legStartX + CGFloat(i) * (legW + legSpacing)
let lift = (i % 2 == 0 ? legLift : -legLift)
let rect = CGRect(
x: x,
y: legYBase + lift,
width: legW,
height: legH * (1 - 0.12 * legWiggle))
context.cgContext.addPath(CGPath(
roundedRect: rect,
cornerWidth: legW * 0.34,
cornerHeight: legW * 0.34,
transform: nil))
}
context.cgContext.fillPath()
context.cgContext.saveGState()
context.cgContext.setBlendMode(CGBlendMode.clear)
let leftCenter = CGPoint(x: snapX(w / 2 - eyeOffset), y: snapY(eyeY))
let rightCenter = CGPoint(x: snapX(w / 2 + eyeOffset), y: snapY(eyeY))
if earHoles || earScale > 1.05 {
let holeW = snapX(earW * 0.6)
let holeH = snapY(earH * 0.46)
let holeCorner = snapX(holeW * 0.34)
let leftHoleRect = CGRect(
x: snapX(leftEarRect.midX - holeW / 2),
y: snapY(leftEarRect.midY - holeH / 2 + earH * 0.04),
width: holeW,
height: holeH)
let rightHoleRect = CGRect(
x: snapX(rightEarRect.midX - holeW / 2),
y: snapY(rightEarRect.midY - holeH / 2 + earH * 0.04),
width: holeW,
height: holeH)
context.cgContext.addPath(CGPath(
roundedRect: leftHoleRect,
cornerWidth: holeCorner,
cornerHeight: holeCorner,
transform: nil))
context.cgContext.addPath(CGPath(
roundedRect: rightHoleRect,
cornerWidth: holeCorner,
cornerHeight: holeCorner,
transform: nil))
}
let left = CGMutablePath()
left.move(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y - eyeH)))
left.addLine(to: CGPoint(x: snapX(leftCenter.x + eyeW / 2), y: snapY(leftCenter.y)))
left.addLine(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y + eyeH)))
left.closeSubpath()
let right = CGMutablePath()
right.move(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y - eyeH)))
right.addLine(to: CGPoint(x: snapX(rightCenter.x - eyeW / 2), y: snapY(rightCenter.y)))
right.addLine(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y + eyeH)))
right.closeSubpath()
context.cgContext.addPath(left)
context.cgContext.addPath(right)
context.cgContext.fillPath()
context.cgContext.restoreGState()
if let badge {
self.drawBadge(
badge,
canvas: Canvas(w: w, h: h, snapX: snapX, snapY: snapY, context: context.cgContext))
}
} else {
NSGraphicsContext.restoreGraphicsState()
return NSImage(size: self.size)
}
let image = NSImage(size: size)
image.addRepresentation(rep)
image.isTemplate = true
return image
}
private static func drawBadge(_ badge: Badge, canvas: Canvas) {
let strength: CGFloat = switch badge.prominence {
case .primary: 1.0
case .secondary: 0.58
case .overridden: 0.85
}
// Bigger, higher-contrast badge:
// - Increase diameter so tool activity is noticeable.
// - Use a filled "puck" background with a fully-opaque SF Symbol on top.
// (The menu bar image is rendered as a template, so "knocking out" the symbol makes it invisible.)
let diameter = canvas.snapX(canvas.w * 0.52 * (0.92 + 0.08 * strength)) // ~9–10pt on an 18pt canvas
let margin = canvas.snapX(max(0.45, canvas.w * 0.03))
let rect = CGRect(
x: canvas.snapX(canvas.w - diameter - margin),
y: canvas.snapY(margin),
width: diameter,
height: diameter)
canvas.context.saveGState()
canvas.context.setShouldAntialias(true)
// Clear the underlying pixels so the badge stays readable over the critter.
canvas.context.saveGState()
canvas.context.setBlendMode(.clear)
canvas.context.addEllipse(in: rect.insetBy(dx: -1.0, dy: -1.0))
canvas.context.fillPath()
canvas.context.restoreGState()
let fillAlpha: CGFloat = min(1.0, 0.36 + 0.24 * strength)
let strokeAlpha: CGFloat = min(1.0, 0.78 + 0.22 * strength)
canvas.context.setFillColor(NSColor.labelColor.withAlphaComponent(fillAlpha).cgColor)
canvas.context.addEllipse(in: rect)
canvas.context.fillPath()
canvas.context.setStrokeColor(NSColor.labelColor.withAlphaComponent(strokeAlpha).cgColor)
canvas.context.setLineWidth(max(1.25, canvas.snapX(canvas.w * 0.075)))
canvas.context.strokeEllipse(in: rect.insetBy(dx: 0.45, dy: 0.45))
if let base = NSImage(systemSymbolName: badge.symbolName, accessibilityDescription: nil) {
let pointSize = max(6.0, diameter * 0.80)
let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .bold)
let symbol = base.withSymbolConfiguration(config) ?? base
symbol.isTemplate = true
let symbolRect = rect.insetBy(dx: diameter * 0.19, dy: diameter * 0.19)
symbol.draw(
in: symbolRect,
from: .zero,
operation: .sourceOver,
fraction: min(1.0, 0.96 + 0.04 * strength),
respectFlipped: true,
hints: nil)
}
canvas.context.restoreGState()
}
}
@@ -0,0 +1,209 @@
import ClawdisProtocol
import Foundation
import OSLog
@MainActor
final class CronJobsStore: ObservableObject {
static let shared = CronJobsStore()
@Published var jobs: [CronJob] = []
@Published var selectedJobId: String?
@Published var runEntries: [CronRunLogEntry] = []
@Published var isLoadingJobs = false
@Published var isLoadingRuns = false
@Published var lastError: String?
@Published var statusMessage: String?
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "cron.ui")
private var refreshTask: Task<Void, Never>?
private var runsTask: Task<Void, Never>?
private var eventTask: Task<Void, Never>?
private var pollTask: Task<Void, Never>?
private let interval: TimeInterval = 30
private let isPreview: Bool
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview
}
func start() {
guard !self.isPreview else { return }
guard self.eventTask == nil else { return }
self.startGatewaySubscription()
self.pollTask = Task.detached { [weak self] in
guard let self else { return }
await self.refreshJobs()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
await self.refreshJobs()
}
}
}
func stop() {
self.refreshTask?.cancel()
self.refreshTask = nil
self.runsTask?.cancel()
self.runsTask = nil
self.eventTask?.cancel()
self.eventTask = nil
self.pollTask?.cancel()
self.pollTask = nil
}
func refreshJobs() async {
guard !self.isLoadingJobs else { return }
self.isLoadingJobs = true
self.lastError = nil
self.statusMessage = nil
defer { self.isLoadingJobs = false }
do {
let data = try await self.request(
method: "cron.list",
params: ["includeDisabled": true])
let res = try JSONDecoder().decode(CronListResponse.self, from: data)
self.jobs = res.jobs
if self.jobs.isEmpty {
self.statusMessage = "No cron jobs yet."
}
} catch {
self.logger.error("cron.list failed \(error.localizedDescription, privacy: .public)")
self.lastError = error.localizedDescription
}
}
func refreshRuns(jobId: String, limit: Int = 200) async {
guard !self.isLoadingRuns else { return }
self.isLoadingRuns = true
defer { self.isLoadingRuns = false }
do {
let data = try await self.request(
method: "cron.runs",
params: ["id": jobId, "limit": limit])
let res = try JSONDecoder().decode(CronRunsResponse.self, from: data)
self.runEntries = res.entries
} catch {
self.logger.error("cron.runs failed \(error.localizedDescription, privacy: .public)")
self.lastError = error.localizedDescription
}
}
func runJob(id: String, force: Bool = true) async {
do {
_ = try await self.request(
method: "cron.run",
params: ["id": id, "mode": force ? "force" : "due"],
timeoutMs: 20_000)
} catch {
self.lastError = error.localizedDescription
}
}
func removeJob(id: String) async {
do {
_ = try await self.request(method: "cron.remove", params: ["id": id])
await self.refreshJobs()
if self.selectedJobId == id {
self.selectedJobId = nil
self.runEntries = []
}
} catch {
self.lastError = error.localizedDescription
}
}
func setJobEnabled(id: String, enabled: Bool) async {
do {
_ = try await self.request(
method: "cron.update",
params: ["id": id, "patch": ["enabled": enabled]])
await self.refreshJobs()
} catch {
self.lastError = error.localizedDescription
}
}
func upsertJob(
id: String?,
payload: [String: Any]) async throws
{
if let id {
_ = try await self.request(method: "cron.update", params: ["id": id, "patch": payload])
} else {
_ = try await self.request(method: "cron.add", params: payload)
}
await self.refreshJobs()
}
// MARK: - Gateway events
private func startGatewaySubscription() {
self.eventTask?.cancel()
self.eventTask = Task { [weak self] in
guard let self else { return }
let stream = await GatewayConnection.shared.subscribe()
for await push in stream {
if Task.isCancelled { return }
await MainActor.run { [weak self] in
self?.handle(push: push)
}
}
}
}
private func handle(push: GatewayPush) {
switch push {
case let .event(evt) where evt.event == "cron":
guard let payload = evt.payload else { return }
if let cronEvt = try? GatewayPayloadDecoding.decode(payload, as: CronEvent.self) {
self.handle(cronEvent: cronEvt)
}
case .seqGap:
self.scheduleRefresh()
default:
break
}
}
private func handle(cronEvent evt: CronEvent) {
// Keep UI in sync with the gateway scheduler.
self.scheduleRefresh(delayMs: 250)
if evt.action == "finished", let selected = self.selectedJobId, selected == evt.jobId {
self.scheduleRunsRefresh(jobId: selected, delayMs: 200)
}
}
private func scheduleRefresh(delayMs: Int = 250) {
self.refreshTask?.cancel()
self.refreshTask = Task { [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
await self.refreshJobs()
}
}
private func scheduleRunsRefresh(jobId: String, delayMs: Int = 200) {
self.runsTask?.cancel()
self.runsTask = Task { [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
await self.refreshRuns(jobId: jobId)
}
}
// MARK: - RPC
private func request(
method: String,
params: [String: Any]?,
timeoutMs: Double? = nil) async throws -> Data
{
let rawParams = params?.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) }
return try await GatewayConnection.shared.request(method: method, params: rawParams, timeoutMs: timeoutMs)
}
}
+212
View File
@@ -0,0 +1,212 @@
import Foundation
enum CronSessionTarget: String, CaseIterable, Identifiable, Codable {
case main
case isolated
var id: String { self.rawValue }
}
enum CronWakeMode: String, CaseIterable, Identifiable, Codable {
case now
case nextHeartbeat = "next-heartbeat"
var id: String { self.rawValue }
}
enum CronSchedule: Codable, Equatable {
case at(atMs: Int)
case every(everyMs: Int, anchorMs: Int?)
case cron(expr: String, tz: String?)
enum CodingKeys: String, CodingKey { case kind, atMs, everyMs, anchorMs, expr, tz }
var kind: String {
switch self {
case .at: "at"
case .every: "every"
case .cron: "cron"
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let kind = try container.decode(String.self, forKey: .kind)
switch kind {
case "at":
self = .at(atMs: try container.decode(Int.self, forKey: .atMs))
case "every":
self = .every(
everyMs: try container.decode(Int.self, forKey: .everyMs),
anchorMs: try container.decodeIfPresent(Int.self, forKey: .anchorMs))
case "cron":
self = .cron(
expr: try container.decode(String.self, forKey: .expr),
tz: try container.decodeIfPresent(String.self, forKey: .tz))
default:
throw DecodingError.dataCorruptedError(
forKey: .kind,
in: container,
debugDescription: "Unknown schedule kind: \(kind)")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.kind, forKey: .kind)
switch self {
case let .at(atMs):
try container.encode(atMs, forKey: .atMs)
case let .every(everyMs, anchorMs):
try container.encode(everyMs, forKey: .everyMs)
try container.encodeIfPresent(anchorMs, forKey: .anchorMs)
case let .cron(expr, tz):
try container.encode(expr, forKey: .expr)
try container.encodeIfPresent(tz, forKey: .tz)
}
}
}
enum CronPayload: Codable, Equatable {
case systemEvent(text: String)
case agentTurn(
message: String,
thinking: String?,
timeoutSeconds: Int?,
deliver: Bool?,
channel: String?,
to: String?,
bestEffortDeliver: Bool?)
enum CodingKeys: String, CodingKey {
case kind, text, message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver
}
var kind: String {
switch self {
case .systemEvent: "systemEvent"
case .agentTurn: "agentTurn"
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let kind = try container.decode(String.self, forKey: .kind)
switch kind {
case "systemEvent":
self = .systemEvent(text: try container.decode(String.self, forKey: .text))
case "agentTurn":
self = .agentTurn(
message: try container.decode(String.self, forKey: .message),
thinking: try container.decodeIfPresent(String.self, forKey: .thinking),
timeoutSeconds: try container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
deliver: try container.decodeIfPresent(Bool.self, forKey: .deliver),
channel: try container.decodeIfPresent(String.self, forKey: .channel),
to: try container.decodeIfPresent(String.self, forKey: .to),
bestEffortDeliver: try container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
default:
throw DecodingError.dataCorruptedError(
forKey: .kind,
in: container,
debugDescription: "Unknown payload kind: \(kind)")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.kind, forKey: .kind)
switch self {
case let .systemEvent(text):
try container.encode(text, forKey: .text)
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
try container.encode(message, forKey: .message)
try container.encodeIfPresent(thinking, forKey: .thinking)
try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
try container.encodeIfPresent(deliver, forKey: .deliver)
try container.encodeIfPresent(channel, forKey: .channel)
try container.encodeIfPresent(to, forKey: .to)
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
}
}
}
struct CronIsolation: Codable, Equatable {
var postToMain: Bool?
var postToMainPrefix: String?
}
struct CronJobState: Codable, Equatable {
var nextRunAtMs: Int?
var runningAtMs: Int?
var lastRunAtMs: Int?
var lastStatus: String?
var lastError: String?
var lastDurationMs: Int?
}
struct CronJob: Identifiable, Codable, Equatable {
let id: String
var name: String?
var enabled: Bool
let createdAtMs: Int
let updatedAtMs: Int
let schedule: CronSchedule
let sessionTarget: CronSessionTarget
let wakeMode: CronWakeMode
let payload: CronPayload
let isolation: CronIsolation?
let state: CronJobState
var displayName: String {
let trimmed = (self.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "Untitled job" : trimmed
}
var nextRunDate: Date? {
guard let ms = self.state.nextRunAtMs else { return nil }
return Date(timeIntervalSince1970: TimeInterval(ms) / 1000)
}
var lastRunDate: Date? {
guard let ms = self.state.lastRunAtMs else { return nil }
return Date(timeIntervalSince1970: TimeInterval(ms) / 1000)
}
}
struct CronEvent: Codable, Sendable {
let jobId: String
let action: String
let runAtMs: Int?
let durationMs: Int?
let status: String?
let error: String?
let nextRunAtMs: Int?
}
struct CronRunLogEntry: Codable, Identifiable, Sendable {
var id: String { "\(self.jobId)-\(self.ts)" }
let ts: Int
let jobId: String
let action: String
let status: String?
let error: String?
let runAtMs: Int?
let durationMs: Int?
let nextRunAtMs: Int?
var date: Date { Date(timeIntervalSince1970: TimeInterval(self.ts) / 1000) }
var runDate: Date? {
guard let runAtMs else { return nil }
return Date(timeIntervalSince1970: TimeInterval(runAtMs) / 1000)
}
}
struct CronListResponse: Codable {
let jobs: [CronJob]
}
struct CronRunsResponse: Codable {
let entries: [CronRunLogEntry]
}
@@ -0,0 +1,809 @@
import SwiftUI
struct CronSettings: View {
@ObservedObject var store: CronJobsStore
@State private var showEditor = false
@State private var editingJob: CronJob?
@State private var editorError: String?
@State private var isSaving = false
@State private var confirmDelete: CronJob?
init(store: CronJobsStore = .shared) {
self.store = store
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
self.header
self.content
Spacer(minLength: 0)
}
.onAppear { self.store.start() }
.onDisappear { self.store.stop() }
.sheet(isPresented: self.$showEditor) {
CronJobEditor(
job: self.editingJob,
isSaving: self.$isSaving,
error: self.$editorError,
onCancel: {
self.showEditor = false
self.editingJob = nil
},
onSave: { payload in
Task {
await self.save(payload: payload)
}
})
}
.alert("Delete cron job?", isPresented: Binding(
get: { self.confirmDelete != nil },
set: { if !$0 { self.confirmDelete = nil } }))
{
Button("Cancel", role: .cancel) { self.confirmDelete = nil }
Button("Delete", role: .destructive) {
if let job = self.confirmDelete {
Task { await self.store.removeJob(id: job.id) }
}
self.confirmDelete = nil
}
} message: {
if let job = self.confirmDelete {
Text(job.displayName)
}
}
.onChange(of: self.store.selectedJobId) { _, newValue in
guard let newValue else { return }
Task { await self.store.refreshRuns(jobId: newValue) }
}
}
private var header: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text("Cron")
.font(.headline)
Text("Manage Gateway cron jobs (main session vs isolated runs) and inspect run history.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
HStack(spacing: 8) {
Button {
Task { await self.store.refreshJobs() }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
.disabled(self.store.isLoadingJobs)
Button {
self.editorError = nil
self.editingJob = nil
self.showEditor = true
} label: {
Label("New Job", systemImage: "plus")
}
.buttonStyle(.borderedProminent)
}
}
}
private var content: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 8) {
if let err = self.store.lastError {
Text("Error: \(err)")
.font(.footnote)
.foregroundStyle(.red)
} else if let msg = self.store.statusMessage {
Text(msg)
.font(.footnote)
.foregroundStyle(.secondary)
}
List(selection: self.$store.selectedJobId) {
ForEach(self.store.jobs) { job in
self.jobRow(job)
.tag(job.id)
.contextMenu { self.jobContextMenu(job) }
}
}
.listStyle(.inset)
}
.frame(width: 250)
Divider()
self.detail
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
@ViewBuilder
private var detail: some View {
if let selected = self.selectedJob {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 12) {
self.detailHeader(selected)
self.detailCard(selected)
self.runHistoryCard(selected)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 2)
}
} else {
VStack(alignment: .leading, spacing: 8) {
Text("Select a job to inspect details and run history.")
.font(.callout)
.foregroundStyle(.secondary)
Text("Tip: use ‘New Job’ to add one, or enable cron in your gateway config.")
.font(.caption)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.top, 8)
}
}
private var selectedJob: CronJob? {
guard let id = self.store.selectedJobId else { return nil }
return self.store.jobs.first(where: { $0.id == id })
}
private func jobRow(_ job: CronJob) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text(job.displayName)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
.truncationMode(.middle)
Spacer()
if !job.enabled {
StatusPill(text: "disabled", tint: .secondary)
} else if let next = job.nextRunDate {
StatusPill(text: self.nextRunLabel(next), tint: .secondary)
} else {
StatusPill(text: "no next run", tint: .secondary)
}
}
HStack(spacing: 6) {
StatusPill(text: job.sessionTarget.rawValue, tint: .secondary)
StatusPill(text: job.wakeMode.rawValue, tint: .secondary)
if let status = job.state.lastStatus {
StatusPill(text: status, tint: status == "ok" ? .green : .orange)
}
}
}
.padding(.vertical, 6)
}
@ViewBuilder
private func jobContextMenu(_ job: CronJob) -> some View {
Button("Run now") { Task { await self.store.runJob(id: job.id, force: true) } }
Divider()
Button(job.enabled ? "Disable" : "Enable") {
Task { await self.store.setJobEnabled(id: job.id, enabled: !job.enabled) }
}
Button("Edit…") {
self.editingJob = job
self.editorError = nil
self.showEditor = true
}
Divider()
Button("Delete…", role: .destructive) {
self.confirmDelete = job
}
}
private func detailHeader(_ job: CronJob) -> some View {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 4) {
Text(job.displayName)
.font(.title3.weight(.semibold))
Text(job.id)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer()
HStack(spacing: 8) {
Toggle("Enabled", isOn: Binding(
get: { job.enabled },
set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } }))
.toggleStyle(.switch)
.labelsHidden()
Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } }
.buttonStyle(.borderedProminent)
Button("Edit") {
self.editingJob = job
self.editorError = nil
self.showEditor = true
}
.buttonStyle(.bordered)
}
}
}
private func detailCard(_ job: CronJob) -> some View {
VStack(alignment: .leading, spacing: 10) {
LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) }
LabeledContent("Session") { Text(job.sessionTarget.rawValue) }
LabeledContent("Wake") { Text(job.wakeMode.rawValue) }
LabeledContent("Next run") {
if let date = job.nextRunDate {
Text(date.formatted(date: .abbreviated, time: .standard))
} else {
Text("—").foregroundStyle(.secondary)
}
}
LabeledContent("Last run") {
if let date = job.lastRunDate {
Text("\(date.formatted(date: .abbreviated, time: .standard)) · \(relativeAge(from: date))")
} else {
Text("—").foregroundStyle(.secondary)
}
}
if let status = job.state.lastStatus {
LabeledContent("Last status") { Text(status) }
}
if let err = job.state.lastError, !err.isEmpty {
Text(err)
.font(.footnote)
.foregroundStyle(.orange)
.textSelection(.enabled)
}
self.payloadSummary(job.payload)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color.secondary.opacity(0.06))
.cornerRadius(8)
}
private func runHistoryCard(_ job: CronJob) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Run history")
.font(.headline)
Spacer()
Button {
Task { await self.store.refreshRuns(jobId: job.id) }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
.disabled(self.store.isLoadingRuns)
}
if self.store.isLoadingRuns {
ProgressView().controlSize(.small)
}
if self.store.runEntries.isEmpty {
Text("No run log entries yet.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 6) {
ForEach(self.store.runEntries) { entry in
self.runRow(entry)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color.secondary.opacity(0.06))
.cornerRadius(8)
}
private func runRow(_ entry: CronRunLogEntry) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
StatusPill(text: entry.status ?? "unknown", tint: self.statusTint(entry.status))
Text(entry.date.formatted(date: .abbreviated, time: .standard))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if let ms = entry.durationMs {
Text("\(ms)ms")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
}
if let error = entry.error, !error.isEmpty {
Text(error)
.font(.caption)
.foregroundStyle(.orange)
.textSelection(.enabled)
.lineLimit(2)
}
}
.padding(.vertical, 4)
}
private func statusTint(_ status: String?) -> Color {
switch (status ?? "").lowercased() {
case "ok": .green
case "error": .red
case "skipped": .orange
default: .secondary
}
}
private func scheduleSummary(_ schedule: CronSchedule) -> String {
switch schedule {
case let .at(atMs):
let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
return "at \(date.formatted(date: .abbreviated, time: .standard))"
case let .every(everyMs, _):
return "every \(self.formatDuration(ms: everyMs))"
case let .cron(expr, tz):
if let tz, !tz.isEmpty { return "cron \(expr) (\(tz))" }
return "cron \(expr)"
}
}
private func payloadSummary(_ payload: CronPayload) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("Payload")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
switch payload {
case let .systemEvent(text):
Text(text)
.font(.callout)
.textSelection(.enabled)
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, _):
VStack(alignment: .leading, spacing: 4) {
Text(message)
.font(.callout)
.textSelection(.enabled)
HStack(spacing: 8) {
if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) }
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
if (deliver ?? false) {
StatusPill(text: "deliver", tint: .secondary)
if let channel, !channel.isEmpty { StatusPill(text: channel, tint: .secondary) }
if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
}
}
}
}
}
}
private func formatDuration(ms: Int) -> String {
if ms < 1000 { return "\(ms)ms" }
let s = Double(ms) / 1000.0
if s < 60 { return "\(Int(round(s)))s" }
let m = s / 60.0
if m < 60 { return "\(Int(round(m)))m" }
let h = m / 60.0
if h < 48 { return "\(Int(round(h)))h" }
let d = h / 24.0
return "\(Int(round(d)))d"
}
private func nextRunLabel(_ date: Date, now: Date = .init()) -> String {
let delta = date.timeIntervalSince(now)
if delta <= 0 { return "due" }
if delta < 60 { return "in <1m" }
let minutes = Int(round(delta / 60))
if minutes < 60 { return "in \(minutes)m" }
let hours = Int(round(Double(minutes) / 60))
if hours < 48 { return "in \(hours)h" }
let days = Int(round(Double(hours) / 24))
return "in \(days)d"
}
private func save(payload: [String: Any]) async {
guard !self.isSaving else { return }
self.isSaving = true
self.editorError = nil
do {
try await self.store.upsertJob(id: self.editingJob?.id, payload: payload)
await MainActor.run {
self.isSaving = false
self.showEditor = false
self.editingJob = nil
}
} catch {
await MainActor.run {
self.isSaving = false
self.editorError = error.localizedDescription
}
}
}
}
private struct StatusPill: View {
let text: String
let tint: Color
var body: some View {
Text(self.text)
.font(.caption2.weight(.semibold))
.padding(.horizontal, 7)
.padding(.vertical, 3)
.foregroundStyle(self.tint == .secondary ? .secondary : self.tint)
.background((self.tint == .secondary ? Color.secondary : self.tint).opacity(0.12))
.clipShape(Capsule())
}
}
private struct CronJobEditor: View {
let job: CronJob?
@Binding var isSaving: Bool
@Binding var error: String?
let onCancel: () -> Void
let onSave: ([String: Any]) -> Void
@State private var name: String = ""
@State private var enabled: Bool = true
@State private var sessionTarget: CronSessionTarget = .main
@State private var wakeMode: CronWakeMode = .nextHeartbeat
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
@State private var scheduleKind: ScheduleKind = .every
@State private var atDate: Date = Date().addingTimeInterval(60 * 5)
@State private var everyText: String = "1h"
@State private var cronExpr: String = "0 9 * * 3"
@State private var cronTz: String = ""
enum PayloadKind: String, CaseIterable, Identifiable { case systemEvent, agentTurn; var id: String { rawValue } }
@State private var payloadKind: PayloadKind = .systemEvent
@State private var systemEventText: String = ""
@State private var agentMessage: String = ""
@State private var deliver: Bool = false
@State private var channel: String = "last"
@State private var to: String = ""
@State private var thinking: String = ""
@State private var timeoutSeconds: String = ""
@State private var bestEffortDeliver: Bool = false
@State private var postToMain: Bool = false
@State private var postPrefix: String = "Cron"
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text(self.job == nil ? "New cron job" : "Edit cron job")
.font(.title3.weight(.semibold))
Spacer()
}
Form {
Section("Basics") {
TextField("Name (optional)", text: self.$name)
Toggle("Enabled", isOn: self.$enabled)
Picker("Session target", selection: self.$sessionTarget) {
Text("main").tag(CronSessionTarget.main)
Text("isolated").tag(CronSessionTarget.isolated)
}
Picker("Wake mode", selection: self.$wakeMode) {
Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat)
Text("now").tag(CronWakeMode.now)
}
}
Section("Schedule") {
Picker("Kind", selection: self.$scheduleKind) {
Text("at").tag(ScheduleKind.at)
Text("every").tag(ScheduleKind.every)
Text("cron").tag(ScheduleKind.cron)
}
.pickerStyle(.segmented)
switch self.scheduleKind {
case .at:
DatePicker("At", selection: self.$atDate, displayedComponents: [.date, .hourAndMinute])
case .every:
TextField("Every (e.g. 10m, 1h, 1d)", text: self.$everyText)
.textFieldStyle(.roundedBorder)
case .cron:
TextField("Cron expr (5-field)", text: self.$cronExpr)
.textFieldStyle(.roundedBorder)
TextField("Timezone (optional)", text: self.$cronTz)
.textFieldStyle(.roundedBorder)
}
}
Section("Payload") {
if self.sessionTarget == .isolated {
Text("Isolated jobs always run an agent turn.")
.font(.caption)
.foregroundStyle(.secondary)
self.agentTurnEditor
} else {
Picker("Kind", selection: self.$payloadKind) {
Text("systemEvent").tag(PayloadKind.systemEvent)
Text("agentTurn").tag(PayloadKind.agentTurn)
}
.pickerStyle(.segmented)
switch self.payloadKind {
case .systemEvent:
TextField("System event text", text: self.$systemEventText, axis: .vertical)
.lineLimit(3...6)
case .agentTurn:
self.agentTurnEditor
}
}
}
if self.payloadKind == .agentTurn {
Section("Post summary to main (optional)") {
Toggle("Post to main", isOn: self.$postToMain)
if self.postToMain {
TextField("Prefix", text: self.$postPrefix)
}
}
}
}
.frame(minWidth: 560, minHeight: 520)
if let error, !error.isEmpty {
Text(error)
.font(.footnote)
.foregroundStyle(.red)
}
HStack {
Button("Cancel") { self.onCancel() }
.buttonStyle(.bordered)
Spacer()
Button {
self.save()
} label: {
if self.isSaving {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.isSaving)
}
}
.padding(18)
.onAppear { self.hydrateFromJob() }
.onChange(of: self.sessionTarget) { _, newValue in
if newValue == .isolated {
self.payloadKind = .agentTurn
}
}
}
private var agentTurnEditor: some View {
VStack(alignment: .leading, spacing: 8) {
TextField("Agent message", text: self.$agentMessage, axis: .vertical)
.lineLimit(3...6)
TextField("Thinking (optional)", text: self.$thinking)
TextField("Timeout seconds (optional)", text: self.$timeoutSeconds)
.textFieldStyle(.roundedBorder)
Toggle("Deliver result", isOn: self.$deliver)
if self.deliver {
Picker("Channel", selection: self.$channel) {
Text("last").tag("last")
Text("whatsapp").tag("whatsapp")
Text("telegram").tag("telegram")
}
TextField("To (optional)", text: self.$to)
Toggle("Best-effort deliver", isOn: self.$bestEffortDeliver)
}
}
}
private func hydrateFromJob() {
guard let job else { return }
self.name = job.name ?? ""
self.enabled = job.enabled
self.sessionTarget = job.sessionTarget
self.wakeMode = job.wakeMode
switch job.schedule {
case let .at(atMs):
self.scheduleKind = .at
self.atDate = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
case let .every(everyMs, _):
self.scheduleKind = .every
self.everyText = self.formatDuration(ms: everyMs)
case let .cron(expr, tz):
self.scheduleKind = .cron
self.cronExpr = expr
self.cronTz = tz ?? ""
}
switch job.payload {
case let .systemEvent(text):
self.payloadKind = .systemEvent
self.systemEventText = text
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
self.payloadKind = .agentTurn
self.agentMessage = message
self.thinking = thinking ?? ""
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
self.deliver = deliver ?? false
self.channel = channel ?? "last"
self.to = to ?? ""
self.bestEffortDeliver = bestEffortDeliver ?? false
}
self.postToMain = job.isolation?.postToMain ?? false
self.postPrefix = job.isolation?.postToMainPrefix ?? "Cron"
}
private func save() {
do {
self.error = nil
let payload = try self.buildPayload()
self.onSave(payload)
} catch {
self.error = error.localizedDescription
}
}
private func buildPayload() throws -> [String: Any] {
let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
let schedule: [String: Any]
switch self.scheduleKind {
case .at:
schedule = ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
case .every:
guard let ms = Self.parseDurationMs(self.everyText) else {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."])
}
schedule = ["kind": "every", "everyMs": ms]
case .cron:
let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines)
if expr.isEmpty {
throw NSError(domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
}
let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines)
if tz.isEmpty {
schedule = ["kind": "cron", "expr": expr]
} else {
schedule = ["kind": "cron", "expr": expr, "tz": tz]
}
}
let payload: [String: Any] = {
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
switch self.payloadKind {
case .systemEvent:
let text = self.systemEventText.trimmingCharacters(in: .whitespacesAndNewlines)
return ["kind": "systemEvent", "text": text]
case .agentTurn:
return self.buildAgentTurnPayload()
}
}()
if payload["kind"] as? String == "systemEvent" {
if (payload["text"] as? String ?? "").isEmpty {
throw NSError(domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "System event text is required."])
}
} else if payload["kind"] as? String == "agentTurn" {
if (payload["message"] as? String ?? "").isEmpty {
throw NSError(domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
}
}
var root: [String: Any] = [
"enabled": self.enabled,
"schedule": schedule,
"sessionTarget": self.sessionTarget.rawValue,
"wakeMode": self.wakeMode.rawValue,
"payload": payload,
]
if !name.isEmpty { root["name"] = name }
if self.payloadKind == .agentTurn || self.sessionTarget == .isolated {
if self.postToMain {
root["isolation"] = [
"postToMain": true,
"postToMainPrefix": self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Cron" : self.postPrefix,
]
} else if self.job != nil {
// Allow clearing isolation on edit.
root["isolation"] = ["postToMain": false]
}
}
return root
}
private func buildAgentTurnPayload() -> [String: Any] {
let msg = self.agentMessage.trimmingCharacters(in: .whitespacesAndNewlines)
var payload: [String: Any] = ["kind": "agentTurn", "message": msg]
let thinking = self.thinking.trimmingCharacters(in: .whitespacesAndNewlines)
if !thinking.isEmpty { payload["thinking"] = thinking }
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
payload["deliver"] = self.deliver
if self.deliver {
payload["channel"] = self.channel
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { payload["to"] = to }
payload["bestEffortDeliver"] = self.bestEffortDeliver
}
return payload
}
private static func parseDurationMs(_ input: String) -> Int? {
let raw = input.trimmingCharacters(in: .whitespacesAndNewlines)
if raw.isEmpty { return nil }
let rx = try? NSRegularExpression(pattern: #"^(\d+(?:\.\d+)?)(ms|s|m|h|d)$"#, options: [.caseInsensitive])
guard let match = rx?.firstMatch(in: raw, range: NSRange(location: 0, length: raw.utf16.count)) else {
return nil
}
func group(_ idx: Int) -> String {
let range = match.range(at: idx)
guard let r = Range(range, in: raw) else { return "" }
return String(raw[r])
}
let n = Double(group(1)) ?? 0
if !n.isFinite || n <= 0 { return nil }
let unit = group(2).lowercased()
let factor: Double = switch unit {
case "ms": 1
case "s": 1000
case "m": 60_000
case "h": 3_600_000
default: 86_400_000
}
return Int(floor(n * factor))
}
private func formatDuration(ms: Int) -> String {
if ms < 1000 { return "\(ms)ms" }
let s = Double(ms) / 1000.0
if s < 60 { return "\(Int(round(s)))s" }
let m = s / 60.0
if m < 60 { return "\(Int(round(m)))m" }
let h = m / 60.0
if h < 48 { return "\(Int(round(h)))h" }
let d = h / 24.0
return "\(Int(round(d)))d"
}
}
#if DEBUG
struct CronSettings_Previews: PreviewProvider {
static var previews: some View {
let store = CronJobsStore(isPreview: true)
store.jobs = [
CronJob(
id: "job-1",
name: "Daily summary",
enabled: true,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .every(everyMs: 86_400_000, anchorMs: nil),
sessionTarget: .isolated,
wakeMode: .now,
payload: .agentTurn(
message: "Summarize inbox",
thinking: "low",
timeoutSeconds: 600,
deliver: true,
channel: "last",
to: nil,
bestEffortDeliver: true),
isolation: CronIsolation(postToMain: true, postToMainPrefix: "Cron"),
state: CronJobState(nextRunAtMs: Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000), runningAtMs: nil, lastRunAtMs: nil, lastStatus: nil, lastError: nil, lastDurationMs: nil)),
]
store.selectedJobId = "job-1"
store.runEntries = [
CronRunLogEntry(ts: Int(Date().timeIntervalSince1970 * 1000), jobId: "job-1", action: "finished", status: "ok", error: nil, runAtMs: nil, durationMs: 1234, nextRunAtMs: nil),
]
return CronSettings(store: store)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}
#endif
@@ -0,0 +1,273 @@
import AppKit
import Foundation
import SwiftUI
enum DebugActions {
private static let verboseDefaultsKey = "clawdis.debug.verboseMain"
private static let sessionMenuLimit = 12
@MainActor
static func openAgentEventsWindow() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 620, height: 420),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false)
window.title = "Agent Events"
window.isReleasedWhenClosed = false
window.contentView = NSHostingView(rootView: AgentEventsWindow())
window.center()
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
@MainActor
static func openLog() {
let path = self.pinoLogPath()
let url = URL(fileURLWithPath: path)
guard FileManager.default.fileExists(atPath: path) else {
let alert = NSAlert()
alert.messageText = "Log file not found"
alert.informativeText = path
alert.runModal()
return
}
NSWorkspace.shared.activateFileViewerSelecting([url])
}
@MainActor
static func openConfigFolder() {
let url = FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdis", isDirectory: true)
NSWorkspace.shared.activateFileViewerSelecting([url])
}
@MainActor
static func openSessionStore() {
let path = self.resolveSessionStorePath()
let url = URL(fileURLWithPath: path)
if FileManager.default.fileExists(atPath: path) {
NSWorkspace.shared.activateFileViewerSelecting([url])
} else {
NSWorkspace.shared.open(url.deletingLastPathComponent())
}
}
static func sendTestNotification() async {
_ = await NotificationManager().send(title: "Clawdis", body: "Test notification", sound: nil)
}
static func sendDebugVoice() async -> Result<String, DebugActionError> {
let message = """
This is a debug test from the Mac app. Reply with "Debug test works (and a funny pun)" \
if you received that.
"""
let result = await VoiceWakeForwarder.forward(transcript: message)
switch result {
case .success:
return .success("Sent. Await reply.")
case let .failure(error):
let detail = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
return .failure(.message("Send failed: \(detail)"))
}
}
static func restartGateway() {
Task { @MainActor in
switch AppStateStore.shared.connectionMode {
case .local:
GatewayProcessManager.shared.stop()
// Kick the control channel + health check so the UI recovers immediately.
await GatewayConnection.shared.shutdown()
try? await Task.sleep(nanoseconds: 300_000_000)
GatewayProcessManager.shared.setActive(true)
Task { try? await ControlChannel.shared.configure(mode: .local) }
Task { await HealthStore.shared.refresh(onDemand: true) }
case .remote:
// In remote mode, there is no local gateway to restart. "Restart Gateway" should
// reset the SSH control tunnel + reconnect so the menu recovers.
await RemoteTunnelManager.shared.stopAll()
await GatewayConnection.shared.shutdown()
do {
_ = try await RemoteTunnelManager.shared.ensureControlTunnel()
let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote(
target: settings.target,
identity: settings.identity))
} catch {
// ControlChannel will surface a degraded state; also refresh health to update the menu text.
Task { await HealthStore.shared.refresh(onDemand: true) }
}
}
}
}
static func pinoLogPath() -> String {
LogLocator.bestLogFile()?.path ?? LogLocator.launchdLogPath
}
@MainActor
static func runHealthCheckNow() async {
await HealthStore.shared.refresh(onDemand: true)
}
static func sendTestHeartbeat() async -> Result<ControlHeartbeatEvent?, Error> {
do {
_ = await AgentRPC.shared.setHeartbeatsEnabled(true)
await ControlChannel.shared.configure()
let data = try await ControlChannel.shared.request(method: "last-heartbeat")
if let evt = try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data) {
return .success(evt)
}
return .success(nil)
} catch {
return .failure(error)
}
}
static var verboseLoggingEnabledMain: Bool {
UserDefaults.standard.bool(forKey: self.verboseDefaultsKey)
}
static func toggleVerboseLoggingMain() async -> Bool {
let newValue = !self.verboseLoggingEnabledMain
UserDefaults.standard.set(newValue, forKey: self.verboseDefaultsKey)
_ = try? await ControlChannel.shared.request(
method: "system-event",
params: ["text": AnyHashable("verbose-main:\(newValue ? "on" : "off")")])
return newValue
}
@MainActor
static func restartApp() {
let url = Bundle.main.bundleURL
let task = Process()
// Relaunch shortly after this instance exits so we get a true restart even in debug.
task.launchPath = "/bin/sh"
task.arguments = ["-c", "sleep 0.2; open -n \"$1\"", "_", url.path]
try? task.run()
NSApp.terminate(nil)
}
private static func resolveSessionStorePath() -> String {
let defaultPath = SessionLoader.defaultStorePath
let configURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdis/clawdis.json")
guard
let data = try? Data(contentsOf: configURL),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let inbound = parsed["inbound"] as? [String: Any],
let reply = inbound["reply"] as? [String: Any],
let session = reply["session"] as? [String: Any],
let path = session["store"] as? String,
!path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
return defaultPath
}
return path
}
// MARK: - Sessions (thinking / verbose)
static func recentSessions(limit: Int = sessionMenuLimit) async -> [SessionRow] {
let hints = SessionLoader.configHints()
let store = SessionLoader.resolveStorePath(override: hints.storePath)
let defaults = SessionDefaults(
model: hints.model ?? SessionLoader.fallbackModel,
contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens)
guard let rows = try? await SessionLoader.loadRows(at: store, defaults: defaults) else { return [] }
return Array(rows.prefix(limit))
}
static func updateSession(
key: String,
thinking: String?,
verbose: String?) async throws
{
let hints = SessionLoader.configHints()
let store = SessionLoader.resolveStorePath(override: hints.storePath)
let url = URL(fileURLWithPath: store)
guard FileManager.default.fileExists(atPath: store) else {
throw DebugActionError.message("Session store missing at \(store)")
}
let data = try Data(contentsOf: url)
var decoded = try JSONDecoder().decode([String: SessionEntryRecord].self, from: data)
var entry = decoded[key] ?? SessionEntryRecord(
sessionId: nil,
updatedAt: Date().timeIntervalSince1970 * 1000,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
model: nil,
contextTokens: nil)
entry = SessionEntryRecord(
sessionId: entry.sessionId,
updatedAt: Date().timeIntervalSince1970 * 1000,
systemSent: entry.systemSent,
abortedLastRun: entry.abortedLastRun,
thinkingLevel: thinking,
verboseLevel: verbose,
inputTokens: entry.inputTokens,
outputTokens: entry.outputTokens,
totalTokens: entry.totalTokens,
model: entry.model,
contextTokens: entry.contextTokens)
decoded[key] = entry
let encoded = try JSONEncoder().encode(decoded)
try encoded.write(to: url, options: [.atomic])
}
// MARK: - Port diagnostics
typealias PortListener = PortGuardian.ReportListener
typealias PortReport = PortGuardian.PortReport
@MainActor
static func openChatInBrowser() async {
let session = WebChatManager.shared.preferredSessionKey()
await WebChatManager.shared.openInBrowser(sessionKey: session)
}
static func checkGatewayPorts() async -> [PortReport] {
let mode = CommandResolver.connectionSettings().mode
return await PortGuardian.shared.diagnose(mode: mode)
}
static func killProcess(_ pid: Int) async -> Result<Void, DebugActionError> {
let primary = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2)
if primary.ok { return .success(()) }
let force = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2)
if force.ok { return .success(()) }
let detail = force.message ?? primary.message ?? "kill failed"
return .failure(.message(detail))
}
@MainActor
static func openSessionStoreInCode() {
let path = SessionLoader.defaultStorePath
let proc = Process()
proc.launchPath = "/usr/bin/env"
proc.arguments = ["code", path]
try? proc.run()
}
}
enum DebugActionError: LocalizedError {
case message(String)
var errorDescription: String? {
switch self {
case let .message(text):
text
}
}
}
@@ -0,0 +1,715 @@
import AppKit
import SwiftUI
import UniformTypeIdentifiers
struct DebugSettings: View {
private let isPreview = ProcessInfo.processInfo.isPreview
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
@AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue
@AppStorage(canvasEnabledKey) private var canvasEnabled: Bool = true
@AppStorage(deepLinkAgentEnabledKey) private var deepLinkAgentEnabled: Bool = false
@State private var modelsCount: Int?
@State private var modelsLoading = false
@State private var modelsError: String?
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
@ObservedObject private var healthStore = HealthStore.shared
@State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
@State private var sessionStoreSaveError: String?
@State private var debugSendInFlight = false
@State private var debugSendStatus: String?
@State private var debugSendError: String?
@State private var portCheckInFlight = false
@State private var portReports: [DebugActions.PortReport] = []
@State private var portKillStatus: String?
@State private var pendingKill: DebugActions.PortListener?
@AppStorage(webChatSwiftUIEnabledKey) private var webChatSwiftUIEnabled: Bool = false
@AppStorage(attachExistingGatewayOnlyKey) private var attachExistingGatewayOnly: Bool = false
@AppStorage(debugFileLogEnabledKey) private var diagnosticsFileLogEnabled: Bool = false
@State private var canvasSessionKey: String = "main"
@State private var canvasStatus: String?
@State private var canvasError: String?
@State private var canvasEvalJS: String = "document.title"
@State private var canvasEvalResult: String?
@State private var canvasSnapshotPath: String?
var body: some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 10) {
LabeledContent("Health") {
HStack(spacing: 8) {
Circle().fill(self.healthStore.state.tint).frame(width: 10, height: 10)
Text(self.healthStore.summaryLine)
}
}
LabeledContent("CLI helper") {
let loc = CLIInstaller.installedLocation()
Text(loc ?? "missing")
.font(.caption.monospaced())
.foregroundStyle(loc == nil ? Color.red : Color.secondary)
}
LabeledContent("PID") { Text("\(ProcessInfo.processInfo.processIdentifier)") }
LabeledContent("Log file") {
Button("Open pino log") { DebugActions.openLog() }
.help(DebugActions.pinoLogPath())
Text(DebugActions.pinoLogPath())
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
LabeledContent("Diagnostics log") {
VStack(alignment: .leading, spacing: 6) {
Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled)
.toggleStyle(.switch)
.help(
"Writes a rotating, local-only diagnostics log under ~/Library/Logs/Clawdis/. Enable only while actively debugging.")
HStack(spacing: 8) {
Button("Open folder") {
NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL())
}
.buttonStyle(.bordered)
Button("Clear") {
Task { try? await DiagnosticsFileLog.shared.clear() }
}
.buttonStyle(.bordered)
}
Text(DiagnosticsFileLog.logFileURL().path)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
LabeledContent("Binary path") { Text(Bundle.main.bundlePath).font(.footnote) }
LabeledContent("Gateway status") {
HStack(spacing: 6) {
Text(self.gatewayManager.status.label)
Text("Restarts: \(self.gatewayManager.restartCount)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Toggle("Only attach to existing gateway (don’t spawn locally)", isOn: self.$attachExistingGatewayOnly)
.toggleStyle(.switch)
.help(
"When enabled in local mode, the mac app will only connect to an already-running gateway and will not start one itself.")
LabeledContent("URL scheme") {
VStack(alignment: .leading, spacing: 8) {
Toggle("Allow URL scheme (agent)", isOn: self.$deepLinkAgentEnabled)
.toggleStyle(.switch)
.help("Enables handling of clawdis://agent?... deep links to trigger an agent run.")
let key = DeepLinkHandler.currentKey()
HStack(spacing: 8) {
Text(key)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
Button("Copy key") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(key, forType: .string)
}
.buttonStyle(.bordered)
}
Button("Copy sample agent URL") {
let msg = "Hello from deep link"
let encoded = msg.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? msg
let url = "clawdis://agent?message=\(encoded)&key=\(key)"
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(url, forType: .string)
}
.buttonStyle(.bordered)
}
}
VStack(alignment: .leading, spacing: 4) {
Text("Gateway stdout/stderr")
.font(.caption.weight(.semibold))
ScrollView {
Text(self.gatewayManager.log.isEmpty ? "—" : self.gatewayManager.log)
.font(.caption.monospaced())
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
.frame(height: 180)
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2)))
}
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text("Port diagnostics")
.font(.caption.weight(.semibold))
if self.portCheckInFlight { ProgressView().controlSize(.small) }
Spacer()
Button("Check gateway ports") {
Task { await self.runPortCheck() }
}
.buttonStyle(.borderedProminent)
.disabled(self.portCheckInFlight)
}
if let portKillStatus {
Text(portKillStatus)
.font(.caption2)
.foregroundStyle(.secondary)
}
if self.portReports.isEmpty, !self.portCheckInFlight {
Text("Check which process owns 18788/18789 and suggest fixes.")
.font(.caption2)
.foregroundStyle(.secondary)
} else {
ForEach(self.portReports) { report in
VStack(alignment: .leading, spacing: 4) {
Text("Port \(report.port)")
.font(.footnote.weight(.semibold))
Text(report.summary)
.font(.caption)
.foregroundStyle(.secondary)
ForEach(report.listeners) { listener in
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 8) {
Text("\(listener.command) (\(listener.pid))")
.font(.caption.monospaced())
.foregroundStyle(listener.expected ? .secondary : Color.red)
.lineLimit(1)
Spacer()
Button("Kill") {
self.requestKill(listener)
}
.buttonStyle(.bordered)
}
Text(listener.fullCommand)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.lineLimit(2)
.truncationMode(.middle)
}
.padding(6)
.background(Color.secondary.opacity(0.05))
.cornerRadius(4)
}
}
.padding(8)
.background(Color.secondary.opacity(0.08))
.cornerRadius(6)
}
}
}
VStack(alignment: .leading, spacing: 6) {
Text("Clawdis project root")
.font(.caption.weight(.semibold))
HStack(spacing: 8) {
TextField("Path to clawdis repo", text: self.$gatewayRootInput)
.textFieldStyle(.roundedBorder)
.font(.caption.monospaced())
.onSubmit { self.saveRelayRoot() }
Button("Save") { self.saveRelayRoot() }
.buttonStyle(.borderedProminent)
Button("Reset") {
let def = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Projects/clawdis").path
self.gatewayRootInput = def
self.saveRelayRoot()
}
.buttonStyle(.bordered)
}
Text("Used for pnpm/node fallback and PATH population when launching the gateway.")
.font(.caption2)
.foregroundStyle(.secondary)
}
LabeledContent("Session store") {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
TextField("Path", text: self.$sessionStorePath)
.textFieldStyle(.roundedBorder)
.font(.caption.monospaced())
.frame(width: 340)
Button("Save") { self.saveSessionStorePath() }
.buttonStyle(.borderedProminent)
}
if let sessionStoreSaveError {
Text(sessionStoreSaveError)
.font(.footnote)
.foregroundStyle(.secondary)
} else {
Text("Used by the CLI session loader; stored in ~/.clawdis/clawdis.json.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
LabeledContent("Model catalog") {
VStack(alignment: .leading, spacing: 6) {
Text(self.modelCatalogPath)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(2)
HStack(spacing: 8) {
Button {
self.chooseCatalogFile()
} label: {
Label("Choose models.generated.ts…", systemImage: "folder")
}
.buttonStyle(.bordered)
Button {
Task { await self.reloadModels() }
} label: {
Label(
self.modelsLoading ? "Reloading…" : "Reload models",
systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
.disabled(self.modelsLoading)
}
if let modelsError {
Text(modelsError)
.font(.footnote)
.foregroundStyle(.secondary)
} else if let modelsCount {
Text("Loaded \(modelsCount) models")
.font(.footnote)
.foregroundStyle(.secondary)
}
Text("Used by the Config tab model picker; point at a different build when debugging.")
.font(.footnote)
.foregroundStyle(.tertiary)
}
}
Button("Send Test Notification") {
Task { await DebugActions.sendTestNotification() }
}
.buttonStyle(.bordered)
Button("Open Agent Events") {
DebugActions.openAgentEventsWindow()
}
.buttonStyle(.borderedProminent)
VStack(alignment: .leading, spacing: 6) {
Button {
Task { await self.sendVoiceDebug() }
} label: {
Label(
self.debugSendInFlight ? "Sending debug voice…" : "Send debug voice",
systemImage: self.debugSendInFlight ? "bolt.horizontal.circle" : "waveform")
}
.buttonStyle(.borderedProminent)
.disabled(self.debugSendInFlight)
if !self.debugSendInFlight {
if let debugSendStatus {
Text(debugSendStatus)
.font(.caption)
.foregroundStyle(.secondary)
} else if let debugSendError {
Text(debugSendError)
.font(.caption)
.foregroundStyle(.red)
} else {
Text(
"""
Uses the Voice Wake path: forwards over SSH when configured,
otherwise runs locally via rpc.
""")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
HStack {
Button("Restart app") { DebugActions.restartApp() }
Button("Reveal app in Finder") { self.revealApp() }
Button("Restart Gateway") { DebugActions.restartGateway() }
Button("Clear log") { GatewayProcessManager.shared.clearLog() }
}
.buttonStyle(.bordered)
Divider()
VStack(alignment: .leading, spacing: 8) {
Text("Canvas")
.font(.caption.weight(.semibold))
Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled)
.toggleStyle(.switch)
.help(
"When off, agent Canvas requests return “Canvas disabled by user”. Manual debug actions still work.")
HStack(spacing: 8) {
TextField("Session", text: self.$canvasSessionKey)
.textFieldStyle(.roundedBorder)
.font(.caption.monospaced())
.frame(width: 160)
Button("Show panel") {
Task { await self.canvasShow() }
}
.buttonStyle(.borderedProminent)
Button("Hide panel") {
CanvasManager.shared.hideAll()
self.canvasStatus = "hidden"
self.canvasError = nil
}
.buttonStyle(.bordered)
Button("Write sample page") {
Task { await self.canvasWriteSamplePage() }
}
.buttonStyle(.bordered)
}
HStack(spacing: 8) {
TextField("Eval JS", text: self.$canvasEvalJS)
.textFieldStyle(.roundedBorder)
.font(.caption.monospaced())
.frame(maxWidth: 420)
Button("Eval") {
Task { await self.canvasEval() }
}
.buttonStyle(.bordered)
Button("Snapshot") {
Task { await self.canvasSnapshot() }
}
.buttonStyle(.bordered)
}
if let canvasStatus {
Text(canvasStatus)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
if let canvasEvalResult {
Text("eval → \(canvasEvalResult)")
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.lineLimit(2)
.truncationMode(.middle)
.textSelection(.enabled)
}
if let canvasSnapshotPath {
HStack(spacing: 8) {
Text("snapshot → \(canvasSnapshotPath)")
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.textSelection(.enabled)
Button("Reveal") {
NSWorkspace.shared
.activateFileViewerSelecting([URL(fileURLWithPath: canvasSnapshotPath)])
}
.buttonStyle(.bordered)
}
}
if let canvasError {
Text(canvasError)
.font(.caption2)
.foregroundStyle(.red)
} else {
Text("Tip: the session directory is returned by “Show panel”.")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
LabeledContent("Icon override") {
Picker("Icon override", selection: self.bindingOverride) {
ForEach(IconOverrideSelection.allCases) { option in
Text(option.label).tag(option.rawValue)
}
}
.labelsHidden()
.frame(maxWidth: 280)
}
Toggle("Use SwiftUI web chat (glass, gateway WS)", isOn: self.$webChatSwiftUIEnabled)
.toggleStyle(.switch)
.help(
"When enabled, the menu bar chat window/panel uses the native SwiftUI UI instead of the bundled WKWebView.")
Spacer(minLength: 8)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
.task {
guard !self.isPreview else { return }
await self.reloadModels()
self.loadSessionStorePath()
}
.alert(item: self.$pendingKill) { listener in
Alert(
title: Text("Kill \(listener.command) (\(listener.pid))?"),
message: Text("This process looks expected for the current mode. Kill anyway?"),
primaryButton: .destructive(Text("Kill")) {
Task { await self.killConfirmed(listener.pid) }
},
secondaryButton: .cancel())
}
}
@MainActor
private func runPortCheck() async {
self.portCheckInFlight = true
self.portKillStatus = nil
let reports = await DebugActions.checkGatewayPorts()
self.portReports = reports
self.portCheckInFlight = false
}
@MainActor
private func requestKill(_ listener: DebugActions.PortListener) {
if listener.expected {
self.pendingKill = listener
} else {
Task { await self.killConfirmed(listener.pid) }
}
}
@MainActor
private func killConfirmed(_ pid: Int32) async {
let result = await DebugActions.killProcess(Int(pid))
switch result {
case .success:
self.portKillStatus = "Sent kill to \(pid)."
await self.runPortCheck()
case let .failure(err):
self.portKillStatus = "Kill \(pid) failed: \(err.localizedDescription)"
}
}
private func chooseCatalogFile() {
let panel = NSOpenPanel()
panel.title = "Select models.generated.ts"
let tsType = UTType(filenameExtension: "ts")
?? UTType(tag: "ts", tagClass: .filenameExtension, conformingTo: .sourceCode)
?? .item
panel.allowedContentTypes = [tsType]
panel.allowsMultipleSelection = false
panel.directoryURL = URL(fileURLWithPath: self.modelCatalogPath).deletingLastPathComponent()
if panel.runModal() == .OK, let url = panel.url {
self.modelCatalogPath = url.path
self.modelCatalogReloadBump += 1
Task { await self.reloadModels() }
}
}
private func reloadModels() async {
guard !self.modelsLoading else { return }
self.modelsLoading = true
self.modelsError = nil
self.modelCatalogReloadBump += 1
defer { self.modelsLoading = false }
do {
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
self.modelsCount = loaded.count
} catch {
self.modelsCount = nil
self.modelsError = error.localizedDescription
}
}
private func sendVoiceDebug() async {
await MainActor.run {
self.debugSendInFlight = true
self.debugSendError = nil
self.debugSendStatus = nil
}
let result = await DebugActions.sendDebugVoice()
await MainActor.run {
self.debugSendInFlight = false
switch result {
case let .success(message):
self.debugSendStatus = message
self.debugSendError = nil
case let .failure(error):
self.debugSendStatus = nil
self.debugSendError = error.localizedDescription
}
}
}
private func revealApp() {
let url = Bundle.main.bundleURL
NSWorkspace.shared.activateFileViewerSelecting([url])
}
private func saveRelayRoot() {
GatewayProcessManager.shared.setProjectRoot(path: self.gatewayRootInput)
}
private func loadSessionStorePath() {
let url = self.configURL()
guard
let data = try? Data(contentsOf: url),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let inbound = parsed["inbound"] as? [String: Any],
let reply = inbound["reply"] as? [String: Any],
let session = reply["session"] as? [String: Any],
let path = session["store"] as? String
else {
self.sessionStorePath = SessionLoader.defaultStorePath
return
}
self.sessionStorePath = path
}
private func saveSessionStorePath() {
let trimmed = self.sessionStorePath.trimmingCharacters(in: .whitespacesAndNewlines)
var root: [String: Any] = [:]
let url = self.configURL()
if let data = try? Data(contentsOf: url),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
{
root = parsed
}
var inbound = root["inbound"] as? [String: Any] ?? [:]
var reply = inbound["reply"] as? [String: Any] ?? [:]
var session = reply["session"] as? [String: Any] ?? [:]
session["store"] = trimmed.isEmpty ? SessionLoader.defaultStorePath : trimmed
reply["session"] = session
inbound["reply"] = reply
root["inbound"] = inbound
do {
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
self.sessionStoreSaveError = nil
} catch {
self.sessionStoreSaveError = error.localizedDescription
}
}
private var bindingOverride: Binding<String> {
Binding {
self.iconOverrideRaw
} set: { newValue in
self.iconOverrideRaw = newValue
if let selection = IconOverrideSelection(rawValue: newValue) {
Task { @MainActor in
AppStateStore.shared.iconOverride = selection
WorkActivityStore.shared.resolveIconState(override: selection)
}
}
}
}
private func configURL() -> URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdis")
.appendingPathComponent("clawdis.json")
}
// MARK: - Canvas debug actions
@MainActor
private func canvasShow() async {
self.canvasError = nil
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
do {
let dir = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/")
self.canvasStatus = "dir: \(dir)"
} catch {
self.canvasError = error.localizedDescription
}
}
@MainActor
private func canvasWriteSamplePage() async {
self.canvasError = nil
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
do {
let dir = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/")
let url = URL(fileURLWithPath: dir).appendingPathComponent("index.html", isDirectory: false)
let now = ISO8601DateFormatter().string(from: Date())
let html = """
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Canvas Debug</title>
<style>
:root { color-scheme: dark; }
html,body { height:100%; margin:0; background:#0b1020; color:#e5e7eb; }
body { font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; }
.wrap { padding:16px; }
.row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
.pill { padding:6px 10px; border-radius:999px; background:rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.12); }
button { background:#22c55e; color:#04110a; border:0; border-radius:10px; padding:8px 10px; font-weight:700; cursor:pointer; }
button:active { transform: translateY(1px); }
.panel { margin-top:14px; padding:14px; border-radius:14px; background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.1); }
.grid { display:grid; grid-template-columns: repeat(12, 1fr); gap:10px; margin-top:12px; }
.box { grid-column: span 4; height:80px; border-radius:14px; background: linear-gradient(135deg, rgba(59,130,246,.35), rgba(168,85,247,.25)); border:1px solid rgba(255,255,255,.12); }
.muted { color: rgba(229,231,235,.7); }
</style>
</head>
<body>
<div class="wrap">
<div class="row">
<div class="pill">Canvas Debug</div>
<div class="pill muted">generated: \(now)</div>
<div class="pill muted">userAgent: <span id="ua"></span></div>
<button id="btn">Click me</button>
<div class="pill">count: <span id="count">0</span></div>
</div>
<div class="panel">
<div class="muted">This is a local file served by the WKURLSchemeHandler.</div>
<div class="grid">
<div class="box"></div><div class="box"></div><div class="box"></div>
<div class="box"></div><div class="box"></div><div class="box"></div>
</div>
</div>
</div>
<script>
document.getElementById('ua').textContent = navigator.userAgent;
let n = 0;
document.getElementById('btn').addEventListener('click', () => {
n++;
document.getElementById('count').textContent = String(n);
document.title = 'Canvas Debug (' + n + ')';
});
</script>
</body>
</html>
"""
try html.write(to: url, atomically: true, encoding: .utf8)
self.canvasStatus = "wrote: \(url.path)"
try CanvasManager.shared.goto(sessionKey: session.isEmpty ? "main" : session, path: "/")
} catch {
self.canvasError = error.localizedDescription
}
}
@MainActor
private func canvasEval() async {
self.canvasError = nil
self.canvasEvalResult = nil
do {
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
let result = try await CanvasManager.shared.eval(
sessionKey: session.isEmpty ? "main" : session,
javaScript: self.canvasEvalJS)
self.canvasEvalResult = result
} catch {
self.canvasError = error.localizedDescription
}
}
@MainActor
private func canvasSnapshot() async {
self.canvasError = nil
self.canvasSnapshotPath = nil
do {
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
let path = try await CanvasManager.shared.snapshot(
sessionKey: session.isEmpty ? "main" : session,
outPath: nil)
self.canvasSnapshotPath = path
} catch {
self.canvasError = error.localizedDescription
}
}
}
#if DEBUG
struct DebugSettings_Previews: PreviewProvider {
static var previews: some View {
DebugSettings()
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}
#endif
+123
View File
@@ -0,0 +1,123 @@
import AppKit
import ClawdisKit
import Foundation
import OSLog
import Security
private let deepLinkLogger = Logger(subsystem: "com.steipete.clawdis", category: "DeepLink")
@MainActor
final class DeepLinkHandler {
static let shared = DeepLinkHandler()
private var lastPromptAt: Date = .distantPast
func handle(url: URL) async {
guard let route = DeepLinkParser.parse(url) else {
deepLinkLogger.debug("ignored url \(url.absoluteString, privacy: .public)")
return
}
guard UserDefaults.standard.bool(forKey: deepLinkAgentEnabledKey) else {
self.presentAlert(
title: "Deep links are disabled",
message: "Enable “Allow URL scheme (agent)” in Clawdis Debug Settings to accept clawdis:// links.")
return
}
guard !AppStateStore.shared.isPaused else {
self.presentAlert(title: "Clawdis is paused", message: "Unpause Clawdis to run agent actions.")
return
}
switch route {
case let .agent(link):
await self.handleAgent(link: link, originalURL: url)
}
}
private func handleAgent(link: AgentDeepLink, originalURL: URL) async {
let messagePreview = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
if messagePreview.count > 20000 {
self.presentAlert(title: "Deep link too large", message: "Message exceeds 20,000 characters.")
return
}
let allowUnattended = link.key == Self.expectedKey()
if !allowUnattended {
if Date().timeIntervalSince(self.lastPromptAt) < 1.0 {
deepLinkLogger.debug("throttling deep link prompt")
return
}
self.lastPromptAt = Date()
let trimmed = messagePreview.count > 240 ? "\(messagePreview.prefix(240))…" : messagePreview
let body =
"Run the agent with this message?\n\n\(trimmed)\n\nURL:\n\(originalURL.absoluteString)"
guard self.confirm(title: "Run Clawdis agent?", message: body) else { return }
}
if AppStateStore.shared.connectionMode == .local {
GatewayProcessManager.shared.setActive(true)
}
do {
var params: [String: AnyCodable] = [
"message": AnyCodable(messagePreview),
"idempotencyKey": AnyCodable(UUID().uuidString),
]
if let sessionKey = link.sessionKey, !sessionKey.isEmpty { params["sessionKey"] = AnyCodable(sessionKey) }
if let thinking = link.thinking, !thinking.isEmpty { params["thinking"] = AnyCodable(thinking) }
if let to = link.to, !to.isEmpty { params["to"] = AnyCodable(to) }
if let channel = link.channel, !channel.isEmpty { params["channel"] = AnyCodable(channel) }
if let timeout = link.timeoutSeconds { params["timeout"] = AnyCodable(timeout) }
params["deliver"] = AnyCodable(link.deliver)
_ = try await GatewayConnection.shared.request(method: "agent", params: params)
} catch {
self.presentAlert(title: "Agent request failed", message: error.localizedDescription)
}
}
// MARK: - Auth
static func currentKey() -> String {
self.expectedKey()
}
private static func expectedKey() -> String {
let defaults = UserDefaults.standard
if let key = defaults.string(forKey: deepLinkKeyKey), !key.isEmpty {
return key
}
var bytes = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
let data = Data(bytes)
let key = data
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
defaults.set(key, forKey: deepLinkKeyKey)
return key
}
// MARK: - UI
private func confirm(title: String, message: String) -> Bool {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.addButton(withTitle: "Run")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
return alert.runModal() == .alertFirstButtonReturn
}
private func presentAlert(title: String, message: String) {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.addButton(withTitle: "OK")
alert.alertStyle = .informational
alert.runModal()
}
}
@@ -0,0 +1,133 @@
import Foundation
actor DiagnosticsFileLog {
static let shared = DiagnosticsFileLog()
private let fileName = "diagnostics.jsonl"
private let maxBytes: Int64 = 5 * 1024 * 1024
private let maxBackups = 5
struct Record: Codable, Sendable {
let ts: String
let pid: Int32
let category: String
let event: String
let fields: [String: String]?
}
nonisolated static func isEnabled() -> Bool {
UserDefaults.standard.bool(forKey: debugFileLogEnabledKey)
}
nonisolated static func logDirectoryURL() -> URL {
let library = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
return library
.appendingPathComponent("Logs", isDirectory: true)
.appendingPathComponent("Clawdis", isDirectory: true)
}
nonisolated static func logFileURL() -> URL {
self.logDirectoryURL().appendingPathComponent("diagnostics.jsonl", isDirectory: false)
}
nonisolated func log(category: String, event: String, fields: [String: String]? = nil) {
guard Self.isEnabled() else { return }
let record = Record(
ts: ISO8601DateFormatter().string(from: Date()),
pid: ProcessInfo.processInfo.processIdentifier,
category: category,
event: event,
fields: fields)
Task { await self.write(record: record) }
}
func clear() throws {
let fm = FileManager.default
let base = Self.logFileURL()
if fm.fileExists(atPath: base.path) {
try fm.removeItem(at: base)
}
for idx in 1...self.maxBackups {
let url = self.rotatedURL(index: idx)
if fm.fileExists(atPath: url.path) {
try fm.removeItem(at: url)
}
}
}
private func write(record: Record) {
do {
try self.ensureDirectory()
try self.rotateIfNeeded()
try self.append(record: record)
} catch {
// Best-effort only: never crash or block the app on logging.
}
}
private func ensureDirectory() throws {
try FileManager.default.createDirectory(
at: Self.logDirectoryURL(),
withIntermediateDirectories: true)
}
private func append(record: Record) throws {
let url = Self.logFileURL()
let data = try JSONEncoder().encode(record)
var line = Data()
line.append(data)
line.append(0x0A) // newline
let fm = FileManager.default
if !fm.fileExists(atPath: url.path) {
fm.createFile(atPath: url.path, contents: nil)
}
let handle = try FileHandle(forWritingTo: url)
defer { try? handle.close() }
try handle.seekToEnd()
try handle.write(contentsOf: line)
}
private func rotateIfNeeded() throws {
let url = Self.logFileURL()
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
let size = attrs[.size] as? NSNumber
else { return }
if size.int64Value < self.maxBytes { return }
let fm = FileManager.default
let oldest = self.rotatedURL(index: self.maxBackups)
if fm.fileExists(atPath: oldest.path) {
try fm.removeItem(at: oldest)
}
if self.maxBackups > 1 {
for idx in stride(from: self.maxBackups - 1, through: 1, by: -1) {
let src = self.rotatedURL(index: idx)
let dst = self.rotatedURL(index: idx + 1)
if fm.fileExists(atPath: src.path) {
if fm.fileExists(atPath: dst.path) {
try fm.removeItem(at: dst)
}
try fm.moveItem(at: src, to: dst)
}
}
}
let first = self.rotatedURL(index: 1)
if fm.fileExists(atPath: first.path) {
try fm.removeItem(at: first)
}
if fm.fileExists(atPath: url.path) {
try fm.moveItem(at: url, to: first)
}
}
private func rotatedURL(index: Int) -> URL {
Self.logDirectoryURL().appendingPathComponent("\(self.fileName).\(index)", isDirectory: false)
}
}
@@ -0,0 +1,456 @@
import ClawdisProtocol
import Foundation
import OSLog
protocol WebSocketTasking: AnyObject {
var state: URLSessionTask.State { get }
func resume()
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)
func send(_ message: URLSessionWebSocketTask.Message) async throws
func receive() async throws -> URLSessionWebSocketTask.Message
func receive(completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
}
extension URLSessionWebSocketTask: WebSocketTasking {}
struct WebSocketTaskBox: @unchecked Sendable {
let task: any WebSocketTasking
var state: URLSessionTask.State { self.task.state }
func resume() { self.task.resume() }
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
self.task.cancel(with: closeCode, reason: reason)
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
try await self.task.send(message)
}
func receive() async throws -> URLSessionWebSocketTask.Message {
try await self.task.receive()
}
func receive(completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
self.task.receive(completionHandler: completionHandler)
}
}
protocol WebSocketSessioning: AnyObject {
func makeWebSocketTask(url: URL) -> WebSocketTaskBox
}
extension URLSession: WebSocketSessioning {
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
WebSocketTaskBox(task: self.webSocketTask(with: url))
}
}
struct WebSocketSessionBox: @unchecked Sendable {
let session: any WebSocketSessioning
}
// Avoid ambiguity with the app's own AnyCodable type.
private typealias ProtoAnyCodable = ClawdisProtocol.AnyCodable
actor GatewayChannelActor {
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway")
private var task: WebSocketTaskBox?
private var pending: [String: CheckedContinuation<GatewayFrame, Error>] = [:]
private var connected = false
private var isConnecting = false
private var connectWaiters: [CheckedContinuation<Void, Error>] = []
private var url: URL
private var token: String?
private let session: WebSocketSessioning
private var backoffMs: Double = 500
private var shouldReconnect = true
private var lastSeq: Int?
private var lastTick: Date?
private var tickIntervalMs: Double = 30000
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private var watchdogTask: Task<Void, Never>?
private var tickTask: Task<Void, Never>?
private let defaultRequestTimeoutMs: Double = 15000
private let pushHandler: (@Sendable (GatewayPush) async -> Void)?
init(
url: URL,
token: String?,
session: WebSocketSessionBox? = nil,
pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil)
{
self.url = url
self.token = token
self.session = session?.session ?? URLSession(configuration: .default)
self.pushHandler = pushHandler
Task { [weak self] in
await self?.startWatchdog()
}
}
func shutdown() async {
self.shouldReconnect = false
self.connected = false
self.watchdogTask?.cancel()
self.watchdogTask = nil
self.tickTask?.cancel()
self.tickTask = nil
self.task?.cancel(with: .goingAway, reason: nil)
self.task = nil
await self.failPending(NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
let waiters = self.connectWaiters
self.connectWaiters.removeAll()
for waiter in waiters {
waiter.resume(throwing: NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
}
}
private func startWatchdog() {
self.watchdogTask?.cancel()
self.watchdogTask = Task { [weak self] in
guard let self else { return }
await self.watchdogLoop()
}
}
private func watchdogLoop() async {
// Keep nudging reconnect in case exponential backoff stalls.
while self.shouldReconnect {
try? await Task.sleep(nanoseconds: 30 * 1_000_000_000) // 30s cadence
guard self.shouldReconnect else { return }
if self.connected { continue }
do {
try await self.connect()
} catch {
let wrapped = self.wrap(error, context: "gateway watchdog reconnect")
self.logger.error("gateway watchdog reconnect failed \(wrapped.localizedDescription, privacy: .public)")
}
}
}
func connect() async throws {
if self.connected, self.task?.state == .running { return }
if self.isConnecting {
try await withCheckedThrowingContinuation { cont in
self.connectWaiters.append(cont)
}
return
}
self.isConnecting = true
defer { self.isConnecting = false }
self.task?.cancel(with: .goingAway, reason: nil)
self.task = self.session.makeWebSocketTask(url: self.url)
self.task?.resume()
do {
try await self.sendConnect()
} catch {
let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
self.connected = false
self.task?.cancel(with: .goingAway, reason: nil)
let waiters = self.connectWaiters
self.connectWaiters.removeAll()
for waiter in waiters {
waiter.resume(throwing: wrapped)
}
self.logger.error("gateway ws connect failed \(wrapped.localizedDescription, privacy: .public)")
throw wrapped
}
self.listen()
self.connected = true
self.backoffMs = 500
self.lastSeq = nil
let waiters = self.connectWaiters
self.connectWaiters.removeAll()
for waiter in waiters {
waiter.resume(returning: ())
}
}
private func sendConnect() async throws {
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
let clientName = InstanceIdentity.displayName
let reqId = UUID().uuidString
let client: [String: ProtoAnyCodable] = [
"name": ProtoAnyCodable(clientName),
"version": ProtoAnyCodable(
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
"platform": ProtoAnyCodable(platform),
"mode": ProtoAnyCodable("app"),
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
]
var params: [String: ProtoAnyCodable] = [
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"client": ProtoAnyCodable(client),
"caps": ProtoAnyCodable([] as [String]),
"locale": ProtoAnyCodable(primaryLocale),
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
]
if let token = self.token {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
}
let frame = RequestFrame(
type: "req",
id: reqId,
method: "connect",
params: ProtoAnyCodable(params))
let data = try self.encoder.encode(frame)
try await self.task?.send(.data(data))
guard let msg = try await task?.receive() else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (no response)"])
}
try await self.handleConnectResponse(msg, reqId: reqId)
}
private func handleConnectResponse(_ msg: URLSessionWebSocketTask.Message, reqId: String) async throws {
let data: Data? = switch msg {
case let .data(d): d
case let .string(s): s.data(using: .utf8)
@unknown default: nil
}
guard let data else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (empty response)"])
}
let decoder = JSONDecoder()
guard let frame = try? decoder.decode(GatewayFrame.self, from: data) else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (invalid response)"])
}
guard case let .res(res) = frame, res.id == reqId else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (unexpected response)"])
}
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
}
guard let payload = res.payload else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (missing payload)"])
}
let payloadData = try self.encoder.encode(payload)
let ok = try decoder.decode(HelloOk.self, from: payloadData)
if let tick = ok.policy["tickIntervalMs"]?.value as? Double {
self.tickIntervalMs = tick
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
self.tickIntervalMs = Double(tick)
}
self.lastTick = Date()
self.tickTask?.cancel()
self.tickTask = Task { [weak self] in
guard let self else { return }
await self.watchTicks()
}
await self.pushHandler?(.snapshot(ok))
return
}
private func listen() {
self.task?.receive { [weak self] result in
guard let self else { return }
switch result {
case let .failure(err):
Task { await self.handleReceiveFailure(err) }
case let .success(msg):
Task {
await self.handle(msg)
await self.listen()
}
}
}
}
private func handleReceiveFailure(_ err: Error) async {
let wrapped = self.wrap(err, context: "gateway receive")
self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)")
self.connected = false
await self.failPending(wrapped)
await self.scheduleReconnect()
}
private func handle(_ msg: URLSessionWebSocketTask.Message) async {
let data: Data? = switch msg {
case let .data(d): d
case let .string(s): s.data(using: .utf8)
@unknown default: nil
}
guard let data else { return }
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else {
self.logger.error("gateway decode failed")
return
}
switch frame {
case let .res(res):
let id = res.id
if let waiter = pending.removeValue(forKey: id) {
waiter.resume(returning: .res(res))
}
case let .event(evt):
if let seq = evt.seq {
if let last = lastSeq, seq > last + 1 {
await self.pushHandler?(.seqGap(expected: last + 1, received: seq))
}
self.lastSeq = seq
}
if evt.event == "tick" { self.lastTick = Date() }
await self.pushHandler?(.event(evt))
default:
break
}
}
private func watchTicks() async {
let tolerance = self.tickIntervalMs * 2
while self.connected {
try? await Task.sleep(nanoseconds: UInt64(tolerance * 1_000_000))
guard self.connected else { return }
if let last = self.lastTick {
let delta = Date().timeIntervalSince(last) * 1000
if delta > tolerance {
self.logger.error("gateway tick missed; reconnecting")
self.connected = false
await self.failPending(
NSError(
domain: "Gateway",
code: 4,
userInfo: [NSLocalizedDescriptionKey: "gateway tick missed; reconnecting"]))
await self.scheduleReconnect()
return
}
}
}
}
private func scheduleReconnect() async {
guard self.shouldReconnect else { return }
let delay = self.backoffMs / 1000
self.backoffMs = min(self.backoffMs * 2, 30000)
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
guard self.shouldReconnect else { return }
do {
try await self.connect()
} catch {
let wrapped = self.wrap(error, context: "gateway reconnect")
self.logger.error("gateway reconnect failed \(wrapped.localizedDescription, privacy: .public)")
await self.scheduleReconnect()
}
}
func request(method: String, params: [String: AnyCodable]?, timeoutMs: Double? = nil) async throws -> Data {
do {
try await self.connect()
} catch {
throw self.wrap(error, context: "gateway connect")
}
let id = UUID().uuidString
let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
let paramsObject: ProtoAnyCodable? = params.map { entries in
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
dict[entry.key] = ProtoAnyCodable(entry.value.value)
}
return ProtoAnyCodable(dict)
}
let frame = RequestFrame(
type: "req",
id: id,
method: method,
params: paramsObject)
let data = try self.encoder.encode(frame)
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
self.pending[id] = cont
Task { [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
await self.timeoutRequest(id: id, timeoutMs: effectiveTimeout)
}
Task {
do {
try await self.task?.send(.data(data))
} catch {
let wrapped = self.wrap(error, context: "gateway send \(method)")
let waiter = self.pending.removeValue(forKey: id)
// Treat send failures as a broken socket: mark disconnected and trigger reconnect.
self.connected = false
self.task?.cancel(with: .goingAway, reason: nil)
Task { [weak self] in
guard let self else { return }
await self.scheduleReconnect()
}
if let waiter { waiter.resume(throwing: wrapped) }
}
}
}
guard case let .res(res) = response else {
throw NSError(domain: "Gateway", code: 2, userInfo: [NSLocalizedDescriptionKey: "unexpected frame"])
}
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway error"
throw NSError(domain: "Gateway", code: 3, userInfo: [NSLocalizedDescriptionKey: msg])
}
if let payload = res.payload {
// Encode back to JSON with Swift's encoder to preserve types and avoid ObjC bridging exceptions.
return try self.encoder.encode(payload)
}
return Data() // Should not happen, but tolerate empty payloads.
}
// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
private func wrap(_ error: Error, context: String) -> Error {
if let urlError = error as? URLError {
let desc = urlError.localizedDescription.isEmpty ? "cancelled" : urlError.localizedDescription
return NSError(
domain: URLError.errorDomain,
code: urlError.errorCode,
userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"])
}
let ns = error as NSError
let desc = ns.localizedDescription.isEmpty ? "unknown" : ns.localizedDescription
return NSError(domain: ns.domain, code: ns.code, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"])
}
private func failPending(_ error: Error) async {
let waiters = self.pending
self.pending.removeAll()
for (_, waiter) in waiters {
waiter.resume(throwing: error)
}
}
private func timeoutRequest(id: String, timeoutMs: Double) async {
guard let waiter = self.pending.removeValue(forKey: id) else { return }
let err = NSError(
domain: "Gateway",
code: 5,
userInfo: [NSLocalizedDescriptionKey: "gateway request timed out after \(Int(timeoutMs))ms"])
waiter.resume(throwing: err)
}
}
// Intentionally no `GatewayChannel` wrapper: the app should use the single shared `GatewayConnection`.
@@ -0,0 +1,114 @@
import ClawdisProtocol
import Foundation
/// Single, shared Gateway websocket connection for the whole app.
///
/// This owns exactly one `GatewayChannelActor` and reuses it across all callers
/// (ControlChannel, AgentRPC, SwiftUI WebChat, etc.).
actor GatewayConnection {
static let shared = GatewayConnection()
typealias Config = (url: URL, token: String?)
private let configProvider: @Sendable () async throws -> Config
private let sessionBox: WebSocketSessionBox?
private var client: GatewayChannelActor?
private var configuredURL: URL?
private var configuredToken: String?
private var subscribers: [UUID: AsyncStream<GatewayPush>.Continuation] = [:]
private var lastSnapshot: HelloOk?
init(
configProvider: @escaping @Sendable () async throws -> Config = GatewayConnection.defaultConfigProvider,
sessionBox: WebSocketSessionBox? = nil)
{
self.configProvider = configProvider
self.sessionBox = sessionBox
}
func request(
method: String,
params: [String: AnyCodable]?,
timeoutMs: Double? = nil) async throws -> Data
{
let cfg = try await self.configProvider()
await self.configure(url: cfg.url, token: cfg.token)
guard let client else {
throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
}
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
}
/// Ensure the underlying socket is configured (and replaced if config changed).
func refresh() async throws {
let cfg = try await self.configProvider()
await self.configure(url: cfg.url, token: cfg.token)
}
func shutdown() async {
if let client {
await client.shutdown()
}
self.client = nil
self.configuredURL = nil
self.configuredToken = nil
self.lastSnapshot = nil
}
func subscribe(bufferingNewest: Int = 100) -> AsyncStream<GatewayPush> {
let id = UUID()
let snapshot = self.lastSnapshot
let connection = self
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
if let snapshot {
continuation.yield(.snapshot(snapshot))
}
self.subscribers[id] = continuation
continuation.onTermination = { @Sendable _ in
Task { await connection.removeSubscriber(id) }
}
}
}
private func removeSubscriber(_ id: UUID) {
self.subscribers[id] = nil
}
private func broadcast(_ push: GatewayPush) {
if case let .snapshot(snapshot) = push {
self.lastSnapshot = snapshot
}
for (_, continuation) in self.subscribers {
continuation.yield(push)
}
}
private func configure(url: URL, token: String?) async {
if self.client != nil, self.configuredURL == url, self.configuredToken == token {
return
}
if let client {
await client.shutdown()
}
self.lastSnapshot = nil
self.client = GatewayChannelActor(
url: url,
token: token,
session: self.sessionBox,
pushHandler: { [weak self] push in
await self?.handle(push: push)
})
self.configuredURL = url
self.configuredToken = token
}
private func handle(push: GatewayPush) {
self.broadcast(push)
}
private static func defaultConfigProvider() async throws -> Config {
try await GatewayEndpointStore.shared.requireConfig()
}
}
@@ -0,0 +1,139 @@
import Foundation
import OSLog
enum GatewayEndpointState: Sendable, Equatable {
case ready(mode: AppState.ConnectionMode, url: URL, token: String?)
case unavailable(mode: AppState.ConnectionMode, reason: String)
}
/// Single place to resolve (and publish) the effective gateway control endpoint.
///
/// This is intentionally separate from `GatewayConnection`:
/// - `GatewayConnection` consumes the resolved endpoint (no tunnel side-effects).
/// - The endpoint store owns observation + explicit "ensure tunnel" actions.
actor GatewayEndpointStore {
static let shared = GatewayEndpointStore()
struct Deps: Sendable {
let mode: @Sendable () async -> AppState.ConnectionMode
let token: @Sendable () -> String?
let localPort: @Sendable () -> Int
let remotePortIfRunning: @Sendable () async -> UInt16?
let ensureRemoteTunnel: @Sendable () async throws -> UInt16
static let live = Deps(
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
token: { ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"] },
localPort: { GatewayEnvironment.gatewayPort() },
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() })
}
private let deps: Deps
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway-endpoint")
private var state: GatewayEndpointState
private var subscribers: [UUID: AsyncStream<GatewayEndpointState>.Continuation] = [:]
init(deps: Deps = .live) {
self.deps = deps
let port = deps.localPort()
self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: deps.token())
}
func subscribe(bufferingNewest: Int = 1) -> AsyncStream<GatewayEndpointState> {
let id = UUID()
let initial = self.state
let store = self
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
continuation.yield(initial)
self.subscribers[id] = continuation
continuation.onTermination = { @Sendable _ in
Task { await store.removeSubscriber(id) }
}
}
}
func refresh() async {
let mode = await self.deps.mode()
await self.setMode(mode)
}
func setMode(_ mode: AppState.ConnectionMode) async {
let token = self.deps.token()
switch mode {
case .local:
let port = self.deps.localPort()
self.setState(.ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token))
case .remote:
let port = await self.deps.remotePortIfRunning()
guard let port else {
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
return
}
self.setState(.ready(mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token))
}
}
/// Explicit action: ensure the remote control tunnel is established and publish the resolved endpoint.
func ensureRemoteControlTunnel() async throws -> UInt16 {
let mode = await self.deps.mode()
guard mode == .remote else {
throw NSError(
domain: "RemoteTunnel",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
let port = try await self.deps.ensureRemoteTunnel()
await self.setMode(.remote)
return port
}
func requireConfig() async throws -> GatewayConnection.Config {
await self.refresh()
switch self.state {
case let .ready(_, url, token):
return (url, token)
case let .unavailable(mode, reason):
guard mode == .remote else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason])
}
// Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet),
// recreate it on demand so callers can recover without a manual reconnect.
do {
let forwarded = try await self.deps.ensureRemoteTunnel()
let token = self.deps.token()
let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
self.setState(.ready(mode: .remote, url: url, token: token))
return (url, token)
} catch {
let msg = "\(reason) (\(error.localizedDescription))"
self.setState(.unavailable(mode: .remote, reason: msg))
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg])
}
}
}
private func removeSubscriber(_ id: UUID) {
self.subscribers[id] = nil
}
private func setState(_ next: GatewayEndpointState) {
guard next != self.state else { return }
self.state = next
for (_, continuation) in self.subscribers {
continuation.yield(next)
}
switch next {
case let .ready(mode, url, _):
self.logger
.debug(
"resolved endpoint mode=\(String(describing: mode), privacy: .public) url=\(url.absoluteString, privacy: .public)")
case let .unavailable(mode, reason):
self.logger
.debug(
"endpoint unavailable mode=\(String(describing: mode), privacy: .public) reason=\(reason, privacy: .public)")
}
}
}
@@ -0,0 +1,211 @@
import ClawdisIPC
import Foundation
// Lightweight SemVer helper (major.minor.patch only) for gateway compatibility checks.
struct Semver: Comparable, CustomStringConvertible, Sendable {
let major: Int
let minor: Int
let patch: Int
var description: String { "\(self.major).\(self.minor).\(self.patch)" }
static func < (lhs: Semver, rhs: Semver) -> Bool {
if lhs.major != rhs.major { return lhs.major < rhs.major }
if lhs.minor != rhs.minor { return lhs.minor < rhs.minor }
return lhs.patch < rhs.patch
}
static func parse(_ raw: String?) -> Semver? {
guard let raw, !raw.isEmpty else { return nil }
let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "^v", with: "", options: .regularExpression)
let parts = cleaned.split(separator: ".")
guard parts.count >= 3,
let major = Int(parts[0]),
let minor = Int(parts[1])
else { return nil }
let patch = Int(parts[2]) ?? 0
return Semver(major: major, minor: minor, patch: patch)
}
func compatible(with required: Semver) -> Bool {
// Same major and not older than required.
self.major == required.major && self >= required
}
}
enum GatewayEnvironmentKind: Equatable {
case checking
case ok
case missingNode
case missingGateway
case incompatible(found: String, required: String)
case error(String)
}
struct GatewayEnvironmentStatus: Equatable {
let kind: GatewayEnvironmentKind
let nodeVersion: String?
let gatewayVersion: String?
let requiredGateway: String?
let message: String
static var checking: Self {
.init(kind: .checking, nodeVersion: nil, gatewayVersion: nil, requiredGateway: nil, message: "Checking…")
}
}
struct GatewayCommandResolution {
let status: GatewayEnvironmentStatus
let command: [String]?
}
enum GatewayEnvironment {
static func gatewayPort() -> Int {
let stored = UserDefaults.standard.integer(forKey: "gatewayPort")
return stored > 0 ? stored : 18789
}
static func expectedGatewayVersion() -> Semver? {
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
return Semver.parse(bundleVersion)
}
// Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
static func expectedGatewayVersion(from versionString: String?) -> Semver? {
Semver.parse(versionString)
}
static func check() -> GatewayEnvironmentStatus {
let expected = self.expectedGatewayVersion()
let projectRoot = CommandResolver.projectRoot()
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
switch RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths()) {
case let .failure(err):
return GatewayEnvironmentStatus(
kind: .missingNode,
nodeVersion: nil,
gatewayVersion: nil,
requiredGateway: expected?.description,
message: RuntimeLocator.describeFailure(err))
case let .success(runtime):
let gatewayBin = CommandResolver.clawdisExecutable()
if gatewayBin == nil, projectEntrypoint == nil {
return GatewayEnvironmentStatus(
kind: .missingGateway,
nodeVersion: runtime.version.description,
gatewayVersion: nil,
requiredGateway: expected?.description,
message: "clawdis CLI not found in PATH; install the global package.")
}
let installed = gatewayBin.flatMap { self.readGatewayVersion(binary: $0) }
?? self.readLocalGatewayVersion(projectRoot: projectRoot)
if let expected, let installed, !installed.compatible(with: expected) {
return GatewayEnvironmentStatus(
kind: .incompatible(found: installed.description, required: expected.description),
nodeVersion: runtime.version.description,
gatewayVersion: installed.description,
requiredGateway: expected.description,
message: """
Gateway version \(installed.description) is incompatible with app \(expected.description);
install or update the global package.
""")
}
let gatewayLabel = gatewayBin != nil ? "global" : "local"
let gatewayVersionText = installed?.description ?? "unknown"
// Avoid repeating "(local)" twice; if using the local entrypoint, show the path once.
let localPathHint = gatewayBin == nil && projectEntrypoint != nil
? " (local: \(projectEntrypoint ?? "unknown"))"
: ""
let gatewayLabelText = gatewayBin != nil
? "(\(gatewayLabel))"
: localPathHint.isEmpty ? "(\(gatewayLabel))" : localPathHint
return GatewayEnvironmentStatus(
kind: .ok,
nodeVersion: runtime.version.description,
gatewayVersion: gatewayVersionText,
requiredGateway: expected?.description,
message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)")
}
}
static func resolveGatewayCommand() -> GatewayCommandResolution {
let projectRoot = CommandResolver.projectRoot()
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
let status = self.check()
let gatewayBin = CommandResolver.clawdisExecutable()
let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths())
guard case .ok = status.kind else {
return GatewayCommandResolution(status: status, command: nil)
}
let port = self.gatewayPort()
if let gatewayBin {
let cmd = [gatewayBin, "gateway", "--port", "\(port)"]
return GatewayCommandResolution(status: status, command: cmd)
}
if let entry = projectEntrypoint,
case let .success(resolvedRuntime) = runtime
{
let cmd = [resolvedRuntime.path, entry, "gateway", "--port", "\(port)"]
return GatewayCommandResolution(status: status, command: cmd)
}
return GatewayCommandResolution(status: status, command: nil)
}
static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async {
let preferred = CommandResolver.preferredPaths().joined(separator: ":")
let target = version?.description ?? "latest"
let pnpm = CommandResolver.findExecutable(named: "pnpm") ?? "pnpm"
let cmd = [pnpm, "add", "-g", "clawdis@\(target)"]
statusHandler("Installing clawdis@\(target) via pnpm…")
let response = await ShellExecutor.run(command: cmd, cwd: nil, env: ["PATH": preferred], timeout: 300)
if response.ok {
statusHandler("Installed clawdis@\(target)")
} else {
let detail = response.message ?? "install failed"
statusHandler("Install failed: \(detail)")
}
}
// MARK: - Internals
private static func readGatewayVersion(binary: String) -> Semver? {
let process = Process()
process.executableURL = URL(fileURLWithPath: binary)
process.arguments = ["--version"]
process.environment = ["PATH": CommandResolver.preferredPaths().joined(separator: ":")]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
return Semver.parse(raw)
} catch {
return nil
}
}
private static func readLocalGatewayVersion(projectRoot: URL) -> Semver? {
let pkg = projectRoot.appendingPathComponent("package.json")
guard let data = try? Data(contentsOf: pkg) else { return nil }
guard
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let version = json["version"] as? String
else { return nil }
return Semver.parse(version)
}
}
Loaded 100 of 4195 files, more files were not shown because too many files have changed in this diff. Show more