@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
@@ -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.
@@ -0,0 +1,260 @@
1
+ import { desc, eq } from "drizzle-orm";
2
+
3
+ import { getDb } from "../persistence/db-connection.js";
4
+ import { acpSessionHistory } from "../persistence/schema/index.js";
5
+ import { getLogger } from "../util/logger.js";
6
+ import { acpAuthMarkerStillCurrent } from "./acp-auth-marker-store.js";
7
+ import { getAcpSessionManager } from "./index.js";
8
+ import type { AcpSessionManager } from "./session-manager.js";
9
+ import type { AcpSessionState } from "./types.js";
10
+
11
+ const log = getLogger("acp:session-snapshot");
12
+
13
+ export interface AcpSessionSnapshot {
14
+ id: string;
15
+ agentId: string;
16
+ acpSessionId: string;
17
+ parentConversationId: string;
18
+ status: string;
19
+ startedAt: number;
20
+ completedAt?: number | null;
21
+ error?: string | null;
22
+ stopReason?: string | null;
23
+ task?: string;
24
+ parentToolUseId?: string;
25
+ authErrorCode?: string;
26
+ authErrorCredential?: string;
27
+ model?: string;
28
+ availableModels?: AcpSessionState["availableModels"];
29
+ modelRevisionEpoch?: string;
30
+ modelRevision?: number;
31
+ usedTokens?: number;
32
+ contextSize?: number;
33
+ costAmount?: number;
34
+ costCurrency?: string;
35
+ inputTokens?: number;
36
+ outputTokens?: number;
37
+ eventLog?: unknown[];
38
+ source: "live" | "history";
39
+ resumable: boolean;
40
+ cwd?: string | null;
41
+ }
42
+
43
+ export interface AcpSessionSnapshotPage {
44
+ sessions: AcpSessionSnapshot[];
45
+ sawEveryHistoryRow: boolean;
46
+ }
47
+
48
+ interface SnapshotOptions {
49
+ includeEventLog?: boolean;
50
+ }
51
+
52
+ function fromLiveState(
53
+ state: AcpSessionState,
54
+ manager: AcpSessionManager,
55
+ opts: SnapshotOptions,
56
+ ): AcpSessionSnapshot {
57
+ return {
58
+ id: state.id,
59
+ agentId: state.agentId,
60
+ acpSessionId: state.acpSessionId,
61
+ parentConversationId: state.parentConversationId,
62
+ status: state.status,
63
+ startedAt: state.startedAt,
64
+ completedAt: state.completedAt ?? null,
65
+ error: state.error ?? null,
66
+ stopReason: state.stopReason ?? null,
67
+ task: state.task,
68
+ parentToolUseId: state.parentToolUseId,
69
+ authErrorCode: state.authErrorCode,
70
+ authErrorCredential: state.authErrorCredential,
71
+ model: state.model,
72
+ availableModels: state.availableModels,
73
+ modelRevisionEpoch: state.modelRevisionEpoch,
74
+ modelRevision: state.modelRevision,
75
+ usedTokens: state.latestUsage?.usedTokens,
76
+ contextSize: state.latestUsage?.contextSize,
77
+ costAmount: state.latestUsage?.costAmount,
78
+ costCurrency: state.latestUsage?.costCurrency,
79
+ inputTokens: state.latestUsage?.inputTokens,
80
+ outputTokens: state.latestUsage?.outputTokens,
81
+ eventLog: opts.includeEventLog
82
+ ? manager.getBufferedUpdates(state.id)
83
+ : undefined,
84
+ source: "live",
85
+ resumable: false,
86
+ };
87
+ }
88
+
89
+ function isResumableHistoryRow(
90
+ row: typeof acpSessionHistory.$inferSelect,
91
+ ): boolean {
92
+ return Boolean(row.cwd && row.acpSessionId);
93
+ }
94
+
95
+ export function snapshotHistoryRow(
96
+ row: typeof acpSessionHistory.$inferSelect,
97
+ opts: SnapshotOptions = { includeEventLog: true },
98
+ ): AcpSessionSnapshot {
99
+ let eventLog: unknown[] | undefined;
100
+ if (opts.includeEventLog !== false) {
101
+ eventLog = [];
102
+ try {
103
+ const parsed = JSON.parse(row.eventLogJson) as unknown;
104
+ if (Array.isArray(parsed)) {
105
+ eventLog = parsed;
106
+ }
107
+ } catch (err) {
108
+ log.warn(
109
+ { id: row.id, err },
110
+ "Failed to parse event_log_json for ACP session history row",
111
+ );
112
+ }
113
+ }
114
+
115
+ return {
116
+ id: row.id,
117
+ agentId: row.agentId,
118
+ acpSessionId: row.acpSessionId,
119
+ parentConversationId: row.parentConversationId,
120
+ status: row.status,
121
+ startedAt: row.startedAt,
122
+ completedAt: row.completedAt,
123
+ error: row.error,
124
+ stopReason: row.stopReason,
125
+ task: row.task ?? undefined,
126
+ parentToolUseId: row.parentToolUseId ?? undefined,
127
+ authErrorCode: row.authErrorCode ?? undefined,
128
+ authErrorCredential: row.authErrorCredential ?? undefined,
129
+ usedTokens: row.usedTokens ?? undefined,
130
+ contextSize: row.contextSize ?? undefined,
131
+ costAmount: row.costAmount ?? undefined,
132
+ costCurrency: row.costCurrency ?? undefined,
133
+ inputTokens: row.inputTokens ?? undefined,
134
+ outputTokens: row.outputTokens ?? undefined,
135
+ eventLog,
136
+ source: "history",
137
+ resumable: isResumableHistoryRow(row),
138
+ cwd: row.cwd,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Blank `authErrorCode` on any session whose marker no longer describes the
144
+ * credential its agent would resolve.
145
+ *
146
+ * This comparison is what retires a Connect card: the marker no longer
147
+ * describing the credential in use. Applied after merging rather than inside
148
+ * the query, so live sessions and history rows are judged by the same rule.
149
+ *
150
+ * Resolved per agent and memoised across the batch, because precedence is per
151
+ * agent and each resolution costs a vault read.
152
+ */
153
+ export async function withCurrentAuthMarkers<
154
+ T extends {
155
+ agentId: string;
156
+ authErrorCode?: string;
157
+ authErrorCredential?: string;
158
+ },
159
+ >(
160
+ sessions: readonly T[],
161
+ resolvedFor: (agentId: string) => Promise<string | undefined>,
162
+ ): Promise<T[]> {
163
+ if (!sessions.some((session) => session.authErrorCode !== undefined)) {
164
+ return [...sessions];
165
+ }
166
+ const resolvedByAgent = new Map<string, string | undefined>();
167
+ const resolve = async (agentId: string) => {
168
+ if (!resolvedByAgent.has(agentId)) {
169
+ resolvedByAgent.set(agentId, await resolvedFor(agentId));
170
+ }
171
+ return resolvedByAgent.get(agentId);
172
+ };
173
+ const judged: T[] = [];
174
+ for (const session of sessions) {
175
+ if (session.authErrorCode === undefined) {
176
+ judged.push(session);
177
+ continue;
178
+ }
179
+ const current = acpAuthMarkerStillCurrent(
180
+ session.authErrorCredential,
181
+ await resolve(session.agentId),
182
+ );
183
+ judged.push(
184
+ current ? session : { ...session, authErrorCode: undefined },
185
+ );
186
+ }
187
+ return judged;
188
+ }
189
+
190
+ export function getAcpSessionSnapshot(
191
+ acpSessionId: string,
192
+ opts: SnapshotOptions = {},
193
+ ): AcpSessionSnapshot | undefined {
194
+ const manager = getAcpSessionManager();
195
+ const snapshotOptions = {
196
+ includeEventLog: opts.includeEventLog ?? true,
197
+ };
198
+ const live = (manager.getStatus() as AcpSessionState[]).find(
199
+ (state) => state.id === acpSessionId,
200
+ );
201
+ if (live) {
202
+ return fromLiveState(live, manager, snapshotOptions);
203
+ }
204
+
205
+ const row = getDb()
206
+ .select()
207
+ .from(acpSessionHistory)
208
+ .where(eq(acpSessionHistory.id, acpSessionId))
209
+ .get();
210
+ return row ? snapshotHistoryRow(row, snapshotOptions) : undefined;
211
+ }
212
+
213
+ export function listAcpSessionSnapshots(opts: {
214
+ limit: number;
215
+ conversationId?: string;
216
+ includeEventLog?: boolean;
217
+ }): AcpSessionSnapshotPage {
218
+ const manager = getAcpSessionManager();
219
+ const inMemory = manager.getStatus() as AcpSessionState[];
220
+ const snapshotOptions = {
221
+ includeEventLog: opts.includeEventLog ?? true,
222
+ };
223
+
224
+ const merged = new Map<string, AcpSessionSnapshot>();
225
+ for (const state of inMemory) {
226
+ if (
227
+ opts.conversationId &&
228
+ state.parentConversationId !== opts.conversationId
229
+ ) {
230
+ continue;
231
+ }
232
+ merged.set(state.id, fromLiveState(state, manager, snapshotOptions));
233
+ }
234
+
235
+ const db = getDb();
236
+ const baseQuery = db.select().from(acpSessionHistory);
237
+ const filtered = opts.conversationId
238
+ ? baseQuery.where(
239
+ eq(acpSessionHistory.parentConversationId, opts.conversationId),
240
+ )
241
+ : baseQuery;
242
+ const historyLimit = opts.limit + merged.size;
243
+ const historyRows = filtered
244
+ .orderBy(desc(acpSessionHistory.startedAt))
245
+ .limit(historyLimit)
246
+ .all();
247
+
248
+ for (const row of historyRows) {
249
+ if (!merged.has(row.id)) {
250
+ merged.set(row.id, snapshotHistoryRow(row, snapshotOptions));
251
+ }
252
+ }
253
+
254
+ return {
255
+ sessions: Array.from(merged.values()).sort(
256
+ (a, b) => b.startedAt - a.startedAt,
257
+ ),
258
+ sawEveryHistoryRow: historyRows.length < historyLimit,
259
+ };
260
+ }
@@ -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" },