@vellumai/assistant 0.6.5 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (443) hide show
  1. package/AGENTS.md +9 -1
  2. package/ARCHITECTURE.md +15 -17
  3. package/Dockerfile +6 -4
  4. package/__tests__/permissions/gateway-threshold-reader.test.ts +283 -0
  5. package/docs/architecture/integrations.md +32 -39
  6. package/docs/architecture/memory.md +25 -30
  7. package/docs/architecture/security.md +7 -6
  8. package/docs/browser-use-architecture-phase2.md +63 -20
  9. package/docs/plugins.md +761 -0
  10. package/examples/plugins/echo/README.md +132 -0
  11. package/examples/plugins/echo/package.json +17 -0
  12. package/examples/plugins/echo/register.ts +187 -0
  13. package/node_modules/@vellumai/egress-proxy/src/types.ts +19 -0
  14. package/openapi.yaml +212 -68
  15. package/package.json +1 -1
  16. package/src/__tests__/app-compiler.test.ts +57 -0
  17. package/src/__tests__/approval-cascade.test.ts +7 -2
  18. package/src/__tests__/auto-analysis-end-to-end.test.ts +1 -0
  19. package/src/__tests__/avatar-generator.test.ts +4 -2
  20. package/src/__tests__/bundled-asset.test.ts +6 -6
  21. package/src/__tests__/catalog-cache.test.ts +69 -0
  22. package/src/__tests__/checker.test.ts +459 -171
  23. package/src/__tests__/circuit-breaker-pipeline.test.ts +406 -0
  24. package/src/__tests__/compaction-events.test.ts +501 -0
  25. package/src/__tests__/compaction-pipeline.test.ts +210 -0
  26. package/src/__tests__/compaction-strip-metadata-clear.test.ts +181 -0
  27. package/src/__tests__/compaction-timeout-recovery.test.ts +262 -0
  28. package/src/__tests__/config-model-image-provider.test.ts +110 -0
  29. package/src/__tests__/config-schema.test.ts +22 -9
  30. package/src/__tests__/config-watcher-cleanup-throttle.test.ts +0 -4
  31. package/src/__tests__/contacts-tools.test.ts +26 -0
  32. package/src/__tests__/context-overflow-policy.test.ts +7 -7
  33. package/src/__tests__/context-window-manager.test.ts +355 -4
  34. package/src/__tests__/conversation-abort-tool-results.test.ts +4 -1
  35. package/src/__tests__/conversation-agent-loop-overflow.test.ts +26 -30
  36. package/src/__tests__/conversation-agent-loop.test.ts +30 -141
  37. package/src/__tests__/conversation-confirmation-signals.test.ts +6 -1
  38. package/src/__tests__/conversation-history-web-search.test.ts +1 -0
  39. package/src/__tests__/conversation-init.benchmark.test.ts +2 -16
  40. package/src/__tests__/conversation-pairing.test.ts +174 -10
  41. package/src/__tests__/conversation-pre-run-repair.test.ts +4 -1
  42. package/src/__tests__/conversation-process-callsite.test.ts +3 -0
  43. package/src/__tests__/conversation-provider-retry-repair.test.ts +16 -7
  44. package/src/__tests__/conversation-queue.test.ts +29 -14
  45. package/src/__tests__/conversation-routes-disk-view.test.ts +7 -6
  46. package/src/__tests__/conversation-runtime-assembly.test.ts +155 -110
  47. package/src/__tests__/conversation-runtime-workspace.test.ts +23 -38
  48. package/src/__tests__/conversation-seed-composer.test.ts +2 -2
  49. package/src/__tests__/conversation-slash-queue.test.ts +7 -2
  50. package/src/__tests__/conversation-slash-unknown.test.ts +25 -2
  51. package/src/__tests__/conversation-speed-override.test.ts +6 -1
  52. package/src/__tests__/conversation-title-service.test.ts +116 -0
  53. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +41 -2
  54. package/src/__tests__/conversation-usage.test.ts +1 -1
  55. package/src/__tests__/conversation-workspace-cache-state.test.ts +4 -1
  56. package/src/__tests__/conversation-workspace-injection.test.ts +3 -0
  57. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +4 -1
  58. package/src/__tests__/credential-health-service.test.ts +78 -9
  59. package/src/__tests__/credential-security-invariants.test.ts +2 -2
  60. package/src/__tests__/db-schedule-syntax-migration.test.ts +1 -0
  61. package/src/__tests__/empty-response-pipeline.test.ts +305 -0
  62. package/src/__tests__/extension-id-sync-guard.test.ts +3 -3
  63. package/src/__tests__/first-greeting.test.ts +247 -5
  64. package/src/__tests__/headless-browser-mode.test.ts +57 -0
  65. package/src/__tests__/history-repair-pipeline.test.ts +399 -0
  66. package/src/__tests__/host-browser-e2e-cloud.test.ts +307 -0
  67. package/src/__tests__/host-browser-e2e-self-hosted.test.ts +3 -3
  68. package/src/__tests__/host-proxy-interface.test.ts +36 -2
  69. package/src/__tests__/image-credentials.test.ts +137 -0
  70. package/src/__tests__/image-service-dispatcher.test.ts +186 -0
  71. package/src/__tests__/injector-chain.test.ts +526 -0
  72. package/src/__tests__/intent-routing.test.ts +0 -26
  73. package/src/__tests__/llm-call-pipeline.test.ts +285 -0
  74. package/src/__tests__/llm-schema.test.ts +1 -1
  75. package/src/__tests__/media-generate-image.test.ts +119 -13
  76. package/src/__tests__/memory-retrieval-pipeline.test.ts +401 -0
  77. package/src/__tests__/memory-upsert-concurrency.test.ts +1 -0
  78. package/src/__tests__/migration-import-from-url.test.ts +5 -68
  79. package/src/__tests__/model-intents.test.ts +4 -2
  80. package/src/__tests__/notification-broadcaster.test.ts +3 -3
  81. package/src/__tests__/notification-decision-strategy.test.ts +0 -11
  82. package/src/__tests__/notification-schedule-notify-dedup.test.ts +108 -0
  83. package/src/__tests__/oauth-apps-routes.test.ts +1 -1
  84. package/src/__tests__/oauth-cli.test.ts +14 -12
  85. package/src/__tests__/oauth-connect-orchestrator.test.ts +4 -13
  86. package/src/__tests__/oauth-provider-serializer.test.ts +6 -4
  87. package/src/__tests__/oauth-provider-visibility.test.ts +3 -5
  88. package/src/__tests__/oauth-providers-routes.test.ts +3 -2
  89. package/src/__tests__/oauth-store.test.ts +41 -76
  90. package/src/__tests__/onboarding-template-contract.test.ts +16 -64
  91. package/src/__tests__/openai-image-service.test.ts +368 -0
  92. package/src/__tests__/overflow-reduce-pipeline.test.ts +676 -0
  93. package/src/__tests__/permission-checker-host-gate.test.ts +0 -24
  94. package/src/__tests__/persist-onboarding-artifacts.test.ts +266 -0
  95. package/src/__tests__/persistence-pipeline.test.ts +377 -0
  96. package/src/__tests__/pipeline-runner.test.ts +565 -0
  97. package/src/__tests__/platform.test.ts +5 -2
  98. package/src/__tests__/plugin-bootstrap.test.ts +483 -0
  99. package/src/__tests__/plugin-registry.test.ts +273 -0
  100. package/src/__tests__/plugin-route-contribution.test.ts +288 -0
  101. package/src/__tests__/plugin-skill-contribution.test.ts +367 -0
  102. package/src/__tests__/plugin-tool-contribution.test.ts +286 -0
  103. package/src/__tests__/plugin-types.test.ts +320 -0
  104. package/src/__tests__/pricing.test.ts +44 -12
  105. package/src/__tests__/proxy-approval-callback.test.ts +69 -8
  106. package/src/__tests__/reaction-persistence.test.ts +1 -0
  107. package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +1 -0
  108. package/src/__tests__/registry.test.ts +0 -2
  109. package/src/__tests__/schedule-routes.test.ts +131 -1
  110. package/src/__tests__/scheduler-recurrence.test.ts +14 -70
  111. package/src/__tests__/scheduler-reuse-conversation.test.ts +10 -50
  112. package/src/__tests__/secret-detection-handler.test.ts +0 -10
  113. package/src/__tests__/shell-identity.test.ts +0 -134
  114. package/src/__tests__/suggestion-routes.test.ts +103 -4
  115. package/src/__tests__/task-memory-cleanup.test.ts +1 -0
  116. package/src/__tests__/task-scheduler.test.ts +3 -15
  117. package/src/__tests__/test-preload.ts +11 -0
  118. package/src/__tests__/title-generate-pipeline.test.ts +224 -0
  119. package/src/__tests__/token-estimate-pipeline.test.ts +431 -0
  120. package/src/__tests__/tool-error-pipeline.test.ts +244 -0
  121. package/src/__tests__/tool-execute-pipeline.test.ts +431 -0
  122. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +0 -6
  123. package/src/__tests__/tool-executor-shell-integration.test.ts +7 -10
  124. package/src/__tests__/tool-executor.test.ts +141 -0
  125. package/src/__tests__/tool-result-truncate-pipeline.test.ts +356 -0
  126. package/src/__tests__/tool-result-truncation.test.ts +0 -110
  127. package/src/__tests__/user-plugin-loader.test.ts +191 -0
  128. package/src/__tests__/workspace-migration-046-seed-conversation-starters-callsite.test.ts +185 -0
  129. package/src/__tests__/workspace-migration-049-release-notes-default-sonnet.test.ts +100 -0
  130. package/src/__tests__/workspace-migration-050-seed-main-agent-opus-callsite.test.ts +171 -0
  131. package/src/__tests__/workspace-migration-051-seed-conversation-summarization-callsite.test.ts +252 -0
  132. package/src/__tests__/workspace-migration-remove-hooks.test.ts +99 -0
  133. package/src/__tests__/workspace-policy.test.ts +21 -3
  134. package/src/agent/loop.ts +340 -102
  135. package/src/approvals/__tests__/guardian-feed-event.test.ts +304 -0
  136. package/src/approvals/guardian-request-resolvers.ts +80 -0
  137. package/src/backup/__tests__/backup-worker.test.ts +2 -13
  138. package/src/backup/backup-worker.ts +3 -15
  139. package/src/bundler/app-compiler.ts +84 -1
  140. package/src/calls/call-state.ts +2 -2
  141. package/src/channels/__tests__/types.test.ts +3 -3
  142. package/src/channels/types.ts +6 -4
  143. package/src/cli/__tests__/notifications.test.ts +87 -211
  144. package/src/cli/commands/__tests__/backup.test.ts +1 -1
  145. package/src/cli/commands/__tests__/image-generation.test.ts +255 -35
  146. package/src/cli/commands/__tests__/inference-send.test.ts +12 -0
  147. package/src/cli/commands/__tests__/tts-synthesize.test.ts +12 -0
  148. package/src/cli/commands/backup.ts +2 -2
  149. package/src/cli/commands/clients.ts +138 -0
  150. package/src/cli/commands/completions.ts +2 -9
  151. package/src/cli/commands/conversations.ts +55 -7
  152. package/src/cli/commands/image-generation.ts +33 -34
  153. package/src/cli/commands/notifications.ts +68 -103
  154. package/src/cli/commands/oauth/__tests__/providers-register.test.ts +1 -1
  155. package/src/cli/commands/oauth/__tests__/providers-update.test.ts +1 -1
  156. package/src/cli/commands/oauth/connect.ts +2 -2
  157. package/src/cli/commands/oauth/providers.ts +176 -8
  158. package/src/cli/commands/oauth/status.ts +46 -36
  159. package/src/cli/commands/skills.ts +3 -4
  160. package/src/cli/program.ts +25 -29
  161. package/src/config/__tests__/backup-schema.test.ts +7 -2
  162. package/src/config/bundled-skills/app-builder/SKILL.md +2 -2
  163. package/src/config/bundled-skills/app-builder/references/WIDGETS.md +10 -10
  164. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +66 -87
  165. package/src/config/bundled-skills/contacts/tools/contact-search.ts +28 -51
  166. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +22 -40
  167. package/src/config/bundled-skills/image-studio/SKILL.md +2 -1
  168. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -1
  169. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +23 -39
  170. package/src/config/bundled-skills/messaging/SKILL.md +3 -3
  171. package/src/config/bundled-skills/messaging/tools/__tests__/messaging-feed-events.test.ts +207 -0
  172. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +12 -0
  173. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +58 -0
  174. package/src/config/bundled-skills/schedule/SKILL.md +8 -3
  175. package/src/config/bundled-skills/schedule/TOOLS.json +15 -7
  176. package/src/config/bundled-skills/schedule/references/SCRIPT_MODE_PATTERNS.md +59 -0
  177. package/src/config/bundled-tool-registry.ts +0 -15
  178. package/src/config/feature-flag-registry.json +17 -1
  179. package/src/config/schema.ts +19 -0
  180. package/src/config/schemas/backup.ts +1 -1
  181. package/src/config/schemas/conversations.ts +16 -0
  182. package/src/config/schemas/llm.ts +2 -3
  183. package/src/config/schemas/security.ts +6 -6
  184. package/src/config/schemas/tts.ts +11 -0
  185. package/src/config/skill-state.ts +6 -2
  186. package/src/config/skills.ts +94 -5
  187. package/src/context/__tests__/compact-prompt.test.ts +27 -9
  188. package/src/context/prompts/compact.md +26 -12
  189. package/src/context/tool-result-truncation.ts +3 -63
  190. package/src/context/window-manager.ts +190 -16
  191. package/src/credential-health/credential-health-service.ts +19 -6
  192. package/src/daemon/__tests__/conversation-feed-event.test.ts +317 -0
  193. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +4 -12
  194. package/src/daemon/__tests__/conversation-tool-setup.test.ts +14 -15
  195. package/src/daemon/config-watcher.ts +0 -2
  196. package/src/daemon/context-overflow-policy.ts +4 -13
  197. package/src/daemon/conversation-agent-loop-handlers.ts +83 -22
  198. package/src/daemon/conversation-agent-loop.ts +984 -683
  199. package/src/daemon/conversation-history.ts +10 -19
  200. package/src/daemon/conversation-lifecycle.ts +37 -19
  201. package/src/daemon/conversation-notifiers.ts +2 -110
  202. package/src/daemon/conversation-process.ts +14 -7
  203. package/src/daemon/conversation-runtime-assembly.ts +532 -411
  204. package/src/daemon/conversation-tool-setup.ts +41 -4
  205. package/src/daemon/conversation.ts +80 -35
  206. package/src/daemon/external-plugins-bootstrap.ts +478 -0
  207. package/src/daemon/first-greeting.ts +191 -14
  208. package/src/daemon/handlers/config-model.ts +11 -0
  209. package/src/daemon/handlers/skills.ts +5 -1
  210. package/src/daemon/lifecycle.ts +33 -68
  211. package/src/daemon/message-types/computer-use.ts +2 -34
  212. package/src/daemon/message-types/conversations.ts +49 -0
  213. package/src/daemon/message-types/messages.ts +12 -0
  214. package/src/daemon/server.ts +5 -3
  215. package/src/daemon/shutdown-handlers.ts +2 -12
  216. package/src/daemon/tool-side-effects.ts +14 -56
  217. package/src/heartbeat/__tests__/heartbeat-feed-event.test.ts +160 -0
  218. package/src/heartbeat/heartbeat-service.ts +24 -1
  219. package/src/home/__tests__/feed-population-integration.test.ts +312 -0
  220. package/src/home/emit-feed-event.ts +7 -0
  221. package/src/home/feed-types.ts +41 -2
  222. package/src/home/rewrite-command-preview.ts +66 -0
  223. package/src/ipc/__tests__/socket-path.test.ts +11 -50
  224. package/src/ipc/cli-client.ts +1 -1
  225. package/src/ipc/cli-server.ts +3 -3
  226. package/src/ipc/gateway-client.ts +4 -1
  227. package/src/ipc/routes/browser-context.ts +2 -0
  228. package/src/ipc/routes/browser.ts +1 -0
  229. package/src/ipc/routes/get-contact.ts +16 -0
  230. package/src/ipc/routes/index.ts +14 -0
  231. package/src/ipc/routes/list-clients.ts +31 -0
  232. package/src/ipc/routes/merge-contacts.ts +17 -0
  233. package/src/ipc/routes/notification.ts +133 -0
  234. package/src/ipc/routes/rename-conversation.ts +59 -0
  235. package/src/ipc/routes/search-contacts.ts +19 -0
  236. package/src/ipc/routes/upsert-contact.ts +25 -0
  237. package/src/ipc/socket-path.ts +14 -38
  238. package/src/media/app-icon-generator.ts +23 -46
  239. package/src/media/avatar-router.ts +26 -41
  240. package/src/media/gemini-image-service.ts +8 -41
  241. package/src/media/image-credentials.ts +73 -0
  242. package/src/media/image-service.ts +85 -0
  243. package/src/media/openai-image-service.ts +131 -0
  244. package/src/media/types.ts +46 -0
  245. package/src/memory/conversation-crud.ts +48 -18
  246. package/src/memory/conversation-queries.ts +57 -4
  247. package/src/memory/conversation-title-service.ts +25 -0
  248. package/src/memory/db-init.ts +8 -0
  249. package/src/memory/embedding-gemini.test.ts +41 -2
  250. package/src/memory/embedding-gemini.ts +6 -1
  251. package/src/memory/graph/bootstrap.test.ts +282 -0
  252. package/src/memory/graph/bootstrap.ts +8 -5
  253. package/src/memory/graph/extraction.ts +10 -2
  254. package/src/memory/graph/graph-search.test.ts +1 -0
  255. package/src/memory/graph/inspect.ts +2 -2
  256. package/src/memory/graph/retriever.ts +10 -3
  257. package/src/memory/migrations/041-approval-prompt-ts-tracker.ts +26 -0
  258. package/src/memory/migrations/149-oauth-tables.ts +1 -0
  259. package/src/memory/migrations/223-schedule-script-column.ts +11 -0
  260. package/src/memory/migrations/224-oauth-providers-managed-service-is-paid.ts +24 -0
  261. package/src/memory/migrations/225-oauth-providers-available-scopes.ts +13 -0
  262. package/src/memory/migrations/index.ts +4 -0
  263. package/src/memory/pkb/pkb-index.test.ts +1 -0
  264. package/src/memory/pkb/pkb-reconcile.test.ts +1 -0
  265. package/src/memory/pkb/pkb-search.test.ts +65 -4
  266. package/src/memory/pkb/pkb-search.ts +40 -18
  267. package/src/memory/qdrant-client.test.ts +60 -0
  268. package/src/memory/qdrant-client.ts +25 -0
  269. package/src/memory/schema/infrastructure.ts +1 -0
  270. package/src/memory/schema/oauth.ts +4 -1
  271. package/src/messaging/providers/slack/render-transcript.test.ts +77 -29
  272. package/src/messaging/providers/slack/render-transcript.ts +58 -0
  273. package/src/notifications/conversation-pairing.ts +78 -19
  274. package/src/notifications/copy-composer.ts +0 -5
  275. package/src/notifications/emit-signal.ts +1 -1
  276. package/src/notifications/signal.ts +1 -2
  277. package/src/oauth/AGENTS.md +1 -1
  278. package/src/oauth/__tests__/identity-verifier.test.ts +2 -1
  279. package/src/oauth/connect-orchestrator.ts +8 -34
  280. package/src/oauth/connect-types.ts +6 -10
  281. package/src/oauth/manual-token-connection.ts +23 -0
  282. package/src/oauth/oauth-store.ts +30 -14
  283. package/src/oauth/provider-serializer.ts +6 -1
  284. package/src/oauth/seed-providers.ts +56 -108
  285. package/src/outbound-proxy/http-forwarder.ts +9 -0
  286. package/src/permissions/approval-policy.test.ts +293 -18
  287. package/src/permissions/approval-policy.ts +110 -58
  288. package/src/permissions/arg-parser.test.ts +161 -0
  289. package/src/permissions/arg-parser.ts +141 -0
  290. package/src/permissions/bash-risk-classifier.test.ts +414 -2
  291. package/src/permissions/bash-risk-classifier.ts +303 -60
  292. package/src/permissions/checker.ts +157 -29
  293. package/src/permissions/command-registry.test.ts +239 -0
  294. package/src/permissions/command-registry.ts +234 -54
  295. package/src/permissions/defaults.ts +5 -4
  296. package/src/permissions/gateway-threshold-reader.ts +196 -0
  297. package/src/permissions/prompter.ts +4 -0
  298. package/src/permissions/risk-types.ts +61 -4
  299. package/src/permissions/schedule-risk-classifier.test.ts +129 -0
  300. package/src/permissions/schedule-risk-classifier.ts +85 -0
  301. package/src/permissions/shell-identity.ts +2 -42
  302. package/src/permissions/types.ts +2 -0
  303. package/src/permissions/workspace-policy.ts +8 -3
  304. package/src/plugins/defaults/circuit-breaker.ts +146 -0
  305. package/src/plugins/defaults/compaction.ts +145 -0
  306. package/src/plugins/defaults/empty-response.ts +126 -0
  307. package/src/plugins/defaults/history-repair.ts +85 -0
  308. package/src/plugins/defaults/index.ts +116 -0
  309. package/src/plugins/defaults/injectors.ts +491 -0
  310. package/src/plugins/defaults/llm-call.ts +82 -0
  311. package/src/plugins/defaults/memory-retrieval.ts +226 -0
  312. package/src/plugins/defaults/overflow-reduce.ts +181 -0
  313. package/src/plugins/defaults/persistence.ts +129 -0
  314. package/src/plugins/defaults/title-generate.ts +95 -0
  315. package/src/plugins/defaults/token-estimate.ts +104 -0
  316. package/src/plugins/defaults/tool-error.ts +126 -0
  317. package/src/plugins/defaults/tool-execute.ts +89 -0
  318. package/src/plugins/defaults/tool-result-truncate.ts +88 -0
  319. package/src/plugins/pipeline.ts +316 -0
  320. package/src/plugins/plugin-skill-contributions.ts +292 -0
  321. package/src/plugins/registry.ts +241 -0
  322. package/src/plugins/types.ts +1134 -0
  323. package/src/plugins/user-loader.ts +177 -0
  324. package/src/prompts/templates/BOOTSTRAP.md +27 -77
  325. package/src/providers/model-catalog.ts +52 -29
  326. package/src/providers/model-intents.ts +1 -1
  327. package/src/providers/openrouter/client.ts +5 -1
  328. package/src/providers/speech-to-text/deepgram-realtime.test.ts +61 -0
  329. package/src/providers/speech-to-text/deepgram-realtime.ts +57 -0
  330. package/src/providers/speech-to-text/xai-realtime.test.ts +72 -4
  331. package/src/providers/speech-to-text/xai-realtime.ts +39 -14
  332. package/src/runtime/AGENTS.md +25 -16
  333. package/src/runtime/__tests__/browser-extension-pair-routes.test.ts +3 -3
  334. package/src/runtime/__tests__/client-registry.test.ts +293 -0
  335. package/src/runtime/client-registry.ts +261 -0
  336. package/src/runtime/http-server.ts +77 -8
  337. package/src/runtime/http-types.ts +0 -2
  338. package/src/runtime/migrations/vbundle-builder.ts +1 -22
  339. package/src/runtime/routes/approval-prompt-ts-tracker.ts +51 -31
  340. package/src/runtime/routes/approval-routes.ts +17 -0
  341. package/src/runtime/routes/browser-extension-pair-routes.ts +27 -8
  342. package/src/runtime/routes/conversation-routes.ts +223 -116
  343. package/src/runtime/routes/inbound-message-handler.ts +88 -13
  344. package/src/runtime/routes/memory-item-routes.test.ts +1 -0
  345. package/src/runtime/routes/migration-routes.ts +0 -3
  346. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +284 -0
  347. package/src/runtime/routes/playground/__tests__/guard.test.ts +80 -0
  348. package/src/runtime/routes/playground/__tests__/inject-failures.test.ts +294 -0
  349. package/src/runtime/routes/playground/__tests__/reset-circuit.test.ts +271 -0
  350. package/src/runtime/routes/playground/__tests__/seed-conversation.test.ts +202 -0
  351. package/src/runtime/routes/playground/__tests__/seeded-conversations.test.ts +309 -0
  352. package/src/runtime/routes/playground/__tests__/state.test.ts +224 -0
  353. package/src/runtime/routes/playground/conversation-not-found.ts +29 -0
  354. package/src/runtime/routes/playground/deps.ts +56 -0
  355. package/src/runtime/routes/playground/force-compact.ts +73 -0
  356. package/src/runtime/routes/playground/guard.ts +37 -0
  357. package/src/runtime/routes/playground/index.ts +28 -0
  358. package/src/runtime/routes/playground/inject-failures.ts +159 -0
  359. package/src/runtime/routes/playground/reset-circuit.ts +115 -0
  360. package/src/runtime/routes/playground/seed-conversation.ts +139 -0
  361. package/src/runtime/routes/playground/seeded-conversations.ts +78 -0
  362. package/src/runtime/routes/playground/state.ts +78 -0
  363. package/src/runtime/routes/schedule-routes.ts +89 -8
  364. package/src/runtime/skill-route-registry.ts +75 -15
  365. package/src/schedule/run-script.ts +68 -0
  366. package/src/schedule/schedule-store.ts +7 -1
  367. package/src/schedule/scheduler.ts +48 -8
  368. package/src/skills/catalog-cache.ts +12 -5
  369. package/src/tools/browser/__tests__/browser-status.test.ts +189 -0
  370. package/src/tools/browser/browser-execution.ts +88 -19
  371. package/src/tools/browser/cdp-client/__tests__/extension-cdp-client.test.ts +230 -0
  372. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +146 -3
  373. package/src/tools/browser/cdp-client/extension-cdp-client.ts +54 -3
  374. package/src/tools/browser/cdp-client/factory.ts +15 -4
  375. package/src/tools/executor.ts +126 -74
  376. package/src/tools/network/script-proxy/session-manager.ts +37 -1
  377. package/src/tools/permission-checker.ts +98 -49
  378. package/src/tools/policy-context.ts +4 -0
  379. package/src/tools/registry.ts +140 -3
  380. package/src/tools/schedule/create.ts +23 -8
  381. package/src/tools/schedule/update.ts +3 -1
  382. package/src/tools/secret-detection-handler.ts +0 -51
  383. package/src/tools/system/avatar-generator.ts +6 -2
  384. package/src/tools/types.ts +28 -2
  385. package/src/util/platform.ts +7 -2
  386. package/src/util/pricing.ts +26 -3
  387. package/src/workspace/migrations/006-services-config.ts +2 -4
  388. package/src/workspace/migrations/022-move-hooks-to-workspace.ts +2 -3
  389. package/src/workspace/migrations/041-backfill-google-gmail-settings-scope.ts +3 -4
  390. package/src/workspace/migrations/046-seed-conversation-starters-callsite.ts +108 -0
  391. package/src/workspace/migrations/047-remove-watch-callsites.ts +54 -0
  392. package/src/workspace/migrations/048-remove-workspace-hooks.ts +81 -0
  393. package/src/workspace/migrations/049-release-notes-default-sonnet.ts +80 -0
  394. package/src/workspace/migrations/050-seed-main-agent-opus-callsite.ts +86 -0
  395. package/src/workspace/migrations/051-seed-conversation-summarization-callsite.ts +128 -0
  396. package/src/workspace/migrations/registry.ts +12 -0
  397. package/tsconfig.json +1 -1
  398. package/hook-templates/debug-prompt-logger/hook.json +0 -7
  399. package/hook-templates/debug-prompt-logger/run.sh +0 -66
  400. package/src/__tests__/compaction-circuit-breaker.test.ts +0 -336
  401. package/src/__tests__/context-overflow-approval.test.ts +0 -156
  402. package/src/__tests__/hooks-blocking.test.ts +0 -178
  403. package/src/__tests__/hooks-cli.test.ts +0 -182
  404. package/src/__tests__/hooks-config.test.ts +0 -108
  405. package/src/__tests__/hooks-discovery.test.ts +0 -211
  406. package/src/__tests__/hooks-integration.test.ts +0 -196
  407. package/src/__tests__/hooks-manager.test.ts +0 -226
  408. package/src/__tests__/hooks-runner.test.ts +0 -175
  409. package/src/__tests__/hooks-settings.test.ts +0 -160
  410. package/src/__tests__/hooks-templates.test.ts +0 -169
  411. package/src/__tests__/hooks-ts-runner.test.ts +0 -170
  412. package/src/__tests__/hooks-watch.test.ts +0 -112
  413. package/src/__tests__/notification-schedule-dedup.test.ts +0 -213
  414. package/src/__tests__/oauth-scope-policy.test.ts +0 -180
  415. package/src/__tests__/send-notification-tool.test.ts +0 -83
  416. package/src/cli/commands/shotgun.ts +0 -266
  417. package/src/config/bundled-skills/conversations/SKILL.md +0 -20
  418. package/src/config/bundled-skills/conversations/TOOLS.json +0 -23
  419. package/src/config/bundled-skills/conversations/tools/rename-conversation.ts +0 -88
  420. package/src/config/bundled-skills/heartbeat/SKILL.md +0 -43
  421. package/src/config/bundled-skills/notifications/SKILL.md +0 -40
  422. package/src/config/bundled-skills/notifications/TOOLS.json +0 -80
  423. package/src/config/bundled-skills/notifications/tools/send-notification.ts +0 -152
  424. package/src/config/bundled-skills/notifications/tools/shared.ts +0 -13
  425. package/src/config/bundled-skills/screen-watch/SKILL.md +0 -27
  426. package/src/config/bundled-skills/screen-watch/TOOLS.json +0 -35
  427. package/src/config/bundled-skills/screen-watch/tools/start-screen-watch.ts +0 -12
  428. package/src/config/bundled-skills/skills-catalog/SKILL.md +0 -84
  429. package/src/daemon/context-overflow-approval.ts +0 -52
  430. package/src/daemon/watch-handler.ts +0 -399
  431. package/src/hooks/cli.ts +0 -253
  432. package/src/hooks/config.ts +0 -100
  433. package/src/hooks/discovery.ts +0 -135
  434. package/src/hooks/manager.ts +0 -179
  435. package/src/hooks/runner.ts +0 -117
  436. package/src/hooks/templates.ts +0 -77
  437. package/src/hooks/types.ts +0 -75
  438. package/src/oauth/scope-policy.ts +0 -89
  439. package/src/runtime/gateway-internal-client.ts +0 -94
  440. package/src/runtime/routes/watch-routes.ts +0 -156
  441. package/src/signals/shotgun.ts +0 -203
  442. package/src/tools/watch/screen-watch.ts +0 -144
  443. package/src/tools/watch/watch-state.ts +0 -142
