@vellumai/assistant 0.12.2 → 0.12.3-staging.1

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 (298) hide show
  1. package/.env.example +2 -0
  2. package/AGENTS.md +1 -1
  3. package/docs/architecture/integrations.md +7 -0
  4. package/docs/architecture/memory.md +17 -2
  5. package/docs/credential-execution-service.md +1 -1
  6. package/docs/desktop-browser-cli.md +7 -3
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  9. package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
  10. package/node_modules/@vellumai/environments/src/shell.ts +24 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  12. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  13. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
  14. package/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  15. package/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  16. package/openapi.yaml +54 -5
  17. package/package.json +1 -1
  18. package/scripts/smoke-desktop-browser-cli.ts +1 -0
  19. package/scripts/sync-llm-catalog.ts +3 -0
  20. package/scripts/voice-ttft-spike.ts +2 -2
  21. package/src/__tests__/agent-loop.test.ts +259 -0
  22. package/src/__tests__/agent-wake-delegation-prompt.test.ts +64 -2
  23. package/src/__tests__/anthropic-provider.test.ts +55 -0
  24. package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
  25. package/src/__tests__/attachments-store.test.ts +22 -3
  26. package/src/__tests__/channel-approval.test.ts +9 -14
  27. package/src/__tests__/channel-reply-delivery.test.ts +56 -0
  28. package/src/__tests__/chat-credential-redaction.test.ts +23 -0
  29. package/src/__tests__/computer-use-screenshot-attachments.test.ts +411 -0
  30. package/src/__tests__/computer-use-screenshot-selection.test.ts +50 -0
  31. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +2 -0
  32. package/src/__tests__/conversation-agent-loop-overflow.test.ts +2 -0
  33. package/src/__tests__/conversation-agent-loop.test.ts +257 -6
  34. package/src/__tests__/conversation-attachments.test.ts +106 -0
  35. package/src/__tests__/conversation-fork-crud.test.ts +191 -0
  36. package/src/__tests__/conversation-rendered-delegation-state.test.ts +276 -0
  37. package/src/__tests__/credential-execution-client.test.ts +70 -53
  38. package/src/__tests__/db-conversation-tool-surface.test.ts +221 -0
  39. package/src/__tests__/events-tail-route.test.ts +33 -0
  40. package/src/__tests__/history-repair.test.ts +53 -0
  41. package/src/__tests__/inference-profile-session-handler.test.ts +26 -0
  42. package/src/__tests__/list-messages-tool-merge.test.ts +85 -1
  43. package/src/__tests__/llm-catalog-parity.test.ts +27 -4
  44. package/src/__tests__/oauth-apps-routes.test.ts +1 -0
  45. package/src/__tests__/oauth-commands-routes.test.ts +114 -101
  46. package/src/__tests__/oauth-connect-orchestrator.test.ts +2 -0
  47. package/src/__tests__/oauth-provider-serializer.test.ts +1 -0
  48. package/src/__tests__/oauth-providers-routes.test.ts +2 -0
  49. package/src/__tests__/persist-media-references.test.ts +50 -0
  50. package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
  51. package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
  52. package/src/__tests__/schedule-routes.test.ts +20 -0
  53. package/src/__tests__/scheduler-result-notification.test.ts +23 -4
  54. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  55. package/src/__tests__/secret-routes-platform-proxy.test.ts +86 -31
  56. package/src/__tests__/secret-routes-scrub.test.ts +22 -27
  57. package/src/__tests__/secret-scanner.test.ts +20 -0
  58. package/src/__tests__/secure-keys-managed-failover.test.ts +8 -0
  59. package/src/__tests__/secure-keys.test.ts +7 -3
  60. package/src/__tests__/server-tool-pairing.test.ts +107 -0
  61. package/src/__tests__/skills.test.ts +5 -4
  62. package/src/__tests__/subagent-tool-gate-mode.test.ts +226 -0
  63. package/src/__tests__/terminal-tools.test.ts +8 -0
  64. package/src/__tests__/tool-result-follow-up.test.ts +99 -0
  65. package/src/__tests__/tool-result-metadata-plumbing.test.ts +63 -0
  66. package/src/__tests__/unicode.test.ts +36 -0
  67. package/src/agent/loop.ts +37 -12
  68. package/src/agent/tool-result-follow-up.ts +70 -0
  69. package/src/api/attachment-provenance.test.ts +68 -0
  70. package/src/api/computer-use-tool.test.ts +55 -0
  71. package/src/api/computer-use-tool.ts +27 -0
  72. package/src/api/events/assistant-outbound-attachment.ts +3 -0
  73. package/src/api/events/desktop-activity-changed.ts +10 -0
  74. package/src/api/events/question-request.ts +1 -0
  75. package/src/api/index.ts +11 -0
  76. package/src/api/responses/conversation-message.ts +3 -0
  77. package/src/approvals/approval-primitive.ts +5 -2
  78. package/src/approvals/scoped-approval-grants.ts +6 -2
  79. package/src/browser/virtual-desktop-target.ts +1 -2
  80. package/src/cli/commands/__tests__/cli-test-harness.ts +21 -3
  81. package/src/cli/commands/__tests__/plugins.test.ts +20 -1
  82. package/src/cli/commands/__tests__/schedules.test.ts +14 -0
  83. package/src/cli/commands/bash.help.ts +4 -3
  84. package/src/cli/commands/browser.help.ts +5 -1
  85. package/src/cli/commands/credentials.help.ts +3 -3
  86. package/src/cli/commands/oauth/index.help.ts +10 -0
  87. package/src/cli/commands/oauth/providers.ts +12 -0
  88. package/src/cli/commands/oauth/request.test.ts +179 -2
  89. package/src/cli/commands/oauth/request.ts +41 -10
  90. package/src/cli/commands/plugins.ts +12 -5
  91. package/src/cli/commands/schedules.ts +2 -0
  92. package/src/cli/lib/__tests__/inspect-plugin.test.ts +54 -0
  93. package/src/cli/lib/__tests__/install-from-github.test.ts +41 -0
  94. package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +133 -51
  95. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +8 -2
  96. package/src/cli/lib/bundled-marketplace.json +855 -0
  97. package/src/cli/lib/bundled-plugin-packages.json +783 -1
  98. package/src/cli/lib/inspect-plugin.ts +11 -4
  99. package/src/cli/lib/upgrade-plugin.ts +19 -11
  100. package/src/cli/output.ts +18 -4
  101. package/src/config/__tests__/default-provider.test.ts +4 -0
  102. package/src/config/bundled-skills/schedule/SKILL.md +6 -6
  103. package/src/config/loader.ts +9 -17
  104. package/src/config/platform-identity.ts +8 -8
  105. package/src/config/profile-text-generation.test.ts +51 -0
  106. package/src/config/profile-text-generation.ts +51 -0
  107. package/src/config/schemas/__tests__/memory-v3.test.ts +12 -0
  108. package/src/config/schemas/llm.ts +7 -2
  109. package/src/config/schemas/mcp.ts +5 -1
  110. package/src/config/schemas/memory-v3.ts +12 -0
  111. package/src/credential-execution/ces-runtime.ts +2 -2
  112. package/src/credential-execution/executable-discovery.ts +36 -134
  113. package/src/credential-execution/process-manager.test.ts +20 -24
  114. package/src/credential-execution/process-manager.ts +24 -19
  115. package/src/daemon/__tests__/conversation-tool-setup.test.ts +43 -0
  116. package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +36 -0
  117. package/src/daemon/assistant-attachments.ts +20 -12
  118. package/src/daemon/chat-credential-redaction.ts +16 -1
  119. package/src/daemon/conversation-agent-loop-handlers.ts +74 -3
  120. package/src/daemon/conversation-agent-loop.ts +12 -0
  121. package/src/daemon/conversation-attachments.ts +93 -12
  122. package/src/daemon/conversation-tool-setup.ts +71 -3
  123. package/src/daemon/conversation-turn-finalize.ts +26 -13
  124. package/src/daemon/conversation.ts +72 -17
  125. package/src/daemon/daemon-control.ts +3 -7
  126. package/src/daemon/lifecycle.ts +4 -4
  127. package/src/daemon/mcp-reload-service.ts +1 -1
  128. package/src/daemon/message-types/shared.ts +1 -0
  129. package/src/daemon/orphan-reaper.ts +4 -3
  130. package/src/daemon/persist-media-references.ts +20 -3
  131. package/src/daemon/tool-setup-types.ts +6 -0
  132. package/src/daemon/wake-conversation-ops.ts +50 -15
  133. package/src/desktop/desktop-automation-lease.test.ts +230 -0
  134. package/src/desktop/desktop-automation-lease.ts +90 -6
  135. package/src/desktop/desktop-help.ts +15 -0
  136. package/src/desktop/desktop-stream-bridge.test.ts +65 -10
  137. package/src/desktop/desktop-stream-bridge.ts +3 -3
  138. package/src/desktop/desktop-wallpaper-renderer.ts +132 -0
  139. package/src/desktop/desktop-wallpaper-worker.ts +17 -0
  140. package/src/desktop/desktop-wallpaper.test.ts +48 -7
  141. package/src/desktop/desktop-wallpaper.ts +34 -102
  142. package/src/desktop/virtual-desktop-feature.ts +1 -1
  143. package/src/mcp/__tests__/manager-tool-caps.test.ts +111 -0
  144. package/src/mcp/__tests__/startup.test.ts +30 -7
  145. package/src/mcp/__tests__/tool-caps.test.ts +107 -0
  146. package/src/mcp/manager.ts +168 -101
  147. package/src/mcp/startup.ts +33 -13
  148. package/src/mcp/tool-caps.ts +176 -0
  149. package/src/messaging/provider-message-metadata.ts +3 -3
  150. package/src/notifications/__tests__/copy-composer.test.ts +70 -0
  151. package/src/notifications/copy-composer.ts +11 -3
  152. package/src/notifications/schedule-result-producer.ts +4 -6
  153. package/src/oauth/AGENTS.md +2 -0
  154. package/src/oauth/__tests__/identity-verifier.test.ts +36 -1
  155. package/src/oauth/identity-verifier.ts +24 -0
  156. package/src/oauth/oauth-store.ts +11 -1
  157. package/src/oauth/provider-serializer.ts +1 -0
  158. package/src/oauth/seed-providers.ts +12 -0
  159. package/src/permissions/confirmation-guardian-request.test.ts +16 -0
  160. package/src/permissions/confirmation-guardian-request.ts +2 -3
  161. package/src/permissions/question-prompter.test.ts +31 -0
  162. package/src/permissions/question-prompter.ts +2 -0
  163. package/src/persistence/attachments-store.ts +84 -44
  164. package/src/persistence/conversation-crud.ts +111 -20
  165. package/src/persistence/conversation-plugin-facade.ts +30 -0
  166. package/src/persistence/conversation-tool-surface.ts +135 -0
  167. package/src/persistence/conversation-types.test.ts +32 -0
  168. package/src/persistence/conversation-types.ts +26 -4
  169. package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +78 -0
  170. package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +29 -0
  171. package/src/persistence/migrations/379-oauth-providers-response-ok-field.test.ts +92 -0
  172. package/src/persistence/migrations/379-oauth-providers-response-ok-field.ts +32 -0
  173. package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.test.ts +90 -0
  174. package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.ts +34 -0
  175. package/src/persistence/schema/conversation-tool-surfaces.ts +32 -0
  176. package/src/persistence/schema/index.ts +1 -0
  177. package/src/persistence/schema/oauth.ts +1 -0
  178. package/src/persistence/steps.ts +24 -3
  179. package/src/plugin-api/conversation-turn.ts +31 -7
  180. package/src/plugin-api/index.ts +11 -1
  181. package/src/plugin-api/plugin-channel-turn-trust.test.ts +133 -0
  182. package/src/plugin-api/plugin-channel-turn-trust.ts +71 -0
  183. package/src/plugins/defaults/memory/AGENTS.md +39 -5
  184. package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +365 -0
  185. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +43 -0
  186. package/src/plugins/defaults/memory/__tests__/conversation-memory-purge.test.ts +1 -0
  187. package/src/plugins/defaults/memory/__tests__/db-memory-attach.test.ts +2 -0
  188. package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +17 -0
  189. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +74 -0
  190. package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +161 -0
  191. package/src/plugins/defaults/memory/__tests__/relocated-memory-test-rows.ts +10 -0
  192. package/src/plugins/defaults/memory/buffer-file.ts +354 -0
  193. package/src/plugins/defaults/memory/buffer-format.ts +40 -0
  194. package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -2
  195. package/src/plugins/defaults/memory/context-search/format.ts +2 -1
  196. package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -1
  197. package/src/plugins/defaults/memory/context-search/sources/workspace.ts +2 -1
  198. package/src/plugins/defaults/memory/conversation-memory-purge.ts +4 -0
  199. package/src/plugins/defaults/memory/graph/capability-seed.ts +1 -2
  200. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -42
  201. package/src/plugins/defaults/memory/host-utils.ts +0 -10
  202. package/src/plugins/defaults/memory/injectors.ts +4 -3
  203. package/src/plugins/defaults/memory/memory-retrospective-job.ts +72 -183
  204. package/src/plugins/defaults/memory/memory-run-evidence.ts +213 -0
  205. package/src/plugins/defaults/memory/src/memory-item-routes.test.ts +1 -1
  206. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +407 -99
  207. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +10 -0
  208. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +107 -7
  209. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +307 -86
  210. package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +34 -0
  211. package/src/plugins/defaults/memory/substrate/page-index.ts +2 -1
  212. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +89 -49
  213. package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
  214. package/src/plugins/defaults/memory/tools.ts +1 -1
  215. package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
  216. package/src/plugins/defaults/memory/v1/graph/extraction.ts +2 -1
  217. package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
  218. package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +5 -0
  219. package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +5 -2
  220. package/src/plugins/defaults/memory/v2/reranker.ts +2 -1
  221. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +81 -1
  222. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +87 -0
  223. package/src/plugins/defaults/memory/v3/__tests__/plugin-schema.test.ts +12 -0
  224. package/src/plugins/defaults/memory/v3/__tests__/pool-log-store.test.ts +183 -4
  225. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +103 -2
  226. package/src/plugins/defaults/memory/v3/card.ts +2 -1
  227. package/src/plugins/defaults/memory/v3/injector.ts +212 -178
  228. package/src/plugins/defaults/memory/v3/orchestrate.ts +112 -25
  229. package/src/plugins/defaults/memory/v3/plugin-schema.ts +54 -4
  230. package/src/plugins/defaults/memory/v3/pool-log-store.ts +253 -1
  231. package/src/plugins/defaults/memory/v3/pool-select.test.ts +35 -2
  232. package/src/plugins/defaults/memory/v3/pool-select.ts +31 -19
  233. package/src/plugins/defaults/memory/v3/sections.ts +2 -1
  234. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +41 -14
  235. package/src/plugins/defaults/tool-error/hooks/post-tool-use.ts +4 -1
  236. package/src/plugins/defaults/tool-result-truncate/terminal.ts +1 -46
  237. package/src/prompts/__tests__/parallel-tasks-section.test.ts +25 -0
  238. package/src/prompts/delegation-gate.ts +57 -0
  239. package/src/prompts/system-prompt.ts +14 -32
  240. package/src/providers/inference/adapter-factory.ts +6 -0
  241. package/src/providers/jev/client.test.ts +260 -0
  242. package/src/providers/jev/client.ts +518 -0
  243. package/src/providers/model-catalog.ts +58 -3
  244. package/src/providers/server-tool-pairing.ts +16 -7
  245. package/src/runtime/AGENTS.md +2 -2
  246. package/src/runtime/__tests__/agent-wake.test.ts +94 -1
  247. package/src/runtime/agent-wake.ts +31 -4
  248. package/src/runtime/guardian-action-service.ts +2 -17
  249. package/src/runtime/guardian-reply-router.ts +1 -8
  250. package/src/runtime/http-server.ts +2 -2
  251. package/src/runtime/migrations/__tests__/vbundle-import-policy.test.ts +89 -0
  252. package/src/runtime/migrations/vbundle-import-policy.ts +33 -11
  253. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +24 -2
  254. package/src/runtime/routes/channel-route-shared.ts +1 -9
  255. package/src/runtime/routes/conversation-routes.ts +29 -4
  256. package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
  257. package/src/runtime/routes/desktop-setup-routes.ts +8 -5
  258. package/src/runtime/routes/guardian-approval-interception.ts +24 -0
  259. package/src/runtime/routes/inbound-message-handler.ts +2 -3
  260. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
  261. package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -4
  262. package/src/runtime/routes/inference-profile-session-handler.ts +11 -1
  263. package/src/runtime/routes/inference-profiles-routes.ts +9 -0
  264. package/src/runtime/routes/oauth-commands-routes.ts +57 -54
  265. package/src/runtime/routes/oauth-providers.ts +4 -0
  266. package/src/runtime/routes/oauth-request-hints.test.ts +246 -0
  267. package/src/runtime/routes/oauth-request-hints.ts +149 -0
  268. package/src/runtime/routes/schedule-routes.ts +2 -0
  269. package/src/runtime/routes/secret-routes.ts +99 -119
  270. package/src/schedule/__tests__/worker-mcp-bootstrap.test.ts +107 -0
  271. package/src/schedule/__tests__/worker-mcp-readiness.test.ts +173 -0
  272. package/src/schedule/__tests__/worker-mcp-tools.test.ts +13 -47
  273. package/src/schedule/run-script.ts +2 -2
  274. package/src/schedule/scheduler.ts +47 -11
  275. package/src/schedule/tool-surface-readiness.ts +65 -0
  276. package/src/schedule/worker-mcp.ts +85 -0
  277. package/src/schedule/worker.ts +9 -31
  278. package/src/security/secure-keys.ts +60 -48
  279. package/src/tools/ask-question/ask-question-tool.test.ts +167 -5
  280. package/src/tools/ask-question/ask-question-tool.ts +119 -23
  281. package/src/tools/browser/browser-execution.ts +7 -1
  282. package/src/tools/host-terminal/host-shell.ts +12 -6
  283. package/src/tools/shared/filesystem/file-ops-service.ts +1 -31
  284. package/src/tools/shared/shell-output.test.ts +10 -0
  285. package/src/tools/shared/shell-output.ts +14 -2
  286. package/src/tools/skills/sandbox-runner.ts +13 -2
  287. package/src/tools/skills/scaffold-managed.ts +2 -1
  288. package/src/tools/terminal/__tests__/safe-env.test.ts +33 -4
  289. package/src/tools/terminal/__tests__/sanitized-bash.test.ts +0 -14
  290. package/src/tools/terminal/safe-env.ts +35 -24
  291. package/src/tools/terminal/sanitized-bash.ts +15 -2
  292. package/src/tools/terminal/shell-launch.test.ts +162 -0
  293. package/src/tools/terminal/shell.test.ts +29 -0
  294. package/src/tools/terminal/shell.ts +13 -7
  295. package/src/util/browser-human-verification.ts +14 -0
  296. package/src/util/host-process.test.ts +17 -1
  297. package/src/util/host-process.ts +24 -0
  298. package/src/util/unicode.ts +29 -0
