Compare commits

...

1159 Commits

Author SHA1 Message Date
Peter Steinberger
b01cb41950 iOS: copy bridge URL/host/port 2025-12-13 23:40:12 +00:00
Peter Steinberger
7642cbb5b7 iOS: show local IP in settings 2025-12-13 23:37:02 +00:00
Peter Steinberger
7a6334d920 iOS: copy + clean bridge address 2025-12-13 23:32:57 +00:00
Peter Steinberger
d96bc38bea style(macos): mark Reject destructive 2025-12-13 23:32:57 +00:00
Peter Steinberger
a31a569d52 chore(peekaboo): update submodule 2025-12-13 23:22:24 +00:00
Peter Steinberger
0d3aacd316 chore: bump Peekaboo submodule 2025-12-13 23:02:04 +00:00
Peter Steinberger
ece8a3e701 fix(macos): clamp web chat to visible frame 2025-12-13 22:38:10 +00:00
Peter Steinberger
ceb3980b93 iOS: disable VoiceWake on Simulator 2025-12-13 20:52:31 +00:00
Peter Steinberger
01eba1b8d9 chore: bump Peekaboo submodule 2025-12-13 20:46:20 +00:00
Peter Steinberger
cf28ea0d1c test: raise vitest coverage 2025-12-13 20:37:56 +00:00
Peter Steinberger
41dd3b11b7 fix: harden pi package resolution 2025-12-13 20:37:46 +00:00
Peter Steinberger
5a1687484c fix(ci): stabilize runners 2025-12-13 20:04:33 +00:00
Peter Steinberger
6143338116 chore(swift): run swiftformat and clear swiftlint 2025-12-13 19:53:17 +00:00
Peter Steinberger
39c232548c fix(macos): restore control + webchat build 2025-12-13 19:38:35 +00:00
Peter Steinberger
e2a93e17f9 refactor: apply stashed bridge + CLI changes 2025-12-13 19:30:46 +00:00
Peter Steinberger
0b990443de style(macos): tidy settings and CLI 2025-12-13 19:23:41 +00:00
Peter Steinberger
02fe19effa chore(macos): expose remote test helper 2025-12-13 19:22:57 +00:00
Peter Steinberger
920cc9ac38 fix(ios): avoid actor-isolated access from audio tap 2025-12-13 19:14:36 +00:00
Peter Steinberger
ba22890205 feat(browser): add ai snapshot refs + click 2025-12-13 18:48:55 +00:00
Peter Steinberger
a59cfa7670 chore(deps): add playwright-core 2025-12-13 18:48:49 +00:00
Peter Steinberger
7cdd7c5333 fix(browser): apply clawd theme color 2025-12-13 18:41:31 +00:00
Peter Steinberger
e3379b960e chore(peekaboo): update bridge host TeamID check 2025-12-13 18:35:26 +00:00
Peter Steinberger
7b675864a8 feat(browser): add DOM inspection commands 2025-12-13 18:33:04 +00:00
Peter Steinberger
3b853b329f fix(bridge): prefer bonjour TXT displayName 2025-12-13 18:31:06 +00:00
Peter Steinberger
537c515dde fix(macos): show full browser tab ids 2025-12-13 18:17:01 +00:00
Peter Steinberger
238afbc2f8 fix(browser): accept targetId prefixes 2025-12-13 18:17:01 +00:00
Peter Steinberger
2a71c20ee4 fix(mac): place debug menu under Settings 2025-12-13 18:11:00 +00:00
Peter Steinberger
40c66b1741 chore(webchat): refresh bundled assets 2025-12-13 18:10:29 +00:00
Peter Steinberger
94ad808028 fix(mac): clarify attach-only gateway errors 2025-12-13 18:10:29 +00:00
Peter Steinberger
0c8b5ed59a test(mac): cover codesign + node manager paths 2025-12-13 18:10:29 +00:00
Peter Steinberger
56fe23549c feat(browser): clamp screenshots under 5MB 2025-12-13 18:10:29 +00:00
Peter Steinberger
867d7e5d25 chore(peekaboo): bump submodule 2025-12-13 18:09:45 +00:00
Peter Steinberger
a0cd761c96 fix(mac): flatten config sections + use checkboxes 2025-12-13 18:06:32 +00:00
Peter Steinberger
7c3502f031 fix(ios): improve bridge discovery and pairing UX 2025-12-13 17:58:03 +00:00
Peter Steinberger
61ab07ced3 fix(mac): flatten debug sections + use checkboxes 2025-12-13 17:57:45 +00:00
Peter Steinberger
281c6d6069 chore(deps): update JS deps 2025-12-13 17:52:23 +00:00
Peter Steinberger
82634dfe3b fix(mac): add divider below context 2025-12-13 17:51:25 +00:00
Peter Steinberger
9be3394bac fix(cli): improve browser control errors 2025-12-13 17:37:37 +00:00
Peter Steinberger
4228ee326c fix(browser): open tabs via CDP websocket 2025-12-13 17:37:37 +00:00
Peter Steinberger
fa1110e4d3 refactor(mac): reorganize debug settings 2025-12-13 17:36:35 +00:00
Peter Steinberger
050c47d3a7 fix(macos): encode gateway params without AnyHashable 2025-12-13 17:31:11 +00:00
Peter Steinberger
161895ed1a fix(mac): show clawd browser path in config 2025-12-13 17:23:41 +00:00
Peter Steinberger
aeffdc3632 fix(mac): show link cursor in About 2025-12-13 17:18:22 +00:00
Peter Steinberger
ecf0da1796 docs(mac): document clawdis ui passthrough 2025-12-13 17:17:42 +00:00
Peter Steinberger
990fafa988 fix(mac): use pointing hand cursor on tool links 2025-12-13 17:15:31 +00:00
Peter Steinberger
ceb0a8b3e3 fix(macos): surface gateway sessions load errors 2025-12-13 17:15:00 +00:00
Peter Steinberger
3b283f3167 fix(cli): improve ui arg passthrough 2025-12-13 17:12:51 +00:00
Peter Steinberger
2b29d08064 chore(peekaboo): bump submodule 2025-12-13 17:11:14 +00:00
Peter Steinberger
86ed3de1c1 feat(browser): add clawdis-mac browser controls 2025-12-13 17:05:58 +00:00
Peter Steinberger
acf035d848 fix(mac): align config tab padding 2025-12-13 17:00:44 +00:00
Peter Steinberger
cab71c9711 fix(mac): polish config + cron layouts 2025-12-13 16:59:25 +00:00
Peter Steinberger
c17440f5b4 feat(mac): host PeekabooBridge for ui 2025-12-13 16:56:22 +00:00
Peter Steinberger
fd566bda14 chore(submodule): add Peekaboo 2025-12-13 16:56:22 +00:00
Peter Steinberger
e47dccbe87 chore(webchat): refresh webchat bundle 2025-12-13 16:48:53 +00:00
Peter Steinberger
2a172f9779 fix(mac): expand config settings width 2025-12-13 16:48:36 +00:00
Peter Steinberger
ce630a6381 feat(webchat): polish SwiftUI chat 2025-12-13 16:45:35 +00:00
Peter Steinberger
a882798143 fix(mac): hide empty MCP servers section 2025-12-13 16:44:43 +00:00
Peter Steinberger
44f9327087 test(gateway): extend sessions RPC coverage 2025-12-13 16:36:09 +00:00
Peter Steinberger
e654676148 docs(session): note gateway session source of truth 2025-12-13 16:33:22 +00:00
Peter Steinberger
840e266b5d feat(macos): load sessions via gateway 2025-12-13 16:33:14 +00:00
Peter Steinberger
7d89fa2591 feat(gateway): add sessions list/patch RPC 2025-12-13 16:32:42 +00:00
Peter Steinberger
5f67c023a2 docs(clawdis-mac): improve help for browser control 2025-12-13 16:26:48 +00:00
Peter Steinberger
af3e5b299c feat(clawdis-mac): add browser subcommand 2025-12-13 16:26:48 +00:00
Peter Steinberger
b3b4013637 feat(mac): restructure config settings grid 2025-12-13 16:26:48 +00:00
Peter Steinberger
9ad341d668 feat(mac): add browser control menu toggle 2025-12-13 16:26:48 +00:00
Peter Steinberger
d7a8d9a1c7 fix(browser): default control url uses 18791 2025-12-13 16:26:48 +00:00
Peter Steinberger
2d36ae6326 fix(browser): derive cdp port from control url 2025-12-13 16:26:48 +00:00
Peter Steinberger
208ba02a4a feat(browser): add clawd browser control 2025-12-13 16:26:48 +00:00
Peter Steinberger
4cdb21c5cd docs: pixel lobster terminal theme 2025-12-13 16:23:15 +00:00
Peter Steinberger
5d6cc8125b test(telegram): cover inbound media download 2025-12-13 16:18:48 +00:00
Peter Steinberger
237933069e fix(telegram): download inbound media via file_path 2025-12-13 16:18:44 +00:00
Peter Steinberger
99660db73f fix(macos): prevent menubar menu width jump 2025-12-13 15:50:57 +00:00
Peter Steinberger
68fa676cbf chore(webchat): refresh bundled webchat 2025-12-13 14:19:42 +00:00
Peter Steinberger
8794f002d5 Merge remote-tracking branch 'origin/main' 2025-12-13 14:15:48 +00:00
Peter Steinberger
d52ef185b1 fix(macos): make status lines non-selectable 2025-12-13 13:59:53 +00:00
Peter Steinberger
3ca77c46c7 fix(ui): improve light-mode green for context bar 2025-12-13 13:55:16 +00:00
Peter Steinberger
89d5d807ee docs: pi-only terminology 2025-12-13 13:26:44 +00:00
Peter Steinberger
9e3427a37e docs(readme): pi-only wording 2025-12-13 13:26:44 +00:00
Peter Steinberger
7ce25ecfca docs(site): refresh clawdis.ai for Pi 2025-12-13 13:26:44 +00:00
Peter Steinberger
1ca77bee26 chore(ios): rename app to Clawdis 2025-12-13 13:11:31 +00:00
Peter Steinberger
5dbc7cc68d feat(onboarding): highlight voice wake, panel, and tools 2025-12-13 13:04:41 +00:00
Peter Steinberger
0d45c78917 fix(onboarding): drop finish footer line 2025-12-13 13:02:03 +00:00
Peter Steinberger
31fb4f7c8b fix(macos): install gateway via npm 2025-12-13 13:00:59 +00:00
Peter Steinberger
e9acb6fad5 fix(ui): align SSH target discovery row 2025-12-13 12:58:00 +00:00
Peter Steinberger
ab402e1178 docs(onboarding): explain primary gateway and remotes 2025-12-13 12:55:09 +00:00
Peter Steinberger
293701f520 fix(onboarding): tighten welcome copy and raise nav 2025-12-13 12:50:30 +00:00
Peter Steinberger
7b38ba0e65 refactor(cron): drop auto-migration 2025-12-13 12:45:02 +00:00
Peter Steinberger
5d8ee8fc28 docs(cron): update store + run log paths 2025-12-13 12:38:12 +00:00
Peter Steinberger
3e2e4be680 refactor(cron): move store into ~/.clawdis/cron 2025-12-13 12:38:08 +00:00
Peter Steinberger
3863fe6412 fix(ios): stabilize voice wake + bridge UI 2025-12-13 12:29:39 +00:00
Peter Steinberger
2b71ea21ad fix(gateway): advertise bonjour hostname 2025-12-13 12:29:39 +00:00
Peter Steinberger
36f21c5a4f feat!(mac): move screenshot to ui 2025-12-13 12:29:39 +00:00
Peter Steinberger
cf90bd9c86 feat(macos): manage cron jobs 2025-12-13 12:09:27 +00:00
Peter Steinberger
5f159c43c5 feat(cli): expand cron commands 2025-12-13 12:09:20 +00:00
Peter Steinberger
c02613e15f feat(cron): post isolated summaries 2025-12-13 12:09:15 +00:00
Peter Steinberger
32cd1175fb refactor(cron): simplify main-summary prefix config 2025-12-13 11:43:18 +00:00
Peter Steinberger
0152e053e1 feat!(mac): add ui screens + text clawdis-mac 2025-12-13 11:42:42 +00:00
Peter Steinberger
8d1e73edc7 feat(cron): always post isolated summaries to main 2025-12-13 11:33:46 +00:00
Peter Steinberger
a5f51eadf1 macOS: add onboarding security notice 2025-12-13 11:23:46 +00:00
Peter Steinberger
4ac21a4f63 docs(onboarding): explain WhatsApp + Telegram setup 2025-12-13 11:19:54 +00:00
Peter Steinberger
91fdf2aa25 macOS: align context padding 2025-12-13 11:16:33 +00:00
Peter Steinberger
44614d4a7d Merge remote-tracking branch 'origin/main' 2025-12-13 11:14:56 +00:00
Peter Steinberger
0e9f617667 macOS: align sessions list with header 2025-12-13 11:14:50 +00:00
Peter Steinberger
7e7e348a14 fix(bonjour): normalize hostnames for beacons 2025-12-13 11:14:05 +00:00
Peter Steinberger
cc3d0d1ef7 Merge remote-tracking branch 'origin/main' 2025-12-13 11:11:32 +00:00
Peter Steinberger
5b608718bb test(clawdiskit): cover BonjourEscapes decoding 2025-12-13 11:10:30 +00:00
Peter Steinberger
3a6ab81549 fix(ui): increase onboarding horizontal padding 2025-12-13 11:10:22 +00:00
Peter Steinberger
c48681b2f0 Merge remote-tracking branch 'origin/main' 2025-12-13 11:04:31 +00:00
Peter Steinberger
86d786cbc0 macOS: increase context card row spacing 2025-12-13 11:04:11 +00:00
Peter Steinberger
ec653b7b80 chore: share bonjour escapes + refresh webchat bundle 2025-12-13 10:59:48 +00:00
Peter Steinberger
cbc34e1c8a fix(ui): show bonjour masters inline 2025-12-13 10:48:25 +00:00
Peter Steinberger
1f37d94f9e feat(discovery): bonjour beacons + bridge presence 2025-12-13 04:28:43 +00:00
Peter Steinberger
3ee0e041fa Merge remote-tracking branch 'origin/main' 2025-12-13 04:01:20 +00:00
Peter Steinberger
4074f4fffa macOS: adjust context card padding 2025-12-13 04:00:48 +00:00
Peter Steinberger
7286fd6e3f feat(macos): add master discovery to onboarding 2025-12-13 04:00:25 +00:00
Peter Steinberger
4b608117a2 fix(discovery): lazy-load bonjour; add tests 2025-12-13 03:55:36 +00:00
Peter Steinberger
47b4d245aa test(cron): cover default-enabled scheduling 2025-12-13 03:54:21 +00:00
Peter Steinberger
36ff508fec macOS: stabilize context menu card layout 2025-12-13 03:52:09 +00:00
Peter Steinberger
772b5fdf0f feat(cron): default scheduler enabled 2025-12-13 03:49:42 +00:00
Peter Steinberger
eace21dcae feat(discovery): gateway bonjour + node pairing bridge 2025-12-13 03:47:53 +00:00
Peter Steinberger
163080b609 test(cron): cover disabled scheduler 2025-12-13 03:43:55 +00:00
Peter Steinberger
4938fbffa8 feat(macos): show cron scheduler status 2025-12-13 03:43:51 +00:00
Peter Steinberger
d5db20c296 feat(cli): add cron status + warn when disabled 2025-12-13 03:43:47 +00:00
Peter Steinberger
415cb857d9 feat(cron): add scheduler status endpoint 2025-12-13 03:43:40 +00:00
Peter Steinberger
a641250da6 macOS: prewarm context menu card 2025-12-13 03:42:36 +00:00
Peter Steinberger
4d674a3f17 macOS: compact context menu context rows 2025-12-13 03:30:50 +00:00
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 Lisboa
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 Lisboa
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 Lisboa
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 Lisboa
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 Lisboa
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 Lisboa
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 Steinberger
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 Steinberger
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 Steinberger
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
4254 changed files with 847363 additions and 6903 deletions

