@vellumai/assistant 0.12.0 → 0.12.1-dev.202609141913.5e16607

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 (441) hide show
  1. package/AGENTS.md +5 -1
  2. package/ARCHITECTURE.md +11 -12
  3. package/docs/architecture/turn-actor.md +15 -8
  4. package/docs/guardian-request-flow.md +16 -0
  5. package/openapi.yaml +292 -50
  6. package/package.json +1 -1
  7. package/scripts/smoke-desktop-window-theme.ts +186 -0
  8. package/src/__tests__/always-loaded-tools-guard.test.ts +18 -10
  9. package/src/__tests__/app-compiler.test.ts +6 -0
  10. package/src/__tests__/app-control-tool-schemas.test.ts +5 -2
  11. package/src/__tests__/app-dir-path-guard.test.ts +1 -3
  12. package/src/__tests__/computer-use-skill-proxy-bridge.test.ts +5 -2
  13. package/src/__tests__/computer-use-tools.test.ts +17 -5
  14. package/src/__tests__/config-schema-cmd.test.ts +2 -2
  15. package/src/__tests__/config-schema.test.ts +28 -29
  16. package/src/__tests__/config-watcher.test.ts +23 -8
  17. package/src/__tests__/connection-model-compat.test.ts +2 -2
  18. package/src/__tests__/conversation-key-store-disk-view.test.ts +1 -1
  19. package/src/__tests__/conversation-pairing.test.ts +26 -0
  20. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +4 -10
  21. package/src/__tests__/conversation-queue.test.ts +48 -207
  22. package/src/__tests__/conversation-retry-discard.test.ts +54 -1
  23. package/src/__tests__/conversation-skill-tools.test.ts +27 -0
  24. package/src/__tests__/conversation-surfaces-app-control.test.ts +8 -3
  25. package/src/__tests__/conversation-surfaces-task-progress.test.ts +67 -0
  26. package/src/__tests__/conversation-title-service.test.ts +14 -10
  27. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +3 -5
  28. package/src/__tests__/conversation-tool-setup-attribution.test.ts +51 -0
  29. package/src/__tests__/credential-record-retire.test.ts +171 -1
  30. package/src/__tests__/credential-routes.test.ts +212 -3
  31. package/src/__tests__/credential-security-invariants.test.ts +4 -2
  32. package/src/__tests__/cu-unified-flow.test.ts +8 -2
  33. package/src/__tests__/document-sync-tags.test.ts +25 -0
  34. package/src/__tests__/drain-kick-guard.test.ts +0 -2
  35. package/src/__tests__/drain-requeue-on-contention.test.ts +0 -6
  36. package/src/__tests__/evict-conversations-for-reload.test.ts +1 -1
  37. package/src/__tests__/git-porcelain.test.ts +47 -0
  38. package/src/__tests__/helpers/set-workspace-mcp.ts +11 -0
  39. package/src/__tests__/host-bash-proxy.test.ts +26 -45
  40. package/src/__tests__/host-browser-proxy.test.ts +6 -17
  41. package/src/__tests__/host-file-proxy-targeted.test.ts +23 -0
  42. package/src/__tests__/host-file-proxy.test.ts +11 -23
  43. package/src/__tests__/host-transfer-proxy-targeted.test.ts +30 -9
  44. package/src/__tests__/llm-catalog-parity.test.ts +25 -0
  45. package/src/__tests__/mcp-auth-routes.test.ts +6 -9
  46. package/src/__tests__/mcp-config-secret-boundary.test.ts +78 -136
  47. package/src/__tests__/mcp-health-check.test.ts +8 -11
  48. package/src/__tests__/mcp-list-plugin-servers.test.ts +8 -10
  49. package/src/__tests__/message-deleted-hook-dispatch.test.ts +119 -0
  50. package/src/__tests__/messages-after-tiebreaker.test.ts +91 -0
  51. package/src/__tests__/messages-read-boundary-guard.test.ts +1 -1
  52. package/src/__tests__/messaging-send-tool.test.ts +355 -548
  53. package/src/__tests__/model-intents.test.ts +3 -0
  54. package/src/__tests__/notification-vellum-adapter.test.ts +85 -2
  55. package/src/__tests__/outlook-messaging-provider.test.ts +6 -6
  56. package/src/__tests__/platform-callback-registration.test.ts +8 -16
  57. package/src/__tests__/platform-client-verify-credential.test.ts +2 -0
  58. package/src/__tests__/plugin-import-boundary-guard.test.ts +13 -0
  59. package/src/__tests__/resolve-app.test.ts +135 -0
  60. package/src/__tests__/script-proxy-certs.test.ts +35 -0
  61. package/src/__tests__/service-account-token.test.ts +247 -0
  62. package/src/__tests__/skill-feature-flags-integration.test.ts +1 -1
  63. package/src/__tests__/skill-feature-flags.test.ts +2 -2
  64. package/src/__tests__/source-fingerprint.test.ts +184 -0
  65. package/src/__tests__/subagent-tool-gate-mode.test.ts +19 -3
  66. package/src/__tests__/subagent-tools.test.ts +3 -2
  67. package/src/__tests__/telephony-tts-guard.test.ts +38 -3
  68. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +4 -0
  69. package/src/__tests__/watch-retro-tool-availability.test.ts +8 -17
  70. package/src/__tests__/workspace-git-service.test.ts +26 -0
  71. package/src/__tests__/workspace-migration-154-repair-retired-codex-gpt-5-4-model-ids.test.ts +336 -0
  72. package/src/acp/__tests__/agent-process.test.ts +258 -4
  73. package/src/acp/__tests__/auth-required.test.ts +96 -0
  74. package/src/acp/__tests__/client-handler.test.ts +90 -2
  75. package/src/acp/__tests__/helpers/acp-model-option.ts +51 -0
  76. package/src/acp/__tests__/session-manager-persistence.test.ts +5 -1
  77. package/src/acp/__tests__/session-manager-resume.test.ts +270 -6
  78. package/src/acp/__tests__/session-manager-usage.test.ts +4 -2
  79. package/src/acp/__tests__/session-manager.test.ts +895 -4
  80. package/src/acp/agent-process.test.ts +4 -1
  81. package/src/acp/agent-process.ts +94 -9
  82. package/src/acp/auth-required.ts +50 -1
  83. package/src/acp/auto-install.test.ts +9 -7
  84. package/src/acp/client-handler.ts +16 -3
  85. package/src/acp/model-config.test.ts +166 -0
  86. package/src/acp/model-config.ts +138 -0
  87. package/src/acp/resolve-agent.test.ts +220 -6
  88. package/src/acp/resolve-agent.ts +88 -23
  89. package/src/acp/session-manager.test.ts +196 -3
  90. package/src/acp/session-manager.ts +441 -18
  91. package/src/acp/types.ts +50 -5
  92. package/src/api/events/acp-session-model-update.test.ts +88 -0
  93. package/src/api/events/acp-session-model-update.ts +46 -0
  94. package/src/api/events/app-files-changed.ts +4 -3
  95. package/src/api/events/notification-intent.ts +2 -0
  96. package/src/api/index.ts +6 -0
  97. package/src/apps/app-store.ts +1 -1
  98. package/src/apps/resolve-app.ts +81 -0
  99. package/src/apps/source-fingerprint.ts +266 -0
  100. package/src/bundler/app-compiler.ts +15 -0
  101. package/src/calls/__tests__/barge-in-guard.test.ts +81 -0
  102. package/src/{__tests__ → calls/__tests__}/call-controller.test.ts +503 -82
  103. package/src/{__tests__ → calls/__tests__}/media-stream-audio-transcode.test.ts +1 -1
  104. package/src/{__tests__ → calls/__tests__}/media-stream-output.test.ts +37 -8
  105. package/src/{__tests__ → calls/__tests__}/media-stream-parser.test.ts +1 -1
  106. package/src/{__tests__ → calls/__tests__}/media-stream-server-integration.test.ts +150 -57
  107. package/src/{__tests__ → calls/__tests__}/media-stream-stt-session.test.ts +5 -5
  108. package/src/{__tests__ → calls/__tests__}/media-turn-detector.test.ts +1 -1
  109. package/src/{live-voice → calls}/__tests__/progress-narration.test.ts +4 -4
  110. package/src/{live-voice → calls}/__tests__/progress-phrases.test.ts +1 -1
  111. package/src/calls/__tests__/voice-control-protocol.test.ts +58 -25
  112. package/src/calls/__tests__/voice-leg-coordinator.test.ts +229 -0
  113. package/src/calls/__tests__/voice-progress-cadence.test.ts +274 -0
  114. package/src/{__tests__/voice-session-bridge.test.ts → calls/__tests__/voice-session-bridge-turn-flow.test.ts} +20 -12
  115. package/src/calls/__tests__/voice-session-bridge.test.ts +37 -1
  116. package/src/calls/__tests__/voice-triage-escalate.test.ts +105 -0
  117. package/src/calls/barge-in-guard.ts +118 -0
  118. package/src/calls/call-constants.ts +24 -4
  119. package/src/calls/call-controller.ts +499 -199
  120. package/src/calls/call-transport.ts +16 -0
  121. package/src/calls/media-stream-output.ts +32 -0
  122. package/src/calls/media-stream-server.ts +82 -30
  123. package/src/calls/media-stream-stt-session.ts +15 -0
  124. package/src/{live-voice → calls}/progress-narration.ts +2 -2
  125. package/src/calls/resolve-call-tts-provider.ts +33 -2
  126. package/src/calls/tts-call-strategy.ts +9 -5
  127. package/src/calls/types.ts +2 -0
  128. package/src/calls/voice-control-protocol.ts +53 -21
  129. package/src/calls/voice-leg-coordinator.ts +189 -0
  130. package/src/calls/voice-progress-cadence.ts +423 -0
  131. package/src/calls/voice-session-bridge.ts +59 -46
  132. package/src/calls/voice-triage-escalate.ts +175 -56
  133. package/src/cli/commands/__tests__/apps.test.ts +245 -0
  134. package/src/cli/commands/__tests__/bash.test.ts +53 -0
  135. package/src/cli/commands/__tests__/roadmap.help.test.ts +19 -0
  136. package/src/cli/commands/apps.help.ts +67 -13
  137. package/src/cli/commands/apps.ts +183 -0
  138. package/src/cli/commands/bash.help.ts +8 -13
  139. package/src/cli/commands/bash.ts +7 -25
  140. package/src/cli/commands/channels/__tests__/send.test.ts +218 -0
  141. package/src/cli/commands/channels/index.help.ts +70 -2
  142. package/src/cli/commands/channels/index.ts +16 -3
  143. package/src/cli/commands/channels/send.ts +128 -0
  144. package/src/cli/commands/credentials.help.ts +6 -2
  145. package/src/cli/commands/credentials.ts +2 -2
  146. package/src/cli/commands/mcp.help.ts +8 -8
  147. package/src/cli/commands/mcp.ts +1 -1
  148. package/src/cli/commands/roadmap.help.ts +5 -1
  149. package/src/cli/commands/roadmap.ts +1 -1
  150. package/src/cli/lib/bundled-marketplace.json +103 -90
  151. package/src/config/__tests__/default-profile-catalog.test.ts +22 -0
  152. package/src/config/__tests__/memory-retrospective-schema.test.ts +15 -1
  153. package/src/config/__tests__/platform-identity.test.ts +255 -0
  154. package/src/config/__tests__/platform-rehydration.test.ts +99 -9
  155. package/src/config/acp-agent-write.ts +43 -0
  156. package/src/config/acp-defaults.test.ts +13 -3
  157. package/src/config/acp-defaults.ts +23 -6
  158. package/src/config/acp-schema.test.ts +81 -0
  159. package/src/config/acp-schema.ts +30 -2
  160. package/src/config/bundled-skills/acp/SKILL.md +22 -14
  161. package/src/config/bundled-skills/acp/TOOLS.json +5 -1
  162. package/src/config/bundled-skills/app-builder/SKILL.md +12 -9
  163. package/src/config/bundled-skills/app-builder/references/CUSTOM_ROUTES.md +1 -1
  164. package/src/config/bundled-skills/messaging/SKILL.md +3 -3
  165. package/src/config/bundled-skills/messaging/TOOLS.json +3 -3
  166. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +67 -139
  167. package/src/config/bundled-skills/phone-calls/references/TRANSCRIPTS.md +1 -1
  168. package/src/config/bundled-skills/schedule/SKILL.md +3 -4
  169. package/src/config/bundled-skills/screen-annotation/SKILL.md +8 -1
  170. package/src/config/bundled-skills/screen-annotation/tools/screen-annotation.test.ts +4 -1
  171. package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
  172. package/src/config/default-profile-catalog.ts +1 -1
  173. package/src/config/env.ts +2 -2
  174. package/src/config/feature-flag-registry.json +24 -12
  175. package/src/config/loader.ts +1 -0
  176. package/src/config/memory-v3-gate.test.ts +31 -0
  177. package/src/config/memory-v3-gate.ts +20 -16
  178. package/src/config/platform-identity.ts +201 -0
  179. package/src/config/platform-rehydration.ts +11 -27
  180. package/src/config/schema.ts +2 -2
  181. package/src/config/schemas/__tests__/live-voice.test.ts +8 -158
  182. package/src/config/schemas/__tests__/voice.test.ts +148 -0
  183. package/src/config/schemas/calls.ts +1 -28
  184. package/src/config/schemas/live-voice.ts +1 -173
  185. package/src/config/schemas/mcp.ts +1 -1
  186. package/src/config/schemas/memory-retrospective.ts +10 -0
  187. package/src/config/schemas/services.ts +8 -0
  188. package/src/config/schemas/skills.ts +0 -14
  189. package/src/config/schemas/voice.ts +150 -0
  190. package/src/credential-execution/process-manager.test.ts +18 -19
  191. package/src/daemon/__tests__/conversation-tool-setup.test.ts +66 -121
  192. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +4 -1
  193. package/src/daemon/__tests__/skill-memory-refresh-mtime.test.ts +133 -0
  194. package/src/daemon/app-change-notify.ts +45 -0
  195. package/src/daemon/config-watcher.ts +13 -194
  196. package/src/daemon/conversation-process.ts +35 -64
  197. package/src/daemon/conversation-skill-tools.ts +13 -31
  198. package/src/daemon/conversation-store.ts +1 -1
  199. package/src/daemon/conversation-surfaces.ts +10 -8
  200. package/src/daemon/conversation-tool-setup.ts +37 -43
  201. package/src/daemon/conversation.ts +0 -3
  202. package/src/daemon/host-bash-proxy.ts +8 -12
  203. package/src/daemon/host-browser-proxy.ts +15 -1
  204. package/src/daemon/host-file-proxy.ts +8 -10
  205. package/src/daemon/host-transfer-proxy.ts +14 -18
  206. package/src/daemon/lifecycle.ts +0 -5
  207. package/src/daemon/mcp-reload-service.ts +5 -5
  208. package/src/daemon/message-types/sync.ts +1 -0
  209. package/src/daemon/orphan-reaper.ts +1 -1
  210. package/src/daemon/providers-setup.ts +1 -1
  211. package/src/daemon/shutdown-handlers.ts +1 -9
  212. package/src/daemon/skill-memory-refresh.ts +64 -0
  213. package/src/daemon/tool-side-effects.ts +7 -18
  214. package/src/desktop/__tests__/fake-desktop.ts +119 -0
  215. package/src/desktop/desktop-chrome-policy.test.ts +71 -0
  216. package/src/desktop/desktop-chrome-policy.ts +42 -0
  217. package/src/desktop/desktop-dependencies.test.ts +102 -0
  218. package/src/desktop/desktop-dependencies.ts +306 -0
  219. package/src/desktop/desktop-feature.test.ts +18 -0
  220. package/src/desktop/desktop-feature.ts +19 -0
  221. package/src/desktop/desktop-panel-config.test.ts +135 -0
  222. package/src/desktop/desktop-panel-config.ts +149 -0
  223. package/src/desktop/desktop-session-manager.test.ts +627 -0
  224. package/src/desktop/desktop-session-manager.ts +814 -0
  225. package/src/desktop/desktop-stream-bridge.test.ts +395 -0
  226. package/src/desktop/desktop-stream-bridge.ts +214 -0
  227. package/src/desktop/desktop-wallpaper.test.ts +191 -0
  228. package/src/desktop/desktop-wallpaper.ts +113 -0
  229. package/src/desktop/desktop-window-theme.ts +170 -0
  230. package/src/home/__tests__/feed-writer.test.ts +22 -0
  231. package/src/home/feed-writer.ts +11 -2
  232. package/src/hooks/types.ts +33 -0
  233. package/src/i18n/AGENTS.md +22 -0
  234. package/src/i18n/__tests__/i18n.test.ts +108 -0
  235. package/src/i18n/index.ts +16 -0
  236. package/src/i18n/locales.ts +93 -0
  237. package/src/i18n/messages.ts +61 -0
  238. package/src/i18n/title.ts +25 -0
  239. package/src/inbound/platform-callback-registration.ts +4 -5
  240. package/src/live-voice/__tests__/live-voice-metrics.test.ts +0 -3
  241. package/src/live-voice/__tests__/live-voice-progress.test.ts +15 -15
  242. package/src/live-voice/__tests__/live-voice-vad.test.ts +2 -2
  243. package/src/live-voice/__tests__/runtime-websocket-shell.test.ts +13 -69
  244. package/src/live-voice/live-voice-session.ts +213 -693
  245. package/src/live-voice/protocol.ts +0 -2
  246. package/src/mcp/__tests__/effective-config.test.ts +2 -2
  247. package/src/mcp/__tests__/workspace-mcp-config.test.ts +135 -0
  248. package/src/mcp/effective-config.ts +3 -3
  249. package/src/mcp/mcp-header-store.ts +29 -45
  250. package/src/mcp/spec-schema.ts +111 -0
  251. package/src/mcp/startup.ts +8 -4
  252. package/src/mcp/workspace-mcp-config.ts +188 -0
  253. package/src/messaging/provider.ts +10 -3
  254. package/src/messaging/providers/__tests__/proactive-address.test.ts +95 -0
  255. package/src/messaging/providers/callback-routing.ts +18 -0
  256. package/src/messaging/providers/channel-transport.ts +56 -0
  257. package/src/messaging/providers/discord/transport.ts +17 -0
  258. package/src/messaging/providers/slack/__tests__/adapter-token-routing.test.ts +6 -17
  259. package/src/messaging/providers/slack/adapter.ts +7 -44
  260. package/src/messaging/providers/slack/transport.ts +16 -0
  261. package/src/messaging/providers/telegram-bot/adapter.ts +1 -48
  262. package/src/messaging/providers/telegram-bot/client.ts +0 -26
  263. package/src/messaging/providers/telegram-bot/transport.ts +20 -0
  264. package/src/messaging/providers/whatsapp/adapter.ts +1 -48
  265. package/src/messaging/providers/whatsapp/transport.ts +18 -0
  266. package/src/monitoring/__tests__/workspace-git-heartbeat.test.ts +8 -0
  267. package/src/notifications/__tests__/assistant-reply-producer.test.ts +55 -0
  268. package/src/notifications/__tests__/guardian-feed-projection.test.ts +158 -2
  269. package/src/notifications/__tests__/home-feed-side-effect.test.ts +153 -3
  270. package/src/notifications/__tests__/schedule-result-producer.test.ts +99 -3
  271. package/src/notifications/adapters/macos.ts +3 -0
  272. package/src/notifications/assistant-reply-producer.ts +8 -3
  273. package/src/notifications/conversation-pairing.ts +36 -16
  274. package/src/notifications/delivered-post-record.ts +11 -1
  275. package/src/notifications/guardian-feed-projection.ts +125 -5
  276. package/src/notifications/home-feed-side-effect.ts +55 -7
  277. package/src/notifications/schedule-result-producer.ts +66 -22
  278. package/src/oauth/__tests__/seed-providers-managed.test.ts +54 -0
  279. package/src/oauth/byo-connection.test.ts +45 -0
  280. package/src/oauth/byo-connection.ts +36 -6
  281. package/src/oauth/connection-resolver.test.ts +73 -0
  282. package/src/oauth/connection-resolver.ts +95 -2
  283. package/src/oauth/seed-providers.ts +165 -0
  284. package/src/outbound-proxy/certs.ts +3 -5
  285. package/src/permissions/checker.test.ts +11 -6
  286. package/src/persistence/compaction-log-store-clickhouse.ts +3 -3
  287. package/src/persistence/conversation-crud.ts +71 -63
  288. package/src/persistence/conversation-lineage.ts +12 -4
  289. package/src/persistence/conversation-title-placeholders.ts +23 -0
  290. package/src/persistence/conversation-title-service.ts +9 -26
  291. package/src/persistence/llm-request-log-sink-clickhouse.ts +3 -3
  292. package/src/persistence/llm-request-log-source-clickhouse.ts +3 -3
  293. package/src/persistence/message-cursor.ts +78 -0
  294. package/src/persistence/message-reads.ts +30 -0
  295. package/src/persistence/migrations/157-invite-contact-id.ts +18 -6
  296. package/src/persistence/migrations/__tests__/157-invite-contact-id.test.ts +149 -0
  297. package/src/persistence/schema/memory-core.ts +5 -0
  298. package/src/platform/client.test.ts +4 -2
  299. package/src/platform/client.ts +2 -10
  300. package/src/plugin-api/constants.ts +2 -0
  301. package/src/plugin-api/index.ts +1 -0
  302. package/src/plugin-api/types.ts +2 -0
  303. package/src/plugins/defaults/index.ts +2 -0
  304. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +46 -0
  305. package/src/plugins/defaults/memory/__tests__/memory-retrospective-cursor-backfill.test.ts +118 -0
  306. package/src/plugins/defaults/memory/__tests__/memory-retrospective-cursor-preserve.test.ts +83 -0
  307. package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +9 -5
  308. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +20 -10
  309. package/src/plugins/defaults/memory/__tests__/memory-retrospective-state.test.ts +219 -0
  310. package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +31 -0
  311. package/src/plugins/defaults/memory/__tests__/memory-retrospective-trigger-check.test.ts +63 -0
  312. package/src/plugins/defaults/memory/__tests__/shutdown-hook.test.ts +4 -0
  313. package/src/plugins/defaults/memory/graph/capability-seed.ts +4 -6
  314. package/src/plugins/defaults/memory/hooks/init.ts +10 -0
  315. package/src/plugins/defaults/memory/hooks/message-deleted.ts +21 -0
  316. package/src/plugins/defaults/memory/hooks/shutdown.ts +2 -0
  317. package/src/plugins/defaults/memory/jobs-worker.ts +13 -0
  318. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +34 -60
  319. package/src/plugins/defaults/memory/memory-retrospective-cursor-backfill.ts +126 -0
  320. package/src/plugins/defaults/memory/memory-retrospective-cursor-preserve.ts +79 -0
  321. package/src/plugins/defaults/memory/memory-retrospective-cursor.ts +28 -0
  322. package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +3 -5
  323. package/src/plugins/defaults/memory/memory-retrospective-job.ts +12 -3
  324. package/src/plugins/defaults/memory/memory-retrospective-state.ts +312 -71
  325. package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +2 -1
  326. package/src/plugins/defaults/memory/memory-retrospective-trigger-check.ts +5 -3
  327. package/src/plugins/defaults/memory/skill-md-mtime-poll.ts +9 -0
  328. package/src/plugins/defaults/memory/startup.ts +6 -1
  329. package/src/plugins/defaults/memory/substrate/__tests__/skill-content.test.ts +7 -9
  330. package/src/plugins/defaults/memory/substrate/skill-content.ts +2 -2
  331. package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +89 -0
  332. package/src/plugins/defaults/memory/worker.ts +6 -0
  333. package/src/plugins/mcp-servers.ts +15 -75
  334. package/src/providers/__tests__/connection-model-compat.test.ts +3 -3
  335. package/src/providers/gemini/client.ts +9 -23
  336. package/src/providers/gemini-thought-signature.test.ts +63 -0
  337. package/src/providers/gemini-thought-signature.ts +44 -0
  338. package/src/providers/inference/auth.ts +2 -3
  339. package/src/providers/inference/connection-availability.ts +27 -8
  340. package/src/providers/inference/resolve-auth.ts +22 -4
  341. package/src/providers/inference/service-account-token.ts +248 -0
  342. package/src/providers/model-catalog.ts +34 -0
  343. package/src/providers/model-intents.ts +10 -5
  344. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +146 -18
  345. package/src/providers/openai/__tests__/chat-completions-provider-thought-signature.test.ts +613 -0
  346. package/src/providers/openai/__tests__/google-thought-signature.test.ts +235 -0
  347. package/src/providers/openai/__tests__/tool-choice-mapping.test.ts +83 -1
  348. package/src/providers/openai/chat-completions-provider.ts +192 -78
  349. package/src/providers/openai/codex-models.ts +0 -4
  350. package/src/providers/openai/google-thought-signature.ts +320 -0
  351. package/src/runtime/AGENTS.md +2 -0
  352. package/src/runtime/__tests__/channel-send.test.ts +443 -0
  353. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +89 -0
  354. package/src/runtime/__tests__/live-voice-upgrade.test.ts +58 -0
  355. package/src/runtime/__tests__/runtime-ws-test-utils.ts +72 -0
  356. package/src/runtime/__tests__/watch-stream-upgrade.test.ts +59 -0
  357. package/src/runtime/auth/same-actor.ts +38 -3
  358. package/src/runtime/channel-send.ts +316 -0
  359. package/src/runtime/http-server.ts +160 -151
  360. package/src/runtime/routes/__tests__/acp-routes.test.ts +159 -4
  361. package/src/runtime/routes/__tests__/apps-refresh-route.test.ts +205 -0
  362. package/src/runtime/routes/__tests__/channel-send-routes.test.ts +127 -0
  363. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +108 -1
  364. package/src/runtime/routes/__tests__/default-provider-routes.test.ts +2 -2
  365. package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +107 -0
  366. package/src/runtime/routes/__tests__/mcp-add-default-risk.test.ts +16 -12
  367. package/src/runtime/routes/acp-routes.test.ts +15 -0
  368. package/src/runtime/routes/acp-routes.ts +72 -9
  369. package/src/runtime/routes/app-management-routes.ts +73 -0
  370. package/src/runtime/routes/app-routes.ts +1 -1
  371. package/src/runtime/routes/channel-send-routes.ts +85 -0
  372. package/src/runtime/routes/conversation-list-routes.ts +14 -3
  373. package/src/runtime/routes/conversation-query-routes.ts +40 -0
  374. package/src/runtime/routes/credential-routes.ts +19 -10
  375. package/src/runtime/routes/desktop-setup-routes.test.ts +74 -0
  376. package/src/runtime/routes/desktop-setup-routes.ts +35 -0
  377. package/src/runtime/routes/diagnostics-routes.ts +55 -3
  378. package/src/runtime/routes/index.ts +4 -2
  379. package/src/runtime/routes/log-export-routes.ts +2 -0
  380. package/src/runtime/routes/mcp-auth-routes.ts +39 -47
  381. package/src/runtime/routes/migration-routes.ts +6 -20
  382. package/src/runtime/routes/platform-routes.ts +18 -15
  383. package/src/runtime/services/__tests__/conversation-serializer.test.ts +28 -0
  384. package/src/runtime/services/conversation-serializer.ts +16 -3
  385. package/src/runtime/sync/resource-sync-events.ts +2 -1
  386. package/src/schedule/worker.ts +1 -1
  387. package/src/security/__tests__/ces-http-record-backend.test.ts +137 -0
  388. package/src/security/ces-http-record-backend.ts +120 -0
  389. package/src/security/ces-rpc-record-backend.ts +7 -0
  390. package/src/skills/__tests__/workspace-skill-md-signature.test.ts +60 -0
  391. package/src/skills/workspace-skill-md-signature.ts +50 -0
  392. package/src/subagent/consult-context.ts +12 -25
  393. package/src/tools/acp/list-agents.test.ts +3 -1
  394. package/src/tools/acp/spawn.test.ts +146 -11
  395. package/src/tools/acp/spawn.ts +21 -3
  396. package/src/tools/acp/status.test.ts +94 -0
  397. package/src/tools/acp/status.ts +40 -3
  398. package/src/tools/app-control/skill-proxy-bridge.ts +2 -1
  399. package/src/tools/capability-offer.test.ts +22 -2
  400. package/src/tools/capability-offer.ts +16 -1
  401. package/src/tools/computer-use/definitions.ts +4 -2
  402. package/src/tools/computer-use/skill-proxy-bridge.ts +2 -1
  403. package/src/tools/credentials/metadata-store.ts +100 -0
  404. package/src/tools/host-filesystem/edit.test.ts +2 -1
  405. package/src/tools/host-filesystem/edit.ts +2 -2
  406. package/src/tools/host-filesystem/read.test.ts +2 -1
  407. package/src/tools/host-filesystem/read.ts +2 -2
  408. package/src/tools/host-filesystem/transfer.test.ts +2 -1
  409. package/src/tools/host-filesystem/transfer.ts +2 -2
  410. package/src/tools/host-filesystem/write.test.ts +2 -1
  411. package/src/tools/host-filesystem/write.ts +2 -2
  412. package/src/tools/host-terminal/host-shell.test.ts +16 -0
  413. package/src/tools/host-terminal/host-shell.ts +4 -20
  414. package/src/tools/policy-context.ts +6 -7
  415. package/src/tools/subagent/spawn.ts +5 -8
  416. package/src/tools/terminal/__tests__/safe-env.test.ts +6 -6
  417. package/src/tools/terminal/__tests__/sanitized-bash.test.ts +57 -0
  418. package/src/tools/terminal/safe-env.ts +23 -3
  419. package/src/tools/terminal/sanitized-bash.ts +105 -0
  420. package/src/tools/watch/watch-retro-report.ts +0 -8
  421. package/src/util/__tests__/language-subtag.test.ts +23 -1
  422. package/src/util/ca-bundle.ts +13 -0
  423. package/src/util/language-subtag.ts +16 -0
  424. package/src/util/platform.ts +8 -0
  425. package/src/workspace/git-porcelain.ts +66 -0
  426. package/src/workspace/git-service.ts +5 -54
  427. package/src/workspace/migrations/154-repair-retired-codex-gpt-5-4-model-ids.ts +258 -0
  428. package/src/workspace/migrations/155-move-front-model-config-to-voice.ts +78 -0
  429. package/src/workspace/migrations/156-extract-workspace-mcp-json.ts +214 -0
  430. package/src/workspace/migrations/__tests__/155-move-front-model-config-to-voice.test.ts +102 -0
  431. package/src/workspace/migrations/__tests__/156-extract-workspace-mcp-json.test.ts +199 -0
  432. package/src/workspace/migrations/registry.ts +6 -0
  433. package/src/__tests__/app-source-watcher.test.ts +0 -218
  434. package/src/__tests__/config-watcher-skill-reseed.test.ts +0 -359
  435. package/src/__tests__/speaker-identification.test.ts +0 -56
  436. package/src/calls/speaker-identification.ts +0 -233
  437. package/src/daemon/app-source-watcher.ts +0 -218
  438. package/src/messaging/providers/whatsapp/client.ts +0 -29
  439. package/src/runtime/routes/debug-bash-routes.ts +0 -173
  440. package/src/util/fs-watcher-error.ts +0 -36
  441. /package/src/{live-voice → calls}/progress-phrases.ts +0 -0
