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

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 (153) 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/apps/app-store.ts +6 -5
  21. package/src/browser/operations.ts +2 -1
  22. package/src/browser/types.ts +7 -0
  23. package/src/browser/virtual-desktop-target.ts +34 -0
  24. package/src/calls/__tests__/progress-narration.test.ts +23 -0
  25. package/src/calls/__tests__/voice-control-protocol.test.ts +7 -0
  26. package/src/calls/__tests__/voice-session-bridge.test.ts +62 -0
  27. package/src/calls/progress-narration.ts +15 -2
  28. package/src/calls/voice-control-protocol.ts +34 -4
  29. package/src/calls/voice-session-bridge.ts +23 -0
  30. package/src/cli/commands/__tests__/browser.test.ts +84 -3
  31. package/src/cli/commands/browser.help.ts +43 -0
  32. package/src/cli/commands/browser.ts +52 -8
  33. package/src/cli/commands/plugins.ts +16 -5
  34. package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
  35. package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
  36. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
  37. package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
  38. package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
  39. package/src/cli/lib/bundled-marketplace.json +13 -0
  40. package/src/cli/lib/install-from-github.ts +17 -18
  41. package/src/cli/lib/install-from-platform.ts +14 -9
  42. package/src/cli/lib/list-installed-plugins.ts +21 -35
  43. package/src/cli/lib/plugin-details.ts +21 -8
  44. package/src/cli/lib/uninstall-plugin.ts +59 -1
  45. package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
  46. package/src/config/bundled-skills/computer-use/SKILL.md +30 -0
  47. package/src/config/bundled-skills/computer-use/TOOLS.json +2 -2
  48. package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
  49. package/src/config/feature-flag-registry.json +2 -2
  50. package/src/config/schemas/__tests__/voice.test.ts +2 -2
  51. package/src/config/schemas/mcp.ts +9 -7
  52. package/src/config/schemas/monitoring.ts +9 -0
  53. package/src/config/schemas/voice.ts +2 -2
  54. package/src/config/skills.ts +13 -21
  55. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
  56. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
  57. package/src/daemon/conversation-client-surface.ts +26 -0
  58. package/src/daemon/conversation-runtime-assembly.ts +1 -1
  59. package/src/daemon/conversation-tool-setup.ts +31 -29
  60. package/src/daemon/host-cu-proxy.ts +51 -26
  61. package/src/daemon/host-proxy-preactivation.ts +2 -0
  62. package/src/daemon/mcp-reload-service.ts +7 -0
  63. package/src/daemon/message-types/sync.ts +1 -0
  64. package/src/daemon/providers-setup.ts +4 -0
  65. package/src/desktop/__tests__/fake-desktop.ts +1 -0
  66. package/src/desktop/desktop-automation-lease.test.ts +208 -0
  67. package/src/desktop/desktop-automation-lease.ts +270 -0
  68. package/src/desktop/desktop-browser-client.test.ts +413 -0
  69. package/src/desktop/desktop-browser-client.ts +430 -0
  70. package/src/desktop/desktop-browser-cursor.ts +40 -0
  71. package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
  72. package/src/desktop/desktop-browser-endpoint.ts +145 -0
  73. package/src/desktop/desktop-browser-operations.ts +130 -0
  74. package/src/desktop/desktop-chrome-session.test.ts +48 -0
  75. package/src/desktop/desktop-chrome-session.ts +58 -8
  76. package/src/desktop/desktop-dependencies.test.ts +29 -0
  77. package/src/desktop/desktop-dependencies.ts +26 -5
  78. package/src/desktop/desktop-display.ts +9 -0
  79. package/src/desktop/desktop-panel-config.ts +14 -1
  80. package/src/desktop/desktop-session-manager.test.ts +86 -3
  81. package/src/desktop/desktop-session-manager.ts +144 -24
  82. package/src/desktop/desktop-stream-bridge.ts +1 -1
  83. package/src/desktop/virtual-desktop-feature.ts +18 -0
  84. package/src/desktop/virtual-desktop-platform.test.ts +61 -0
  85. package/src/i18n/__tests__/i18n.test.ts +9 -5
  86. package/src/i18n/messages.ts +13 -4
  87. package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
  88. package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
  89. package/src/ipc/assistant-server.ts +15 -1
  90. package/src/ipc/cli-client.ts +13 -5
  91. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +22 -0
  92. package/src/live-voice/__tests__/live-voice-events.test.ts +3 -2
  93. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
  94. package/src/live-voice/__tests__/protocol.test.ts +11 -3
  95. package/src/live-voice/__tests__/session-controls.test.ts +34 -0
  96. package/src/live-voice/live-voice-metrics.ts +9 -0
  97. package/src/live-voice/live-voice-session.ts +3 -0
  98. package/src/live-voice/protocol.ts +10 -1
  99. package/src/live-voice/session-controls.ts +28 -0
  100. package/src/mcp/__tests__/credential-target.test.ts +59 -0
  101. package/src/mcp/__tests__/effective-config.test.ts +11 -0
  102. package/src/mcp/__tests__/manager-state.test.ts +128 -0
  103. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
  104. package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
  105. package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
  106. package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
  107. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
  108. package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
  109. package/src/mcp/client.ts +64 -16
  110. package/src/mcp/credential-target.ts +105 -0
  111. package/src/mcp/effective-config.ts +42 -15
  112. package/src/mcp/manager.ts +61 -8
  113. package/src/mcp/mcp-auth-orchestrator.ts +17 -4
  114. package/src/mcp/mcp-auth-state.ts +38 -6
  115. package/src/mcp/mcp-oauth-provider.ts +101 -33
  116. package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
  117. package/src/monitoring/mount-watch.ts +348 -0
  118. package/src/monitoring/worker.ts +9 -0
  119. package/src/oauth/seed-providers.ts +11 -0
  120. package/src/onboarding/checkin-event.ts +2 -1
  121. package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
  122. package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
  123. package/src/plugins/external-plugin-loader.ts +49 -86
  124. package/src/plugins/installed-plugin-dirs.ts +6 -4
  125. package/src/plugins/mcp-servers.ts +9 -15
  126. package/src/plugins/mtime-cache.ts +2 -1
  127. package/src/plugins/user-loader.ts +2 -1
  128. package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
  129. package/src/providers/inference/endpoint-probe.ts +27 -12
  130. package/src/providers/openai/responses-provider.ts +9 -2
  131. package/src/providers/opencode/client.test.ts +167 -0
  132. package/src/providers/opencode/client.ts +73 -4
  133. package/src/runtime/AGENTS.md +4 -0
  134. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
  135. package/src/runtime/http-server.ts +6 -6
  136. package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
  137. package/src/runtime/routes/browser-context.ts +39 -0
  138. package/src/runtime/routes/browser-routes.ts +16 -47
  139. package/src/runtime/routes/browser-tabs-routes.ts +72 -29
  140. package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
  141. package/src/runtime/routes/desktop-setup-routes.ts +7 -5
  142. package/src/runtime/routes/mcp-auth-routes.ts +106 -94
  143. package/src/runtime/routes/plugins-routes.ts +14 -1
  144. package/src/runtime/sync/resource-sync-events.ts +4 -0
  145. package/src/tools/browser/browser-execution.ts +44 -13
  146. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
  147. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
  148. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
  149. package/src/tools/computer-use/definitions.ts +2 -3
  150. package/src/util/abort-reasons.ts +12 -1
  151. package/src/util/plugin-manifest.ts +203 -0
  152. package/src/desktop/desktop-feature.test.ts +0 -18
  153. package/src/desktop/desktop-feature.ts +0 -19