View File

@@ -7,20 +7,43 @@ on:
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
runtime: [node, bun]
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- 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.sh downloads currently fail with:
# "Failed to list releases from GitHub: 401" -> "Unexpected HTTP response: 400"
bun-download-url: "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip"
- 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 +64,65 @@ 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
with:
submodules: recursive
- 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

11
.gitignore vendored
View File

@@ -4,3 +4,14 @@ dist
pnpm-lock.yaml
coverage
.pnpm-store
.DS_Store
**/.DS_Store
apps/macos/.build/
apps/shared/ClawdisKit/.build/
bin/clawdis-mac
apps/macos/.build-local/
apps/macos/.swiftpm/
Core/
apps/ios/*.xcodeproj/
apps/ios/*.xcworkspace/
apps/ios/.swiftpm/

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "Peekaboo"]
path = Peekaboo
url = https://github.com/steipete/Peekaboo.git

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext

51
.swiftformat Normal file
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,apps/macos/Sources/ClawdisProtocol

142
.swiftlint.yml Normal file
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"

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 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 session logs under `~/.clawdis/sessions/*.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.

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 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 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.
- Pi RPC timeout is now inactivity-based (5m without events) and error messages show seconds only.
- Pi sessions now write to `~/.clawdis/sessions/` by default (legacy session logs from older installs 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 gets `--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, 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 stability:** RPC replies buffered until the assistant turn finishes; parsers return consistent `texts[]`; web auto-replies keep a warm Pi 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.
- **Pi completion signal:** RPC now resolves on Pi’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.
- Pi RPC typing exposes `signal`/`killed`; NDJSON parsers normalized across agents.
- 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.

1
CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md

1
Peekaboo Submodule

Submodule Peekaboo added at 4807e6fdf0

BIN
README-header.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

288
README.md
View File

@@ -1,131 +1,215 @@
# 📡 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) │
│ (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 only (Pi CLI in RPC mode)
- 💬 **Session Management** — Per-sender conversation context
- 🔔 **Heartbeats** — Periodic check-ins for proactive AI
- 🧭 **Clawd Browser** — Dedicated Chrome/Chromium profile with tabs + screenshot control (no interference with your daily browser)
- 👥 **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 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: ["pi", "--mode", "rpc", "{{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.
Optional: enable/configure clawd’s dedicated browser control (defaults are already on):
### 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. |
```json5
{
browser: {
enabled: true,
controlUrl: "http://127.0.0.1:18791",
color: "#FF4500"
}
}
```
Templating tokens: `{{Body}}`, `{{BodyStripped}}`, `{{From}}`, `{{To}}`, `{{MessageSid}}`, plus `{{SessionId}}` and `{{IsNewSession}}` when sessions are enabled.
## Documentation
## 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.
- [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)
## 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
## 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.
CLAWDIS was built for **Clawd**, a space lobster AI assistant. See the full setup in [`docs/clawd.md`](./docs/clawd.md).
- 🦞 **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)
## 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 |
|---------|-------------|
| `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 browser ...` | Manage clawd’s dedicated browser (status/tabs/open/screenshot). |
| `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 wake` | Enqueue a system event and optionally trigger a heartbeat via the Gateway. |
| `clawdis cron ...` | Manage scheduled jobs (via Gateway). |
| `clawdis nodes ...` | Manage Gateway-owned node pairing. |
| `clawdis status` | Web session health + session store summary |
| `clawdis health` | Reports cached provider state from the running gateway. |
| `clawdis webchat` | Start the loopback-only WebChat HTTP server |
#### 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 session 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)) — 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
Swabble/.github/workflows/ci.yml vendored Normal file
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
Swabble/.gitignore vendored Normal file
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
Swabble/.swiftformat Normal file
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
Swabble/.swiftlint.yml Normal file
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
Swabble/LICENSE Normal file
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
Swabble/Package.resolved Normal file
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
Swabble/Package.swift Normal file
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
Swabble/README.md Normal file
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

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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())
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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: [])
}
}

View File

@@ -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) } }
}

View File

@@ -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")
}
}

View File

@@ -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) } }
}

View File

@@ -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)
}
}

View File

@@ -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")
}
}
}

View File

@@ -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) } }
}

View File

@@ -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")
}
}

View File

@@ -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) } }
}

View File

@@ -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)
}
}
}

View File

@@ -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) } }
}

View File

@@ -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)
}
}
}

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)

View File

@@ -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
Swabble/docs/spec.md Normal file
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
Swabble/scripts/format.sh Executable file
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
Swabble/scripts/lint.sh Executable file
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
appcast.xml Normal file
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
apps/ios/.swiftlint.yml Normal file
View File

@@ -0,0 +1,6 @@
parent_config: ../../.swiftlint.yml
included:
- Sources
- ../shared/ClawdisNodeKit/Sources

18
apps/ios/README.md Normal file
View File

@@ -0,0 +1,18 @@
# Clawdis (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 Clawdis.xcodeproj
```
## Shared packages
- `../shared/ClawdisKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing).

View File

@@ -0,0 +1,210 @@
import ClawdisKit
import Foundation
import Network
actor BridgeClient {
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private var lineBuffer = Data()
func pairAndHello(
endpoint: NWEndpoint,
hello: BridgeHello,
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{
self.lineBuffer = Data()
let connection = NWConnection(to: endpoint, using: .tcp)
let queue = DispatchQueue(label: "com.steipete.clawdis.ios.bridge-client")
defer { connection.cancel() }
try await self.withTimeout(seconds: 8, purpose: "connect") {
try await self.startAndWaitForReady(connection, queue: queue)
}
onStatus?("Authenticating…")
try await self.send(hello, over: connection)
let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> ReceivedFrame in
guard let frame = try await self.receiveFrame(over: connection) else {
throw NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Bridge closed connection during hello",
])
}
return frame
}
switch first.base.type {
case "hello-ok":
// We only return a token if we have one; callers should treat empty as "no token yet".
return hello.token ?? ""
case "error":
let err = try self.decoder.decode(BridgeErrorFrame.self, from: first.data)
if err.code != "NOT_PAIRED", err.code != "UNAUTHORIZED" {
throw NSError(domain: "Bridge", code: 1, userInfo: [
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
])
}
onStatus?("Requesting approval…")
try await self.send(
BridgePairRequest(
nodeId: hello.nodeId,
displayName: hello.displayName,
platform: hello.platform,
version: hello.version),
over: connection)
onStatus?("Waiting for approval…")
let ok = try await self.withTimeout(seconds: 60, purpose: "pairing approval") {
while let next = try await self.receiveFrame(over: connection) {
switch next.base.type {
case "pair-ok":
return try self.decoder.decode(BridgePairOk.self, from: next.data)
case "error":
let e = try self.decoder.decode(BridgeErrorFrame.self, from: next.data)
throw NSError(domain: "Bridge", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
])
default:
continue
}
}
throw NSError(domain: "Bridge", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Pairing failed: bridge closed connection",
])
}
return ok.token
default:
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 struct ReceivedFrame {
var base: BridgeBaseFrame
var data: Data
}
private func receiveFrame(over connection: NWConnection) async throws -> ReceivedFrame? {
guard let lineData = try await self.receiveLineData(over: connection) else {
return nil
}
let base = try self.decoder.decode(BridgeBaseFrame.self, from: lineData)
return ReceivedFrame(base: base, data: lineData)
}
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())
}
}
}
private func receiveLineData(over connection: NWConnection) async throws -> Data? {
while true {
if let idx = self.lineBuffer.firstIndex(of: 0x0A) {
let line = self.lineBuffer.prefix(upTo: idx)
self.lineBuffer.removeSubrange(...idx)
return Data(line)
}
let chunk = try await self.receiveChunk(over: connection)
if chunk.isEmpty { return nil }
self.lineBuffer.append(chunk)
}
}
private struct TimeoutError: LocalizedError, Sendable {
var purpose: String
var seconds: Int
var errorDescription: String? {
if self.purpose == "pairing approval" {
return
"Timed out waiting for approval (\(self.seconds)s). " +
"Approve the node on your gateway and try again."
}
return "Timed out during \(self.purpose) (\(self.seconds)s)."
}
}
private func withTimeout<T: Sendable>(
seconds: Int,
purpose: String,
_ op: @escaping @Sendable () async throws -> T) async throws -> T
{
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await op()
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000)
throw TimeoutError(purpose: purpose, seconds: seconds)
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
private func startAndWaitForReady(_ connection: NWConnection, queue: DispatchQueue) async throws {
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
final class ResumeFlag: @unchecked Sendable {
private let lock = NSLock()
private var value = false
func trySet() -> Bool {
self.lock.lock()
defer { self.lock.unlock() }
if self.value { return false }
self.value = true
return true
}
}
let didResume = ResumeFlag()
connection.stateUpdateHandler = { state in
switch state {
case .ready:
if didResume.trySet() { cont.resume(returning: ()) }
case let .failed(err):
if didResume.trySet() { cont.resume(throwing: err) }
case let .waiting(err):
if didResume.trySet() { cont.resume(throwing: err) }
case .cancelled:
if didResume.trySet() {
cont.resume(throwing: NSError(domain: "Bridge", code: 50, userInfo: [
NSLocalizedDescriptionKey: "Connection cancelled",
]))
}
default:
break
}
}
connection.start(queue: queue)
}
}
}

View File

@@ -0,0 +1,90 @@
import ClawdisKit
import Foundation
import Network
@MainActor
final class BridgeDiscoveryModel: ObservableObject {
struct DiscoveredBridge: Identifiable, Equatable {
var id: String { self.stableID }
var name: String
var endpoint: NWEndpoint
var stableID: String
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, _, _, _):
let decodedName = BonjourEscapes.decode(name)
let advertisedName = result.endpoint.txtRecord?.dictionary["displayName"]
let prettyAdvertised = advertisedName
.map(Self.prettifyInstanceName)
.flatMap { $0.isEmpty ? nil : $0 }
let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName)
return DiscoveredBridge(
name: prettyName,
endpoint: result.endpoint,
stableID: BridgeEndpointID.stableID(result.endpoint),
debugID: BridgeEndpointID.prettyDescription(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 prettifyInstanceName(_ decodedName: String) -> String {
let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ")
let stripped = normalized.replacingOccurrences(of: " (Clawdis)", with: "")
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
}
}

View File

@@ -0,0 +1,26 @@
import ClawdisKit
import Foundation
import Network
enum BridgeEndpointID {
static func stableID(_ endpoint: NWEndpoint) -> String {
switch endpoint {
case let .service(name, type, domain, _):
// Keep this stable across encode/decode differences (e.g. `\032` for spaces).
let normalizedName = Self.normalizeServiceNameForID(name)
return "\(type)|\(domain)|\(normalizedName)"
default:
return String(describing: endpoint)
}
}
static func prettyDescription(_ endpoint: NWEndpoint) -> String {
BonjourEscapes.decode(String(describing: endpoint))
}
private static func normalizeServiceNameForID(_ rawName: String) -> String {
let decoded = BonjourEscapes.decode(rawName)
let normalized = decoded.split(whereSeparator: \.isWhitespace).joined(separator: " ")
return normalized.trimmingCharacters(in: .whitespacesAndNewlines)
}
}

View File

@@ -0,0 +1,185 @@
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 currentRemoteAddress() -> String? {
guard let endpoint = self.connection?.currentPath?.remoteEndpoint else { return nil }
return Self.prettyRemoteEndpoint(endpoint)
}
private static func prettyRemoteEndpoint(_ endpoint: NWEndpoint) -> String? {
switch endpoint {
case let .hostPort(host, port):
let hostString = Self.prettyHostString(host)
if hostString.contains(":") {
return "[\(hostString)]:\(port)"
}
return "\(hostString):\(port)"
default:
return String(describing: endpoint)
}
}
private static func prettyHostString(_ host: NWEndpoint.Host) -> String {
var hostString = String(describing: host)
hostString = hostString.replacingOccurrences(of: "::ffff:", with: "")
guard let percentIndex = hostString.firstIndex(of: "%") else { return hostString }
let prefix = hostString[..<percentIndex]
let allowed = CharacterSet(charactersIn: "0123456789abcdefABCDEF:.")
let isIPAddressPrefix = prefix.unicodeScalars.allSatisfy { allowed.contains($0) }
if isIPAddressPrefix {
return String(prefix)
}
return hostString
}
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())
}
}
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,21 @@
import SwiftUI
@main
struct ClawdisApp: 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)
}
}
}
}

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</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 discovers and connects to your Clawdis bridge on the local network.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Clawdis needs microphone access for voice wake.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Clawdis 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>

View File

@@ -0,0 +1,255 @@
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?
@Published var bridgeRemoteAddress: String?
@Published var connectedBridgeID: 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,
hello: BridgeHello)
{
self.bridgeTask?.cancel()
self.bridgeStatusText = "Connecting…"
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
self.bridgeTask = Task {
do {
try await self.bridge.connect(
endpoint: endpoint,
hello: hello,
onConnected: { [weak self] serverName in
guard let self else { return }
await MainActor.run {
self.bridgeStatusText = "Connected"
self.bridgeServerName = serverName
}
if let addr = await self.bridge.currentRemoteAddress() {
await MainActor.run {
self.bridgeRemoteAddress = addr
}
}
},
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
self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil
}
} catch {
await MainActor.run {
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil
}
}
}
}
func disconnectBridge() {
self.bridgeTask?.cancel()
self.bridgeTask = nil
Task { await self.bridge.disconnect() }
self.bridgeStatusText = "Disconnected"
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.connectedBridgeID = 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)
guard let json = String(bytes: data, encoding: .utf8) else {
throw NSError(domain: "NodeAppModel", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8",
])
}
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)
guard let json = String(bytes: data, encoding: .utf8) else {
throw NSError(domain: "NodeAppModel", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8",
])
}
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)
guard let json = String(bytes: data, encoding: .utf8) else {
throw NSError(domain: "NodeAppModel", code: 21, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode payload as UTF-8",
])
}
return json
}
}

View File

@@ -0,0 +1,50 @@
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()
}
.preferredColorScheme(.dark)
}
}

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") }
}
}
}

View File

@@ -0,0 +1,203 @@
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.isOpaque = false
self.webView.backgroundColor = .clear
self.webView.scrollView.backgroundColor = .clear
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>
"""
}

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.
}

View File

@@ -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.
}
}

View File

@@ -0,0 +1,343 @@
import ClawdisKit
import SwiftUI
import UIKit
@MainActor
private final class ConnectStatusStore: ObservableObject {
@Published var text: String?
}
extension ConnectStatusStore: @unchecked Sendable {}
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
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
@StateObject private var discovery = BridgeDiscoveryModel()
@StateObject private var connectStatus = ConnectStatusStore()
@State private var connectingBridgeID: String?
@State private var didAutoConnect = false
@State private var localIPAddress: String?
var body: some View {
NavigationStack {
Form {
Section("Node") {
TextField("Name", text: self.$displayName)
Text(self.instanceId)
.font(.footnote)
.foregroundStyle(.secondary)
LabeledContent("IP", value: self.localIPAddress ?? "—")
.contextMenu {
if let ip = self.localIPAddress {
Button {
UIPasteboard.general.string = ip
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
}
}
}
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)
if let addr = self.appModel.bridgeRemoteAddress {
let parts = Self.parseHostPort(from: addr)
let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr)
LabeledContent("Address") {
Text(urlString)
}
.contextMenu {
Button {
UIPasteboard.general.string = urlString
} label: {
Label("Copy URL", systemImage: "doc.on.doc")
}
if let parts {
Button {
UIPasteboard.general.string = parts.host
} label: {
Label("Copy Host", systemImage: "doc.on.doc")
}
Button {
UIPasteboard.general.string = "\(parts.port)"
} label: {
Label("Copy Port", systemImage: "doc.on.doc")
}
}
}
}
Button("Disconnect", role: .destructive) {
self.appModel.disconnectBridge()
}
self.bridgeList(showing: .availableOnly)
} else {
self.bridgeList(showing: .all)
}
if let text = self.connectStatus.text {
Text(text)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
self.dismiss()
} label: {
Image(systemName: "xmark")
}
.accessibilityLabel("Close")
}
}
.onAppear {
self.discovery.start()
self.localIPAddress = Self.primaryIPv4Address()
}
.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 target = self.pickAutoConnectBridge(from: newValue) else { return }
self.didAutoConnect = true
self.preferredBridgeStableID = target.stableID
self.appModel.connectToBridge(
endpoint: target.endpoint,
hello: BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: existing,
platform: self.platformString(),
version: self.appVersion()))
self.connectStatus.text = nil
}
.onChange(of: self.appModel.bridgeServerName) { _, _ in
self.connectStatus.text = nil
}
}
}
@ViewBuilder
private func bridgeList(showing: BridgeListMode) -> some View {
if self.discovery.bridges.isEmpty {
Text("No bridges found yet.")
.foregroundStyle(.secondary)
} else {
let connectedID = self.appModel.connectedBridgeID
let rows = self.discovery.bridges.filter { bridge in
let isConnected = bridge.stableID == connectedID
switch showing {
case .all:
return true
case .availableOnly:
return !isConnected
}
}
if rows.isEmpty, showing == .availableOnly {
Text("No other bridges found.")
.foregroundStyle(.secondary)
} else {
ForEach(rows) { bridge in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(bridge.name)
}
Spacer()
Button {
Task { await self.connect(bridge) }
} label: {
if self.connectingBridgeID == bridge.id {
ProgressView()
.progressViewStyle(.circular)
} else {
Text("Connect")
}
}
.disabled(self.connectingBridgeID != nil)
}
}
}
}
}
private enum BridgeListMode: Equatable {
case all
case availableOnly
}
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.connectingBridgeID = bridge.id
self.preferredBridgeStableID = bridge.stableID
defer { self.connectingBridgeID = nil }
do {
let existing = KeychainStore.loadString(
service: "com.steipete.clawdis.bridge",
account: self.keychainAccount())
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
existing :
nil
let hello = BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: existingToken,
platform: self.platformString(),
version: self.appVersion())
let token = try await BridgeClient().pairAndHello(
endpoint: bridge.endpoint,
hello: hello,
onStatus: { status in
let store = self.connectStatus
Task { @MainActor in
store.text = status
}
})
if !token.isEmpty, token != existingToken {
_ = KeychainStore.saveString(
token,
service: "com.steipete.clawdis.bridge",
account: self.keychainAccount())
}
self.appModel.connectToBridge(
endpoint: bridge.endpoint,
hello: BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: token,
platform: self.platformString(),
version: self.appVersion()))
} catch {
self.connectStatus.text = "Failed: \(error.localizedDescription)"
}
}
private func pickAutoConnectBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) -> BridgeDiscoveryModel
.DiscoveredBridge? {
if !self.preferredBridgeStableID.isEmpty,
let match = bridges.first(where: { $0.stableID == self.preferredBridgeStableID })
{
return match
}
return bridges.first
}
private static func primaryIPv4Address() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
defer { freeifaddrs(addrList) }
var fallback: String?
var en0: String?
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
let flags = Int32(ptr.pointee.ifa_flags)
let isUp = (flags & IFF_UP) != 0
let isLoopback = (flags & IFF_LOOPBACK) != 0
let name = String(cString: ptr.pointee.ifa_name)
let family = ptr.pointee.ifa_addr.pointee.sa_family
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
var addr = ptr.pointee.ifa_addr.pointee
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = getnameinfo(
&addr,
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard result == 0 else { continue }
let len = buffer.prefix { $0 != 0 }
let bytes = len.map { UInt8(bitPattern: $0) }
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
if name == "en0" { en0 = ip; break }
if fallback == nil { fallback = ip }
}
return en0 ?? fallback
}
private struct HostPort: Equatable {
var host: String
var port: Int
}
private static func parseHostPort(from address: String) -> HostPort? {
let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.hasPrefix("["),
let close = trimmed.firstIndex(of: "]"),
close < trimmed.endIndex
{
let host = String(trimmed[trimmed.index(after: trimmed.startIndex)..<close])
let portStart = trimmed.index(after: close)
guard portStart < trimmed.endIndex, trimmed[portStart] == ":" else { return nil }
let portString = String(trimmed[trimmed.index(after: portStart)...])
guard let port = Int(portString) else { return nil }
return HostPort(host: host, port: port)
}
guard let colon = trimmed.lastIndex(of: ":") else { return nil }
let host = String(trimmed[..<colon])
let portString = String(trimmed[trimmed.index(after: colon)...])
guard !host.isEmpty, let port = Int(portString) else { return nil }
return HostPort(host: host, port: port)
}
private static func httpURLString(host: String?, port: Int?, fallback: String) -> String {
if let host, let port {
let needsBrackets = host.contains(":") && !host.hasPrefix("[") && !host.hasSuffix("]")
let hostPart = needsBrackets ? "[\(host)]" : host
return "http://\(hostPart):\(port)"
}
return "http://\(fallback)"
}
}

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)
}
}
}
}

View File

@@ -0,0 +1,304 @@
import AVFAudio
import Foundation
import Speech
private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
{ buffer, _ in
// This callback is invoked on a realtime audio thread/queue. Keep it tiny and nonisolated.
queue.enqueueCopy(of: buffer)
}
}
private final class AudioBufferQueue: @unchecked Sendable {
private let lock = NSLock()
private var buffers: [AVAudioPCMBuffer] = []
func enqueueCopy(of buffer: AVAudioPCMBuffer) {
guard let copy = buffer.deepCopy() else { return }
self.lock.lock()
self.buffers.append(copy)
self.lock.unlock()
}
func drain() -> [AVAudioPCMBuffer] {
self.lock.lock()
let drained = self.buffers
self.buffers.removeAll(keepingCapacity: true)
self.lock.unlock()
return drained
}
func clear() {
self.lock.lock()
self.buffers.removeAll(keepingCapacity: false)
self.lock.unlock()
}
}
extension AVAudioPCMBuffer {
fileprivate func deepCopy() -> AVAudioPCMBuffer? {
let format = self.format
let frameLength = self.frameLength
guard let copy = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameLength) else {
return nil
}
copy.frameLength = frameLength
if let src = self.floatChannelData, let dst = copy.floatChannelData {
let channels = Int(format.channelCount)
let frames = Int(frameLength)
for ch in 0..<channels {
dst[ch].update(from: src[ch], count: frames)
}
return copy
}
if let src = self.int16ChannelData, let dst = copy.int16ChannelData {
let channels = Int(format.channelCount)
let frames = Int(frameLength)
for ch in 0..<channels {
dst[ch].update(from: src[ch], count: frames)
}
return copy
}
if let src = self.int32ChannelData, let dst = copy.int32ChannelData {
let channels = Int(format.channelCount)
let frames = Int(frameLength)
for ch in 0..<channels {
dst[ch].update(from: src[ch], count: frames)
}
return copy
}
return nil
}
}
@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 tapQueue: AudioBufferQueue?
private var tapDrainTask: Task<Void, Never>?
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 }
if ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil ||
ProcessInfo.processInfo.environment["SIMULATOR_UDID"] != nil
{
// The iOS Simulator’s audio stack is unreliable for long-running microphone capture.
// (We’ve observed CoreAudio deadlocks after TCC permission prompts.)
self.isListening = false
self.statusText = "Voice Wake isn’t supported on Simulator"
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.tapDrainTask?.cancel()
self.tapDrainTask = nil
self.tapQueue?.clear()
self.tapQueue = nil
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
self.tapDrainTask?.cancel()
self.tapDrainTask = nil
self.tapQueue?.clear()
self.tapQueue = nil
let request = SFSpeechAudioBufferRecognitionRequest()
request.shouldReportPartialResults = true
self.recognitionRequest = request
let inputNode = self.audioEngine.inputNode
inputNode.removeTap(onBus: 0)
let recordingFormat = inputNode.outputFormat(forBus: 0)
let queue = AudioBufferQueue()
self.tapQueue = queue
let tapBlock: @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void = makeAudioTapEnqueueCallback(queue: queue)
inputNode.installTap(
onBus: 0,
bufferSize: 1024,
format: recordingFormat,
block: tapBlock)
self.audioEngine.prepare()
try self.audioEngine.start()
let handler = self.makeRecognitionResultHandler()
self.recognitionTask = self.speechRecognizer?.recognitionTask(with: request, resultHandler: handler)
self.tapDrainTask = Task { [weak self] in
guard let self, let queue = self.tapQueue else { return }
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 40_000_000)
let drained = queue.drain()
if drained.isEmpty { continue }
for buf in drained {
request.append(buf)
}
}
}
}
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(isolation: nil) { cont in
AVAudioApplication.requestRecordPermission { ok in
cont.resume(returning: ok)
}
}
}
private nonisolated static func requestSpeechPermission() async -> Bool {
await withCheckedContinuation(isolation: nil) { cont in
SFSpeechRecognizer.requestAuthorization { status in
cont.resume(returning: status == .authorized)
}
}
}
}

56
apps/ios/project.yml Normal file
View File

@@ -0,0 +1,56 @@
name: Clawdis
options:
bundleIdPrefix: com.steipete.clawdis
deploymentTarget:
iOS: "17.0"
xcodeVersion: "16.0"
packages:
ClawdisKit:
path: ../shared/ClawdisKit
targets:
Clawdis:
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.ios
SWIFT_VERSION: "6.0"
info:
path: Sources/Info.plist
properties:
CFBundleDisplayName: Clawdis
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
UIBackgroundModes:
- audio
NSLocalNetworkUsageDescription: Clawdis discovers and connects to your Clawdis bridge on the local network.
NSBonjourServices:
- _clawdis-bridge._tcp
NSMicrophoneUsageDescription: Clawdis needs microphone access for voice wake.
NSSpeechRecognitionUsageDescription: Clawdis uses on-device speech recognition for voice wake.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

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"
}
}

141
apps/macos/Package.resolved Normal file
View File

@@ -0,0 +1,141 @@
{
"originHash" : "d8a19a95c479a3c7cb20aded07bd18cfeda5d85b95284983da83dbee7c941e5c",
"pins" : [
{
"identity" : "eventsource",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mattt/eventsource.git",
"state" : {
"revision" : "ca2a9d90cbe49e09b92f4b6ebd922c03ebea51d0",
"version" : "1.3.0"
}
},
{
"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-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-algorithms",
"state" : {
"revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023",
"version" : "1.2.1"
}
},
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "810496cf121e525d660cd0ea89a758740476b85f",
"version" : "1.5.1"
}
},
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms",
"state" : {
"revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804",
"version" : "1.1.1"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections",
"state" : {
"branch" : "main",
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
}
},
{
"identity" : "swift-configuration",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-configuration",
"state" : {
"revision" : "3528deb75256d7dcbb0d71fa75077caae0a8c749",
"version" : "1.0.0"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095",
"version" : "4.2.0"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca",
"version" : "1.8.0"
}
},
{
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-numerics.git",
"state" : {
"revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
"version" : "1.1.1"
}
},
{
"identity" : "swift-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/modelcontextprotocol/swift-sdk.git",
"state" : {
"revision" : "c0407a0b52677cb395d824cac2879b963075ba8c",
"version" : "0.10.2"
}
},
{
"identity" : "swift-service-lifecycle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/swift-service-lifecycle",
"state" : {
"revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348",
"version" : "2.9.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
}

84
apps/macos/Package.swift Normal file
View File

@@ -0,0 +1,84 @@
// 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"),
.package(path: "../../Peekaboo/Core/PeekabooCore"),
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
.package(path: "../../Peekaboo/Core/PeekabooVisualizer"),
],
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"),
.product(name: "PeekabooBridge", package: "PeekabooCore"),
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
.product(name: "PeekabooVisualizer", package: "PeekabooVisualizer"),
],
resources: [
.copy("Resources/Clawdis.icns"),
.copy("Resources/WebChat"),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "ClawdisCLI",
dependencies: [
"ClawdisIPC",
"ClawdisProtocol",
.product(name: "PeekabooBridge", package: "PeekabooCore"),
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.testTarget(
name: "ClawdisIPCTests",
dependencies: ["ClawdisIPC", "Clawdis", "ClawdisProtocol"],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
]),
.testTarget(
name: "ClawdisCLITests",
dependencies: ["ClawdisCLI"],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
]),
])

View File

@@ -0,0 +1,191 @@
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)
.pointingHandCursor()
.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 }
.pointingHandCursor()
}
}
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

View File

@@ -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()
}
}

View File

@@ -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()
}
}

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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -0,0 +1,403 @@
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 peekabooBridgeEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.peekabooBridgeEnabled, forKey: peekabooBridgeEnabledKey)
Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(self.peekabooBridgeEnabled) }
}
}
}
@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.peekabooBridgeEnabled = UserDefaults.standard
.object(forKey: peekabooBridgeEnabledKey) 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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -0,0 +1,324 @@
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)
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")
if #available(macOS 11.0, *), alert.buttons.indices.contains(1) {
alert.buttons[1].hasDestructiveAction = true
}
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
}
}

View File

@@ -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])
}
}

View File

@@ -0,0 +1,94 @@
import CoreServices
import Foundation
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()
}
}
}

View File

@@ -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.
}

View File

@@ -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"
}
}
}

View File

@@ -0,0 +1,209 @@
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)
let servedPath = standardizedFile.path
canvasLogger.debug(
"served \(session, privacy: .public)/\(path, privacy: .public) -> \(servedPath, privacy: .public)")
return CanvasResponse(mime: mime, data: data)
} catch {
let failedPath = standardizedFile.path
let errorText = error.localizedDescription
canvasLogger
.error(
"failed reading \(failedPath, privacy: .public): \(errorText, 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
}
}
}

View File

@@ -0,0 +1,592 @@
import AppKit
import ClawdisIPC
import Foundation
import OSLog
import QuartzCore
import WebKit
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 let .panel(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 {
panel.constrainFrameRect(frame, to: s)
} else {
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.\(self.sanitizeSessionKey(sessionKey))"
}
private static func loadRestoredFrame(sessionKey: String) -> NSRect? {
let key = self.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 = self.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
}
}
}

View File

@@ -0,0 +1,40 @@
import Foundation
enum ClawdisConfigFile {
static func url() -> URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdis")
.appendingPathComponent("clawdis.json")
}
static func loadDict() -> [String: Any] {
let url = self.url()
guard let data = try? Data(contentsOf: url) else { return [:] }
return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]
}
static func saveDict(_ dict: [String: Any]) {
do {
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
let url = self.url()
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
} catch {}
}
static func browserControlEnabled(defaultValue: Bool = true) -> Bool {
let root = self.loadDict()
let browser = root["browser"] as? [String: Any]
return browser?["enabled"] as? Bool ?? defaultValue
}
static func setBrowserControlEnabled(_ enabled: Bool) {
var root = self.loadDict()
var browser = root["browser"] as? [String: Any] ?? [:]
browser["enabled"] = enabled
root["browser"] = browser
self.saveDict(root)
}
}

View File

@@ -0,0 +1,447 @@
import SwiftUI
@MainActor
struct ConfigSettings: View {
private let isPreview = ProcessInfo.processInfo.isPreview
private let labelColumnWidth: CGFloat = 120
private static let browserAttachOnlyHelp =
"When enabled, the browser server will only connect if the clawd browser is already running."
private static let browserProfileNote =
"Clawd uses a separate Chrome profile and ports (default 18791/18792) "
+ "so it won’t interfere with your daily browser."
@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
// clawd browser settings (stored in ~/.clawdis/clawdis.json under "browser")
@State private var browserEnabled: Bool = true
@State private var browserControlUrl: String = "http://127.0.0.1:18791"
@State private var browserColorHex: String = "#FF4500"
@State private var browserAttachOnly: Bool = false
var body: some View {
ScrollView {
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)
GroupBox("Agent") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("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(maxWidth: .infinity)
.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(maxWidth: .infinity)
.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)
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
GroupBox("Heartbeat") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Schedule")
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")
.frame(width: 150, alignment: .leading)
}
.help("Set to 0 to disable automatic heartbeats")
TextField("HEARTBEAT", text: self.$heartbeatBody)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.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)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
GroupBox("Web Chat") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$webChatEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Port")
TextField("18788", value: self.$webChatPort, formatter: NumberFormatter())
.textFieldStyle(.roundedBorder)
.frame(width: 100)
.disabled(!self.webChatEnabled)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
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: .infinity, alignment: .leading)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
GroupBox("Browser (clawd)") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$browserEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
.onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() }
}
GridRow {
self.gridLabel("Control URL")
TextField("http://127.0.0.1:18791", text: self.$browserControlUrl)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.disabled(!self.browserEnabled)
.onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() }
}
GridRow {
self.gridLabel("Browser path")
VStack(alignment: .leading, spacing: 2) {
if let label = self.browserPathLabel {
Text(label)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
} else {
Text("—")
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Accent")
HStack(spacing: 8) {
TextField("#FF4500", text: self.$browserColorHex)
.textFieldStyle(.roundedBorder)
.frame(width: 120)
.disabled(!self.browserEnabled)
.onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() }
Circle()
.fill(self.browserColor)
.frame(width: 12, height: 12)
.overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1))
Text("lobster-orange")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
GridRow {
self.gridLabel("Attach only")
Toggle("", isOn: self.$browserAttachOnly)
.labelsHidden()
.toggleStyle(.checkbox)
.disabled(!self.browserEnabled)
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
.help(Self.browserAttachOnlyHelp)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(Self.browserProfileNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
.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 gridLabel(_ text: String) -> some View {
Text(text)
.foregroundStyle(.secondary)
.frame(width: self.labelColumnWidth, alignment: .leading)
}
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 browser = parsed["browser"] as? [String: Any]
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 }
if let browser {
if let enabled = browser["enabled"] as? Bool { self.browserEnabled = enabled }
if let url = browser["controlUrl"] as? String, !url.isEmpty { self.browserControlUrl = url }
if let color = browser["color"] as? String, !color.isEmpty { self.browserColorHex = color }
if let attachOnly = browser["attachOnly"] as? Bool { self.browserAttachOnly = attachOnly }
}
}
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] ?? [:]
var browser = root["browser"] 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
browser["enabled"] = self.browserEnabled
let trimmedUrl = self.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedUrl.isEmpty { browser["controlUrl"] = trimmedUrl }
let trimmedColor = self.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedColor.isEmpty { browser["color"] = trimmedColor }
browser["attachOnly"] = self.browserAttachOnly
root["browser"] = browser
ClawdisConfigFile.saveDict(root)
}
private func loadConfigDict() -> [String: Any] {
ClawdisConfigFile.loadDict()
}
private var browserColor: Color {
let raw = self.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
let hex = raw.hasPrefix("#") ? String(raw.dropFirst()) : raw
guard hex.count == 6, let value = Int(hex, radix: 16) else { return .orange }
let r = Double((value >> 16) & 0xFF) / 255.0
let g = Double((value >> 8) & 0xFF) / 255.0
let b = Double(value & 0xFF) / 255.0
return Color(red: r, green: g, blue: b)
}
private var browserPathLabel: String? {
guard self.browserEnabled else { return nil }
let host = (URL(string: self.browserControlUrl)?.host ?? "").lowercased()
if !host.isEmpty, !Self.isLoopbackHost(host) {
return "remote (\(host))"
}
guard let candidate = Self.detectedBrowserCandidate() else { return nil }
return candidate.executablePath ?? candidate.appPath
}
private struct BrowserCandidate {
let name: String
let appPath: String
let executablePath: String?
}
private static func detectedBrowserCandidate() -> BrowserCandidate? {
let candidates: [(name: String, appName: String)] = [
("Google Chrome Canary", "Google Chrome Canary.app"),
("Chromium", "Chromium.app"),
("Google Chrome", "Google Chrome.app"),
]
let roots = [
"/Applications",
"\(NSHomeDirectory())/Applications",
]
let fm = FileManager.default
for (name, appName) in candidates {
for root in roots {
let appPath = "\(root)/\(appName)"
if fm.fileExists(atPath: appPath) {
let bundle = Bundle(url: URL(fileURLWithPath: appPath))
let exec = bundle?.executableURL?.path
return BrowserCandidate(name: name, appPath: appPath, executablePath: exec)
}
}
}
return nil
}
private static func isLoopbackHost(_ host: String) -> Bool {
if host == "localhost" { return true }
if host == "127.0.0.1" { return true }
if host == "::1" { return true }
return false
}
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"
}
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 10) {
configuration.label
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
configuration.content
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
#if DEBUG
struct ConfigSettings_Previews: PreviewProvider {
static var previews: some View {
ConfigSettings()
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}
#endif

View File

@@ -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) }
}
}
}

View File

@@ -0,0 +1,36 @@
import Foundation
let launchdLabel = "com.steipete.clawdis"
let onboardingVersionKey = "clawdis.onboardingVersion"
let currentOnboardingVersion = 4
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 peekabooBridgeEnabledKey = "clawdis.peekabooBridgeEnabled"
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"]

View File

@@ -0,0 +1,121 @@
import Foundation
import SwiftUI
/// Context usage card shown at the top of the menubar menu.
struct ContextMenuCardView: View {
private let rows: [SessionRow]
private let statusText: String?
private let isLoading: Bool
private let paddingTop: CGFloat = 8
private let paddingBottom: CGFloat = 8
private let paddingTrailing: CGFloat = 10
private let paddingLeading: CGFloat = 20
private let barHeight: CGFloat = 3
init(
rows: [SessionRow],
statusText: String? = nil,
isLoading: Bool = false)
{
self.rows = rows
self.statusText = statusText
self.isLoading = isLoading
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline) {
Text("Context")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer(minLength: 10)
Text(self.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
if let statusText {
Text(statusText)
.font(.caption)
.foregroundStyle(.secondary)
} else if self.rows.isEmpty, !self.isLoading {
Text("No active sessions")
.font(.caption)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 12) {
if self.rows.isEmpty, self.isLoading {
ForEach(0..<2, id: \.self) { _ in
self.placeholderRow
}
} else {
ForEach(self.rows) { row in
self.sessionRow(row)
}
}
}
}
}
.padding(.top, self.paddingTop)
.padding(.bottom, self.paddingBottom)
.padding(.leading, self.paddingLeading)
.padding(.trailing, self.paddingTrailing)
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
.transaction { txn in txn.animation = nil }
}
private var subtitle: String {
let count = self.rows.count
if count == 1 { return "1 session · 24h" }
return "\(count) sessions · 24h"
}
@ViewBuilder
private func sessionRow(_ row: SessionRow) -> some View {
VStack(alignment: .leading, spacing: 5) {
ContextUsageBar(
usedTokens: row.tokens.total,
contextTokens: row.tokens.contextTokens,
height: self.barHeight)
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)
}
}
.padding(.vertical, 2)
}
private var placeholderRow: some View {
VStack(alignment: .leading, spacing: 5) {
ContextUsageBar(
usedTokens: 0,
contextTokens: 200_000,
height: self.barHeight)
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("main")
.font(.caption.weight(.semibold))
.lineLimit(1)
.layoutPriority(1)
Spacer(minLength: 8)
Text("000k/000k")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(2)
}
.redacted(reason: .placeholder)
}
}
}

View File

@@ -0,0 +1,81 @@
import SwiftUI
struct ContextUsageBar: View {
let usedTokens: Int
let contextTokens: Int
var width: CGFloat?
var height: CGFloat = 6
private static let okGreen: NSColor = .init(name: nil) { appearance in
let base = NSColor.systemGreen
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
if match == .darkAqua { return base }
return base.blended(withFraction: 0.24, of: .black) ?? base
}
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: Self.okGreen)
}
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)
}
}
}
}

View File

@@ -0,0 +1,282 @@
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.base)
}
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:
if AppStateStore.attachExistingGatewayOnly {
return """
Cannot reach gateway at localhost:\(port) and “Attach existing gateway only” is enabled.
Disable it in Debug Settings or start a gateway on that port.
"""
}
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")
}

View File

@@ -0,0 +1,257 @@
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, case .status = request {
// allow status through
} else if paused {
return Response(ok: false, message: "clawdis paused")
}
switch request {
case let .notify(title, body, sound, priority, delivery):
let notify = NotifyRequest(
title: title,
body: body,
sound: sound,
priority: priority,
delivery: delivery)
return await self.handleNotify(notify, notifier: notifier)
case let .ensurePermissions(caps, interactive):
return await self.handleEnsurePermissions(caps: caps, interactive: interactive)
case .status:
return paused
? Response(ok: false, message: "clawdis paused")
: Response(ok: true, message: "ready")
case .rpcStatus:
return await self.handleRPCStatus()
case let .runShell(command, cwd, env, timeoutSec, needsSR):
return await self.handleRunShell(
command: command,
cwd: cwd,
env: env,
timeoutSec: timeoutSec,
needsSR: needsSR)
case let .agent(message, thinking, session, deliver, to):
return await self.handleAgent(
message: message,
thinking: thinking,
session: session,
deliver: deliver,
to: to)
case let .canvasShow(session, path, placement):
return await self.handleCanvasShow(session: session, path: path, placement: placement)
case let .canvasHide(session):
return await self.handleCanvasHide(session: session)
case let .canvasGoto(session, path, placement):
return await self.handleCanvasGoto(session: session, path: path, placement: placement)
case let .canvasEval(session, javaScript):
return await self.handleCanvasEval(session: session, javaScript: javaScript)
case let .canvasSnapshot(session, outPath):
return await self.handleCanvasSnapshot(session: session, outPath: outPath)
case .nodeList:
return await self.handleNodeList()
case let .nodeInvoke(nodeId, command, paramsJSON):
return await self.handleNodeInvoke(
nodeId: nodeId,
command: command,
paramsJSON: paramsJSON,
logger: logger)
}
}
private struct NotifyRequest {
var title: String
var body: String
var sound: String?
var priority: NotificationPriority?
var delivery: NotificationDelivery?
}
private static func handleNotify(_ request: NotifyRequest, notifier: NotificationManager) async -> Response {
let chosenSound = request.sound?.trimmingCharacters(in: .whitespacesAndNewlines)
let chosenDelivery = request.delivery ?? .system
switch chosenDelivery {
case .system:
let ok = await notifier.send(
title: request.title,
body: request.body,
sound: chosenSound,
priority: request.priority)
return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized")
case .overlay:
await MainActor.run {
NotifyOverlayController.shared.present(title: request.title, body: request.body)
}
return Response(ok: true)
case .auto:
let ok = await notifier.send(
title: request.title,
body: request.body,
sound: chosenSound,
priority: request.priority)
if ok { return Response(ok: true) }
await MainActor.run {
NotifyOverlayController.shared.present(title: request.title, body: request.body)
}
return Response(ok: true, message: "notification not authorized; used overlay")
}
}
private static func handleEnsurePermissions(caps: [Capability], interactive: Bool) async -> Response {
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)
}
private static func handleRPCStatus() async -> Response {
let result = await AgentRPC.shared.status()
return Response(ok: result.ok, message: result.error)
}
private static func handleRunShell(
command: [String],
cwd: String?,
env: [String: String]?,
timeoutSec: Double?,
needsSR: Bool) async -> Response
{
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)
}
private static func handleAgent(
message: String,
thinking: String?,
session: String?,
deliver: Bool,
to: String?) async -> Response
{
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")
}
private static func canvasEnabled() -> Bool {
UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
}
private static func handleCanvasShow(
session: String,
path: String?,
placement: CanvasPlacement?) async -> Response
{
guard self.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)
}
}
private static func handleCanvasHide(session: String) async -> Response {
await MainActor.run { CanvasManager.shared.hide(sessionKey: session) }
return Response(ok: true)
}
private static func handleCanvasGoto(session: String, path: String, placement: CanvasPlacement?) async -> Response {
guard self.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)
}
}
private static func handleCanvasEval(session: String, javaScript: String) async -> Response {
guard self.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)
}
}
private static func handleCanvasSnapshot(session: String, outPath: String?) async -> Response {
guard self.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)
}
}
private static func handleNodeList() async -> Response {
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))
}
private static func handleNodeInvoke(
nodeId: String,
command: String,
paramsJSON: String?,
logger: Logger) async -> Response
{
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 {
logger.error("node invoke failed: \(error.localizedDescription, privacy: .public)")
return Response(ok: false, message: error.localizedDescription)
}
}
}

View File

@@ -0,0 +1,311 @@
import ClawdisIPC
import Darwin
import Foundation
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 {
private nonisolated 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(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) (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?
// `kSecCodeInfoTeamIdentifier` is only included when requesting signing information.
let flags = SecCSFlags(rawValue: UInt32(kSecCSSigningInformation))
guard SecCodeCopySigningInformation(sCode, flags, &infoCF) == errSecSuccess,
let info = infoCF as? [String: Any],
let teamID = info[kSecCodeInfoTeamIdentifier as String] as? String
else {
return false
}
return allowedTeamIDs.contains(teamID)
}
}
#if SWIFT_PACKAGE
extension ControlSocketServer {
nonisolated static func _testTeamIdentifier(pid: pid_t) -> String? {
let attrs: NSDictionary = [kSecGuestAttributePid: pid]
var secCode: SecCode?
guard SecCodeCopyGuestWithAttributes(nil, attrs, SecCSFlags(), &secCode) == errSecSuccess,
let code = secCode else { return nil }
var staticCode: SecStaticCode?
guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess,
let sCode = staticCode else { return nil }
var infoCF: CFDictionary?
let flags = SecCSFlags(rawValue: UInt32(kSecCSSigningInformation))
guard SecCodeCopySigningInformation(sCode, flags, &infoCF) == errSecSuccess,
let info = infoCF as? [String: Any]
else {
return nil
}
return info[kSecCodeInfoTeamIdentifier as String] as? String
}
}
#endif

View File

@@ -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()
}
}

View File

@@ -0,0 +1,229 @@
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 schedulerEnabled: Bool?
@Published var schedulerStorePath: String?
@Published var schedulerNextWakeAtMs: Int?
@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 {
if let status = try? await self.fetchCronStatus() {
self.schedulerEnabled = status.enabled
self.schedulerStorePath = status.storePath
self.schedulerNextWakeAtMs = status.nextWakeAtMs
}
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: 20000)
} 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)
}
private func fetchCronStatus() async throws -> CronStatusResponse {
let data = try await self.request(method: "cron.status", params: nil)
return try JSONDecoder().decode(CronStatusResponse.self, from: data)
}
}
private struct CronStatusResponse: Decodable {
let enabled: Bool
let storePath: String
let jobs: Int
let nextWakeAtMs: Int?
}

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 = try .at(atMs: container.decode(Int.self, forKey: .atMs))
case "every":
self = try .every(
everyMs: container.decode(Int.self, forKey: .everyMs),
anchorMs: container.decodeIfPresent(Int.self, forKey: .anchorMs))
case "cron":
self = try .cron(
expr: container.decode(String.self, forKey: .expr),
tz: 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 = try .systemEvent(text: container.decode(String.self, forKey: .text))
case "agentTurn":
self = try .agentTurn(
message: container.decode(String.self, forKey: .message),
thinking: container.decodeIfPresent(String.self, forKey: .thinking),
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver),
channel: container.decodeIfPresent(String.self, forKey: .channel),
to: container.decodeIfPresent(String.self, forKey: .to),
bestEffortDeliver: 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 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 summary: 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 summary: 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]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,235 @@
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)
}
@MainActor
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] {
guard let snapshot = try? await SessionLoader.loadSnapshot(limit: limit) else { return [] }
return Array(snapshot.rows.prefix(limit))
}
static func updateSession(
key: String,
thinking: String?,
verbose: String?) async throws
{
var params: [String: AnyHashable] = ["key": AnyHashable(key)]
params["thinkingLevel"] = thinking.map(AnyHashable.init) ?? AnyHashable(NSNull())
params["verboseLevel"] = verbose.map(AnyHashable.init) ?? AnyHashable(NSNull())
_ = try await ControlChannel.shared.request(method: "sessions.patch", params: params)
}
// 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
}
}
}

View File

@@ -0,0 +1,895 @@
import AppKit
import SwiftUI
import UniformTypeIdentifiers
struct DebugSettings: View {
private let isPreview = ProcessInfo.processInfo.isPreview
private let labelColumnWidth: CGFloat = 140
@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: 14) {
self.header
self.appInfoSection
self.gatewaySection
self.logsSection
self.portsSection
self.pathsSection
self.quickActionsSection
self.canvasSection
self.experimentsSection
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
.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())
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Debug")
.font(.title3.weight(.semibold))
Text("Tools for diagnosing local issues (Gateway, ports, logs, Canvas).")
.font(.callout)
.foregroundStyle(.secondary)
}
}
private func gridLabel(_ text: String) -> some View {
Text(text)
.foregroundStyle(.secondary)
.frame(width: self.labelColumnWidth, alignment: .leading)
}
private var appInfoSection: some View {
GroupBox("App") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Health")
HStack(spacing: 8) {
Circle().fill(self.healthStore.state.tint).frame(width: 10, height: 10)
Text(self.healthStore.summaryLine)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("CLI helper")
let loc = CLIInstaller.installedLocation()
Text(loc ?? "missing")
.font(.caption.monospaced())
.foregroundStyle(loc == nil ? Color.red : Color.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
}
GridRow {
self.gridLabel("PID")
Text("\(ProcessInfo.processInfo.processIdentifier)")
}
GridRow {
self.gridLabel("Binary path")
Text(Bundle.main.bundlePath)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
}
}
}
}
private var gatewaySection: some View {
GroupBox("Gateway") {
VStack(alignment: .leading, spacing: 10) {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Status")
HStack(spacing: 8) {
Text(self.gatewayManager.status.label)
Text("Restarts: \(self.gatewayManager.restartCount)")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Attach only")
Toggle("", isOn: self.$attachExistingGatewayOnly)
.labelsHidden()
.toggleStyle(.checkbox)
.help(
"When enabled in local mode, the mac app will only connect " +
"to an already-running gateway " +
"and will not start one itself.")
}
GridRow {
self.gridLabel("Deep links")
Toggle("", isOn: self.$deepLinkAgentEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
.help("Enables handling of clawdis://agent?... deep links to trigger an agent run.")
}
}
let key = DeepLinkHandler.currentKey()
HStack(spacing: 8) {
Text("Key")
.foregroundStyle(.secondary)
.frame(width: self.labelColumnWidth, alignment: .leading)
Text(key)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
Button("Copy") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(key, forType: .string)
}
.buttonStyle(.bordered)
Button("Copy sample 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)
Spacer(minLength: 0)
}
VStack(alignment: .leading, spacing: 6) {
Text("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)))
HStack(spacing: 8) {
Button("Restart Gateway") { DebugActions.restartGateway() }
Button("Clear log") { GatewayProcessManager.shared.clearLog() }
Spacer(minLength: 0)
}
.buttonStyle(.bordered)
}
}
}
}
private var logsSection: some View {
GroupBox("Logs") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Pino log")
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Button("Open") { DebugActions.openLog() }
.buttonStyle(.bordered)
Text(DebugActions.pinoLogPath())
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
}
}
}
GridRow {
self.gridLabel("Diagnostics")
VStack(alignment: .leading, spacing: 6) {
Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled)
.toggleStyle(.checkbox)
.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)
.lineLimit(1)
.truncationMode(.middle)
}
}
}
}
}
private var portsSection: some View {
GroupBox("Ports") {
VStack(alignment: .leading, spacing: 10) {
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)
.fixedSize(horizontal: false, vertical: true)
}
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)
.fixedSize(horizontal: false, vertical: true)
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)
}
}
}
}
}
private var pathsSection: some View {
GroupBox("Paths") {
VStack(alignment: .leading, spacing: 12) {
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)
}
Divider()
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Session store")
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
TextField("Path", text: self.$sessionStorePath)
.textFieldStyle(.roundedBorder)
.font(.caption.monospaced())
.frame(width: 360)
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)
}
}
}
GridRow {
self.gridLabel("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)
}
}
}
}
}
}
private var quickActionsSection: some View {
GroupBox("Quick actions") {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Button("Send Test Notification") {
Task { await DebugActions.sendTestNotification() }
}
.buttonStyle(.bordered)
Button("Open Agent Events") {
DebugActions.openAgentEventsWindow()
}
.buttonStyle(.borderedProminent)
Spacer(minLength: 0)
}
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(spacing: 8) {
Button("Restart app") { DebugActions.restartApp() }
Button("Reveal app in Finder") { self.revealApp() }
Spacer(minLength: 0)
}
.buttonStyle(.bordered)
}
}
}
private var canvasSection: some View {
GroupBox("Canvas") {
VStack(alignment: .leading, spacing: 10) {
Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled)
.toggleStyle(.checkbox)
.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)
Spacer(minLength: 0)
}
HStack(spacing: 8) {
TextField("Eval JS", text: self.$canvasEvalJS)
.textFieldStyle(.roundedBorder)
.font(.caption.monospaced())
.frame(maxWidth: 520)
Button("Eval") {
Task { await self.canvasEval() }
}
.buttonStyle(.bordered)
Button("Snapshot") {
Task { await self.canvasSnapshot() }
}
.buttonStyle(.bordered)
Spacer(minLength: 0)
}
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)
Spacer(minLength: 0)
}
}
if let canvasError {
Text(canvasError)
.font(.caption2)
.foregroundStyle(.red)
} else {
Text("Tip: the session directory is returned by “Show panel”.")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
}
private var experimentsSection: some View {
GroupBox("Experiments") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Icon override")
Picker("", selection: self.bindingOverride) {
ForEach(IconOverrideSelection.allCases) { option in
Text(option.label).tag(option.rawValue)
}
}
.labelsHidden()
.frame(maxWidth: 280, alignment: .leading)
}
GridRow {
self.gridLabel("Web chat")
Toggle("Use SwiftUI web chat (glass)", isOn: self.$webChatSwiftUIEnabled)
.toggleStyle(.checkbox)
.help(
"When enabled, the menu bar chat window/panel uses the native SwiftUI UI instead of the " +
"bundled WKWebView.")
}
}
}
}
@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")
}
}
extension DebugSettings {
// 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
}
}
}
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 10) {
configuration.label
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
configuration.content
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
#if DEBUG
struct DebugSettings_Previews: PreviewProvider {
static var previews: some View {
DebugSettings()
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}
#endif

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()
}
}

View File

@@ -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)
}
}

Some files were not shown because too many files have changed in this diff Show More