@vellumai/assistant 0.12.2-staging.2 → 0.12.2-staging.4

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 (162) hide show
  1. package/docs/desktop-browser-cli.md +33 -0
  2. package/openapi.yaml +17 -5
  3. package/package.json +1 -1
  4. package/scripts/smoke-desktop-browser-cli.ts +279 -0
  5. package/src/__tests__/config-schema.test.ts +1 -1
  6. package/src/__tests__/config-sounds-sync.test.ts +23 -0
  7. package/src/__tests__/conversation-error.test.ts +4 -1
  8. package/src/__tests__/conversation-runtime-assembly.test.ts +6 -4
  9. package/src/__tests__/external-plugin-loader.test.ts +52 -0
  10. package/src/__tests__/headless-browser-mode.test.ts +48 -0
  11. package/src/__tests__/host-cu-proxy.test.ts +116 -18
  12. package/src/__tests__/list-all-apps.test.ts +40 -8
  13. package/src/__tests__/mcp-auth-routes.test.ts +99 -0
  14. package/src/__tests__/mcp-client-auth.test.ts +54 -0
  15. package/src/__tests__/mcp-health-check.test.ts +32 -32
  16. package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
  17. package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
  18. package/src/__tests__/skills.test.ts +10 -0
  19. package/src/__tests__/user-plugin-loader.test.ts +21 -0
  20. package/src/acp/session-snapshot.ts +260 -0
  21. package/src/apps/app-store.ts +6 -5
  22. package/src/browser/operations.ts +2 -1
  23. package/src/browser/types.ts +7 -0
  24. package/src/browser/virtual-desktop-target.ts +34 -0
  25. package/src/calls/__tests__/progress-narration.test.ts +23 -0
  26. package/src/calls/__tests__/voice-control-protocol.test.ts +7 -0
  27. package/src/calls/__tests__/voice-session-bridge.test.ts +62 -0
  28. package/src/calls/progress-narration.ts +15 -2
  29. package/src/calls/voice-control-protocol.ts +34 -4
  30. package/src/calls/voice-session-bridge.ts +23 -0
  31. package/src/cli/commands/__tests__/browser.test.ts +84 -3
  32. package/src/cli/commands/browser.help.ts +43 -0
  33. package/src/cli/commands/browser.ts +52 -8
  34. package/src/cli/commands/plugins.ts +16 -5
  35. package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
  36. package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
  37. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
  38. package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
  39. package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
  40. package/src/cli/lib/bundled-marketplace.json +13 -0
  41. package/src/cli/lib/install-from-github.ts +17 -18
  42. package/src/cli/lib/install-from-platform.ts +14 -9
  43. package/src/cli/lib/list-installed-plugins.ts +21 -35
  44. package/src/cli/lib/plugin-details.ts +21 -8
  45. package/src/cli/lib/uninstall-plugin.ts +59 -1
  46. package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
  47. package/src/config/bundled-skills/acp/SKILL.md +1 -1
  48. package/src/config/bundled-skills/acp/TOOLS.json +2 -2
  49. package/src/config/bundled-skills/computer-use/SKILL.md +30 -0
  50. package/src/config/bundled-skills/computer-use/TOOLS.json +2 -2
  51. package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
  52. package/src/config/feature-flag-registry.json +2 -2
  53. package/src/config/schemas/__tests__/voice.test.ts +2 -2
  54. package/src/config/schemas/mcp.ts +9 -7
  55. package/src/config/schemas/monitoring.ts +9 -0
  56. package/src/config/schemas/voice.ts +2 -2
  57. package/src/config/skills.ts +13 -21
  58. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
  59. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
  60. package/src/daemon/conversation-client-surface.ts +26 -0
  61. package/src/daemon/conversation-runtime-assembly.ts +1 -1
  62. package/src/daemon/conversation-tool-setup.ts +31 -29
  63. package/src/daemon/host-cu-proxy.ts +51 -26
  64. package/src/daemon/host-proxy-preactivation.ts +2 -0
  65. package/src/daemon/mcp-reload-service.ts +7 -0
  66. package/src/daemon/message-types/sync.ts +1 -0
  67. package/src/daemon/providers-setup.ts +4 -0
  68. package/src/desktop/__tests__/fake-desktop.ts +1 -0
  69. package/src/desktop/desktop-automation-lease.test.ts +208 -0
  70. package/src/desktop/desktop-automation-lease.ts +270 -0
  71. package/src/desktop/desktop-browser-client.test.ts +413 -0
  72. package/src/desktop/desktop-browser-client.ts +430 -0
  73. package/src/desktop/desktop-browser-cursor.ts +40 -0
  74. package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
  75. package/src/desktop/desktop-browser-endpoint.ts +145 -0
  76. package/src/desktop/desktop-browser-operations.ts +130 -0
  77. package/src/desktop/desktop-chrome-session.test.ts +48 -0
  78. package/src/desktop/desktop-chrome-session.ts +58 -8
  79. package/src/desktop/desktop-dependencies.test.ts +29 -0
  80. package/src/desktop/desktop-dependencies.ts +26 -5
  81. package/src/desktop/desktop-display.ts +9 -0
  82. package/src/desktop/desktop-panel-config.ts +14 -1
  83. package/src/desktop/desktop-session-manager.test.ts +86 -3
  84. package/src/desktop/desktop-session-manager.ts +144 -24
  85. package/src/desktop/desktop-stream-bridge.ts +1 -1
  86. package/src/desktop/virtual-desktop-feature.ts +18 -0
  87. package/src/desktop/virtual-desktop-platform.test.ts +61 -0
  88. package/src/i18n/__tests__/i18n.test.ts +9 -5
  89. package/src/i18n/messages.ts +13 -4
  90. package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
  91. package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
  92. package/src/ipc/assistant-server.ts +15 -1
  93. package/src/ipc/cli-client.ts +13 -5
  94. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +22 -0
  95. package/src/live-voice/__tests__/live-voice-events.test.ts +3 -2
  96. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
  97. package/src/live-voice/__tests__/protocol.test.ts +11 -3
  98. package/src/live-voice/__tests__/session-controls.test.ts +34 -0
  99. package/src/live-voice/live-voice-metrics.ts +9 -0
  100. package/src/live-voice/live-voice-session.ts +3 -0
  101. package/src/live-voice/protocol.ts +10 -1
  102. package/src/live-voice/session-controls.ts +28 -0
  103. package/src/mcp/__tests__/credential-target.test.ts +59 -0
  104. package/src/mcp/__tests__/effective-config.test.ts +11 -0
  105. package/src/mcp/__tests__/manager-state.test.ts +128 -0
  106. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
  107. package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
  108. package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
  109. package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
  110. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
  111. package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
  112. package/src/mcp/client.ts +64 -16
  113. package/src/mcp/credential-target.ts +105 -0
  114. package/src/mcp/effective-config.ts +42 -15
  115. package/src/mcp/manager.ts +61 -8
  116. package/src/mcp/mcp-auth-orchestrator.ts +17 -4
  117. package/src/mcp/mcp-auth-state.ts +38 -6
  118. package/src/mcp/mcp-oauth-provider.ts +101 -33
  119. package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
  120. package/src/monitoring/mount-watch.ts +348 -0
  121. package/src/monitoring/worker.ts +9 -0
  122. package/src/notifications/__tests__/decision-engine.test.ts +175 -0
  123. package/src/notifications/decision-engine.ts +43 -8
  124. package/src/oauth/seed-providers.ts +11 -0
  125. package/src/onboarding/checkin-event.ts +2 -1
  126. package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
  127. package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
  128. package/src/plugins/external-plugin-loader.ts +49 -86
  129. package/src/plugins/installed-plugin-dirs.ts +6 -4
  130. package/src/plugins/mcp-servers.ts +9 -15
  131. package/src/plugins/mtime-cache.ts +2 -1
  132. package/src/plugins/user-loader.ts +2 -1
  133. package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
  134. package/src/providers/inference/endpoint-probe.ts +27 -12
  135. package/src/providers/openai/responses-provider.ts +9 -2
  136. package/src/providers/opencode/client.test.ts +167 -0
  137. package/src/providers/opencode/client.ts +73 -4
  138. package/src/runtime/AGENTS.md +4 -0
  139. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
  140. package/src/runtime/http-server.ts +6 -6
  141. package/src/runtime/routes/__tests__/acp-routes.test.ts +19 -0
  142. package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
  143. package/src/runtime/routes/acp-routes.ts +17 -161
  144. package/src/runtime/routes/browser-context.ts +39 -0
  145. package/src/runtime/routes/browser-routes.ts +16 -47
  146. package/src/runtime/routes/browser-tabs-routes.ts +72 -29
  147. package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
  148. package/src/runtime/routes/desktop-setup-routes.ts +7 -5
  149. package/src/runtime/routes/mcp-auth-routes.ts +106 -94
  150. package/src/runtime/routes/plugins-routes.ts +14 -1
  151. package/src/runtime/sync/resource-sync-events.ts +4 -0
  152. package/src/tools/acp/status.test.ts +276 -21
  153. package/src/tools/acp/status.ts +98 -32
  154. package/src/tools/browser/browser-execution.ts +44 -13
  155. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
  156. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
  157. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
  158. package/src/tools/computer-use/definitions.ts +2 -3
  159. package/src/util/abort-reasons.ts +12 -1
  160. package/src/util/plugin-manifest.ts +203 -0
  161. package/src/desktop/desktop-feature.test.ts +0 -18
  162. package/src/desktop/desktop-feature.ts +0 -19
