@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
@@ -6,8 +6,19 @@
6
6
  * state or the daemon conversation store.
7
7
  */
8
8
 
9
- import { afterEach, describe, expect, mock, test } from "bun:test";
10
-
9
+ import {
10
+ afterAll,
11
+ afterEach,
12
+ describe,
13
+ expect,
14
+ mock,
15
+ spyOn,
16
+ test,
17
+ } from "bun:test";
18
+
19
+ import { desktopDependencyInstaller } from "../../desktop/desktop-dependencies.js";
20
+ import * as desktopFeature from "../../desktop/virtual-desktop-feature.js";
21
+ import { browserManager } from "../../tools/browser/browser-manager.js";
11
22
  import type { ToolExecutionResult } from "../../tools/types.js";
12
23
 
13
24
  // ---------------------------------------------------------------------------
@@ -31,7 +42,10 @@ let mockOperationCalls: Array<{
31
42
  let mockConversation: {
32
43
  trustContext?: { trustClass: string };
33
44
  transportInterface?: string;
45
+ currentTurnClientOs?: string;
46
+ clientOs?: string;
34
47
  getTurnActorPrincipalId?: () => string | undefined;
48
+ abortController?: AbortController;
35
49
  } | null = null;
36
50
 
37
51
  let mockFindConversationCalls: string[] = [];
@@ -66,6 +80,47 @@ mock.module("../../daemon/conversation-registry.js", () => ({
66
80
  },
67
81
  }));
68
82
 
83
+ let desktopEnabled = false;
84
+ let desktopReady = false;
85
+ let desktopFailure = false;
86
+ const enabledSpy = spyOn(
87
+ desktopFeature,
88
+ "isVirtualDesktopEnabled",
89
+ ).mockImplementation(() => desktopEnabled);
90
+ const readySpy = spyOn(
91
+ desktopDependencyInstaller,
92
+ "getStatus",
93
+ ).mockImplementation(() => ({ state: desktopReady ? "ready" : "required" }));
94
+ afterAll(() => {
95
+ enabledSpy.mockRestore();
96
+ readySpy.mockRestore();
97
+ });
98
+
99
+ let desktopContext: import("../../tools/types.js").ToolContext | undefined;
100
+ mock.module("../../desktop/desktop-browser-operations.js", () => ({
101
+ executeDesktopBrowserTabs: async (
102
+ _params: unknown,
103
+ context: import("../../tools/types.js").ToolContext,
104
+ ) => {
105
+ desktopContext = context;
106
+ return {
107
+ content: JSON.stringify({ ok: true, tabs: [{ tabId: 1 }] }),
108
+ isError: false,
109
+ };
110
+ },
111
+ executeDesktopBrowserOperation: async (
112
+ _operation: string,
113
+ _input: Record<string, unknown>,
114
+ context: import("../../tools/types.js").ToolContext,
115
+ ) => {
116
+ desktopContext = context;
117
+ return {
118
+ content: desktopFailure ? "Desktop is busy" : "desktop",
119
+ isError: desktopFailure,
120
+ };
121
+ },
122
+ }));
123
+
69
124
  // Import after mocking — now from the shared routes location
70
125
  const { ROUTES, browserCliConversationKey } =
71
126
  await import("../../runtime/routes/browser-routes.js");