@@ -8,38 +8,35 @@
8
8
  *
9
9
  * 1. A workspace entry of the same id wins. Getting precedence backwards
10
10
  * would let a plugin redirect a server the user configured by hand.
11
- * 2. A plugin server is never health-checked. `McpClient.connect` resolves
12
- * `mcp:<serverId>:headers` and `mcp:<serverId>:tokens` from the
13
- * credential store, and a plugin controls both its server key and its
14
- * URL, so probing one would send a workspace credential to an endpoint
15
- * the plugin chose whenever an id happens to match a stored key.
11
+ * 2. A plugin server is never health-checked as a side effect of listing.
12
+ * OAuth status is read from the plugin and endpoint-scoped credential
13
+ * identity, while workspace static headers remain isolated.
16
14
  */
17
15
 
18
16
  import { mkdirSync, rmSync, writeFileSync } from "node:fs";
19
17
  import { join } from "node:path";
20
18
  import { beforeEach, describe, expect, jest, mock, test } from "bun:test";
21
19
 
22
- const mockConnect = jest.fn();
23
- const mockDisconnect = jest.fn();
24
- /** Server ids the route actually constructed an MCP client for. */
25
- const connectedServerIds: string[] = [];
20
+ const getServerState = jest.fn();
21
+ const hasMcpOAuthTokens = jest.fn(async (target: any) => {
22
+ if (target.source === "plugin" && target.url === "not a url") {
23
+ throw new TypeError("invalid URL");
24
+ }
25
+ return true;
26
+ });
26
27
 