@@ -0,0 +1,293 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ __resetClientRegistryForTests,
5
+ ClientRegistry,
6
+ getClientRegistry,
7
+ } from "../client-registry.js";
8
+
9
+ describe("ClientRegistry", () => {
10
+ beforeEach(() => {
11
+ __resetClientRegistryForTests();
12
+ });
13
+
14
+ // ── register ──────────────────────────────────────────────────────────────
15
+
16
+ test("register creates a new entry with derived capabilities", () => {
17
+ const registry = new ClientRegistry();
18
+ const entry = registry.register({
19
+ clientId: "mac-1",
20
+ interfaceId: "macos",
21
+ hostHomeDir: "/Users/alice",
22
+ hostUsername: "alice",
23
+ });
24
+
25
+ expect(entry.clientId).toBe("mac-1");
26
+ expect(entry.interfaceId).toBe("macos");
27
+ expect(entry.capabilities).toContain("host_bash");
28
+ expect(entry.capabilities).toContain("host_file");
29
+ expect(entry.capabilities).toContain("host_cu");
30
+ expect(entry.capabilities).toContain("host_browser");
31
+ expect(entry.hostHomeDir).toBe("/Users/alice");
32
+ expect(entry.hostUsername).toBe("alice");
33
+ expect(entry.connectedAt).toBeGreaterThan(0);
34
+ expect(entry.lastActiveAt).toBeGreaterThanOrEqual(entry.connectedAt);
35
+ });
36
+
37
+ test("register derives empty capabilities for web interface", () => {
38
+ const registry = new ClientRegistry();
39
+ const entry = registry.register({
40
+ clientId: "web-1",
41
+ interfaceId: "cli",
42
+ });
43
+
44
+ expect(entry.capabilities).toEqual([]);
45
+ });
46
+
47
+ test("register derives host_browser capability for chrome-extension", () => {
48
+ const registry = new ClientRegistry();
49
+ const entry = registry.register({
50
+ clientId: "ext-1",
51
+ interfaceId: "chrome-extension",
52
+ });
53
+
54
+ expect(entry.capabilities).toEqual(["host_browser"]);
55
+ });
56
+
57
+ test("register refreshes lastActiveAt on re-register with same clientId", () => {
58
+ const registry = new ClientRegistry();
59
+ const first = registry.register({
60
+ clientId: "mac-1",
61
+ interfaceId: "macos",
62
+ });
63
+ const firstActive = first.lastActiveAt;
64
+
65
+ // Advance time slightly
66
+ const second = registry.register({
67
+ clientId: "mac-1",
68
+ interfaceId: "macos",
69
+ hostHomeDir: "/Users/bob",
70
+ });
71
+
72
+ // Same object reference — refreshed in place
73
+ expect(second).toBe(first);
74
+ expect(second.lastActiveAt).toBeGreaterThanOrEqual(firstActive);
75
+ expect(second.hostHomeDir).toBe("/Users/bob");
76
+ // connectedAt should NOT change on refresh
77
+ expect(second.connectedAt).toBe(first.connectedAt);
78
+ });
79
+
80
+ test("register does not increase size on re-register", () => {
81
+ const registry = new ClientRegistry();
82
+ registry.register({ clientId: "mac-1", interfaceId: "macos" });
83
+ registry.register({ clientId: "mac-1", interfaceId: "macos" });
84
+ expect(registry.size).toBe(1);
85
+ });
86
+
87
+ // ── unregister ────────────────────────────────────────────────────────────
88
+
89
+ test("unregister removes the entry", () => {
90
+ const registry = new ClientRegistry();
91
+ registry.register({ clientId: "mac-1", interfaceId: "macos" });
92
+ expect(registry.size).toBe(1);
93
+
94
+ registry.unregister("mac-1");
95
+ expect(registry.size).toBe(0);
96
+ expect(registry.get("mac-1")).toBeUndefined();
97
+ });
98
+
99
+ test("unregister is a no-op for unknown clientId", () => {
100
+ const registry = new ClientRegistry();
101
+ registry.register({ clientId: "mac-1", interfaceId: "macos" });
102
+ registry.unregister("unknown");
103
+ expect(registry.size).toBe(1);
104
+ });
105
+
106
+ // ── touch ─────────────────────────────────────────────────────────────────
107
+
108
+ test("touch updates lastActiveAt", () => {
109
+ const registry = new ClientRegistry();
110
+ const entry = registry.register({
111
+ clientId: "mac-1",
112
+ interfaceId: "macos",
113
+ });
114
+ const before = entry.lastActiveAt;
115
+ registry.touch("mac-1");
116
+ expect(entry.lastActiveAt).toBeGreaterThanOrEqual(before);
117
+ });
118
+
119
+ test("touch is a no-op for unknown clientId", () => {
120
+ const registry = new ClientRegistry();
121
+ // Should not throw
122
+ registry.touch("unknown");
123
+ });
124
+
125
+ // ── get ───────────────────────────────────────────────────────────────────
126
+
127
+ test("get returns the entry for a registered clientId", () => {
128
+ const registry = new ClientRegistry();
129
+ const entry = registry.register({
130
+ clientId: "mac-1",
131
+ interfaceId: "macos",
132
+ });
133
+ expect(registry.get("mac-1")).toBe(entry);
134
+ });
135
+
136
+ test("get returns undefined for unknown clientId", () => {
137
+ const registry = new ClientRegistry();
138
+ expect(registry.get("unknown")).toBeUndefined();
139
+ });
140
+
141
+ // ── listAll ───────────────────────────────────────────────────────────────
142
+
143
+ test("listAll returns entries sorted by lastActiveAt descending", () => {
144
+ const registry = new ClientRegistry();
145
+ registry.register({ clientId: "old", interfaceId: "cli" });
146
+ // Touch the second one to make it most recent
147
+ registry.register({ clientId: "new", interfaceId: "macos" });
148
+
149
+ const all = registry.listAll();
150
+ expect(all.length).toBe(2);
151
+ expect(all[0].lastActiveAt).toBeGreaterThanOrEqual(all[1].lastActiveAt);
152
+ });
153
+
154
+ test("listAll returns empty array when no clients registered", () => {
155
+ const registry = new ClientRegistry();
156
+ expect(registry.listAll()).toEqual([]);
157
+ });
158
+
159
+ // ── listByCapability ──────────────────────────────────────────────────────
160
+
161
+ test("listByCapability filters to clients with matching capability", () => {
162
+ const registry = new ClientRegistry();
163
+ registry.register({ clientId: "mac-1", interfaceId: "macos" });
164
+ registry.register({ clientId: "web-1", interfaceId: "cli" });
165
+ registry.register({ clientId: "ext-1", interfaceId: "chrome-extension" });
166
+
167
+ const bashCapable = registry.listByCapability("host_bash");
168
+ expect(bashCapable.length).toBe(1);
169
+ expect(bashCapable[0].clientId).toBe("mac-1");
170
+
171
+ const browserCapable = registry.listByCapability("host_browser");
172
+ expect(browserCapable.length).toBe(2);
173
+ // macOS now supports host_browser too
174
+ const ids = browserCapable.map((e) => e.clientId).sort();
175
+ expect(ids).toEqual(["ext-1", "mac-1"]);
176
+ });
177
+
178
+ test("listByCapability returns empty when no clients match", () => {
179
+ const registry = new ClientRegistry();
180
+ registry.register({ clientId: "web-1", interfaceId: "cli" });
181
+ expect(registry.listByCapability("host_bash")).toEqual([]);
182
+ });
183
+
184
+ // ── getMostRecentByCapability ─────────────────────────────────────────────
185
+
186
+ test("getMostRecentByCapability returns the most recently active client", () => {
187
+ const registry = new ClientRegistry();
188
+ const mac1 = registry.register({ clientId: "mac-1", interfaceId: "macos" });
189
+ const mac2 = registry.register({ clientId: "mac-2", interfaceId: "macos" });
190
+
191
+ // Ensure deterministic ordering — both may register within the same ms
192
+ mac1.lastActiveAt = Date.now() - 5000;
193
+ mac2.lastActiveAt = Date.now();
194
+
195
+ const best = registry.getMostRecentByCapability("host_bash");
196
+ expect(best).toBeDefined();
197
+ expect(best!.clientId).toBe("mac-2");
198
+ });
199
+
200
+ test("getMostRecentByCapability returns undefined when no clients match", () => {
201
+ const registry = new ClientRegistry();
202
+ registry.register({ clientId: "web-1", interfaceId: "cli" });
203
+ expect(registry.getMostRecentByCapability("host_bash")).toBeUndefined();
204
+ });
205
+
206
+ // ── toJSON ────────────────────────────────────────────────────────────────
207
+
208
+ test("toJSON serializes with ISO timestamps", () => {
209
+ const registry = new ClientRegistry();
210
+ const entry = registry.register({
211
+ clientId: "mac-1",
212
+ interfaceId: "macos",
213
+ hostHomeDir: "/Users/alice",
214
+ hostUsername: "alice",
215
+ });
216
+
217
+ const json = ClientRegistry.toJSON(entry);
218
+ expect(json.clientId).toBe("mac-1");
219
+ expect(json.interfaceId).toBe("macos");
220
+ expect(json.capabilities).toContain("host_bash");
221
+ expect(json.connectedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
222
+ expect(json.lastActiveAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
223
+ expect(json.hostHomeDir).toBe("/Users/alice");
224
+ expect(json.hostUsername).toBe("alice");
225
+ });
226
+
227
+ test("toJSON omits host fields when not present", () => {
228
+ const registry = new ClientRegistry();
229
+ const entry = registry.register({
230
+ clientId: "web-1",
231
+ interfaceId: "cli",
232
+ });
233
+
234
+ const json = ClientRegistry.toJSON(entry);
235
+ expect(json.hostHomeDir).toBeUndefined();
236
+ expect(json.hostUsername).toBeUndefined();
237
+ });
238
+
239
+ // ── evictStale ─────────────────────────────────────────────────────────────
240
+
241
+ test("evictStale removes entries older than maxAgeMs", () => {
242
+ const registry = new ClientRegistry();
243
+ const entry = registry.register({
244
+ clientId: "old-mac",
245
+ interfaceId: "macos",
246
+ });
247
+ // Backdate lastActiveAt by 1 hour
248
+ entry.lastActiveAt = Date.now() - 60 * 60 * 1000;
249
+
250
+ const evicted = registry.evictStale(30 * 60 * 1000); // 30 min threshold
251
+ expect(evicted).toBe(1);
252
+ expect(registry.size).toBe(0);
253
+ });
254
+
255
+ test("evictStale keeps fresh entries", () => {
256
+ const registry = new ClientRegistry();
257
+ registry.register({ clientId: "fresh", interfaceId: "macos" });
258
+
259
+ const evicted = registry.evictStale(30 * 60 * 1000);
260
+ expect(evicted).toBe(0);
261
+ expect(registry.size).toBe(1);
262
+ });
263
+
264
+ test("listAll triggers lazy eviction", () => {
265
+ const registry = new ClientRegistry();
266
+ const entry = registry.register({
267
+ clientId: "stale-cli",
268
+ interfaceId: "cli",
269
+ });
270
+ entry.lastActiveAt = Date.now() - 60 * 60 * 1000;
271
+
272
+ registry.register({ clientId: "fresh-mac", interfaceId: "macos" });
273
+
274
+ const all = registry.listAll();
275
+ expect(all.length).toBe(1);
276
+ expect(all[0].clientId).toBe("fresh-mac");
277
+ });
278
+
279
+ // ── singleton ─────────────────────────────────────────────────────────────
280
+
281
+ test("getClientRegistry returns a singleton", () => {
282
+ const a = getClientRegistry();
283
+ const b = getClientRegistry();
284
+ expect(a).toBe(b);
285
+ });
286
+
287
+ test("__resetClientRegistryForTests clears the singleton", () => {
288
+ const a = getClientRegistry();
289
+ __resetClientRegistryForTests();
290
+ const b = getClientRegistry();
291
+ expect(a).not.toBe(b);
292
+ });
293
+ });
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Unified registry of active client connections.
3
+ *
4
+ * Tracks all clients currently connected to the assistant — macOS desktop
5
+ * (SSE), iOS (SSE), web (SSE), chrome-extension (WebSocket), CLI, and
6
+ * channel interfaces. Each entry records the interface type, derived
7
+ * capabilities, connection timestamps, and optional host environment fields.
8
+ *
9
+ * The registry is populated by:
10
+ * - `handleSendMessage` in conversation-routes.ts (registers/refreshes on
11
+ * every inbound message with the interface and transport metadata)
12
+ *
13
+ * Future enhancements:
14
+ * - Deregister on SSE disconnect (events-routes.ts abort signal)
15
+ * - Surface ChromeExtensionRegistry entries through `listAll()`
16
+ * - Stale-client eviction sweep
17
+ *
18
+ * Consumers:
19
+ * - `assistant clients list` CLI command (via `list_clients` IPC route)
20
+ * - Future: deferred host tool routing (Phase 2)
21
+ */
22
+
23
+ import type { HostProxyCapability, InterfaceId } from "../channels/types.js";
24
+ import { supportsHostProxy } from "../channels/types.js";
25
+ import { getLogger } from "../util/logger.js";
26
+
27
+ const log = getLogger("client-registry");
28
+
29
+ /**
30
+ * Default staleness threshold: entries not refreshed within this window are
31
+ * evicted on the next read. 30 minutes is generous — messages refresh the
32
+ * entry on every turn, so any actively used client stays well within this.
33
+ */
34
+ const DEFAULT_STALE_AGE_MS = 30 * 60 * 1000;
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Types
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /** All host-proxy capabilities checked against each interface on register. */
41
+ const ALL_CAPABILITIES: HostProxyCapability[] = [
42
+ "host_bash",
43
+ "host_file",
44
+ "host_cu",
45
+ "host_browser",
46
+ ];
47
+
48
+ export interface ClientEntry {
49
+ /** Stable identifier for this client connection. */
50
+ clientId: string;
51
+ /** Interface type (e.g. "macos", "ios", "web", "chrome-extension"). */
52
+ interfaceId: InterfaceId;
53
+ /** Host-proxy capabilities this client supports. */
54
+ capabilities: HostProxyCapability[];
55
+ /** Wall-clock timestamp (ms) when the client first connected. */
56
+ connectedAt: number;
57
+ /** Wall-clock timestamp (ms) of the most recent activity. */
58
+ lastActiveAt: number;
59
+ /** Home directory on the host machine (only for host-proxy interfaces). */
60
+ hostHomeDir?: string;
61
+ /** Username on the host machine (only for host-proxy interfaces). */
62
+ hostUsername?: string;
63
+ }
64
+
65
+ /** Serialized form returned by the IPC route / CLI command. */
66
+ export interface ClientEntryJSON {
67
+ clientId: string;
68
+ interfaceId: InterfaceId;
69
+ capabilities: HostProxyCapability[];
70
+ connectedAt: string;
71
+ lastActiveAt: string;
72
+ hostHomeDir?: string;
73
+ hostUsername?: string;
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Registry
78
+ // ---------------------------------------------------------------------------
79
+
80
+ export class ClientRegistry {
81
+ private clients = new Map<string, ClientEntry>();
82
+
83
+ /**
84
+ * Register or refresh a client connection.
85
+ *
86
+ * If a client with the same `clientId` already exists, its `lastActiveAt`
87
+ * and host environment fields are updated. Otherwise a new entry is created.
88
+ */
89
+ register(opts: {
90
+ clientId: string;
91
+ interfaceId: InterfaceId;
92
+ hostHomeDir?: string;
93
+ hostUsername?: string;
94
+ }): ClientEntry {
95
+ const existing = this.clients.get(opts.clientId);
96
+ const now = Date.now();
97
+
98
+ if (existing) {
99
+ existing.lastActiveAt = now;
100
+ if (opts.hostHomeDir !== undefined) {
101
+ existing.hostHomeDir = opts.hostHomeDir;
102
+ }
103
+ if (opts.hostUsername !== undefined) {
104
+ existing.hostUsername = opts.hostUsername;
105
+ }
106
+ log.debug(
107
+ { clientId: opts.clientId, interfaceId: opts.interfaceId },
108
+ "client refreshed",
109
+ );
110
+ return existing;
111
+ }
112
+
113
+ const capabilities = ALL_CAPABILITIES.filter((cap) =>
114
+ supportsHostProxy(opts.interfaceId, cap),
115
+ );
116
+
117
+ const entry: ClientEntry = {
118
+ clientId: opts.clientId,
119
+ interfaceId: opts.interfaceId,
120
+ capabilities,
121
+ connectedAt: now,
122
+ lastActiveAt: now,
123
+ hostHomeDir: opts.hostHomeDir,
124
+ hostUsername: opts.hostUsername,
125
+ };
126
+
127
+ this.clients.set(opts.clientId, entry);
128
+ log.info(
129
+ {
130
+ clientId: opts.clientId,
131
+ interfaceId: opts.interfaceId,
132
+ capabilities,
133
+ },
134
+ "client registered",
135
+ );
136
+ return entry;
137
+ }
138
+
139
+ /**
140
+ * Remove a client connection. No-op if the clientId is not registered.
141
+ */
142
+ unregister(clientId: string): void {
143
+ const entry = this.clients.get(clientId);
144
+ if (!entry) return;
145
+ this.clients.delete(clientId);
146
+ log.info(
147
+ { clientId, interfaceId: entry.interfaceId },
148
+ "client unregistered",
149
+ );
150
+ }
151
+
152
+ /**
153
+ * Update `lastActiveAt` for a client without changing other fields.
154
+ * No-op if the clientId is not registered.
155
+ */
156
+ touch(clientId: string): void {
157
+ const entry = this.clients.get(clientId);
158
+ if (entry) {
159
+ entry.lastActiveAt = Date.now();
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Return a specific client entry, or `undefined` if not registered.
165
+ */
166
+ get(clientId: string): ClientEntry | undefined {
167
+ return this.clients.get(clientId);
168
+ }
169
+
170
+ /**
171
+ * Return all registered clients, sorted by `lastActiveAt` descending
172
+ * (most recently active first). Lazily evicts stale entries before
173
+ * returning so disconnected clients don't linger indefinitely.
174
+ */
175
+ listAll(): ClientEntry[] {
176
+ this.evictStale();
177
+ return Array.from(this.clients.values()).sort(
178
+ (a, b) => b.lastActiveAt - a.lastActiveAt,
179
+ );
180
+ }
181
+
182
+ /**
183
+ * Return all registered clients that support the given capability,
184
+ * sorted by `lastActiveAt` descending.
185
+ */
186
+ listByCapability(capability: HostProxyCapability): ClientEntry[] {
187
+ return this.listAll().filter((e) => e.capabilities.includes(capability));
188
+ }
189
+
190
+ /**
191
+ * Return the most recently active client that supports the given
192
+ * capability, or `undefined` if none exists.
193
+ */
194
+ getMostRecentByCapability(
195
+ capability: HostProxyCapability,
196
+ ): ClientEntry | undefined {
197
+ return this.listByCapability(capability)[0];
198
+ }
199
+
200
+ /**
201
+ * Remove entries whose `lastActiveAt` is older than `maxAgeMs`.
202
+ * Called lazily before reads to prevent unbounded map growth from
203
+ * churning client IDs that are never explicitly unregistered.
204
+ *
205
+ * @returns Number of evicted entries.
206
+ */
207
+ evictStale(maxAgeMs: number = DEFAULT_STALE_AGE_MS): number {
208
+ const cutoff = Date.now() - maxAgeMs;
209
+ let evicted = 0;
210
+ for (const [id, entry] of this.clients) {
211
+ if (entry.lastActiveAt < cutoff) {
212
+ this.clients.delete(id);
213
+ evicted++;
214
+ log.debug(
215
+ { clientId: id, interfaceId: entry.interfaceId },
216
+ "client evicted (stale)",
217
+ );
218
+ }
219
+ }
220
+ return evicted;
221
+ }
222
+
223
+ /**
224
+ * Number of currently registered clients.
225
+ */
226
+ get size(): number {
227
+ return this.clients.size;
228
+ }
229
+
230
+ /**
231
+ * Serialize a client entry to JSON (ISO timestamps).
232
+ */
233
+ static toJSON(entry: ClientEntry): ClientEntryJSON {
234
+ return {
235
+ clientId: entry.clientId,
236
+ interfaceId: entry.interfaceId,
237
+ capabilities: entry.capabilities,
238
+ connectedAt: new Date(entry.connectedAt).toISOString(),
239
+ lastActiveAt: new Date(entry.lastActiveAt).toISOString(),
240
+ ...(entry.hostHomeDir ? { hostHomeDir: entry.hostHomeDir } : {}),
241
+ ...(entry.hostUsername ? { hostUsername: entry.hostUsername } : {}),
242
+ };
243
+ }
244
+ }
245
+
246
+ // ── Module-level singleton ────────────────────────────────────────────────
247
+
248
+ let instance: ClientRegistry | null = null;
249
+
250
+ export function getClientRegistry(): ClientRegistry {
251
+ if (!instance) instance = new ClientRegistry();
252
+ return instance;
253
+ }
254
+
255
+ /**
256
+ * Test helper: reset the module-level singleton so each test starts with a
257
+ * fresh registry.
258
+ */
259
+ export function __resetClientRegistryForTests(): void {
260
+ instance = null;
261
+ }