@@ -92,6 +147,11 @@ function callHandler(
92
147
  // ---------------------------------------------------------------------------
93
148
 
94
149
  afterEach(() => {
150
+ desktopEnabled = false;
151
+ desktopReady = false;
152
+ desktopFailure = false;
153
+ desktopContext = undefined;
154
+ browserManager.clearPreferredBackendKind("conv-default-browser");
95
155
  mockOperationResult = { content: "ok", isError: false };
96
156
  mockOperationCalls = [];
97
157
  mockConversation = null;
@@ -422,3 +482,171 @@ describe("browser_execute route", () => {
422
482
  expect(mockOperationCalls[0].input).toEqual({});
423
483
  });
424
484
  });
485
+
486
+ test("desktop route preserves guardian ownership and live-turn cancellation", async () => {
487
+ const abortController = new AbortController();
488
+ mockConversation = {
489
+ trustContext: { trustClass: "guardian" },
490
+ getTurnActorPrincipalId: () => "user-123",
491
+ abortController,
492
+ };
493
+ const result = await callHandler(
494
+ { operation: "snapshot", desktop: true, conversationId: "conv-desktop" },
495
+ { "x-vellum-actor-principal-id": "user-other" },
496
+ );
497
+ expect(result).toMatchObject({ content: "desktop" });
498
+ expect(mockOperationCalls).toHaveLength(0);
499
+ expect(desktopContext).toMatchObject({
500
+ conversationId: "conv-desktop",
501
+ sourceActorPrincipalId: "user-123",
502
+ trustClass: "guardian",
503
+ });
504
+ expect(desktopContext?.signal?.aborted).toBe(false);
505
+ abortController.abort();
506
+ expect(desktopContext?.signal?.aborted).toBe(true);
507
+ });
508
+
509
+ function webConversation(clientOs = "web") {
510
+ desktopEnabled = true;
511
+ desktopReady = true;
512
+ mockConversation = {
513
+ trustContext: { trustClass: "guardian" },
514
+ transportInterface: "web",
515
+ currentTurnClientOs: clientOs,
516
+ getTurnActorPrincipalId: () => "user-123",
517
+ };
518
+ }
519
+
520
+ test("web browser commands default to the installed streamed Chrome", async () => {
521
+ webConversation();
522
+ const result = await callHandler({
523
+ operation: "navigate",
524
+ input: { url: "https://example.com" },
525
+ conversationId: "conv-default-browser",
526
+ });
527
+ expect(result).toMatchObject({ content: "desktop", isError: false });
528
+ expect(mockOperationCalls).toHaveLength(0);
529
+ expect(desktopContext?.clientOs).toBe("web");
530
+ });
531
+
532
+ test.each(["macos", "windows"])(
533
+ "%s renderer uses the personal browser despite its web transport",
534
+ async (clientOs) => {
535
+ webConversation(clientOs);
536
+ await callHandler({
537
+ operation: "snapshot",
538
+ conversationId: "conv-default-browser",
539
+ });
540
+ expect(mockOperationCalls).toHaveLength(1);
541
+ expect(desktopContext).toBeUndefined();
542
+ },
543
+ );
544
+
545
+ test("browser selection uses the active turn rather than a queued message's OS", async () => {
546
+ webConversation("macos");
547
+ mockConversation!.clientOs = "web";
548
+ await callHandler({
549
+ operation: "snapshot",
550
+ conversationId: "conv-default-browser",
551
+ });
552
+ expect(mockOperationCalls).toHaveLength(1);
553
+ expect(desktopContext).toBeUndefined();
554
+ });
555
+
556
+ test.each(["flag", "guardian", "actor"])(
557
+ "web preserves the existing browser when %s is unavailable",
558
+ async (missing) => {
559
+ webConversation();
560
+ if (missing === "flag") {
561
+ desktopEnabled = false;
562
+ }
563
+ if (missing === "guardian") {
564
+ mockConversation!.trustContext = { trustClass: "unknown" };
565
+ }
566
+ if (missing === "actor") {
567
+ mockConversation!.getTurnActorPrincipalId = () => undefined;
568
+ }
569
+ await callHandler({
570
+ operation: "snapshot",
571
+ conversationId: "conv-default-browser",
572
+ });
573
+ expect(mockOperationCalls).toHaveLength(1);
574
+ expect(desktopContext).toBeUndefined();
575
+ },
576
+ );
577
+
578
+ test.each([
579
+ { browser_mode: "local" },
580
+ { browser_mode: "playwright" },
581
+ { browser_mode: "extension" },
582
+ { browser_mode: "cdp-inspect" },
583
+ { target_client_id: "client-123" },
584
+ { use_active_tab: true },
585
+ ])("explicit browser choice %j wins over the web default", async (input) => {
586
+ webConversation();
587
+ await callHandler({
588
+ operation: "snapshot",
589
+ input,
590
+ conversationId: "conv-default-browser",
591
+ });
592
+ expect(mockOperationCalls).toHaveLength(1);
593
+ expect(desktopContext).toBeUndefined();
594
+ });
595
+
596
+ test("follow-up web commands retain an existing personal browser session", async () => {
597
+ webConversation();
598
+ browserManager.setPreferredBackendKind("conv-default-browser", "extension");
599
+ await callHandler({
600
+ operation: "snapshot",
601
+ conversationId: "conv-default-browser",
602
+ });
603
+ expect(mockOperationCalls).toHaveLength(1);
604
+ expect(desktopContext).toBeUndefined();
605
+ });
606
+
607
+ test("native clients can explicitly select the streamed browser", async () => {
608
+ webConversation("windows");
609
+ const result = await callHandler({
610
+ operation: "snapshot",
611
+ desktop: true,
612
+ conversationId: "conv-default-browser",
613
+ });
614
+ expect(result).toMatchObject({ content: "desktop" });
615
+ expect(desktopContext?.clientOs).toBe("windows");
616
+ expect(mockOperationCalls).toHaveLength(0);
617
+ });
618
+
619
+ test("web tab commands share the streamed browser default", async () => {
620
+ webConversation();
621
+ const { ROUTES } =
622
+ await import("../../runtime/routes/browser-tabs-routes.js");
623
+ const result = await ROUTES[0].handler({
624
+ body: { command: "list", conversationId: "conv-default-browser" },
625
+ });
626
+ expect(result).toEqual({ ok: true, tabs: [{ tabId: 1 }] });
627
+ expect(desktopContext?.clientOs).toBe("web");
628
+ });
629
+
630
+ test("a streamed browser failure does not switch to Playwright or personal Chrome", async () => {
631
+ webConversation();
632
+ desktopFailure = true;
633
+ const result = await callHandler({
634
+ operation: "click",
635
+ input: { selector: "#submit" },
636
+ conversationId: "conv-default-browser",
637
+ });
638
+ expect(result).toMatchObject({ isError: true, content: "Desktop is busy" });
639
+ expect(mockOperationCalls).toHaveLength(0);
640
+ });
641
+
642
+ test("first web browser use routes to virtual desktop setup before installation", async () => {
643
+ webConversation();
644
+ desktopReady = false;
645
+ await callHandler({
646
+ operation: "navigate",
647
+ input: { url: "https://example.com" },
648
+ conversationId: "conv-default-browser",
649
+ });
650
+ expect(desktopContext?.clientOs).toBe("web");
651
+ expect(mockOperationCalls).toHaveLength(0);
652
+ });
@@ -0,0 +1,54 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+
3
+ import { setDbReady } from "../../daemon/daemon-readiness.js";
4
+ import type { RouteHandlerArgs } from "../../runtime/routes/types.js";
5
+ import { AssistantIpcServer } from "../assistant-server.js";
6
+ import { cliIpcCall } from "../cli-client.js";
7
+
8
+ let server: AssistantIpcServer | undefined;
9
+ afterEach(() => server?.stop());
10
+
11
+ for (const cancelOnDisconnect of [true, false]) {
12
+ test(`timed-out IPC request cancels its action only when opted in: ${cancelOnDisconnect}`, async () => {
13
+ setDbReady(true);
14
+ server = new AssistantIpcServer();
15
+ const methods = (
16
+ server as unknown as {
17
+ methods: Map<string, (args: RouteHandlerArgs) => Promise<unknown>>;
18
+ }
19
+ ).methods;
20
+ const setup = Promise.withResolvers<void>();
21
+ const completed = Promise.withResolvers<void>();
22
+ let signal: AbortSignal | undefined;
23
+ let actions = 0;
24
+ methods.set("test_browser_setup", async (args) => {
25
+ signal = args.abortSignal;
26
+ await setup.promise;
27
+ if (!signal?.aborted) {
28
+ actions++;
29
+ }
30
+ completed.resolve();
31
+ return { ok: true };
32
+ });
33
+ await server.start();
34
+ try {
35
+ const result = await cliIpcCall(
36
+ "test_browser_setup",
37
+ {},
38
+ {
39
+ timeoutMs: 100,
40
+ cancelOnDisconnect,
41
+ },
42
+ );
43
+ expect(result.timedOut).toBe(true);
44
+ expect(signal).toBeDefined();
45
+ await Bun.sleep(20);
46
+ setup.resolve();
47
+ await completed.promise;
48
+ expect(signal?.aborted).toBe(cancelOnDisconnect);
49
+ expect(actions).toBe(cancelOnDisconnect ? 0 : 1);
50
+ } finally {
51
+ setup.resolve();
52
+ }
53
+ });
54
+ }
@@ -78,6 +78,7 @@ const log = getLogger("assistant-ipc-server");
78
78
  // ---------------------------------------------------------------------------
79
79
 
80
80
  export type IpcRequest = {
81
+ cancelOnDisconnect?: boolean;
81
82
  id: string;
82
83
  method: string;
83
84
  params?: Record<string, unknown>;
@@ -404,6 +405,16 @@ export class AssistantIpcServer {
404
405
  this.abortControllers.set(req.id, abortController);
405
406
  }
406
407
 
408
+ const onDisconnect = () => abortController?.abort();
409
+ if (req.cancelOnDisconnect === true) {
410
+ socket.once("close", onDisconnect);
411
+ }
412
+ const removeDisconnectListener = () => {
413
+ if (req.cancelOnDisconnect === true) {
414
+ socket.off("close", onDisconnect);
415
+ }
416
+ };
417
+
407
418
  try {
408
419
  const handlerArgs = {
409
420
  ...injectLocalActorHeader(req.params),
@@ -434,14 +445,17 @@ export class AssistantIpcServer {
434
445
  reader,
435
446
  this.buildErrorResponse(req.id, err),
436
447
  );
437
- });
448
+ })
449
+ .finally(removeDisconnectListener);
438
450
  } else {
451
+ removeDisconnectListener();
439
452
  if (!isIpcStreamingResponse(result)) {
440
453
  this.abortControllers.delete(req.id);
441
454
  }
442
455
  this.sendResult(socket, reader, req.id, result);
443
456
  }