27
28
  mock.module("../mcp/client.js", () => ({
28
29
  McpClient: class {
29
- constructor(serverId: string) {
30
- connectedServerIds.push(serverId);
31
- }
32
- get isConnected() {
33
- return true;
30
+ constructor() {
31
+ throw new Error("list route must not construct an MCP client");
34
32
  }
35
- get lastError() {
36
- return null;
37
- }
38
- connect = mockConnect;
39
- disconnect = mockDisconnect;
40
33
  },
41
34
  }));
42
35
 
36
+ mock.module("../mcp/manager.js", () => ({
37
+ getMcpServerManager: () => ({ getServerState }),
38
+ }));
39
+
43
40
  mock.module("../mcp/mcp-auth-orchestrator.js", () => ({
44
41
  orchestrateMcpOAuthConnect: async () => ({
45
42
  auth_url: "",
@@ -52,9 +49,7 @@ mock.module("../mcp/mcp-auth-state.js", () => ({
52
49
  }));
53
50
 
54
51
  mock.module("../mcp/mcp-oauth-provider.js", () => ({
55
- // Stand in for a credential store that holds tokens for every id, which
56
- // is the condition under which a leak would be observable.
57
- hasMcpOAuthTokens: async () => true,
52
+ hasMcpOAuthTokens,
58
53
  deleteMcpOAuthCredentials: async () => ({ ok: true, failedKeys: [] }),
59
54
  }));
60
55
 
@@ -108,6 +103,19 @@ function writePlugin(name: string, mcpJson: unknown): void {
108
103
  writeFileSync(join(dir, "mcp.json"), JSON.stringify(mcpJson));
109
104
  }
110
105
 
106
+ function writeStandardPlugin(name: string, mcpJson: unknown): void {
107
+ const dir = join(getWorkspacePluginsDir(), name);
108
+ mkdirSync(dir, { recursive: true });
109
+ writeFileSync(
110
+ join(dir, "plugin.json"),
111
+ JSON.stringify({
112
+ $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
113
+ name,
114
+ }),
115
+ );
116
+ writeFileSync(join(dir, "mcp.json"), JSON.stringify(mcpJson));
117
+ }
118
+
111
119
  function unabyssManifest(): unknown {
112
120
  return {
113
121
  mcpServers: {
@@ -123,9 +131,8 @@ async function listServers(): Promise<ListedServer[]> {
123
131
 
124
132
  describe("internal_mcp_list, plugin-declared servers", () => {
125
133
  beforeEach(() => {
126
- mockConnect.mockReset();
127
- mockDisconnect.mockReset();
128
- connectedServerIds.length = 0;
134
+ getServerState.mockReset();
135
+ hasMcpOAuthTokens.mockClear();
129
136
  rmSync(getWorkspacePluginsDir(), { recursive: true, force: true });
130
137
  mkdirSync(getWorkspacePluginsDir(), { recursive: true });
131
138
  });
@@ -138,6 +145,14 @@ describe("internal_mcp_list, plugin-declared servers", () => {
138
145
  expect(ids).toContain("unabyss");
139
146
  });
140
147
 
148
+ test("a standard-only plugin keeps its server identity", async () => {
149
+ writeStandardPlugin("unabyss", unabyssManifest());
150
+
151
+ const plugin = (await listServers()).find((s) => s.id === "unabyss")!;
152
+ expect(plugin.source).toBe("plugin");
153
+ expect(plugin.pluginName).toBe("unabyss");
154
+ });
155
+
141
156
  test("plugin servers are labelled with their origin, workspace servers are not", async () => {
142
157
  writePlugin("unabyss", unabyssManifest());
143
158
 
@@ -171,20 +186,36 @@ describe("internal_mcp_list, plugin-declared servers", () => {
171
186
  const servers = await listServers();
172
187
 
173
188
  expect(servers.find((s) => s.id === "unabyss")!.status).toEqual("declared");
174
- // The credential mocks above return a token and an Authorization header
175
- // for every id. Constructing a client for the plugin server is what
176
- // would ship them to the plugin-declared URL.
177
- expect(connectedServerIds).toContain("from-workspace");
178
- expect(connectedServerIds).not.toContain("unabyss");
189
+ expect(getServerState).toHaveBeenCalledWith("from-workspace", "workspace");
190
+ expect(getServerState).toHaveBeenCalledWith("unabyss", "plugin");
179
191
  });
180
192
 
181
- test("plugin servers report no assistant-owned auth even when the store has some", async () => {
193
+ test("plugin status uses only state recorded for the plugin source", async () => {
194
+ writePlugin("unabyss", unabyssManifest());
195
+ getServerState.mockImplementation(
196
+ (serverId: string, source: "workspace" | "plugin") =>
197
+ serverId === "unabyss" && source === "plugin" ? "connected" : undefined,
198
+ );
199
+
200
+ const plugin = (await listServers()).find((s) => s.id === "unabyss")!;
201
+ expect(plugin.status).toBe("connected");
202
+ });
203
+
204
+ test("plugin servers report endpoint-scoped OAuth without static auth", async () => {
182
205
  writePlugin("unabyss", unabyssManifest());
183
206
 
184
207
  const plugin = (await listServers()).find((s) => s.id === "unabyss")!;
185
- expect(plugin.hasOAuth).toBe(false);
208
+ expect(plugin.hasOAuth).toBe(true);
186
209
  expect(plugin.hasStaticAuth).toBe(false);
187
210
  expect(plugin.authType).toEqual("none");
211
+ expect(hasMcpOAuthTokens).toHaveBeenCalledWith(
212
+ expect.objectContaining({
213
+ source: "plugin",
214
+ pluginName: "unabyss",
215
+ serverKey: "unabyss",
216
+ url: "https://mcp.unabyss.com",
217
+ }),
218
+ );
188
219
  });
189
220
 
190
221
  test("workspace servers keep reporting their auth state", async () => {
@@ -225,6 +256,20 @@ describe("internal_mcp_list, plugin-declared servers", () => {
225
256
  expect(ids).toContain("from-workspace");
226
257
  });
227
258
 
259
+ test("an invalid plugin transport URL does not break the listing", async () => {
260
+ writePlugin("bad-url", {
261
+ mcpServers: {
262
+ remote: { type: "streamable-http", url: "not a url" },
263
+ },
264
+ });
265
+
266
+ const servers = await listServers();
267
+ expect(servers.find((s) => s.id === "bad-url__remote")?.hasOAuth).toBe(
268
+ false,
269
+ );
270
+ expect(servers.some((s) => s.id === "from-workspace")).toBe(true);
271
+ });
272
+
228
273
  test("no plugins installed leaves the listing unchanged", async () => {
229
274
  const servers = await listServers();
230
275
  expect(servers.every((s) => s.source === "workspace")).toBe(true);
@@ -32,10 +32,12 @@ const { RiskLevel } = await import("../permissions/types.js");
32
32
  type ServerSource = "workspace" | "plugin";
33
33
 
34
34
  function serverConfig(source: ServerSource) {
35
- return {
35
+ const base = {
36
36
  transport: { type: "stdio" as const, command: "echo", args: [] },
37
- source,
38
37
  };
38
+ return source === "workspace"
39
+ ? { ...base, source }
40
+ : { ...base, source, pluginName: "plugin", serverKey: "server" };
39
41
  }
40
42
 
41
43
  interface RiskAnnotations {
@@ -1054,6 +1054,16 @@ describe("ingress-dependent setup skills declare public-ingress intentionally",
1054
1054
  expect(includes ?? []).not.toContain("public-ingress");
1055
1055
  });
1056
1056
 
1057
+ test("telegram-setup documents the private-chat limitation", () => {
1058
+ const content = readFileSync(
1059
+ join(FIRST_PARTY_SKILLS_DIR, "telegram-setup", "SKILL.md"),
1060
+ "utf-8",
1061
+ );
1062
+ expect(content).toMatch(/private-chat only/i);
1063
+ expect(content).toMatch(/group, supergroup, and channel/i);
1064
+ expect(content).toMatch(/button taps/i);
1065
+ });
1066
+
1057
1067
  test("twilio-setup includes public-ingress", () => {
1058
1068
  const includes = readSkillIncludes(FIRST_PARTY_SKILLS_DIR, "twilio-setup");
1059
1069
  expect(includes).toBeDefined();
@@ -93,6 +93,27 @@ describe("user plugin loader", () => {
93
93
  expect(initHooks).toHaveLength(1);
94
94
  });
95
95
 
96
+ test("loads a plugin via a standard plugin.json manifest", async () => {
97
+ const pluginDir = join(PLUGINS_DIR, "standard-plugin");
98
+ mkdirSync(join(pluginDir, "hooks"), { recursive: true });
99
+ writeFileSync(
100
+ join(pluginDir, "plugin.json"),
101
+ JSON.stringify({
102
+ $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
103
+ name: "standard-plugin",
104
+ version: "1.0.0",
105
+ }),
106
+ );
107
+ writeFileSync(
108
+ join(pluginDir, "hooks", "init.ts"),
109
+ "export default async function init(_ctx: unknown): Promise<void> {}\n",
110
+ );
111
+
112
+ await loadUserPlugins();
113
+
114
+ expect(await getUserHooksFor("init")).toHaveLength(1);
115
+ });
116
+
96
117
  test("per-plugin failure is isolated: other plugins still load", async () => {
97
118
  // Plugin A has a malformed package.json; the loader must isolate the
98
119
  // failure and still load the healthy sibling.
@@ -43,6 +43,7 @@ import type { EditEngineResult } from "../tools/shared/filesystem/edit-engine.js
43
43
  import { applyEdit } from "../tools/shared/filesystem/edit-engine.js";
44
44
  import { getLogger } from "../util/logger.js";
45
45
  import { getDataDir, getWorkspacePluginsDir } from "../util/platform.js";
46
+ import { hasPluginManifest } from "../util/plugin-manifest.js";
46
47
 
47
48
  const log = getLogger("app-store");
48
49
 
@@ -787,8 +788,8 @@ function listAppsForPlugin(
787
788
  * `<workspace>/plugins/<name>/apps/`.
788
789
  *
789
790
  * Plugin discovery mirrors the plugin loader's `scanPlugins`: a plugin is an
790
- * entry that resolves to a directory (following symlinks) and carries a
791
- * `package.json` manifest. Stray directories without a manifest are ignored,
791
+ * entry that resolves to a directory (following symlinks) and carries a root
792
+ * plugin manifest. Stray directories without a manifest are ignored,
792
793
  * and disabled plugins (those with a `.disabled` sentinel) contribute nothing,
793
794
  * matching how their other surfaces (tools, hooks, routes) are gated.
794
795
  */
@@ -813,7 +814,7 @@ export function listPluginApps(): EnumeratedApp[] {
813
814
  } catch {
814
815
  continue;
815
816
  }
816
- if (!existsSync(join(pluginDir, "package.json"))) {
817
+ if (!hasPluginManifest(pluginDir)) {
817
818
  continue;
818
819
  }
819
820
  if (isPluginDisabled(name)) {
@@ -874,7 +875,7 @@ function isSafeIdSegment(segment: string): boolean {
874
875
  * (`plugins~<name>~<app>`, resolved by direct path build). Returns null when
875
876
  * the app does not exist, when the id is not a safe path segment, or when a
876
877
  * plugin id fails the same installed-plugin gates as discovery (directory,
877
- * `package.json` manifest, not disabled).
878
+ * root manifest, not disabled).
878
879
  */
879
880
  export function resolveAppSource(id: string): ResolvedAppSource | null {
880
881
  if (id.startsWith(PLUGIN_APP_ID_PREFIX)) {
@@ -900,7 +901,7 @@ export function resolveAppSource(id: string): ResolvedAppSource | null {
900
901
  } catch {
901
902
  return null;
902
903
  }
903
- if (!existsSync(join(pluginDir, "package.json"))) {
904
+ if (!hasPluginManifest(pluginDir)) {
904
905
  return null;
905
906
  }
906
907
  if (isPluginDisabled(pluginName)) {
@@ -32,7 +32,8 @@ import {
32
32
  } from "../tools/browser/browser-execution.js";
33
33
  import { browserManager } from "../tools/browser/browser-manager.js";
34
34
  import { normalizeBrowserMode } from "../tools/browser/browser-mode.js";
35
- import type { ToolContext, ToolExecutionResult } from "../tools/types.js";
35
+ import type { ToolExecutionResult } from "../tools/types.js";
36
+ import type { BrowserOperationContext as ToolContext } from "./types.js";
36
37
  import type { BrowserOperation } from "./types.js";
37
38
 
38
39
  // ── Dispatch handlers ────────────────────────────────────────────────
@@ -1,3 +1,10 @@
1
+ import type { ScopedCdpClient } from "../tools/browser/cdp-client/types.js";
2
+ import type { ToolContext } from "../tools/types.js";
3
+
4
+ export interface BrowserOperationContext extends ToolContext {
5
+ cdpClient?: ScopedCdpClient;
6
+ }
7
+
1
8
  /**
2
9
  * Canonical browser operation identifiers and typed metadata.
3
10
  *
@@ -0,0 +1,34 @@
1
+ import { getConfig } from "../config/loader.js";
2
+ import { isVirtualDesktopEnabled } from "../desktop/virtual-desktop-feature.js";
3
+ import { browserManager } from "../tools/browser/browser-manager.js";
4
+ import { normalizeBrowserMode } from "../tools/browser/browser-mode.js";
5
+ import { getPinnedTab } from "../tools/browser/pinned-tabs.js";
6
+ import type { ToolContext } from "../tools/types.js";
7
+
8
+ export function shouldUseVirtualDesktopBrowser(
9
+ desktop: boolean | undefined,
10
+ input: Record<string, unknown>,
11
+ context: ToolContext,
12
+ ): boolean {
13
+ if (desktop !== undefined) {
14
+ return desktop;
15
+ }
16
+ const mode = normalizeBrowserMode(input.browser_mode);
17
+ if (
18
+ "error" in mode ||
19
+ mode.mode !== "auto" ||
20
+ input.target_client_id ||
21
+ input.use_active_tab ||
22
+ context.transportInterface !== "web" ||
23
+ context.clientOs === "macos" ||
24
+ context.clientOs === "windows" ||
25
+ context.clientOs === "linux" ||
26
+ context.trustClass !== "guardian" ||
27
+ !context.sourceActorPrincipalId ||
28
+ browserManager.getPreferredBackendKind(context.conversationId) !== null ||
29
+ getPinnedTab(context.conversationId)
30
+ ) {
31
+ return false;
32
+ }
33
+ return isVirtualDesktopEnabled(getConfig());
34
+ }
@@ -185,6 +185,29 @@ describe("createVoiceProgressNarrator", () => {
185
185
  expect(Date.now() - startedAt).toBeLessThan(1000);
186
186
  });
187
187
 
188
+ test("an update that runs out of budget aborts as a narration timeout, not a session abort", async () => {
189
+ // The budget lapsing means this beat goes unspoken; it does not mean the
190
+ // call ended. Sharing `voice_session_aborted` made a narrator whose budget
191
+ // sat below the model's roundtrip read in the logs as a session dying once
192
+ // per update, which is what hid a turn that had gone silent for minutes.
193
+ let seen: AbortSignal | undefined;
194
+ const narrator = createVoiceProgressNarrator({
195
+ config: VoiceProgressConfigSchema.parse({ generationTimeoutMs: 20 }),
196
+ getProvider: async () =>
197
+ stubProvider((_messages, options) => {
198
+ seen = options?.signal;
199
+ return new Promise<ProviderResponse>(() => {});
200
+ }),
201
+ });
202
+
203
+ expect(await narrator.generateProgressText(progressInput)).toBeNull();
204
+ expect(seen?.aborted).toBe(true);
205
+ expect(seen?.reason).toMatchObject({
206
+ kind: "voice_progress_narration_timeout",
207
+ source: "voice-progress-narration",
208
+ });
209
+ });
210
+
188
211
  test("a caller abort settles promptly", async () => {
189
212
  const narrator = createVoiceProgressNarrator({
190
213
  config: VoiceProgressConfigSchema.parse({
@@ -154,11 +154,18 @@ describe("session control markers", () => {
154
154
  expect(isIncompleteControlMarkerTail("[MUTE:30]")).toBe(false);
155
155
  expect(isIncompleteControlMarkerTail("[UPDATES:FEW")).toBe(true);
156
156
  expect(isIncompleteControlMarkerTail("[UPDATES:FEWER]")).toBe(false);
157
+ expect(isIncompleteControlMarkerTail("[LOOK:SCR")).toBe(true);
158
+ expect(
159
+ stripInternalSpeechMarkers("Taking a look. [LOOK:SCREEN]").trim(),
160
+ ).toBe("Taking a look.");
157
161
  });
158
162
 
159
163
  test.each([
160
164
  ["Okay, talk soon. [END_CALL]", { action: "end" }],
161
165
  ["Muted. [MUTE]", { action: "mute" }],
166
+ ["Taking a look. [LOOK:SCREEN]", { action: "look_screen" }],
167
+ ["Show me. [LOOK:CAMERA]", { action: "look_camera" }],
168
+ ["Okay, I'll stop looking. [LOOK:STOP]", { action: "look_stop" }],
162
169
  [
163
170
  "I'll check in less. [UPDATES:FEWER]",
164
171
  { action: "updates", cadence: "fewer" },
@@ -2146,6 +2146,68 @@ describe("front-door leg tool suppression", () => {
2146
2146
  });
2147
2147
  });
2148
2148
 
2149
+ describe("desktop skill preactivation", () => {
2150
+ async function preactivatedFor(turn: Record<string, unknown>): Promise<{
2151
+ skillIds: string[];
2152
+ proxyInterfaces: unknown[];
2153
+ skillIdsDuringLoop: string[];
2154
+ }> {
2155
+ const skillIds: string[] = [];
2156
+ const proxyInterfaces: unknown[] = [];
2157
+ let skillIdsDuringLoop: string[] = [];
2158
+ const fake = makeFakeConversation({
2159
+ processing: false,
2160
+ runAgentLoop: async () => {
2161
+ skillIdsDuringLoop = [...skillIds];
2162
+ },
2163
+ });
2164
+ Object.assign(fake.conversation, {
2165
+ addPreactivatedSkillId: (id: string) => {
2166
+ skillIds.push(id);
2167
+ },
2168
+ ensureHostProxiesForTurn: (sourceInterface: unknown) => {
2169
+ proxyInterfaces.push(sourceInterface);
2170
+ },
2171
+ });
2172
+ fakeConversation = fake.conversation;
2173
+
2174
+ await startVoiceTurn({
2175
+ ...makeTurnOptions(undefined, "conv-desktop-skills"),
2176
+ userMessageInterface: "macos",
2177
+ ...turn,
2178
+ });
2179
+ await flushMicrotasks();
2180
+ return { skillIds, proxyInterfaces, skillIdsDuringLoop };
2181
+ }
2182
+
2183
+ test("an escalated leg of a macOS desktop session starts with computer use active", async () => {
2184
+ const result = await preactivatedFor({
2185
+ routingLeg: "escalated",
2186
+ macosDesktopSession: true,
2187
+ });
2188
+
2189
+ expect(result.skillIdsDuringLoop).toContain("computer-use");
2190
+ expect(result.proxyInterfaces).toEqual(["macos"]);
2191
+ });
2192
+
2193
+ test("a session from any other client leaves the desktop skills to be loaded", async () => {
2194
+ const result = await preactivatedFor({ routingLeg: "escalated" });
2195
+
2196
+ expect(result.skillIds).toEqual([]);
2197
+ expect(result.proxyInterfaces).toEqual([]);
2198
+ });
2199
+
2200
+ test("the toolless front-door leg is never preactivated", async () => {
2201
+ const result = await preactivatedFor({
2202
+ routingLeg: "front-door",
2203
+ macosDesktopSession: true,
2204
+ });
2205
+
2206
+ expect(result.skillIds).toEqual([]);
2207
+ expect(result.proxyInterfaces).toEqual([]);
2208
+ });
2209
+ });
2210
+
2149
2211
  describe("cutFrontDoorContentAtVerdict", () => {
2150
2212
  test("null when the content carries no verdict token (committed answer)", () => {
2151
2213
  expect(
@@ -16,7 +16,7 @@ import type {
16
16
  ProviderResponse,
17
17
  ToolDefinition,
18
18
  } from "../providers/types.js";
19
- import { createAbortReason } from "../util/abort-reasons.js";
19
+ import { createAbortReason, isAbortReason } from "../util/abort-reasons.js";
20
20
  import { getLogger } from "../util/logger.js";
21
21
 
22
22
  const log = getLogger("voice-progress-narration");
@@ -164,7 +164,10 @@ async function requestBoundedResponse(args: {
164
164
  const timeoutTimer = setTimeout(
165
165
  () =>
166
166
  timeoutController.abort(
167
- createAbortReason("voice_session_aborted", "voice-progress-narration"),
167
+ createAbortReason(
168
+ "voice_progress_narration_timeout",
169
+ "voice-progress-narration",
170
+ ),
168
171
  ),
169
172
  args.timeoutMs,
170
173
  );
@@ -260,9 +263,19 @@ export function createVoiceProgressNarrator(options: {
260
263
  }
261
264
  return trimmed;
262
265
  } catch (error) {
266
+ // Why the beat went unspoken, as one greppable field. A narrator whose
267
+ // budget is below the model's real roundtrip drops every update at the
268
+ // deadline and reads in the logs as a run of unrelated failures; naming
269
+ // the outcome is what makes that run legible as "the feature is off".
270
+ const outcome = isAbortReason(error)
271
+ ? error.kind === "voice_progress_narration_timeout"
272
+ ? "timed_out"
273
+ : "session_ended"
274
+ : "error";
263
275
  log.info(
264
276
  {
265
277
  error,
278
+ outcome,
266
279
  providerResolveMs,
267
280
  totalMs: Math.round(performance.now() - startedAt),
268
281
  timeoutMs: options.config.generationTimeoutMs,
@@ -60,6 +60,18 @@ export const FEWER_UPDATES_MARKER = "[UPDATES:FEWER]";
60
60
  export const NORMAL_UPDATES_MARKER = "[UPDATES:NORMAL]";
61
61
  const UPDATES_MARKER_PREFIX = "[UPDATES:";
62
62
 
63
+ /**
64
+ * Look session controls for live voice: turn on a screen share
65
+ * ({@link LOOK_SCREEN_MARKER}) or the camera ({@link LOOK_CAMERA_MARKER}) so
66
+ * the call can see what the user means by "take a look". Carried out by the
67
+ * client, like mute and end.
68
+ */
69
+ export const LOOK_SCREEN_MARKER = "[LOOK:SCREEN]";
70
+ export const LOOK_CAMERA_MARKER = "[LOOK:CAMERA]";
71
+ /** Stop showing the call the screen and the camera, whichever is on. */
72
+ export const LOOK_STOP_MARKER = "[LOOK:STOP]";
73
+ const LOOK_MARKER_PREFIX = "[LOOK:";
74
+
63
75
  // ---------------------------------------------------------------------------
64
76
  // Regexes
65
77
  // ---------------------------------------------------------------------------
@@ -82,6 +94,7 @@ const ESCALATE_VERDICT_TOKEN_REGEX = /\[1\]/g;
82
94
  const MINIMIZE_ROOM_MARKER_REGEX = /\[-1\]/g;
83
95
  const MUTE_MARKER_REGEX = /\[MUTE(?::\s*[^\]]*)?\]/g;
84
96
  const UPDATES_MARKER_REGEX = /\[UPDATES:\s*[^\]]*\]/g;
97
+ const LOOK_MARKER_REGEX = /\[LOOK:\s*[^\]]*\]/g;
85
98
  const GUARDIAN_TIMEOUT_MARKER_REGEX = /\[GUARDIAN_TIMEOUT\]/g;
86
99
  const GUARDIAN_UNAVAILABLE_MARKER_REGEX = /\[GUARDIAN_UNAVAILABLE\]/g;
87
100
 
@@ -209,6 +222,7 @@ export function stripInternalSpeechMarkers(text: string): string {
209
222
  .replace(MINIMIZE_ROOM_MARKER_REGEX, "")
210
223
  .replace(MUTE_MARKER_REGEX, "")
211
224
  .replace(UPDATES_MARKER_REGEX, "")
225
+ .replace(LOOK_MARKER_REGEX, "")
212
226
  .replace(GUARDIAN_TIMEOUT_MARKER_REGEX, "")
213
227
  .replace(GUARDIAN_UNAVAILABLE_MARKER_REGEX, "");
214
228
  return result;
@@ -237,6 +251,7 @@ const CONTROL_MARKER_STRINGS = [
237
251
  MUTE_MARKER,
238
252
  MUTE_MARKER_PREFIX,
239
253
  UPDATES_MARKER_PREFIX,
254
+ LOOK_MARKER_PREFIX,
240
255
  "[GUARDIAN_TIMEOUT]",
241
256
  "[GUARDIAN_UNAVAILABLE]",
242
257
  ];
@@ -252,6 +267,7 @@ const FIRST_BRACKET_TERMINATED_PREFIXES = [
252
267
  "[USER_INSTRUCTION:",
253
268
  MUTE_MARKER_PREFIX,
254
269
  UPDATES_MARKER_PREFIX,
270
+ LOOK_MARKER_PREFIX,
255
271
  ];
256
272
 
257
273
  const GUARDIAN_APPROVAL_PREFIX = "[ASK_GUARDIAN_APPROVAL:";
@@ -339,15 +355,19 @@ export function createControlMarkerHoldback(
339
355
  /**
340
356
  * A session control a live-voice reply asked for with a terminal marker:
341
357
  * `end` from {@link END_CALL_MARKER}, `mute` from {@link MUTE_MARKER} or its
342
- * timed form, `updates` from the progress-cadence markers.
358
+ * timed form, `updates` from the progress-cadence markers, `look_screen` and
359
+ * `look_camera` and `look_stop` from the look markers.
343
360
  */
344
361
  export type SessionControlRequest =
345
362
  | { readonly action: "end" }
346
363
  | { readonly action: "mute"; readonly durationMs?: number }
347
- | { readonly action: "updates"; readonly cadence: "fewer" | "normal" };
364
+ | { readonly action: "updates"; readonly cadence: "fewer" | "normal" }
365
+ | { readonly action: "look_screen" }
366
+ | { readonly action: "look_camera" }
367
+ | { readonly action: "look_stop" };
348
368
 
349
369
  const TERMINAL_SESSION_CONTROL_REGEX =
350
- /(\[END_CALL\]|\[UPDATES:(FEWER|NORMAL)\]|\[MUTE\]|\[MUTE:\s*([^\]]*)\])\s*$/;
370
+ /(\[END_CALL\]|\[UPDATES:(FEWER|NORMAL)\]|\[LOOK:(SCREEN|CAMERA|STOP)\]|\[MUTE\]|\[MUTE:\s*([^\]]*)\])\s*$/;
351
371
 
352
372
  /**
353
373
  * The session control a reply ends with, or null.
@@ -378,7 +398,17 @@ export function parseTerminalSessionControl(
378
398
  cadence: match[2] === "FEWER" ? "fewer" : "normal",
379
399
  };
380
400
  }
381
- const seconds = match[3] === undefined ? NaN : Number(match[3].trim());
401
+ if (match[3] !== undefined) {
402
+ return {
403
+ action:
404
+ match[3] === "SCREEN"
405
+ ? "look_screen"
406
+ : match[3] === "CAMERA"
407
+ ? "look_camera"
408
+ : "look_stop",
409
+ };
410
+ }
411
+ const seconds = match[4] === undefined ? NaN : Number(match[4].trim());
382
412
  if (
383
413
  Number.isFinite(seconds) &&
384
414
  seconds > 0 &&
@@ -31,6 +31,7 @@ import {
31
31
  import { CONVERSATION_BUSY_MESSAGE } from "../daemon/conversation-messaging.js";
32
32
  import { resolveChannelCapabilities } from "../daemon/conversation-runtime-assembly.js";
33
33
  import { getOrCreateConversation } from "../daemon/conversation-store.js";
34
+ import { preactivateHostProxySkills } from "../daemon/host-proxy-preactivation.js";
34
35
  import type { TrustContext } from "../daemon/trust-context-types.js";
35
36
  import {
36
37
  newestPersistedSightFrame,
@@ -464,6 +465,14 @@ export interface VoiceTurnOptions {
464
465
  * path has always used.
465
466
  */
466
467
  actorFallbackSuppressed?: boolean;
468
+ /**
469
+ * Whether the session was opened from the macOS desktop client. A leg that
470
+ * can run tools then starts with the desktop skills (computer use, app
471
+ * control, screen annotation) already active, as a typed turn from that
472
+ * client does. The turn interface cannot answer this: every live-voice
473
+ * session reports `macos` for its channel capabilities, iOS included.
474
+ */
475
+ macosDesktopSession?: boolean;
467
476
  /** Whether this is an inbound call (no outbound task). */
468
477
  isInbound: boolean;
469
478
  /** The outbound call task, if any. */
@@ -2016,6 +2025,20 @@ export async function startVoiceTurn(
2016
2025
  (needsImagePin
2017
2026
  ? VOICE_IMAGE_PROFILE
2018
2027
  : (conversationProfile?.profile ?? null));
2028
+ if (opts.macosDesktopSession === true && !frontDoorToolsSuppressed) {
2029
+ const sourceInterface = turnInterfaceContext.userMessageInterface;
2030
+ const sourceActorPrincipalId =
2031
+ voiceTurnValues.actorPrincipalId ?? undefined;
2032
+ conversation.ensureHostProxiesForTurn(
2033
+ sourceInterface,
2034
+ sourceActorPrincipalId,
2035
+ );
2036
+ preactivateHostProxySkills(
2037
+ conversation,
2038
+ sourceInterface,
2039
+ sourceActorPrincipalId,
2040
+ );
2041
+ }
2019
2042
  await conversation.runAgentLoop(persistedContent, messageId, {
2020
2043
  onEvent: (msg: AssistantEvent) => {
2021
2044
  if (msg.type === "assistant_turn_start") {