@@ -7,10 +7,9 @@
7
7
  */
8
8
 
9
9
  import type { AssistantEvent } from "../api/index.js";
10
+ import { shouldUseVirtualDesktopBrowser } from "../browser/virtual-desktop-target.js";
10
11
  import {
11
- type ClientOs,
12
12
  type HostProxyCapability,
13
- parseClientOs,
14
13
  supportsHostProxy,
15
14
  } from "../channels/types.js";
16
15
  import { getIsPlatform } from "../config/env-registry.js";
@@ -74,6 +73,7 @@ import {
74
73
  conversationSupportsGuardianQuestionCards,
75
74
  } from "./channel-ui-capability.js";
76
75
  import type { Conversation } from "./conversation.js";
76
+ import { resolveTurnClientOs } from "./conversation-client-surface.js";
77
77
  import { projectSkillTools } from "./conversation-skill-tools.js";
78
78
  import {
79
79
  restoreSurfaceStateEntry,
@@ -742,30 +742,6 @@ export const ALLOWLIST_ONLY_TOOL_NAMES = new Set<string>([
742
742
  "delete_memory_page",
743
743
  ]);
744
744
 
745
- /**
746
- * Host OS of the client driving this turn. The Electron renderer reports
747
- * `interface: "web"` and carries the real OS in `clientOs`, so this prefers
748
- * the frozen per-turn value and only falls back to a desktop transport.
749
- */
750
- function resolveTurnClientOs(ctx: Conversation): {
751
- clientOs: ClientOs | undefined;
752
- transportInterface: Conversation["transportInterface"];
753
- } {
754
- const pin = ctx.toolContextPin;
755
- const transportInterface = pin
756
- ? pin.transportInterface
757
- : ctx.transportInterface;
758
- const clientOs = pin
759
- ? pin.clientOs
760
- : (parseClientOs(ctx.currentTurnClientOs ?? ctx.clientOs) ??
761
- (transportInterface === "macos" ||
762
- transportInterface === "windows" ||
763
- transportInterface === "linux"
764
- ? transportInterface
765
- : undefined));
766
- return { clientOs, transportInterface };
767
- }
768
-
769
745
  /**
770
746
  * Windows parity gate: skill tools may declare `supported_client_os`; drop
771
747
  * them when the turn's client OS (or pinned OS for wakes) is not listed.
@@ -1149,9 +1125,35 @@ export function createResolveToolsCallback(
1149
1125
  // while background calls persist the full surface content for the next
1150
1126
  // capable client that opens the conversation. Skill tools stay off this
1151
1127
  // list (`skill_execute` dispatch) and are a separate disclosure path.
1152
- let allBaseDefs = [...scopedCoreDefs, ...scopedWorkspaceDefs, ...scopedMcpDefs].filter(
1153
- (d) => !excluded.has(d.name),
1154
- );
1128
+ let allBaseDefs = [
1129
+ ...scopedCoreDefs,
1130
+ ...scopedWorkspaceDefs,
1131
+ ...scopedMcpDefs,
1132
+ ].filter((d) => !excluded.has(d.name));
1133
+ if (
1134
+ ctx.transportInterface === "web" &&
1135
+ shouldUseVirtualDesktopBrowser(
1136
+ undefined,
1137
+ {},
1138
+ {
1139
+ workingDir: ctx.workingDir,
1140
+ conversationId: ctx.conversationId,
1141
+ trustClass: ctx.trustContext?.trustClass ?? "unknown",
1142
+ transportInterface: ctx.transportInterface,
1143
+ clientOs: resolveTurnClientOs(ctx).clientOs,
1144
+ sourceActorPrincipalId: ctx.getTurnActorPrincipalId?.(),
1145
+ },
1146
+ )
1147
+ ) {
1148
+ allBaseDefs = allBaseDefs.map((definition) =>
1149
+ definition.name === "bash"
1150
+ ? {
1151
+ ...definition,
1152
+ description: `${definition.description} For browser tasks, use assistant browser navigate --url <url> directly. It installs the virtual desktop if needed, starts Chrome, and completes the action in one call. Use timeout_seconds: ${getConfig().timeouts.shellMaxTimeoutSec} for first use; setup progress is visible in the Virtual desktop panel. Use assistant browser --help for other browser actions. Use this managed path even if saved notes describe manual setup. Do not install packages or launch Chrome, X servers, or screenshot scripts yourself.`,
1153
+ }
1154
+ : definition,
1155
+ );
1156
+ }
1155
1157
  // Activation-rail conversations carry the optional `activation_moment`
1156
1158
  // telemetry param on ui_show. The marker is written before the first
1157
1159
  // tool resolution (see `applyBootstrapTemplate` in system-prompt.ts), so
@@ -48,8 +48,8 @@ const SCREENSHOT_OMITTED_MESSAGE =
48
48
  "Screenshot omitted: the accessibility tree above is current. Pass include_screenshot: true on computer_use_observe to see the screen.";
49
49
 
50
50
  // computer_use_key combos that change only selection/cursor/clipboard state.
51
- // The AX tree models none of these, so they always produce an empty diff
52
- // exempt them from the "NO VISIBLE EFFECT" signal (mirrors computer_use_wait).
51
+ // The AX tree models none of these, so they always produce an empty diff and
52
+ // are exempt from the unchanged-tree signal.
53
53
  // Stored in canonical form (see canonicalizeKeyCombo): modifier aliases
54
54
  // normalized and ordered, so `cmd + a`, `command+a`, `alt+tab`, `tab+shift`
55
55
  // all match.
@@ -129,12 +129,28 @@ export interface ActionRecord {
129
129
  reasoning?: string;
130
130
  }
131
131
 
132
+ // Steps whose empty diff says nothing about whether they worked: waiting and
133
+ // observing change nothing by design, and an AppleScript reports its own
134
+ // outcome in the execution result, often for an app whose content the tree
135
+ // cannot see. They neither warn nor move the unchanged streak.
136
+ const NO_AX_DIFF_TOOLS = new Set([
137
+ "computer_use_wait",
138
+ "computer_use_observe",
139
+ "computer_use_run_applescript",
140
+ ]);
141
+
142
+ type ActionIdentity = Pick<ActionRecord, "toolName" | "input">;
143
+
144
+ function isNoDiffTool(action: ActionIdentity | undefined): boolean {
145
+ return action !== undefined && NO_AX_DIFF_TOOLS.has(action.toolName);
146
+ }
147
+
132
148
  /**
133
149
  * True when `action` is a computer_use_key press whose key only mutates
134
- * selection/cursor/clipboard state changes the AX tree cannot represent, so
150
+ * selection/cursor/clipboard state: changes the AX tree cannot represent, so
135
151
  * an empty diff is expected rather than a sign the action did nothing.
136
152
  */
137
- function isNoDiffKeyAction(action: ActionRecord | undefined): boolean {
153
+ function isNoDiffKeyAction(action: ActionIdentity | undefined): boolean {
138
154
  if (action?.toolName !== "computer_use_key") {
139
155
  return false;
140
156
  }
@@ -245,6 +261,7 @@ export class HostCuProxy {
245
261
  resetGeneration: number;
246
262
  dispatchedAt: number;
247
263
  toolName: string;
264
+ input: Record<string, unknown>;
248
265
  step: number;
249
266
  }
250
267
  >();
@@ -455,6 +472,7 @@ export class HostCuProxy {
455
472
  resetGeneration: this._resetGeneration,
456
473
  dispatchedAt: Date.now(),
457
474
  toolName,
475
+ input,
458
476
  step: stepNumber,
459
477
  });
460
478
 
@@ -538,7 +556,7 @@ export class HostCuProxy {
538
556
  }
539
557
 
540
558
  // A targeted snapshot has no comparable action/diff baseline; neither it
541
- // nor the first desktop observation after it can imply "no visible effect".
559
+ // nor the first desktop observation after it can imply an unchanged tree.
542
560
  // A response dispatched before the last reset belongs to a finished run,
543
561
  // so it must not change any state the new run has started building.
544
562
  const fromCurrentRun =
@@ -548,6 +566,11 @@ export class HostCuProxy {
548
566
  this._consecutiveUnchangedSteps = 0;
549
567
  }
550
568
  const prevAXTree = this._previousAXTree;
569
+ // Judge the empty diff against the action this request carried, for the
570
+ // same reason the timings line does.
571
+ const action: ActionIdentity | undefined = owned
572
+ ? { toolName: owned.toolName, input: owned.input }
573
+ : this.lastRecordedAction();
551
574
  const comparableObservation = scopedObservation
552
575
  ? { ...observation, axDiff: undefined, secondaryWindows: undefined }
553
576
  : observation;
@@ -557,7 +580,7 @@ export class HostCuProxy {
557
580
  !scopedObservation &&
558
581
  owned?.toolName !== POINT_AT_PROXY_TOOL
559
582
  ) {
560
- this.updateStateFromObservation(comparableObservation);
583
+ this.updateStateFromObservation(comparableObservation, action);
561
584
  // A desktop has had its first look only once pixels from it arrived,
562
585
  // so a failed capture leaves the next request asking again.
563
586
  if (owned && observation.screenshot) {
@@ -568,6 +591,7 @@ export class HostCuProxy {
568
591
  comparableObservation,
569
592
  prevAXTree,
570
593
  owned?.screenshotSkipped ?? false,
594
+ action,
571
595
  );
572
596
  interaction.rpcResolve(result);
573
597
  return result;
@@ -672,6 +696,7 @@ export class HostCuProxy {
672
696
  obs: CuObservationResult,
673
697
  previousAXTree?: string,
674
698
  screenshotSkipped = false,
699
+ action: ActionIdentity | undefined = this.lastRecordedAction(),
675
700
  ): ToolExecutionResult {
676
701
  const prevTree = previousAXTree;
677
702
  const parts: string[] = [];
@@ -690,24 +715,17 @@ export class HostCuProxy {
690
715
  parts.push(obs.axDiff);
691
716
  parts.push("");
692
717
  } else if (prevTree != null && obs.axTree != null) {
693
- const lastAction =
694
- this._actionHistory.length > 0
695
- ? this._actionHistory[this._actionHistory.length - 1]
696
- : undefined;
697
- const isWaitAction = lastAction?.toolName === "computer_use_wait";
698
- const isNoDiffKey = isNoDiffKeyAction(lastAction);
699
-
700
- if (!isWaitAction && !isNoDiffKey) {
718
+ if (!isNoDiffTool(action) && !isNoDiffKeyAction(action)) {
701
719
  if (
702
720
  this._consecutiveUnchangedSteps >=
703
721
  CONSECUTIVE_UNCHANGED_WARNING_THRESHOLD
704
722
  ) {
705
723
  parts.push(
706
- `WARNING: ${this._consecutiveUnchangedSteps} consecutive actions had NO VISIBLE EFFECT on the UI. You MUST try a completely different approach.`,
724
+ `WARNING: the accessibility tree did not change across ${this._consecutiveUnchangedSteps} consecutive actions. If the screenshot does not show them working either, try a different approach.`,
707
725
  );
708
726
  } else {
709
727
  parts.push(
710
- "Your last action had NO VISIBLE EFFECT on the UI. Try something different.",
728
+ "The accessibility tree did not change after your last action. Apps that draw their own content (timelines, canvases, spreadsheet grids) change without it, so check the screenshot before deciding the action did nothing.",
711
729
  );
712
730
  }
713
731
  parts.push("");
@@ -822,19 +840,26 @@ export class HostCuProxy {
822
840
  // Private helpers
823
841
  // ---------------------------------------------------------------------------
824
842
 
825
- private updateStateFromObservation(obs: CuObservationResult): void {
843
+ private lastRecordedAction(): ActionRecord | undefined {
844
+ return this._actionHistory.at(-1);
845
+ }
846
+
847
+ private updateStateFromObservation(
848
+ obs: CuObservationResult,
849
+ action: ActionIdentity | undefined,
850
+ ): void {
826
851
  if (this._stepCount > 0) {
827
- const lastAction =
828
- this._actionHistory.length > 0
829
- ? this._actionHistory[this._actionHistory.length - 1]
830
- : undefined;
831
- if (obs.axDiff != null || isNoDiffKeyAction(lastAction)) {
852
+ if (obs.axDiff != null || isNoDiffKeyAction(action)) {
832
853
  // A real diff, or an exempt key whose effect is invisible by design,
833
- // breaks the no-effect streak clear it rather than preserving a
834
- // stale count so an intervening cmd+a can't bridge two no-op actions
835
- // into a false "consecutive" escalation.
854
+ // breaks the no-effect streak. Clearing it rather than preserving a
855
+ // stale count keeps an intervening cmd+a from bridging two no-op
856
+ // actions into a false "consecutive" escalation.
836
857
  this._consecutiveUnchangedSteps = 0;
837
- } else if (this._previousAXTree != null && obs.axTree != null) {
858
+ } else if (
859
+ !isNoDiffTool(action) &&
860
+ this._previousAXTree != null &&
861
+ obs.axTree != null
862
+ ) {
838
863
  this._consecutiveUnchangedSteps++;
839
864
  }
840
865
  }
@@ -9,6 +9,8 @@
9
9
  * - `daemon/process-message.ts` (create path, prepareConversationForMessage)
10
10
  * - `daemon/conversation-process.ts` `drainSingleMessage` (re-add after dequeue)
11
11
  * - `daemon/conversation-process.ts` `drainBatch` (re-add after dequeue)
12
+ * - `calls/voice-session-bridge.ts` (tool-capable legs of a live-voice
13
+ * session opened from the macOS desktop client)
12
14
  *
13
15
  * The create paths additionally instantiate the proxy itself; that
14
16
  * instantiation logic is per-proxy-class and stays inline at each create
@@ -14,6 +14,7 @@ import { getMcpServerManager } from "../mcp/manager.js";
14
14
  import { migrateLegacyMcpHeaders } from "../mcp/mcp-header-store.js";
15
15
  import { signalMcpReloaded } from "../mcp/reload-signal.js";
16
16
  import { loadWorkspaceMcpConfig } from "../mcp/workspace-mcp-config.js";
17
+ import { publishMcpChanged } from "../runtime/sync/resource-sync-events.js";
17
18
  import { createMcpToolsFromServer } from "../tools/mcp/mcp-tool-factory.js";
18
19
  import { registerMcpTools, unregisterAllMcpTools } from "../tools/registry.js";
19
20
  import { getLogger } from "../util/logger.js";
@@ -80,6 +81,7 @@ export async function reconcilePluginMcpServers(): Promise<void> {
80
81
  }
81
82
 
82
83
  async function doReload(): Promise<McpReloadResult> {
84
+ let teardownStarted = false;
83
85
  try {
84
86
  const manager = getMcpServerManager();
85
87
 
@@ -98,6 +100,7 @@ async function doReload(): Promise<McpReloadResult> {
98
100
  invalidateConfigCache();
99
101
 
100
102
  // 2. Stop existing MCP servers + unregister their tools
103
+ teardownStarted = true;
101
104
  await manager.stop();
102
105
  unregisterAllMcpTools();
103
106
 
@@ -160,5 +163,9 @@ async function doReload(): Promise<McpReloadResult> {
160
163
  const error = err instanceof Error ? err.message : String(err);
161
164
  log.error({ err }, "MCP reload failed");
162
165
  return { success: false, error };
166
+ } finally {
167
+ if (teardownStarted) {
168
+ publishMcpChanged();
169
+ }
163
170
  }
164
171
  }
@@ -18,6 +18,7 @@ export const SYNC_TAGS = {
18
18
  appsList: "apps:list",
19
19
  documentsList: "documents:list",
20
20
  pluginsList: "plugins:list",
21
+ mcpList: "mcp:list",
21
22
  conversationsList: "conversations:list",
22
23
  /** Activation-checklist progress: task launches, step counts, completions. */
23
24
  activationProgress: "activation:progress",
@@ -1,6 +1,7 @@
1
1
  import { maybeDefaultSpeechToManaged } from "../config/managed-speech-defaults.js";
2
2
  import { rehydratePlatformCredentials } from "../config/platform-rehydration.js";
3
3
  import type { AssistantConfig } from "../config/types.js";
4
+ import { getMcpServerManager } from "../mcp/manager.js";
4
5
  import { startConfiguredMcpServers } from "../mcp/startup.js";
5
6
  import { gmailMessagingProvider } from "../messaging/providers/gmail/adapter.js";
6
7
  import { outlookMessagingProvider } from "../messaging/providers/outlook/adapter.js";
@@ -9,6 +10,7 @@ import { telegramBotMessagingProvider } from "../messaging/providers/telegram-bo
9
10
  import { whatsappMessagingProvider } from "../messaging/providers/whatsapp/adapter.js";
10
11
  import { registerMessagingProvider } from "../messaging/registry.js";
11
12
  import { initializeProviders } from "../providers/registry.js";
13
+ import { publishMcpChanged } from "../runtime/sync/resource-sync-events.js";
12
14
  import { validateSubagentRoleAllowlists } from "../subagent/validate-allowlists.js";
13
15
  import { initializeTools } from "../tools/registry.js";
14
16
  import { getLogger } from "../util/logger.js";
@@ -63,7 +65,9 @@ export async function initializeProvidersAndTools(
63
65
  // Start MCP servers — workspace-configured and plugin-declared alike —
64
66
  // and register their tools. Shared with the schedule worker, which hosts
65
67
  // agent turns in its own process and so needs its own connections.
68
+ getMcpServerManager().setUnexpectedCloseHandler(() => publishMcpChanged());
66
69
  await startConfiguredMcpServers();
70
+ publishMcpChanged();
67
71
 
68
72
  log.info("Daemon startup: providers and tools initialized");
69
73
  }
@@ -58,6 +58,7 @@ export function newFakeDesktop(options: FakeDesktopOptions) {
58
58
  let vncReady = true;
59
59
  let chromiumPath: () => Promise<string> = async () => "/fake/chromium";
60
60
  const manager = new DesktopSessionManager({
61
+ allocateDebugPort: async () => 9222,
61
62
  spawn: (role, request) => {
62
63
  if (options.failSpawn?.includes(role)) {
63
64
  throw new Error(`spawn ${role} failed`);
@@ -0,0 +1,208 @@
1
+ import { afterEach, expect, mock, test } from "bun:test";
2
+
3
+ import { DesktopAutomationLease } from "./desktop-automation-lease.js";
4
+ import { DesktopDependencyInstaller } from "./desktop-dependencies.js";
5
+ import type { DesktopSessionManager } from "./desktop-session-manager.js";
6
+
7
+ const context = {
8
+ conversationId: "conv-123",
9
+ sourceActorPrincipalId: "user-123",
10
+ trustClass: "guardian" as const,
11
+ workingDir: "/tmp",
12
+ };
13
+ const cleanups: DesktopAutomationLease[] = [];
14
+ afterEach(async () => {
15
+ await Promise.all(
16
+ cleanups
17
+ .splice(0)
18
+ .map((lease) => lease.runBrowser(context, operation, true)),
19
+ );
20
+ });
21
+ function fixture() {
22
+ let enabled = true;
23
+ let ready = true;
24
+ const setup = Promise.withResolvers<void>();
25
+ const install = mock(() => setup.promise);
26
+ const installer = new DesktopDependencyInstaller({
27
+ supported: () => true,
28
+ ready: () => ready,
29
+ install,
30
+ notify: async () => {},
31
+ });
32
+ const ensureReady = mock((signal: AbortSignal) =>
33
+ installer.ensureReady(signal),
34
+ );
35
+ const started = mock(async () => {});
36
+ const released = mock(() => {});
37
+ const releaseBrowser = mock(async () => {});
38
+ const lease = new DesktopAutomationLease({
39
+ enabled: () => enabled,
40
+ ready: () => ready,
41
+ ensureReady,
42
+ manager: () =>
43
+ ({
44
+ browser: { release: releaseBrowser },
45
+ acquireAutomationSlot: () => ({ ok: true }),
46
+ releaseAutomationSlot: released,
47
+ ensureDesktopRunning: started,
48
+ }) as unknown as DesktopSessionManager,
49
+ });
50
+ cleanups.push(lease);
51
+ return {
52
+ lease,
53
+ ensureReady,
54
+ install,
55
+ failSetup: () => setup.reject(new Error("download failed")),
56
+ completeSetup: () => {
57
+ ready = true;
58
+ setup.resolve();
59
+ },
60
+ started,
61
+ released,
62
+ releaseBrowser,
63
+ disable: () => {
64
+ enabled = false;
65
+ },
66
+ uninstall: () => {
67
+ ready = false;
68
+ },
69
+ };
70
+ }
71
+ const operation = async () => ({ content: "ok", isError: false });
72
+
73
+ test("browser session reuses one automation slot and isolates conversations", async () => {
74
+ const f = fixture();
75
+ await f.lease.runBrowser(context, operation);
76
+ await f.lease.runBrowser(context, operation);
77
+ expect(f.started).toHaveBeenCalledTimes(1);
78
+ await expect(
79
+ f.lease.runBrowser({ ...context, conversationId: "conv-456" }, operation),
80
+ ).rejects.toThrow("Another conversation");
81
+ expect(f.released).not.toHaveBeenCalled();
82
+ await f.lease.runBrowser(context, operation, true);
83
+ expect(f.released).toHaveBeenCalledTimes(1);
84
+ });
85
+
86
+ for (const lose of ["disable", "uninstall"] as const) {
87
+ test(`${lose} blocks startup, reuse and startup races`, async () => {
88
+ const unavailable = fixture();
89
+ unavailable[lose]();
90
+ if (lose === "uninstall") {
91
+ unavailable.ensureReady.mockRejectedValueOnce(new Error("setup failed"));
92
+ }
93
+ await expect(
94
+ unavailable.lease.runBrowser(context, operation),
95
+ ).rejects.toThrow();
96
+ expect(unavailable.started).not.toHaveBeenCalled();
97
+ const race = fixture();
98
+ race.started.mockImplementationOnce(async () => race[lose]());
99
+ const callback = mock(operation);
100
+ await expect(race.lease.runBrowser(context, callback)).rejects.toThrow();
101
+ expect(callback).not.toHaveBeenCalled();
102
+ expect(race.released).toHaveBeenCalledTimes(1);
103
+ const reused = fixture();
104
+ await reused.lease.runBrowser(context, operation);
105
+ reused[lose]();
106
+ await expect(reused.lease.runBrowser(context, callback)).rejects.toThrow();
107
+ expect(callback).not.toHaveBeenCalled();
108
+ expect(reused.released).toHaveBeenCalledTimes(1);
109
+ });
110
+ }
111
+
112
+ test("cancellation stops running and queued browser commands without blocking a new session", async () => {
113
+ const f = fixture();
114
+ const started = Promise.withResolvers<void>();
115
+ const abort = new AbortController();
116
+ const running = f.lease
117
+ .runBrowser({ ...context, signal: abort.signal }, async (signal) => {
118
+ started.resolve();
119
+ await new Promise<void>((_resolve, reject) => {
120
+ signal.addEventListener("abort", () => reject(new Error("aborted")), {
121
+ once: true,
122
+ });
123
+ });
124
+ return operation();
125
+ })
126
+ .catch((error: Error) => error);
127
+ await started.promise;
128
+ const callback = mock(operation);
129
+ const queued = f.lease.runBrowser(context, callback);
130
+ abort.abort();
131
+ expect(await running).toBeInstanceOf(Error);
132
+ expect((await queued).isError).toBe(true);
133
+ expect(callback).not.toHaveBeenCalled();
134
+ await f.lease.runBrowser(context, callback);
135
+ expect(callback).toHaveBeenCalledTimes(1);
136
+ });
137
+
138
+ test("failed browser cleanup retains ownership until release succeeds", async () => {
139
+ const f = fixture();
140
+ await f.lease.runBrowser(context, operation);
141
+ f.releaseBrowser.mockRejectedValueOnce(new Error("Chrome busy"));
142
+ await expect(f.lease.runBrowser(context, operation, true)).rejects.toThrow(
143
+ "Chrome busy",
144
+ );
145
+ expect(f.released).not.toHaveBeenCalled();
146
+ await f.lease.runBrowser(context, operation, true);
147
+ expect(f.released).toHaveBeenCalledTimes(1);
148
+ });
149
+
150
+ test("first browser call waits for one shared install then executes exactly once", async () => {
151
+ const f = fixture();
152
+ f.uninstall();
153
+ const callback = mock(operation);
154
+ const first = f.lease.runBrowser(context, callback);
155
+ await Bun.sleep(0);
156
+ expect(f.install).toHaveBeenCalledTimes(1);
157
+ expect(f.started).not.toHaveBeenCalled();
158
+ expect(callback).not.toHaveBeenCalled();
159
+ f.completeSetup();
160
+ expect((await first).isError).toBe(false);
161
+ expect(callback).toHaveBeenCalledTimes(1);
162
+ await f.lease.runBrowser(context, callback);
163
+ expect(f.install).toHaveBeenCalledTimes(1);
164
+ expect(f.started).toHaveBeenCalledTimes(1);
165
+ });
166
+
167
+ for (const interrupt of ["cancel", "disable", "failure"] as const) {
168
+ test(`${interrupt} during setup prevents a delayed browser action`, async () => {
169
+ const f = fixture();
170
+ f.uninstall();
171
+ const abort = new AbortController();
172
+ const callback = mock(operation);
173
+ const result = f.lease
174
+ .runBrowser({ ...context, signal: abort.signal }, callback)
175
+ .catch((error: unknown) => error);
176
+ await Bun.sleep(0);
177
+ if (interrupt === "cancel") {
178
+ abort.abort();
179
+ } else if (interrupt === "disable") {
180
+ f.disable();
181
+ } else {
182
+ f.failSetup();
183
+ }
184
+ expect(await result).toBeInstanceOf(Error);
185
+ f.completeSetup();
186
+ await Bun.sleep(0);
187
+ expect(f.started).not.toHaveBeenCalled();
188
+ expect(callback).not.toHaveBeenCalled();
189
+ });
190
+ }
191
+
192
+ test("disabled, unidentified, cancelled and released browser calls cannot install", async () => {
193
+ const f = fixture();
194
+ f.uninstall();
195
+ const cancelled = new AbortController();
196
+ cancelled.abort();
197
+ for (const caller of [
198
+ { ...context, trustClass: "unknown" as const },
199
+ { ...context, sourceActorPrincipalId: undefined },
200
+ { ...context, signal: cancelled.signal },
201
+ ]) {
202
+ await expect(f.lease.runBrowser(caller, operation)).rejects.toThrow();
203
+ }
204
+ await f.lease.runBrowser(context, operation, true);
205
+ f.disable();
206
+ await expect(f.lease.runBrowser(context, operation)).rejects.toThrow();
207
+ expect(f.ensureReady).not.toHaveBeenCalled();
208
+ });