444
457
  } catch (err) {
458
+ removeDisconnectListener();
445
459
  this.abortControllers.delete(req.id);
446
460
  log.warn({ err, method: req.method }, "IPC handler error");
447
461
  this.sendResponse(socket, reader, this.buildErrorResponse(req.id, err));
@@ -60,9 +60,8 @@ export interface CliIpcCallResult<T = unknown> {
60
60
  errorDetails?: unknown;
61
61
  /**
62
62
  * Set when the call was abandoned because `timeoutMs` elapsed with no
63
- * response. Distinct from every other `ok: false` shape: the request WAS
64
- * delivered and the daemon may still be executing it — closing the client
65
- * socket does not abort the handler. Callers that would otherwise retry a
63
+ * response. The request was delivered and may still be executing unless
64
+ * `cancelOnDisconnect` was requested. Callers that would otherwise retry a
66
65
  * transport failure must not retry this one until the original can no
67
66
  * longer be in flight.
68
67
  */
@@ -79,7 +78,11 @@ export interface CliIpcCallResult<T = unknown> {
79
78
  export async function cliIpcCall<T = unknown>(
80
79
  method: string,
81
80
  params?: Record<string, unknown>,
82
- options?: { timeoutMs?: number; signal?: AbortSignal },
81
+ options?: {
82
+ timeoutMs?: number;
83
+ signal?: AbortSignal;
84
+ cancelOnDisconnect?: boolean;
85
+ },
83
86
  ): Promise<CliIpcCallResult<T>> {
84
87
  if (options?.signal?.aborted) {
85
88
  throw options.signal.reason ?? new DOMException("Aborted", "AbortError");
@@ -180,7 +183,12 @@ export async function cliIpcCall<T = unknown>(
180
183
 
181
184
  socket.on("connect", () => {
182
185
  clearTimeout(connectTimer);
183
- writeMessage(socket, { id: reqId, method, params });
186
+ writeMessage(socket, {
187
+ id: reqId,
188
+ method,
189
+ params,
190
+ ...(opts?.cancelOnDisconnect ? { cancelOnDisconnect: true } : {}),
191
+ });
184
192
 
185
193
  callTimer = setTimeout(() => {
186
194
  log.debug(
@@ -1043,6 +1043,28 @@ describe("LiveVoiceSession spoken session controls", () => {
1043
1043
  ).toMatchObject({ action: "mute", durationMs: 30_000 });
1044
1044
  });
1045
1045
 
1046
+ test("a declared look sends its look control after the acknowledgement", async () => {
1047
+ const { frames, session, getCallbacks } = createControlsHarness([
1048
+ "look_screen",
1049
+ ]);
1050
+
1051
+ await startReleasedTurn(session, getCallbacks);
1052
+ emitTextDelta(
1053
+ getCallbacks,
1054
+ "Taking a look. What should I focus on? [LOOK:SCREEN]",
1055
+ );
1056
+ emitMessageComplete(getCallbacks);
1057
+ await waitFor(() =>
1058
+ frames.some((frame) => frame.type === "session_control"),
1059
+ );
1060
+
1061
+ expect(
1062
+ frames.find((frame) => frame.type === "session_control"),
1063
+ ).toMatchObject({ action: "look_screen" });
1064
+ // The marker is control, never caption text.
1065
+ expect(assistantDeltaTexts(frames).join("").includes("[LOOK")).toBe(false);
1066
+ });
1067
+
1046
1068
  test("a control the client did not declare is never sent", async () => {
1047
1069
  const { frames, session, getCallbacks } = createControlsHarness(["mute"]);
1048
1070
 
@@ -259,9 +259,10 @@ describe("LiveVoiceSession archive and metrics events", () => {
259
259
  // has nothing to show and no connection to run, and its capability
260
260
  // digest already routes anything needing a tool to the escalated leg
261
261
  // (see live-voice-triage-escalate.test.ts). The start frame declares no
262
- // client controls, so only the session's own update cadence is taught.
262
+ // client controls, so only the session's own update cadence and the
263
+ // no-looks line are taught.
263
264
  voiceControlPrompt:
264
- 'You are speaking in a local live voice session. Keep each reply to one to three spoken sentences. The caller is listening, not reading: lead with the answer, say it in the fewest words that are still complete, and stop. No preamble, no recap of the question, no closing offer of more. Your text is sent directly to a text-to-speech engine. Never use markdown formatting (asterisks, headers, backticks, links), bulleted or numbered lists, or emojis. Write plain conversational text only; if something is naturally a list, say it as one sentence. Speech is the main channel: say the answer, and do not narrate a surface instead of answering. You can also put something on screen when it genuinely helps (a form, a list to pick from, a progress card for long work, or a card with an image or a link instead of reading an address out); the call overlay minimizes by itself once you finish speaking, so the user sees it without doing anything. Never tell the user you cannot show them something. Reply in the language the caller is speaking; if they switch languages, switch with them. The user can also control this call by asking you. Only when they clearly ask:\n- To hear fewer spoken progress updates while you work (for example "don\'t give me updates so often"), confirm that you will only check in now and then and will tell them when it is done, then end your reply with [UPDATES:FEWER]. If they later want regular updates back, confirm and end with [UPDATES:NORMAL].\nThe marker must be the very last thing in your reply. It is never spoken and does nothing anywhere else.',
265
+ 'You are speaking in a local live voice session. Keep each reply to one to three spoken sentences. The caller is listening, not reading: lead with the answer, say it in the fewest words that are still complete, and stop. No preamble, no recap of the question, no closing offer of more. Your text is sent directly to a text-to-speech engine. Never use markdown formatting (asterisks, headers, backticks, links), bulleted or numbered lists, or emojis. Write plain conversational text only; if something is naturally a list, say it as one sentence. Speech is the main channel: say the answer, and do not narrate a surface instead of answering. You can also put something on screen when it genuinely helps (a form, a list to pick from, a progress card for long work, or a card with an image or a link instead of reading an address out); the call overlay minimizes by itself once you finish speaking, so the user sees it without doing anything. Never tell the user you cannot show them something. Reply in the language the caller is speaking; if they switch languages, switch with them. The user can also control this call by asking you. Only when they clearly ask:\n- This call cannot turn on a screen share or the camera. If they ask you to look at their screen or at something and you have no other way to see it, say so briefly instead of pretending to look.\n- To hear fewer spoken progress updates while you work (for example "don\'t give me updates so often"), confirm that you will only check in now and then and will tell them when it is done, then end your reply with [UPDATES:FEWER]. If they later want regular updates back, confirm and end with [UPDATES:NORMAL].\nThe marker must be the very last thing in your reply. It is never spoken and does nothing anywhere else.',
265
266
  });
266
267
  callbacks?.assistant_text_delta?.(makeTextDelta("Hello there."));
267
268
  callbacks?.message_complete?.(makeMessageComplete());
@@ -350,6 +350,25 @@ describe("live-voice turn attribution", () => {
350
350
  expect(options.assistantMessageInterface).toBe("macos");
351
351
  });
352
352
 
353
+ test("a session opened from the macOS desktop client asks for the desktop skills", async () => {
354
+ const options = await captureTurnOptions({
355
+ ...START_FRAME,
356
+ client: "macos",
357
+ });
358
+
359
+ expect(options.macosDesktopSession).toBe(true);
360
+ });
361
+
362
+ test("a session from any other client does not ask for the desktop skills", async () => {
363
+ for (const startFrame of [{ ...START_FRAME, client: "ios" }, START_FRAME]) {
364
+ const options = await captureTurnOptions(
365
+ startFrame as LiveVoiceClientStartFrame,
366
+ );
367
+
368
+ expect(options.macosDesktopSession).toBeUndefined();
369
+ }
370
+ });
371
+
353
372
  test("omits the client when the start frame declares none", async () => {
354
373
  const options = await captureTurnOptions(START_FRAME);
355
374
 
@@ -701,7 +701,15 @@ describe("parseLiveVoiceClientTextFrame", () => {
701
701
  test("keeps known session controls and drops unknown ones", () => {
702
702
  const result = validateLiveVoiceClientFrame({
703
703
  type: "start",
704
- sessionControls: ["mute", "look_screen", "end", "mute", 7],
704
+ sessionControls: [
705
+ "mute",
706
+ "look_stop",
707
+ "look_camera",
708
+ "end",
709
+ "mute",
710
+ 7,
711
+ "fly",
712
+ ],
705
713
  audio: { mimeType: "audio/pcm", sampleRate: 24000, channels: 1 },
706
714
  });
707
715
 
@@ -712,14 +720,14 @@ describe("parseLiveVoiceClientTextFrame", () => {
712
720
  // A newer client's controls cost it nothing on an older daemon.
713
721
  expect(result.frame).toMatchObject({
714
722
  type: "start",
715
- sessionControls: ["end", "mute"],
723
+ sessionControls: ["end", "mute", "look_camera", "look_stop"],
716
724
  });
717
725
  });
718
726
 
719
727
  test.each([
720
728
  ["absent", {}],
721
729
  ["not an array", { sessionControls: "end" }],
722
- ["all unknown", { sessionControls: ["look_screen"] }],
730
+ ["all unknown", { sessionControls: ["fly"] }],
723
731
  ])(
724
732
  "omits sessionControls from the start frame when %s",
725
733
  (_label, extra: Record<string, unknown>) => {
@@ -71,6 +71,40 @@ describe("sessionControlTeaching", () => {
71
71
  expect(teaching).toContain("Never emit any other bracketed marker.");
72
72
  });
73
73
 
74
+ test("a device that can show both asks which one a bare look means", () => {
75
+ const teaching = sessionControlTeaching(["look_screen", "look_camera"], {});
76
+
77
+ expect(teaching).toContain("[LOOK:SCREEN]");
78
+ expect(teaching).toContain("[LOOK:CAMERA]");
79
+ expect(teaching).toContain("ask which one instead of guessing");
80
+ });
81
+
82
+ test("a device that can look can be told to stop", () => {
83
+ const teaching = sessionControlTeaching(["look_screen", "look_stop"], {});
84
+
85
+ expect(teaching).toContain("[LOOK:STOP]");
86
+ expect(
87
+ requestedSessionControl("Stopping. [LOOK:STOP]", ["look_stop"]),
88
+ ).toEqual({ action: "look_stop" });
89
+ });
90
+
91
+ test("a device that can show one look needs no question", () => {
92
+ const teaching = sessionControlTeaching(["look_camera"], {});
93
+
94
+ expect(teaching).toContain("[LOOK:CAMERA]");
95
+ expect(teaching).not.toContain("[LOOK:SCREEN]");
96
+ expect(teaching).not.toContain("ask which one");
97
+ expect(teaching).not.toContain("cannot turn on a screen share");
98
+ });
99
+
100
+ // The silent failure in the report this exists for: asked to look, the
101
+ // assistant neither looked nor said it could not.
102
+ test("a device that can show neither says so rather than pretending", () => {
103
+ expect(sessionControlTeaching(["end"], {})).toContain(
104
+ "This call cannot turn on a screen share or the camera.",
105
+ );
106
+ });
107
+
74
108
  test("the front-door leg keeps its verdict tokens", () => {
75
109
  expect(sessionControlTeaching(["end"], { frontDoor: true })).not.toContain(
76
110
  "Never emit any other bracketed marker.",
@@ -511,6 +511,15 @@ export class LiveVoiceMetricsCollector {
511
511
  turnId: turn.turnId,
512
512
  finishReason,
513
513
  ...aggregateFieldsForTurn(snapshotTurn(turn)),
514
+ // Stated even at zero, unlike the telemetry field this overrides.
515
+ // Zero is the single most informative value this count takes: it is
516
+ // what a turn asked for fewer updates looks like, and equally what a
517
+ // narrator that is failing every attempt looks like. Left absent, the
518
+ // two read identically to anyone reading the log, and "the feature is
519
+ // off" becomes indistinguishable from "the feature did its job".
520
+ // The wire payload keeps the omission (see optionalTurnFields), so
521
+ // turns that never engage narration are unchanged for telemetry.
522
+ progressUpdatesSpoken: turn.progressUpdatesSpoken,
514
523
  },
515
524
  "Live voice turn latency",
516
525
  );
@@ -5322,6 +5322,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5322
5322
  // as telemetry instead, on `voiceTelemetry` below.
5323
5323
  userMessageInterface: "macos",
5324
5324
  assistantMessageInterface: "macos",
5325
+ ...(this.context.startFrame.client === "macos"
5326
+ ? { macosDesktopSession: true }
5327
+ : {}),
5325
5328
  voiceTelemetry: {
5326
5329
  sessionId: this.context.sessionId,
5327
5330
  ...(this.context.startFrame.client
@@ -168,7 +168,13 @@ export interface LiveVoiceClientStartFrame {
168
168
  readonly sessionControls?: readonly LiveVoiceSessionControl[];
169
169
  }
170
170
 
171
- const LIVE_VOICE_SESSION_CONTROLS = ["end", "mute"] as const;
171
+ const LIVE_VOICE_SESSION_CONTROLS = [
172
+ "end",
173
+ "mute",
174
+ "look_screen",
175
+ "look_camera",
176
+ "look_stop",
177
+ ] as const;
172
178
 
173
179
  /** A session control a client can carry out on the assistant's behalf. */
174
180
  export type LiveVoiceSessionControl =
@@ -633,6 +639,9 @@ export interface LiveVoiceMinimizeRoomServerFrame extends LiveVoiceServerFrameBa
633
639
  * elapses; without, stay muted until the user unmutes. The timer is the
634
640
  * client's: a muted microphone sends silence, so the daemon cannot hear an
635
641
  * "unmute".
642
+ * - `look_screen`: start showing the call the user's screen.
643
+ * - `look_camera`: start showing the call what the camera sees.
644
+ * - `look_stop`: stop showing the call the screen and the camera.
636
645
  */
637
646
  export interface LiveVoiceSessionControlServerFrame extends LiveVoiceServerFrameBase {
638
647
  readonly type: "session_control";
@@ -1,6 +1,9 @@
1
1
  import {
2
2
  END_CALL_MARKER,
3
3
  FEWER_UPDATES_MARKER,
4
+ LOOK_CAMERA_MARKER,
5
+ LOOK_SCREEN_MARKER,
6
+ LOOK_STOP_MARKER,
4
7
  MUTE_MARKER,
5
8
  NORMAL_UPDATES_MARKER,
6
9
  parseTerminalSessionControl,
@@ -24,6 +27,9 @@ import type { LiveVoiceSessionControl } from "./protocol.js";
24
27
 
25
28
  const CLIENT_CONTROL_LINES: Record<LiveVoiceSessionControl, string> = {
26
29
  end: `- To end the call (for example "I'm all done" or "okay, I'm gonna go"), say a brief goodbye, then end your reply with ${END_CALL_MARKER}. Being done with a task is not the same as leaving the call; end only when they are leaving.`,
30
+ look_screen: `- To look at their screen (for example "take a look at my screen" or "can you see what I'm looking at?"), say you are taking a look, then end your reply with ${LOOK_SCREEN_MARKER}. Their screen starts being shared with you once you finish speaking, so you cannot describe it yet: ask what they want you to look at, or say you will take it from their next words.`,
31
+ look_camera: `- To look through their camera (for example "look at this" or "can you see this?" while they hold something up), say you are taking a look, then end your reply with ${LOOK_CAMERA_MARKER}. The camera turns on once you finish speaking, so you cannot describe what it sees yet: ask them to show you, or say you will take it from their next words.`,
32
+ look_stop: `- To stop showing you their screen or camera (for example "stop sharing" or "you can stop looking now"), confirm in a few words, then end your reply with ${LOOK_STOP_MARKER}.`,
27
33
  mute: `- To mute their microphone (for example "mute for 30 seconds" or "mute yourself, I need to take this"), confirm in a few words, then end your reply with [MUTE:<seconds>] when they gave a duration or ${MUTE_MARKER} when they did not. While muted you cannot hear them, so mention they can unmute from the call controls unless the mute is timed.`,
28
34
  };
29
35
 
@@ -47,11 +53,33 @@ export function sessionControlTeaching(
47
53
  return [
48
54
  "The user can also control this call by asking you. Only when they clearly ask:",
49
55
  ...controls.map((control) => CLIENT_CONTROL_LINES[control]),
56
+ ...lookGuidance(controls),
50
57
  UPDATES_LINE,
51
58
  `The marker must be the very last thing in your reply. It is never spoken and does nothing anywhere else.${leg.frontDoor === true ? "" : " Never emit any other bracketed marker."}`,
52
59
  ].join("\n");
53
60
  }
54
61
 
62
+ /**
63
+ * What to say about looking beyond the per-control lines: ask which when the
64
+ * device can do both and the request does not say, and say so plainly when it
65
+ * can do neither, rather than failing silently.
66
+ */
67
+ function lookGuidance(controls: readonly LiveVoiceSessionControl[]): string[] {
68
+ const screen = controls.includes("look_screen");
69
+ const camera = controls.includes("look_camera");
70
+ if (screen && camera) {
71
+ return [
72
+ `- If they just say "take a look" and it is not clear whether they mean their screen or their camera, ask which one instead of guessing, and use no marker until they answer.`,
73
+ ];
74
+ }
75
+ if (!screen && !camera) {
76
+ return [
77
+ "- This call cannot turn on a screen share or the camera. If they ask you to look at their screen or at something and you have no other way to see it, say so briefly instead of pretending to look.",
78
+ ];
79
+ }
80
+ return [];
81
+ }
82
+
55
83
  /** A session control the client carries out, sent as a `session_control` frame. */
56
84
  export type ClientSessionControlRequest = Exclude<
57
85
  SessionControlRequest,