@@ -43,7 +43,7 @@ function isPositiveInteger(v: unknown): v is number {
43
43
 
44
44
  /**
45
45
  * Read daemon timeout values directly from the config JSON file, bypassing
46
- * loadConfig() and its ensureMigratedDataDir()/ensureDataDir() side effects.
46
+ * loadConfig() and its ensureDataDir() side effects.
47
47
  * Falls back to hardcoded defaults on any error (missing file, malformed JSON,
48
48
  * unexpected shape) so daemon stop/start never fails due to config issues.
49
49
  */
@@ -258,15 +258,11 @@ async function getDaemonStatus(): Promise<{
258
258
  return { running: true, pid };
259
259
  }
260
260
 
261
- function getStartupLockPath(): string {
262
- return getDaemonStartupLockPath();
263
- }
264
-
265
261
  /** Attempt to acquire a startup lock. Returns true on success. Stale locks
266
262
  * (older than STARTUP_LOCK_STALE_MS) are forcibly removed to prevent
267
263
  * permanent deadlocks from a crashed caller. */
268
264
  function acquireStartupLock(): boolean {
269
- const lockPath = getStartupLockPath();
265
+ const lockPath = getDaemonStartupLockPath();
270
266
  try {
271
267
  // Ensure the root directory exists before attempting the lock file write.
272
268
  // On a first-time run, getRootDir() may not exist yet, and writeFileSync
@@ -293,7 +289,7 @@ function acquireStartupLock(): boolean {
293
289
 
294
290
  function releaseStartupLock(): void {
295
291
  try {
296
- unlinkSync(getStartupLockPath());
292
+ unlinkSync(getDaemonStartupLockPath());
297
293
  } catch {
298
294
  /* already removed */
299
295
  }
@@ -673,11 +673,11 @@ export async function runDaemon(): Promise<void> {
673
673
  // blocked.
674
674
  startConsentRefresh();
675
675
 
676
- // Bring up the daemon's CES connection (process + handshake + reconnect
676
+ // Bring up the assistant's CES connection (process + handshake + reconnect
677
677
  // wiring). Blocks up to a 20s timeout so credential reads route through CES
678
- // before provider init; non-fatal falls back to the direct credential store
679
- // on failure. The sidecar accepts exactly one bootstrap connection, so this
680
- // happens at the process level.
678
+ // before provider init; non-fatal, falls back to the direct credential store
679
+ // on failure. CES serves a multi-connection bootstrap socket, so this
680
+ // happens at the process level and child processes can connect independently.
681
681
  await startCes(config);
682
682
 
683
683
  // Bring up the plugin layer: install the runtime bridge, register the
@@ -116,7 +116,7 @@ async function doReload(): Promise<McpReloadResult> {
116
116
  const servers: McpReloadServerResult[] = [];
117
117
 
118
118
  if (serverIds.length > 0) {
119
- const serverToolInfos = await manager.start(mcpConfig);
119
+ const { servers: serverToolInfos } = await manager.start(mcpConfig);
120
120
  for (const { serverId, serverConfig, tools } of serverToolInfos) {
121
121
  const mcpTools = createMcpToolsFromServer(
122
122
  tools,
@@ -39,6 +39,7 @@ export interface UserMessageAttachment {
39
39
  data: string;
40
40
  /** Origin of the attachment on the daemon side, when known. */
41
41
  sourceType?: "sandbox_file" | "host_file" | "tool_block";
42
+ computerUseScreenshot?: boolean;
42
43
  extractedText?: string;
43
44
  /** Original file size in bytes. Present when data was omitted from history_response to reduce payload size. */
44
45
  sizeBytes?: number;
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Periodic reaper for orphaned subprocesses that reparent to the daemon.
3
3
  *
4
- * Tools run commands in their own process group (`detached: true`) and, on
5
- * timeout/abort, group-kill with `process.kill(-pgid, SIGKILL)` (the bash and
6
- * host_bash tools, and the skill sandbox runner). The
4
+ * POSIX tools run commands in their own process group (`detached: true`) and,
5
+ * on timeout/abort, group-kill with `process.kill(-pgid, SIGKILL)` (the bash
6
+ * and host_bash tools, and the skill sandbox runner). Windows piped shell
7
+ * children stay attached and are torn down with `taskkill /T`. The
7
8
  * immediate child is reaped by Bun/libuv, but its descendants — e.g. git's
8
9
  * transport helpers or a skill runner's `bun` process — were never spawned by
9
10
  * the daemon, so when the group dies they reparent to PID 1. When the daemon
@@ -29,6 +29,7 @@ import type {
29
29
  WorkspaceRefMediaSource,
30
30
  } from "../providers/types.js";
31
31
  import { getLogger } from "../util/logger.js";
32
+ import { toolImageFilename } from "./assistant-attachments.js";
32
33
 
33
34
  const log = getLogger("persist-media-references");
34
35
 
@@ -62,6 +63,7 @@ async function referenceMediaBlock(
62
63
  messageId: string,
63
64
  block: ImageContent | FileContent,
64
65
  position: number,
66
+ imageFilenameOverride?: string,
65
67
  ): Promise<ContentBlock | null> {
66
68
  if (block.source.type !== "base64") {
67
69
  return block;
@@ -69,7 +71,9 @@ async function referenceMediaBlock(
69
71
  const { data, media_type } = block.source;
70
72
  const filename =
71
73
  block.source.filename ??
72
- (block.type === "image" ? imageFilename(media_type) : "attachment");
74
+ (block.type === "image"
75
+ ? (imageFilenameOverride ?? imageFilename(media_type))
76
+ : "attachment");
73
77
 
74
78
  let stored: { id: string; sizeBytes: number };
75
79
  try {
@@ -127,11 +131,15 @@ export async function referenceMediaBlocksForPersist(
127
131
  conversationCreatedAt: number,
128
132
  messageId: string,
129
133
  blocks: ContentBlock[],
134
+ computerUseToolNames?: ReadonlyMap<string, string>,
130
135
  ): Promise<ContentBlock[]> {
131
136
  // A single link position counter across all attachments in the message so
132
137
  // their `message_attachments` rows keep content order.
133
138
  let position = 0;
134
- const convert = async (block: ContentBlock): Promise<ContentBlock> => {
139
+ const convert = async (
140
+ block: ContentBlock,
141
+ imageFilenameOverride?: string,
142
+ ): Promise<ContentBlock> => {
135
143
  if (block.type === "image" || block.type === "file") {
136
144
  if (block.source.type !== "base64") {
137
145
  return block;
@@ -143,13 +151,22 @@ export async function referenceMediaBlocksForPersist(
143
151
  messageId,
144
152
  block,
145
153
  position++,
154
+ imageFilenameOverride,
146
155
  )) ?? block
147
156
  );
148
157
  }
149
158
  if (block.type === "tool_result" && block.contentBlocks?.length) {
150
159
  const contentBlocks: ContentBlock[] = [];
160
+ const computerUseToolName = computerUseToolNames?.get(block.tool_use_id);
151
161
  for (const cb of block.contentBlocks) {
152
- contentBlocks.push(await convert(cb));
162
+ contentBlocks.push(
163
+ await convert(
164
+ cb,
165
+ cb.type === "image" && computerUseToolName
166
+ ? toolImageFilename(cb.source.media_type, computerUseToolName)
167
+ : undefined,
168
+ ),
169
+ );
153
170
  }
154
171
  return { ...block, contentBlocks };
155
172
  }
@@ -59,6 +59,12 @@ export interface SubagentToolStats {
59
59
  * parity for desktop/web sources; channel-routed sources resolve every tool
60
60
  * gate identically under `hasNoClient: true` with or without them.)
61
61
  *
62
+ * A wake that also carries its source's recorded wire array
63
+ * (`Conversation.wireToolReplay`, set by `scopeWakeAllowedTools`) sends that
64
+ * array verbatim, so the pin then shapes only the execution-side inventory
65
+ * (`allowedToolNames`); the pin alone carries wire parity for a source with
66
+ * no recorded surface.
67
+ *
62
68
  * Tool-definition resolution ONLY. The executor callback and host-proxy
63
69
  * attachment paths never read the pin, so it cannot make a host tool
64
70
  * runnable: in execution gate mode every non-allowlisted call is rejected
@@ -22,7 +22,7 @@ import {
22
22
  import { syncMessageToDisk } from "../persistence/conversation-disk-view.js";
23
23
  import { backfillMessageIdOnLogs } from "../persistence/llm-request-log-store.js";
24
24
  import { resolveMediaSourceData } from "../providers/media-resolve.js";
25
- import type { Message } from "../providers/types.js";
25
+ import type { Message, ToolDefinition } from "../providers/types.js";
26
26
  import { broadcastMessage } from "../runtime/assistant-event-hub.js";
27
27
  import { publishConversationMessagesChanged } from "../runtime/sync/resource-sync-events.js";
28
28
  import type { CompletedBackgroundTool } from "../tools/background-tool-registry.js";
@@ -350,38 +350,70 @@ export async function persistWakeTriggerMessage(
350
350
  }
351
351
  }
352
352
 
353
+ /**
354
+ * How a wake's tool allowlist is applied beyond the allowlist itself. Every
355
+ * field is set on the conversation together with the allowlist and restored
356
+ * with it.
357
+ */
358
+ export interface WakeToolScopeOptions {
359
+ /** How the allowlist is enforced; see {@link SubagentToolGateMode}. */
360
+ gateMode?: SubagentToolGateMode;
361
+ /**
362
+ * Freezes the client-context inputs for tool-definition resolution
363
+ * (execution-gate-mode cache-parity wakes); see {@link WakeToolContextPin}.
364
+ * Its `requestOrigin`, when set, is stamped onto the conversation's per-turn
365
+ * origin so the permission checker's origin-scoped auto-grants fire.
366
+ */
367
+ toolContextPin?: WakeToolContextPin;
368
+ /**
369
+ * Skills whose bundled tools join the turn's active set
370
+ * (`allowedToolNames`) so they are callable without a prior `skill_load`.
371
+ */
372
+ preactivateSkillIds?: readonly string[];
373
+ /**
374
+ * Sent verbatim as the wake's wire tool array in place of the resolved one
375
+ * (the source conversation's recorded surface, replayed for provider
376
+ * prompt-cache parity). Changes nothing about what may execute.
377
+ */
378
+ wireToolDefinitions?: readonly ToolDefinition[];
379
+ /**
380
+ * The delegation section's rendered state recorded with the source's
381
+ * surface, replayed into the wake's system prompt alongside
382
+ * `wireToolDefinitions`. Changes nothing about what may execute.
383
+ */
384
+ delegateIndependentTasks?: boolean;
385
+ }
386
+
353
387
  /**
354
388
  * Temporarily restrict the tools visible/executable during a wake by
355
389
  * reusing the conversation's subagent allowlist slot. Returns a restore
356
390
  * callback that reinstates the previous allowlist so the wake can release
357
391
  * the scope before any queued user turn is drained.
358
- *
359
- * `gateMode` controls how the allowlist is enforced — see
360
- * {@link SubagentToolGateMode}. `toolContextPin`, when provided
361
- * (execution-gate-mode cache-parity wakes), freezes the client-context
362
- * inputs for tool-definition resolution — see {@link WakeToolContextPin}.
363
- * Its `requestOrigin`, when set, is stamped onto the conversation's per-turn
364
- * origin so the permission checker's origin-scoped auto-grants fire for the
365
- * wake. `preactivateSkillIds`, when non-empty, activates those skills' bundled
366
- * tools in the turn's active set (`allowedToolNames`) for the wake so they are
367
- * callable without a prior `skill_load`. All are set and restored alongside the
368
- * allowlist.
369
392
  */
370
393
  export function scopeWakeAllowedTools(
371
394
  conversation: Conversation,
372
395
  tools: ReadonlySet<string>,
373
- gateMode: SubagentToolGateMode = "wire",
374
- toolContextPin?: WakeToolContextPin,
375
- preactivateSkillIds?: readonly string[],
396
+ {
397
+ gateMode = "wire",
398
+ toolContextPin,
399
+ preactivateSkillIds,
400
+ wireToolDefinitions,
401
+ delegateIndependentTasks,
402
+ }: WakeToolScopeOptions = {},
376
403
  ): () => void {
377
404
  const previous = conversation.subagentAllowedTools;
378
405
  const previousGateMode = conversation.subagentToolGateMode;
379
406
  const previousToolContextPin = conversation.toolContextPin;
380
407
  const previousRequestOrigin = conversation.currentTurnRequestOrigin;
381
408
  const previousPreactivated = conversation.preactivatedSkillIds;
409
+ const previousWireToolReplay = conversation.wireToolReplay;
410
+ const previousDelegateIndependentTasksReplay =
411
+ conversation.delegateIndependentTasksReplay;
382
412
  conversation.setSubagentAllowedTools(new Set(tools));
383
413
  conversation.subagentToolGateMode = gateMode;
384
414
  conversation.toolContextPin = toolContextPin;
415
+ conversation.wireToolReplay = wireToolDefinitions;
416
+ conversation.delegateIndependentTasksReplay = delegateIndependentTasks;
385
417
  if (toolContextPin?.requestOrigin !== undefined) {
386
418
  conversation.currentTurnRequestOrigin = toolContextPin.requestOrigin;
387
419
  }
@@ -394,5 +426,8 @@ export function scopeWakeAllowedTools(
394
426
  conversation.toolContextPin = previousToolContextPin;
395
427
  conversation.currentTurnRequestOrigin = previousRequestOrigin;
396
428
  conversation.setPreactivatedSkillIds(previousPreactivated);
429
+ conversation.wireToolReplay = previousWireToolReplay;
430
+ conversation.delegateIndependentTasksReplay =
431
+ previousDelegateIndependentTasksReplay;
397
432
  };
398
433
  }
@@ -35,7 +35,9 @@ function fixture() {
35
35
  const started = mock(async () => {});
36
36
  const released = mock(() => {});
37
37
  const releaseBrowser = mock(async () => {});
38
+ const notify = mock(async () => {});
38
39
  const lease = new DesktopAutomationLease({
40
+ notify,
39
41
  enabled: () => enabled,
40
42
  ready: () => ready,
41
43
  ensureReady,
@@ -50,6 +52,7 @@ function fixture() {
50
52
  cleanups.push(lease);
51
53
  return {
52
54
  lease,
55
+ notify,
53
56
  ensureReady,
54
57
  install,
55
58
  failSetup: () => setup.reject(new Error("download failed")),
@@ -128,6 +131,7 @@ test("cancellation stops running and queued browser commands without blocking a
128
131
  const callback = mock(operation);
129
132
  const queued = f.lease.runBrowser(context, callback);
130
133
  abort.abort();
134
+ expect(f.lease.isActive).toBe(false);
131
135
  expect(await running).toBeInstanceOf(Error);
132
136
  expect((await queued).isError).toBe(true);
133
137
  expect(callback).not.toHaveBeenCalled();
@@ -206,3 +210,229 @@ test("disabled, unidentified, cancelled and released browser calls cannot instal
206
210
  await expect(f.lease.runBrowser(context, operation)).rejects.toThrow();
207
211
  expect(f.ensureReady).not.toHaveBeenCalled();
208
212
  });
213
+
214
+ test("activity follows the browser lease and clears before failed cleanup", async () => {
215
+ const f = fixture();
216
+ expect(f.lease.isActive).toBe(false);
217
+ await f.lease.runBrowser(context, operation);
218
+ expect(f.lease.isActive).toBe(true);
219
+ expect(f.notify).toHaveBeenCalledTimes(1);
220
+ await f.lease.runBrowser(context, operation);
221
+ expect(f.notify).toHaveBeenCalledTimes(1);
222
+ f.releaseBrowser.mockRejectedValueOnce(new Error("cleanup failed"));
223
+ await expect(f.lease.runBrowser(context, operation, true)).rejects.toThrow(
224
+ "cleanup failed",
225
+ );
226
+ expect(f.lease.isActive).toBe(false);
227
+ expect(f.notify).toHaveBeenCalledTimes(2);
228
+ await f.lease.runBrowser(context, operation, true);
229
+ expect(f.notify).toHaveBeenCalledTimes(2);
230
+ });
231
+
232
+ test("turn completion releases only its own desktop before cleanup finishes", async () => {
233
+ const f = fixture();
234
+ await f.lease.runBrowser(context, operation);
235
+ f.lease.releaseForConversation("conv-456");
236
+ expect(f.lease.isActive).toBe(true);
237
+ expect(f.notify).toHaveBeenCalledTimes(1);
238
+
239
+ const cleanup = Promise.withResolvers<void>();
240
+ f.releaseBrowser.mockImplementationOnce(() => cleanup.promise);
241
+ f.lease.releaseForConversation(context.conversationId);
242
+ expect(f.lease.isActive).toBe(false);
243
+ expect(f.notify).toHaveBeenCalledTimes(2);
244
+ await Bun.sleep(0);
245
+ expect(f.released).not.toHaveBeenCalled();
246
+
247
+ const callback = mock(operation);
248
+ const next = f.lease.runBrowser(context, callback);
249
+ await Bun.sleep(0);
250
+ expect(callback).not.toHaveBeenCalled();
251
+ cleanup.resolve();
252
+ expect((await next).isError).toBe(false);
253
+ expect(f.released).toHaveBeenCalledTimes(1);
254
+ expect(f.started).toHaveBeenCalledTimes(2);
255
+ expect(f.lease.isActive).toBe(true);
256
+ });
257
+
258
+ test("turn completion cancels running and queued desktop work", async () => {
259
+ const f = fixture();
260
+ const started = Promise.withResolvers<void>();
261
+ const running = f.lease
262
+ .runBrowser(context, async (signal) => {
263
+ started.resolve();
264
+ await new Promise<void>((_resolve, reject) => {
265
+ signal.addEventListener("abort", () => reject(new Error("aborted")), {
266
+ once: true,
267
+ });
268
+ });
269
+ return operation();
270
+ })
271
+ .catch((error: unknown) => error);
272
+ await started.promise;
273
+ const callback = mock(operation);
274
+ const queued = f.lease.runBrowser(context, callback);
275
+ f.lease.releaseForConversation(context.conversationId);
276
+ expect(f.lease.isActive).toBe(false);
277
+ expect(await running).toBeInstanceOf(Error);
278
+ expect((await queued).isError).toBe(true);
279
+ expect(callback).not.toHaveBeenCalled();
280
+ await f.lease.runBrowser(context, callback);
281
+ expect(callback).toHaveBeenCalledTimes(1);
282
+ });
283
+
284
+ test("turn handoff retries failed cleanup without another turn or restart", async () => {
285
+ const f = fixture();
286
+ await f.lease.runBrowser(context, operation);
287
+ f.releaseBrowser.mockRejectedValueOnce(new Error("Chrome cleanup timed out"));
288
+ f.lease.releaseForConversation(context.conversationId);
289
+ expect(f.lease.isActive).toBe(false);
290
+ await Bun.sleep(0);
291
+ expect(f.released).not.toHaveBeenCalled();
292
+ await Bun.sleep(1_100);
293
+ expect(f.released).toHaveBeenCalledTimes(1);
294
+ const nextContext = { ...context, conversationId: "conv-456" };
295
+ expect((await f.lease.runBrowser(nextContext, operation)).isError).toBe(
296
+ false,
297
+ );
298
+ await f.lease.runBrowser(nextContext, operation, true);
299
+ });
300
+
301
+ test("a delayed cleanup retry cannot release a replacement owner", async () => {
302
+ const f = fixture();
303
+ await f.lease.runBrowser(context, operation);
304
+ f.releaseBrowser.mockRejectedValueOnce(new Error("Chrome cleanup timed out"));
305
+ f.lease.releaseForConversation(context.conversationId);
306
+ await f.lease.runBrowser(context, operation, true);
307
+ const nextContext = { ...context, conversationId: "conv-456" };
308
+ await f.lease.runBrowser(nextContext, operation);
309
+ await Bun.sleep(1_100);
310
+ expect(f.lease.isActive).toBe(true);
311
+ expect(f.released).toHaveBeenCalledTimes(1);
312
+ await f.lease.runBrowser(nextContext, operation, true);
313
+ });
314
+
315
+ test("cleanup retries preserve commands queued behind an explicit handoff", async () => {
316
+ const f = fixture();
317
+ await f.lease.runBrowser(context, operation);
318
+ f.releaseBrowser.mockRejectedValueOnce(new Error("Chrome cleanup timed out"));
319
+ f.lease.releaseForConversation(context.conversationId);
320
+ await Bun.sleep(0);
321
+ const cleanup = Promise.withResolvers<void>();
322
+ f.releaseBrowser.mockImplementationOnce(() => cleanup.promise);
323
+ const detached = f.lease.runBrowser(context, operation, true);
324
+ await Bun.sleep(0);
325
+ const callback = mock(operation);
326
+ const next = f.lease.runBrowser(context, callback);
327
+ await Bun.sleep(1_100);
328
+ cleanup.resolve();
329
+ await detached;
330
+ expect((await next).isError).toBe(false);
331
+ expect(callback).toHaveBeenCalledTimes(1);
332
+ expect(f.lease.isActive).toBe(true);
333
+ });
334
+
335
+ test("turn completion cancels commands queued behind an in-progress detach", async () => {
336
+ const f = fixture();
337
+ await f.lease.runBrowser(context, operation);
338
+ const cleanup = Promise.withResolvers<void>();
339
+ f.releaseBrowser.mockImplementationOnce(() => cleanup.promise);
340
+ const detached = f.lease.runBrowser(context, operation, true);
341
+ await Bun.sleep(0);
342
+ const callback = mock(operation);
343
+ const queued = f.lease.runBrowser(context, callback);
344
+ f.lease.releaseForConversation(context.conversationId);
345
+ cleanup.resolve();
346
+ await detached;
347
+ expect((await queued).isError).toBe(true);
348
+ expect(callback).not.toHaveBeenCalled();
349
+ await f.lease.runBrowser(context, callback);
350
+ expect(callback).toHaveBeenCalledTimes(1);
351
+ });
352
+
353
+ test("human help releases held input while reserving the desktop, then resumes its owner", async () => {
354
+ const f = fixture();
355
+ await f.lease.runBrowser(context, operation);
356
+ const finish = await f.lease.reserveForHuman(context);
357
+ expect(f.releaseBrowser).toHaveBeenCalledTimes(2);
358
+ expect(f.released).not.toHaveBeenCalled();
359
+ expect(f.lease.isActive).toBe(false);
360
+ const callback = mock(operation);
361
+ await expect(f.lease.runBrowser(context, callback)).rejects.toThrow(
362
+ "reserved",
363
+ );
364
+ await expect(f.lease.runBrowser(context, callback, true)).rejects.toThrow(
365
+ "reserved",
366
+ );
367
+ const other = { ...context, conversationId: "conv-456" };
368
+ await expect(f.lease.runBrowser(other, callback)).rejects.toThrow(
369
+ "Another conversation",
370
+ );
371
+ expect(callback).not.toHaveBeenCalled();
372
+ await finish(true);
373
+ expect(f.lease.isActive).toBe(true);
374
+ await expect(f.lease.runBrowser(other, callback)).rejects.toThrow(
375
+ "Another conversation",
376
+ );
377
+ await f.lease.runBrowser(context, callback);
378
+ expect(callback).toHaveBeenCalledTimes(1);
379
+ expect(f.started).toHaveBeenCalledTimes(1);
380
+ });
381
+
382
+ test("closing human help releases its reservation and stale cleanup preserves the next owner", async () => {
383
+ const f = fixture();
384
+ const finish = await f.lease.reserveForHuman(context);
385
+ await finish(false);
386
+ expect(f.released).toHaveBeenCalledTimes(1);
387
+ const other = { ...context, conversationId: "conv-456" };
388
+ await f.lease.runBrowser(other, operation);
389
+ await finish(false);
390
+ expect(f.lease.isActive).toBe(true);
391
+ expect(f.released).toHaveBeenCalledTimes(1);
392
+ await f.lease.runBrowser(other, operation, true);
393
+ });
394
+
395
+ test("aborting human help releases its reservation", async () => {
396
+ const f = fixture();
397
+ const abort = new AbortController();
398
+ const finish = await f.lease.reserveForHuman({
399
+ ...context,
400
+ signal: abort.signal,
401
+ });
402
+ abort.abort();
403
+ await finish(true);
404
+ expect(f.lease.isActive).toBe(false);
405
+ expect(f.released).toHaveBeenCalledTimes(1);
406
+ await f.lease.runBrowser(context, operation);
407
+ expect(f.started).toHaveBeenCalledTimes(2);
408
+ });
409
+
410
+ test("the browser idle timeout cannot steal a pending human-help reservation", async () => {
411
+ const f = fixture();
412
+ const finish = await f.lease.reserveForHuman(context);
413
+ const now = Date.now;
414
+ const future = now() + 10 * 60_000;
415
+ Date.now = () => future;
416
+ try {
417
+ await Bun.sleep(1_100);
418
+ expect(f.released).not.toHaveBeenCalled();
419
+ await expect(
420
+ f.lease.runBrowser({ ...context, conversationId: "conv-456" }, operation),
421
+ ).rejects.toThrow("Another conversation");
422
+ } finally {
423
+ Date.now = now;
424
+ await finish(false);
425
+ }
426
+ expect(f.released).toHaveBeenCalledTimes(1);
427
+ });
428
+
429
+ test("ending human help retries failed input cleanup without another turn", async () => {
430
+ const f = fixture();
431
+ const finish = await f.lease.reserveForHuman(context);
432
+ f.releaseBrowser.mockRejectedValueOnce(new Error("cleanup failed"));
433
+ await expect(finish(false)).rejects.toThrow("cleanup failed");
434
+ await Bun.sleep(0);
435
+ expect(f.released).toHaveBeenCalledTimes(1);
436
+ await f.lease.runBrowser(context, operation);
437
+ expect(f.lease.isActive).toBe(true);
438
+ });
@@ -1,4 +1,4 @@
1
- import { getConfig } from "../config/loader.js";
1
+ import { broadcastMessage } from "../runtime/assistant-event-hub.js";
2
2
  import type { ToolContext, ToolExecutionResult } from "../tools/types.js";
3
3
  import { getLogger } from "../util/logger.js";
4
4
  import { desktopDependencyInstaller } from "./desktop-dependencies.js";
@@ -21,6 +21,7 @@ type Owner = {
21
21
  actions: number;
22
22
  lastActivity: number;
23
23
  desktopLost: boolean;
24
+ humanHelp?: symbol;
24
25
  };
25
26
 
26
27
  export class DesktopAutomationLease {
@@ -35,14 +36,33 @@ export class DesktopAutomationLease {
35
36
  ready: () => boolean;
36
37
  ensureReady: (signal: AbortSignal) => Promise<void>;
37
38
  manager: () => DesktopSessionManager;
39
+ notify: () => Promise<unknown>;
38
40
  } = {
39
- enabled: () => isVirtualDesktopEnabled(getConfig()),
41
+ enabled: isVirtualDesktopEnabled,
40
42
  ready: () => desktopDependencyInstaller.getStatus().state === "ready",
41
43
  ensureReady: (signal) => desktopDependencyInstaller.ensureReady(signal),
42
44
  manager: getDesktopSessionManager,
45
+ notify: async () =>
46
+ broadcastMessage({ type: "desktop_activity_changed" }),
43
47
  },
44
48
  ) {}
45
49
 
50
+ get isActive(): boolean {
51
+ return (
52
+ this.owner !== null &&
53
+ !this.owner.humanHelp &&
54
+ !this.owner.abort.signal.aborted
55
+ );
56
+ }
57
+
58
+ private notify(): void {
59
+ void this.deps
60
+ .notify()
61
+ .catch((err) =>
62
+ log.warn({ err }, "Desktop browser activity notification failed"),
63
+ );
64
+ }
65
+
46
66
  private exclusive<T>(run: () => Promise<T>): Promise<T> {
47
67
  const next = this.tail.then(run, run);
48
68
  this.tail = next.catch(() => {});
@@ -84,9 +104,25 @@ export class DesktopAutomationLease {
84
104
  }
85
105
  this.generation += 1;
86
106
  owner.abort.abort();
87
- void this.exclusive(() => this.release()).catch((err) =>
88
- log.warn({ err }, "Desktop browser session cleanup failed"),
89
- );
107
+ this.releaseCancelledOwner(owner);
108
+ }
109
+
110
+ private releaseCancelledOwner(owner: Owner): void {
111
+ void this.exclusive(async () => {
112
+ if (this.owner === owner) {
113
+ await this.release();
114
+ }
115
+ }).catch((err) => {
116
+ log.warn({ err }, "Desktop browser session cleanup failed");
117
+ const retry = setTimeout(() => this.releaseCancelledOwner(owner), 1_000);
118
+ retry.unref?.();
119
+ });
120
+ }
121
+
122
+ releaseForConversation(conversationId: string): void {
123
+ if (this.owner?.conversationId === conversationId) {
124
+ this.cancel(this.owner);
125
+ }
90
126
  }
91
127
 
92
128
  private bindCancellation(owner: Owner, signal?: AbortSignal): void {
@@ -122,12 +158,17 @@ export class DesktopAutomationLease {
122
158
  throw new Error("The desktop is busy or shutting down");
123
159
  }
124
160
  this.owner = owner;
161
+ owner.abort.signal.addEventListener("abort", () => this.notify(), {
162
+ once: true,
163
+ });
164
+ this.notify();
125
165
  this.bindCancellation(owner, context.signal);
126
166
  const cancel = () => this.cancel(owner);
127
167
  this.watchdog = setInterval(() => {
128
168
  try {
129
169
  if (
130
- Date.now() - owner.lastActivity > IDLE_TIMEOUT_MS ||
170
+ (!owner.humanHelp &&
171
+ Date.now() - owner.lastActivity > IDLE_TIMEOUT_MS) ||
131
172
  !this.deps.enabled() ||
132
173
  !this.deps.ready()
133
174
  ) {
@@ -156,6 +197,44 @@ export class DesktopAutomationLease {
156
197
  }
157
198
  }
158
199
 
200
+ async reserveForHuman(
201
+ context: ToolContext,
202
+ ): Promise<(resume: boolean) => Promise<void>> {
203
+ let reservedOwner: Owner | undefined;
204
+ const reservation = Symbol("human-help");
205
+ const result = await this.runBrowser(context, async () => {
206
+ await this.deps.manager().browser.release();
207
+ reservedOwner = this.owner!;
208
+ reservedOwner.humanHelp = reservation;
209
+ this.notify();
210
+ return { content: "", isError: false };
211
+ });
212
+ if (result.isError || !reservedOwner) {
213
+ throw new Error(
214
+ "Desktop help was interrupted before control could be handed over.",
215
+ );
216
+ }
217
+ const owner = reservedOwner;
218
+ return (resume) =>
219
+ this.exclusive(async () => {
220
+ if (this.owner !== owner || owner.humanHelp !== reservation) {
221
+ return;
222
+ }
223
+ if (resume && !owner.abort.signal.aborted) {
224
+ owner.humanHelp = undefined;
225
+ owner.lastActivity = Date.now();
226
+ this.notify();
227
+ } else {
228
+ try {
229
+ await this.release();
230
+ } catch (error) {
231
+ this.releaseCancelledOwner(owner);
232
+ throw error;
233
+ }
234
+ }
235
+ });
236
+ }
237
+
159
238
  runBrowser(
160
239
  context: ToolContext,
161
240
  operation: (signal: AbortSignal) => Promise<ToolExecutionResult>,
@@ -179,6 +258,11 @@ export class DesktopAutomationLease {
179
258
  ) {
180
259
  throw new Error("Another conversation is controlling the desktop");
181
260
  }
261
+ if (this.owner?.humanHelp) {
262
+ throw new Error(
263
+ "The desktop is reserved while the user is helping. Wait for Done or Skip.",
264
+ );
265
+ }
182
266
  if (done) {
183
267
  await this.release();
184
268
  return { content: "Desktop browser session released.", isError: false };