@@ -0,0 +1,814 @@
1
+ /**
2
+ * On-demand assistant desktop: Xtigervnc (VNC on loopback only, so the
3
+ * authenticated `/v1/desktop/stream` upgrade is the sole way in), openbox, the
4
+ * xcompmgr compositor, the Plank dock, the tigervncconfig clipboard bridge and
5
+ * Google Chrome, started by the first viewer and lingering after the
6
+ * last one leaves so a reconnect is instant.
7
+ */
8
+
9
+ import { mkdirSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ import { readAvatarState } from "../avatar/avatar-manifest.js";
13
+ import { resolveNotificationAccentHex } from "../avatar/notification-avatar.js";
14
+ import { getIsContainerized } from "../config/env-registry.js";
15
+ import { terminateProcessTree } from "../util/host-process.js";
16
+ import { getLogger } from "../util/logger.js";
17
+ import { getDataDir } from "../util/platform.js";
18
+ import { sleep } from "../util/retry.js";
19
+ import { writeDesktopChromePolicy } from "./desktop-chrome-policy.js";
20
+ import {
21
+ desktopChromePath,
22
+ resolveDesktopBinaries,
23
+ } from "./desktop-dependencies.js";
24
+ import { writeDesktopPanelConfig } from "./desktop-panel-config.js";
25
+ import { renderCurrentDesktopWallpaper } from "./desktop-wallpaper.js";
26
+ import { writeDesktopWindowTheme } from "./desktop-window-theme.js";
27
+
28
+ const log = getLogger("desktop-session");
29
+
30
+ const DESKTOP_DISPLAY = ":99";
31
+ export const DESKTOP_VNC_PORT = 5999;
32
+ const DESKTOP_WIDTH = 1440;
33
+ const DESKTOP_HEIGHT = 900;
34
+ const DESKTOP_GEOMETRY = `${DESKTOP_WIDTH}x${DESKTOP_HEIGHT}`;
35
+ const DESKTOP_LINGER_MS = 5 * 60_000;
36
+ const VNC_READY_DEADLINE_MS = 10_000;
37
+ const VNC_PROBE_INTERVAL_MS = 100;
38
+ const KILL_GRACE_MS = 2_000;
39
+ const BROWSER_CRASH_WINDOW_MS = 60_000;
40
+ const BROWSER_CRASH_LIMIT = 3;
41
+
42
+ /**
43
+ * What the desktop children see. Deliberately not `buildSanitizedEnv()`: its
44
+ * kata chroot PATH and LD_LIBRARY_PATH overlay would give this Chrome
45
+ * different libraries than the browser tool's, which launches with the plain
46
+ * process env.
47
+ */
48
+ const DESKTOP_ENV_KEYS = [
49
+ "PATH",
50
+ "HOME",
51
+ "LANG",
52
+ "LC_ALL",
53
+ "XDG_RUNTIME_DIR",
54
+ "TMPDIR",
55
+ ] as const;
56
+
57
+ /**
58
+ * Close codes for `/v1/desktop/stream`. Application-range values cannot
59
+ * collide with velay's own 1013 or be remapped by the gateway's velay bridge.
60
+ */
61
+ export const DESKTOP_CLOSE = {
62
+ goingAway: 1001,
63
+ unavailable: 4008,
64
+ failed: 4011,
65
+ busy: 4013,
66
+ } as const;
67
+
68
+ export type DesktopCloseCode =
69
+ (typeof DESKTOP_CLOSE)[keyof typeof DESKTOP_CLOSE];
70
+
71
+ const SHUTTING_DOWN_LOSS: DesktopLoss = {
72
+ code: DESKTOP_CLOSE.goingAway,
73
+ reason: "The assistant is shutting down",
74
+ };
75
+ export const START_FAILED_LOSS: DesktopLoss = {
76
+ code: DESKTOP_CLOSE.failed,
77
+ reason: "Desktop failed to start",
78
+ };
79
+ const BUSY_LOSS: DesktopLoss = {
80
+ code: DESKTOP_CLOSE.busy,
81
+ reason: "Desktop is in use by another viewer",
82
+ };
83
+
84
+ export type DesktopChildRole =
85
+ | "x-server"
86
+ | "window-manager"
87
+ | "compositor"
88
+ | "panel"
89
+ | "wallpaper"
90
+ | "clipboard"
91
+ | "browser";
92
+
93
+ /** Optional desktop decoration processes. */
94
+ const COSMETIC_ROLES: ReadonlySet<DesktopChildRole> = new Set([
95
+ "compositor",
96
+ "panel",
97
+ "wallpaper",
98
+ ]);
99
+
100
+ /** The slice of `Bun.Subprocess` the manager drives, so tests can fake it. */
101
+ export interface DesktopChild {
102
+ readonly pid: number;
103
+ readonly exited: Promise<number>;
104
+ kill(signal?: NodeJS.Signals | number): void;
105
+ }
106
+
107
+ export interface DesktopSpawnRequest {
108
+ readonly cmd: readonly string[];
109
+ readonly env: Record<string, string>;
110
+ }
111
+
112
+ /** Why a viewer cannot have the desktop, as the close code it relays. */
113
+ export interface DesktopLoss {
114
+ readonly code: DesktopCloseCode;
115
+ readonly reason: string;
116
+ }
117
+
118
+ /** Callback surface of the socket holding the viewer slot. */
119
+ export interface DesktopViewer {
120
+ onDesktopLost(loss: DesktopLoss): void;
121
+ }
122
+
123
+ /** A start that failed, carrying the loss for a viewer `onDesktopLost` missed. */
124
+ export class DesktopStartError extends Error {
125
+ constructor(
126
+ readonly loss: DesktopLoss,
127
+ cause: unknown,
128
+ ) {
129
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
130
+ this.name = "DesktopStartError";
131
+ }
132
+ }
133
+
134
+ export interface DesktopTcpSocket {
135
+ /** Returns the bytes accepted; fewer than offered means backpressure. */
136
+ write(data: Uint8Array): number;
137
+ end(): void;
138
+ }
139
+
140
+ export interface DesktopTcpHandlers {
141
+ onData(data: Uint8Array): void;
142
+ onDrain(): void;
143
+ onClose(): void;
144
+ onError(err: Error): void;
145
+ }
146
+
147
+ type DesktopSignal = "SIGTERM" | "SIGKILL";
148
+
149
+ type ViewerSlotResult =
150
+ | { readonly ok: true }
151
+ | { readonly ok: false; readonly loss: DesktopLoss };
152
+
153
+ interface DesktopSessionManagerOptions {
154
+ readonly spawn?: (
155
+ role: DesktopChildRole,
156
+ request: DesktopSpawnRequest,
157
+ ) => DesktopChild;
158
+ /** PATH lookup for the desktop binaries; `null` when one is missing. */
159
+ readonly which?: (binary: string) => string | null;
160
+ /** Whether the VNC server accepts connections on `port`. */
161
+ readonly probeVncPort?: (port: number) => Promise<boolean>;
162
+ /** Path of the installed Google Chrome binary. */
163
+ readonly resolveChromePath?: () => Promise<string>;
164
+ readonly killProcessGroup?: (
165
+ child: DesktopChild,
166
+ signal: DesktopSignal,
167
+ ) => void;
168
+ readonly lingerMs?: number;
169
+ readonly readyDeadlineMs?: number;
170
+ readonly killGraceMs?: number;
171
+ readonly profileDir?: string;
172
+ /** Directory for dock config, launchers, icons and wallpaper. */
173
+ readonly panelConfigDir?: string;
174
+ readonly renderWallpaper?: (
175
+ width: number,
176
+ height: number,
177
+ ) => Promise<Buffer | null>;
178
+ /** Where the children's allowlisted env is read from. */
179
+ readonly sourceEnv?: NodeJS.ProcessEnv;
180
+ }
181
+
182
+ type DesktopBinaries = ReturnType<typeof resolveDesktopBinaries>;
183
+
184
+ export class DesktopSessionManager {
185
+ private readonly children = new Map<DesktopChildRole, DesktopChild>();
186
+ private starting: Promise<void> | null = null;
187
+ /** The kill of the last tree, which a new start waits out. */
188
+ private tearingDown: Promise<void> | null = null;
189
+ private running = false;
190
+ /** Bumped on every teardown so an in-flight start notices it lost its tree. */
191
+ private generation = 0;
192
+ private viewer: DesktopViewer | null = null;
193
+ private lingerTimer: ReturnType<typeof setTimeout> | null = null;
194
+ private ingressClosed = false;
195
+ private browserExitsAt: number[] = [];
196
+ /** Resolved for the current tree, and read again when the dock comes up. */
197
+ private binaries: DesktopBinaries | null = null;
198
+ /** Whether this tree has already had its one dock start attempted. */
199
+ private panelStarted = false;
200
+ private wallpaperStarting: {
201
+ generation: number;
202
+ refreshQueued: boolean;
203
+ } | null = null;
204
+
205
+ private readonly spawn: NonNullable<DesktopSessionManagerOptions["spawn"]>;
206
+ private readonly which: NonNullable<DesktopSessionManagerOptions["which"]>;
207
+ private readonly probeVncPort: NonNullable<
208
+ DesktopSessionManagerOptions["probeVncPort"]
209
+ >;
210
+ private readonly resolveChromePath: NonNullable<
211
+ DesktopSessionManagerOptions["resolveChromePath"]
212
+ >;
213
+ private readonly killProcessGroup: NonNullable<
214
+ DesktopSessionManagerOptions["killProcessGroup"]
215
+ >;
216
+ private readonly lingerMs: number;
217
+ private readonly readyDeadlineMs: number;
218
+ private readonly killGraceMs: number;
219
+ private readonly profileDir: string;
220
+ private readonly panelConfigDir: string;
221
+ private readonly renderWallpaper: NonNullable<
222
+ DesktopSessionManagerOptions["renderWallpaper"]
223
+ >;
224
+ private readonly sourceEnv: NodeJS.ProcessEnv;
225
+
226
+ constructor(options: DesktopSessionManagerOptions = {}) {
227
+ this.spawn = options.spawn ?? spawnDetached;
228
+ this.which = options.which ?? Bun.which;
229
+ this.probeVncPort = options.probeVncPort ?? probeLoopbackPort;
230
+ this.resolveChromePath =
231
+ options.resolveChromePath ??
232
+ (async () => {
233
+ if (process.platform === "linux" && getIsContainerized()) {
234
+ // Google Chrome reads policies from /etc even when its deb is extracted.
235
+ try {
236
+ writeDesktopChromePolicy("/etc/opt/chrome/policies/managed");
237
+ } catch (err) {
238
+ log.warn({ err }, "Desktop Chrome policy could not be applied");
239
+ }
240
+ }
241
+ return desktopChromePath();
242
+ });
243
+ this.killProcessGroup = options.killProcessGroup ?? killProcessGroup;
244
+ this.lingerMs = options.lingerMs ?? DESKTOP_LINGER_MS;
245
+ this.readyDeadlineMs = options.readyDeadlineMs ?? VNC_READY_DEADLINE_MS;
246
+ this.killGraceMs = options.killGraceMs ?? KILL_GRACE_MS;
247
+ this.profileDir =
248
+ options.profileDir ?? join(getDataDir(), "desktop-profile");
249
+ this.panelConfigDir =
250
+ options.panelConfigDir ?? join(getDataDir(), "desktop-panel");
251
+ this.sourceEnv = options.sourceEnv ?? process.env;
252
+ this.renderWallpaper =
253
+ options.renderWallpaper ?? renderCurrentDesktopWallpaper;
254
+ }
255
+
256
+ // ── Viewer slot ────────────────────────────────────────────────────
257
+
258
+ acquireViewerSlot(viewer: DesktopViewer): ViewerSlotResult {
259
+ if (this.ingressClosed) {
260
+ return { ok: false, loss: SHUTTING_DOWN_LOSS };
261
+ }
262
+ if (this.viewer) {
263
+ return { ok: false, loss: BUSY_LOSS };
264
+ }
265
+ this.viewer = viewer;
266
+ this.clearLinger();
267
+ return { ok: true };
268
+ }
269
+
270
+ /**
271
+ * Give the slot back. Only the holder can release it, so a socket that was
272
+ * turned away as busy never disturbs the viewer that turned it away.
273
+ */
274
+ releaseViewerSlot(viewer: DesktopViewer): void {
275
+ if (this.viewer !== viewer) {
276
+ return;
277
+ }
278
+ this.viewer = null;
279
+ if (this.running || this.starting) {
280
+ this.armLinger();
281
+ }
282
+ }
283
+
284
+ // ── Process tree ───────────────────────────────────────────────────
285
+
286
+ /**
287
+ * Start the desktop if needed. Concurrent callers share one start; it
288
+ * rejects with a `DesktopStartError`.
289
+ */
290
+ ensureDesktopRunning(): Promise<void> {
291
+ if (this.ingressClosed) {
292
+ return Promise.reject(ingressClosedError());
293
+ }
294
+ if (this.running) {
295
+ void this.refreshWallpaper(this.childEnv(), this.generation);
296
+ void this.ensureBrowser(this.childEnv(), this.generation);
297
+ return Promise.resolve();
298
+ }
299
+ this.starting ??= this.startDesktop().finally(() => {
300
+ this.starting = null;
301
+ });
302
+ return this.starting;
303
+ }
304
+
305
+ /**
306
+ * Refuse new viewers and kill the process tree. Resolves once every child
307
+ * has exited or been hard-killed after the grace period. Safe to repeat.
308
+ */
309
+ destroy(): Promise<void> {
310
+ this.ingressClosed = true;
311
+ return this.teardown(SHUTTING_DOWN_LOSS);
312
+ }
313
+
314
+ private async startDesktop(): Promise<void> {
315
+ // The last tree may still be in its kill grace; its X server holds the
316
+ // display and port until it is gone.
317
+ await this.tearingDown;
318
+ if (this.ingressClosed) {
319
+ throw ingressClosedError();
320
+ }
321
+ const generation = this.generation;
322
+ let env: Record<string, string>;
323
+ try {
324
+ this.binaries = resolveDesktopBinaries(this.which);
325
+ env = this.childEnv();
326
+ this.launch("x-server", xServerCommand(this.binaries.xServer), env);
327
+ const ready = await this.waitForVnc(generation);
328
+ if (this.generation !== generation) {
329
+ throw new Error("Desktop was torn down while starting");
330
+ }
331
+ if (!ready) {
332
+ throw new Error(
333
+ `Desktop VNC server not ready on port ${DESKTOP_VNC_PORT} after ${this.readyDeadlineMs}ms`,
334
+ );
335
+ }
336
+ const windowManagerCommand = [this.binaries.windowManager];
337
+ try {
338
+ windowManagerCommand.push(
339
+ "--config-file",
340
+ writeDesktopWindowTheme(
341
+ this.panelConfigDir,
342
+ env.HOME,
343
+ resolveNotificationAccentHex(readAvatarState()),
344
+ ),
345
+ );
346
+ } catch (err) {
347
+ log.warn({ err }, "Desktop window theme could not be applied");
348
+ }
349
+ this.launch("window-manager", windowManagerCommand, env);
350
+ // Before the dock, which only gets the ARGB visual its rounded corners
351
+ // and translucency need if a compositor is already running.
352
+ this.launchCosmetic("compositor", [this.binaries.compositor], env);
353
+ this.launch("clipboard", [this.binaries.clipboard, "-nowin"], env);
354
+ } catch (err) {
355
+ if (this.generation === generation) {
356
+ void this.teardown(START_FAILED_LOSS);
357
+ }
358
+ throw new DesktopStartError(START_FAILED_LOSS, err);
359
+ }
360
+ this.running = true;
361
+ log.info({ display: DESKTOP_DISPLAY }, "Desktop started");
362
+ void this.refreshWallpaper(env, generation);
363
+ void this.ensureBrowser(env, generation);
364
+ }
365
+
366
+ private async refreshWallpaper(
367
+ env: Record<string, string>,
368
+ generation: number,
369
+ ): Promise<void> {
370
+ if (this.wallpaperStarting?.generation === generation) {
371
+ this.wallpaperStarting.refreshQueued = true;
372
+ return;
373
+ }
374
+ const pending = { generation, refreshQueued: false };
375
+ this.wallpaperStarting = pending;
376
+ try {
377
+ const png = await this.renderWallpaper(DESKTOP_WIDTH, DESKTOP_HEIGHT);
378
+ if (
379
+ !png ||
380
+ pending.refreshQueued ||
381
+ this.generation !== generation ||
382
+ !this.binaries
383
+ ) {
384
+ return;
385
+ }
386
+ mkdirSync(this.panelConfigDir, { recursive: true });
387
+ const path = join(this.panelConfigDir, "wallpaper.png");
388
+ writeFileSync(path, png);
389
+ this.launchCosmetic(
390
+ "wallpaper",
391
+ [this.binaries.wallpaper, "--no-fehbg", "--bg-fill", path],
392
+ env,
393
+ );
394
+ } catch (err) {
395
+ log.warn({ err }, "Desktop wallpaper could not be applied");
396
+ } finally {
397
+ if (this.wallpaperStarting === pending) {
398
+ this.wallpaperStarting = null;
399
+ if (
400
+ pending.refreshQueued &&
401
+ this.generation === generation &&
402
+ this.running
403
+ ) {
404
+ void this.refreshWallpaper(env, generation);
405
+ }
406
+ }
407
+ }
408
+ }
409
+
410
+ private async waitForVnc(generation: number): Promise<boolean> {
411
+ const deadline = Date.now() + this.readyDeadlineMs;
412
+ while (this.generation === generation) {
413
+ if (await this.probeVncPort(DESKTOP_VNC_PORT)) {
414
+ return true;
415
+ }
416
+ if (Date.now() >= deadline) {
417
+ return false;
418
+ }
419
+ await sleep(VNC_PROBE_INTERVAL_MS);
420
+ }
421
+ return false;
422
+ }
423
+
424
+ /** Launch Chrome and its dock once the X server is ready. */
425
+ private async ensureBrowser(
426
+ env: Record<string, string>,
427
+ generation: number,
428
+ ): Promise<void> {
429
+ if (this.children.has("browser")) {
430
+ return;
431
+ }
432
+ try {
433
+ const executable = await this.resolveChromePath();
434
+ if (this.generation !== generation || this.children.has("browser")) {
435
+ return;
436
+ }
437
+ mkdirSync(this.profileDir, { recursive: true });
438
+ this.startPanel(executable, env);
439
+ this.launch("browser", browserCommand(executable, this.profileDir), env);
440
+ } catch (err) {
441
+ log.warn({ err }, "Desktop browser failed to launch");
442
+ if (this.generation === generation) {
443
+ void this.teardown({
444
+ code: DESKTOP_CLOSE.failed,
445
+ reason: "Desktop browser failed to start",
446
+ });
447
+ }
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Bring the dock up once per tree. It waits on Chrome because its launcher
453
+ * points at that executable, and its window manager and compositor are long
454
+ * up by then.
455
+ */
456
+ private startPanel(chromiumPath: string, env: Record<string, string>): void {
457
+ const binaries = this.binaries;
458
+ if (this.panelStarted || !binaries) {
459
+ return;
460
+ }
461
+ this.panelStarted = true;
462
+ try {
463
+ writeDesktopPanelConfig({
464
+ configDir: this.panelConfigDir,
465
+ chromiumPath,
466
+ chromiumProfileDir: this.profileDir,
467
+ terminalPath: binaries.terminal,
468
+ });
469
+ } catch (err) {
470
+ log.warn({ err }, "Desktop dock config could not be written");
471
+ return;
472
+ }
473
+ this.launchCosmetic(
474
+ "panel",
475
+ [binaries.panelSession, "--", binaries.panel],
476
+ {
477
+ ...env,
478
+ XDG_CONFIG_HOME: this.panelConfigDir,
479
+ XDG_DATA_HOME: this.panelConfigDir,
480
+ GSETTINGS_BACKEND: "keyfile",
481
+ },
482
+ );
483
+ }
484
+
485
+ /** Spawn a child the desktop looks worse without but works fine without. */
486
+ private launchCosmetic(
487
+ role: DesktopChildRole,
488
+ cmd: readonly string[],
489
+ env: Record<string, string>,
490
+ ): void {
491
+ try {
492
+ this.launch(role, cmd, env);
493
+ } catch (err) {
494
+ log.warn({ err, role }, "Desktop child failed to start");
495
+ }
496
+ }
497
+
498
+ private launch(
499
+ role: DesktopChildRole,
500
+ cmd: readonly string[],
501
+ env: Record<string, string>,
502
+ ): void {
503
+ const stale = this.children.get(role);
504
+ if (stale) {
505
+ log.warn({ role, pid: stale.pid }, "Desktop process already running");
506
+ void this.killAll(new Map([[role, stale]]));
507
+ }
508
+ const child = this.spawn(role, { cmd, env });
509
+ this.children.set(role, child);
510
+ child.exited.then(
511
+ (code) => this.onChildExit(role, child, code),
512
+ (err: unknown) => this.onChildExit(role, child, err),
513
+ );
514
+ }
515
+
516
+ private onChildExit(
517
+ role: DesktopChildRole,
518
+ child: DesktopChild,
519
+ outcome: unknown,
520
+ ): void {
521
+ if (this.children.get(role) !== child) {
522
+ return;
523
+ }
524
+ // Dock-launched applications can outlive the panel's session wrapper.
525
+ if (role !== "panel") {
526
+ this.children.delete(role);
527
+ }
528
+ if (role === "wallpaper" && outcome === 0) {
529
+ return;
530
+ }
531
+ if (role === "browser") {
532
+ log.info({ outcome }, "Desktop browser exited");
533
+ this.onBrowserExit();
534
+ return;
535
+ }
536
+ if (COSMETIC_ROLES.has(role)) {
537
+ // A dead dock or compositor costs the desktop its looks, not its use.
538
+ log.warn({ role, outcome }, "Desktop child exited");
539
+ return;
540
+ }
541
+ log.warn({ role, outcome }, "Desktop process exited, tearing down");
542
+ void this.teardown({
543
+ code: DESKTOP_CLOSE.failed,
544
+ reason: `Desktop stopped: ${role} exited`,
545
+ });
546
+ }
547
+
548
+ /**
549
+ * A closed browser is normal use when nobody is watching; the next viewer
550
+ * gets a fresh window. Under a viewer it is relaunched so they are not
551
+ * stranded on an empty desktop, unless it keeps dying.
552
+ */
553
+ private onBrowserExit(): void {
554
+ if (!this.viewer || !this.running) {
555
+ return;
556
+ }
557
+ const now = Date.now();
558
+ this.browserExitsAt = this.browserExitsAt.filter(
559
+ (at) => now - at < BROWSER_CRASH_WINDOW_MS,
560
+ );
561
+ this.browserExitsAt.push(now);
562
+ if (this.browserExitsAt.length > BROWSER_CRASH_LIMIT) {
563
+ log.warn("Desktop browser is crash looping, tearing down");
564
+ void this.teardown({
565
+ code: DESKTOP_CLOSE.failed,
566
+ reason: "Desktop browser keeps crashing",
567
+ });
568
+ return;
569
+ }
570
+ void this.ensureBrowser(this.childEnv(), this.generation);
571
+ }
572
+
573
+ /**
574
+ * Kill the tree. The viewer, if any, hears `loss` before the kill starts;
575
+ * the linger path passes none since nobody is watching by then.
576
+ */
577
+ private teardown(loss?: DesktopLoss): Promise<void> {
578
+ this.clearLinger();
579
+ this.generation += 1;
580
+ this.running = false;
581
+ this.browserExitsAt = [];
582
+ this.binaries = null;
583
+ this.panelStarted = false;
584
+ const children = new Map(this.children);
585
+ this.children.clear();
586
+ const viewer = this.viewer;
587
+ this.viewer = null;
588
+ if (viewer && loss) {
589
+ viewer.onDesktopLost(loss);
590
+ }
591
+ // Chain onto an earlier kill still in its grace so a start waits for both.
592
+ const done: Promise<void> = Promise.all([
593
+ this.tearingDown,
594
+ this.killAll(children),
595
+ ])
596
+ .then(() => undefined)
597
+ .finally(() => {
598
+ if (this.tearingDown === done) {
599
+ this.tearingDown = null;
600
+ }
601
+ });
602
+ this.tearingDown = done;
603
+ return done;
604
+ }
605
+
606
+ /**
607
+ * SIGTERM so X and Chrome exit cleanly, then SIGKILL whatever is left and
608
+ * wait for it too: a killed X server holds the display and port until it is
609
+ * reaped. Both waits are bounded by the grace so shutdown cannot hang.
610
+ */
611
+ private async killAll(
612
+ children: ReadonlyMap<DesktopChildRole, DesktopChild>,
613
+ ): Promise<void> {
614
+ if (children.size === 0) {
615
+ return;
616
+ }
617
+ const alive = new Map(children);
618
+ const exits = Promise.all(
619
+ [...children].map(([role, child]) => {
620
+ this.killProcessGroup(child, "SIGTERM");
621
+ return child.exited.catch(() => 0).then(() => alive.delete(role));
622
+ }),
623
+ );
624
+ const panel = children.get("panel");
625
+ await this.waitForExits(
626
+ panel ? Promise.all([exits, sleep(this.killGraceMs)]) : exits,
627
+ );
628
+ for (const [role, child] of children) {
629
+ // A reaped panel does not prove its application process group is empty.
630
+ if (role === "panel" || alive.has(role)) {
631
+ this.killProcessGroup(child, "SIGKILL");
632
+ }
633
+ }
634
+ await this.waitForExits(exits);
635
+ if (alive.size > 0) {
636
+ const survivors = [...alive].map(([role, child]) => ({
637
+ role,
638
+ pid: child.pid,
639
+ }));
640
+ log.warn({ survivors }, "Desktop processes still alive after SIGKILL");
641
+ }
642
+ }
643
+
644
+ private waitForExits(exits: Promise<unknown>): Promise<void> {
645
+ let grace: ReturnType<typeof setTimeout> | undefined;
646
+ return Promise.race([
647
+ exits.then(() => undefined),
648
+ new Promise<void>((resolve) => {
649
+ grace = setTimeout(resolve, this.killGraceMs);
650
+ }),
651
+ ]).finally(() => clearTimeout(grace));
652
+ }
653
+
654
+ private armLinger(): void {
655
+ this.clearLinger();
656
+ this.lingerTimer = setTimeout(() => {
657
+ this.lingerTimer = null;
658
+ log.info("Desktop idle, tearing down");
659
+ void this.teardown();
660
+ }, this.lingerMs);
661
+ this.lingerTimer.unref?.();
662
+ }
663
+
664
+ private clearLinger(): void {
665
+ if (this.lingerTimer) {
666
+ clearTimeout(this.lingerTimer);
667
+ this.lingerTimer = null;
668
+ }
669
+ }
670
+
671
+ private childEnv(): Record<string, string> {
672
+ const env: Record<string, string> = {};
673
+ for (const key of DESKTOP_ENV_KEYS) {
674
+ const value = this.sourceEnv[key];
675
+ if (value != null) {
676
+ env[key] = value;
677
+ }
678
+ }
679
+ env.DISPLAY = DESKTOP_DISPLAY;
680
+ return env;
681
+ }
682
+ }
683
+
684
+ function ingressClosedError(): DesktopStartError {
685
+ return new DesktopStartError(
686
+ SHUTTING_DOWN_LOSS,
687
+ new Error("Desktop ingress is closed"),
688
+ );
689
+ }
690
+
691
+ // ---------------------------------------------------------------------------
692
+ // Commands
693
+ // ---------------------------------------------------------------------------
694
+
695
+ function xServerCommand(executable: string): string[] {
696
+ return [
697
+ executable,
698
+ DESKTOP_DISPLAY,
699
+ "-localhost",
700
+ "-SecurityTypes",
701
+ "None",
702
+ "-rfbport",
703
+ String(DESKTOP_VNC_PORT),
704
+ "-geometry",
705
+ DESKTOP_GEOMETRY,
706
+ "-depth",
707
+ "24",
708
+ "-desktop",
709
+ "Vellum",
710
+ ];
711
+ }
712
+
713
+ function browserCommand(executable: string, profileDir: string): string[] {
714
+ // Root containers require --no-sandbox; set geometry before openbox maps it.
715
+ return [
716
+ executable,
717
+ "--no-sandbox",
718
+ "--no-first-run",
719
+ "--disable-dev-shm-usage",
720
+ "--start-maximized",
721
+ "--window-position=0,0",
722
+ `--window-size=${DESKTOP_WIDTH},${DESKTOP_HEIGHT}`,
723
+ `--user-data-dir=${profileDir}`,
724
+ ];
725
+ }
726
+
727
+ // ---------------------------------------------------------------------------
728
+ // Default collaborators
729
+ // ---------------------------------------------------------------------------
730
+
731
+ function spawnDetached(
732
+ _role: DesktopChildRole,
733
+ request: DesktopSpawnRequest,
734
+ ): DesktopChild {
735
+ // Each child leads its own process group so teardown can kill everything
736
+ // it forked (Chrome's renderers, openbox's autostart) in one signal.
737
+ return Bun.spawn([...request.cmd], {
738
+ env: request.env,
739
+ detached: true,
740
+ stdio: ["ignore", "ignore", "ignore"],
741
+ windowsHide: true,
742
+ });
743
+ }
744
+
745
+ function killProcessGroup(child: DesktopChild, signal: DesktopSignal): void {
746
+ if (signal === "SIGKILL") {
747
+ terminateProcessTree(child);
748
+ return;
749
+ }
750
+ try {
751
+ process.kill(-child.pid, signal);
752
+ } catch {
753
+ // Not a group leader, or already gone.
754
+ child.kill(signal);
755
+ }
756
+ }
757
+
758
+ /** Dial a loopback TCP port; resolves on connect, rejects when refused. */
759
+ export function connectLoopback(
760
+ port: number,
761
+ handlers: DesktopTcpHandlers,
762
+ ): Promise<DesktopTcpSocket> {
763
+ return new Promise((resolve, reject) => {
764
+ Bun.connect({
765
+ hostname: "127.0.0.1",
766
+ port,
767
+ socket: {
768
+ open: (socket) => resolve(socket),
769
+ data: (_socket, data) => handlers.onData(data),
770
+ drain: () => handlers.onDrain(),
771
+ close: () => handlers.onClose(),
772
+ error: (_socket, err) => handlers.onError(err),
773
+ connectError: (_socket, err) => reject(err),
774
+ },
775
+ }).catch(reject);
776
+ });
777
+ }
778
+
779
+ const silentHandlers: DesktopTcpHandlers = {
780
+ onData() {},
781
+ onDrain() {},
782
+ onClose() {},
783
+ onError() {},
784
+ };
785
+
786
+ async function probeLoopbackPort(port: number): Promise<boolean> {
787
+ try {
788
+ const socket = await connectLoopback(port, silentHandlers);
789
+ socket.end();
790
+ return true;
791
+ } catch {
792
+ return false;
793
+ }
794
+ }
795
+
796
+ // ---------------------------------------------------------------------------
797
+ // Singleton
798
+ // ---------------------------------------------------------------------------
799
+
800
+ let sharedManager: DesktopSessionManager | null = null;
801
+
802
+ /** One instance because there is one display and one VNC port. */
803
+ export function getDesktopSessionManager(): DesktopSessionManager {
804
+ sharedManager ??= new DesktopSessionManager();
805
+ return sharedManager;
806
+ }
807
+
808
+ /**
809
+ * Shut the shared desktop down. Latches even when none was ever served, so a
810
+ * socket arriving mid-shutdown cannot start a tree that nothing would kill.
811
+ */
812
+ export function destroyDesktopSessionManager(): Promise<void> {
813
+ return getDesktopSessionManager().destroy();
814
+ }