@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
@@ -67,9 +67,8 @@ mock.module("../../../util/logger.js", () => ({
67
67
  // Import module under test (after mocks)
68
68
  // ---------------------------------------------------------------------------
69
69
 
70
- const { formatBrowserStatusLines, registerBrowserCommand } = await import(
71
- "../browser.js"
72
- );
70
+ const { formatBrowserStatusLines, registerBrowserCommand } =
71
+ await import("../browser.js");
73
72
 
74
73
  // ---------------------------------------------------------------------------
75
74
  // Test helper
@@ -641,3 +640,85 @@ describe("error exit codes", () => {
641
640
  expect(exitCode).toBe(0);
642
641
  });
643
642
  });
643
+
644
+ for (const desktopFlag of ["--virtual-desktop", "--desktop"]) {
645
+ describe(`virtual desktop routing via ${desktopFlag}`, () => {
646
+ test("routes semantic actions through IPC with conversation and element identity", async () => {
647
+ process.env.__CONVERSATION_ID = "conv-desktop";
648
+ const result = await runCommand([
649
+ "browser",
650
+ desktopFlag,
651
+ "--json",
652
+ "click",
653
+ "--element-id",
654
+ "e7",
655
+ ]);
656
+ expect(result.exitCode).toBe(0);
657
+ expect(lastIpcCall?.method).toBe("browser_execute");
658
+ expect(lastBody()).toMatchObject({
659
+ desktop: true,
660
+ conversationId: "conv-desktop",
661
+ operation: "click",
662
+ input: { element_id: "e7" },
663
+ });
664
+ expect(
665
+ (lastBody().input as Record<string, unknown>).desktop,
666
+ ).toBeUndefined();
667
+ });
668
+
669
+ test.each([
670
+ { args: ["list"] },
671
+ { args: ["select", "--tab-id", "7"] },
672
+ { args: ["new"] },
673
+ { args: ["close", "--tab-id", "7"] },
674
+ ])("routes desktop tabs $args through IPC", async ({ args }) => {
675
+ mockIpcResult = { ok: true, result: { ok: true, tabs: [] } };
676
+ const result = await runCommand([
677
+ "browser",
678
+ desktopFlag,
679
+ "--json",
680
+ "tabs",
681
+ ...args,
682
+ ]);
683
+ expect(result.exitCode).toBe(0);
684
+ expect(lastIpcCall?.method).toBe("browser_tabs");
685
+ expect(lastBody()).toMatchObject({ desktop: true, command: args[0] });
686
+ });
687
+
688
+ test.each([
689
+ { flags: ["--browser-mode", "extension"] },
690
+ { flags: ["--target-client-id", "client-123"] },
691
+ ])("rejects conflicting desktop targets before IPC", async ({ flags }) => {
692
+ const result = await runCommand([
693
+ "browser",
694
+ desktopFlag,
695
+ ...flags,
696
+ "tabs",
697
+ "list",
698
+ ]);
699
+ expect(result.exitCode).not.toBe(0);
700
+ expect(lastIpcCall).toBeNull();
701
+ });
702
+ });
703
+ }
704
+
705
+ test.each([
706
+ { args: ["list"] },
707
+ { args: ["select", "--tab-id", "1"] },
708
+ { args: ["new", "--url", "https://example.com"] },
709
+ { args: ["close", "--tab-id", "1"] },
710
+ ])(
711
+ "tab command $args forwards an explicit backend override",
712
+ async ({ args }) => {
713
+ mockIpcResult = { ok: true, result: { ok: true, tabs: [] } };
714
+ await runCommand([
715
+ "browser",
716
+ "--browser-mode",
717
+ "extension",
718
+ "tabs",
719
+ ...args,
720
+ ]);
721
+ expect(lastIpcCall?.method).toBe("browser_tabs");
722
+ expect(lastBody().browserMode).toBe("extension");
723
+ },
724
+ );
@@ -96,6 +96,15 @@ export const browserHelp: CliCommandHelp = {
96
96
  name: "browser",
97
97
  description: "Control the browser via the running assistant.",
98
98
  options: [
99
+ {
100
+ flags: "--desktop",
101
+ description: "Compatibility alias for --virtual-desktop.",
102
+ },
103
+ {
104
+ flags: "--virtual-desktop",
105
+ description:
106
+ "Use Chrome in the platform-hosted virtual desktop with visible cursor feedback.",
107
+ },
99
108
  {
100
109
  flags: "--session <id>",
101
110
  description: "Session ID to preserve browser state across invocations.",
@@ -118,6 +127,38 @@ export const browserHelp: CliCommandHelp = {
118
127
  ],
119
128
  helpText: `
120
129
  Browser operations are executed through the running assistant.
130
+ Virtual desktop is available only for platform-hosted assistants.
131
+ In the web client, automatic selection prefers virtual desktop Chrome when
132
+ its feature is enabled and the conversation has an
133
+ identified guardian. The macOS and Windows apps keep their existing browser
134
+ selection and fallback behavior; use --virtual-desktop to select it there.
135
+ Existing sessions and explicit backend/client choices take priority.
136
+ --browser-mode local (alias playwright) means the assistant's Playwright browser,
137
+ not the user's Chrome. --desktop remains an alias for --virtual-desktop.
138
+ Use --virtual-desktop for Chrome in the Desktop panel.
139
+ Run navigate --url <url> directly to open a page.
140
+ The assistant-desktop feature flag and completed installation are required.
141
+ The first browser command installs missing components, starts Chrome, and completes
142
+ the requested action in one call. Allow up to 600 seconds for this command when
143
+ using bash (timeout_seconds: 600). Opening the Desktop panel also starts
144
+ the same installation and shows progress. No separate setup command is needed.
145
+ Report other availability errors without switching to a personal computer.
146
+ The assistant manages Chrome startup. Do not launch a separate desktop stack,
147
+ install desktop packages, or drive webpages with shell-level xdotool.
148
+ With --virtual-desktop, screenshot captures the page directly from Chrome over CDP
149
+ as a color JPEG. Use --output to save it and file_read to view it. Do not use
150
+ shell-level xwd or custom screenshot conversion scripts.
151
+ Use snapshot for current element IDs and tabs list for current tab IDs.
152
+ Refresh the snapshot after navigation, tab changes or stale-element errors.
153
+ Inspect the result before retrying a failed action; it may have happened.
154
+ A purple page pointer shows CDP mouse movement in the stream. Browser toolbar
155
+ controls, native dialogs and other apps are outside this CLI's page controls.
156
+ Do not combine --virtual-desktop with personal browser targets, other browser modes
157
+ or --use-active-tab. Download waiting is unavailable on this target.
158
+ Users can interact directly with the expanded desktop. If they ask you to pause,
159
+ stop and wait. Take a fresh snapshot before resuming. Closing the viewer does not
160
+ end automation. Run assistant browser --virtual-desktop detach when finished or
161
+ blocked, including before asking a question. Chrome stays running.
121
162
  Each subcommand maps to a browser operation and communicates
122
163
  with the assistant process.
123
164
 
@@ -131,6 +172,8 @@ local. Useful for debugging or when deterministic backend selection
131
172
  is required.
132
173
 
133
174
  Examples:
175
+ $ assistant browser --virtual-desktop status
176
+ $ assistant browser --virtual-desktop screenshot --output /tmp/desktop-page.jpg
134
177
  $ assistant browser navigate --url https://example.com
135
178
  $ assistant browser snapshot
136
179
  $ assistant browser click --selector "#login"
@@ -28,6 +28,8 @@ import { registerCommand } from "../lib/register-command.js";
28
28
  import { log } from "../logger.js";
29
29
  import { browserHelp, toKebab } from "./browser.help.js";
30
30
 
31
+ const BROWSER_REQUEST_TIMEOUT_MS = 10 * 60_000;
32
+
31
33
  // ── Naming helpers ───────────────────────────────────────────────────
32
34
 
33
35
  /**
@@ -116,6 +118,7 @@ function attachOperationAction(
116
118
 
117
119
  subcmd.action(async (opts: Record<string, unknown>) => {
118
120
  const parentOpts = browser.opts() as {
121
+ desktop?: boolean;
119
122
  session?: string;
120
123
  json?: boolean;
121
124
  browserMode?: string;
@@ -130,6 +133,8 @@ function attachOperationAction(
130
133
  // targetClientId) and screenshot ergonomics (output).
131
134
  const input: Record<string, unknown> = {};
132
135
  const excludeKeys = new Set([
136
+ "desktop",
137
+ "virtualDesktop",
133
138
  "session",
134
139
  "json",
135
140
  "output",
@@ -163,9 +168,7 @@ function attachOperationAction(
163
168
  }
164
169
  }
165
170
 
166
- // Browser operations can be long-running (page loads, auth
167
- // challenges, downloads up to 120s, etc.), so use a generous
168
- // IPC timeout that exceeds any server-side operation timeout.
171
+ // First use can include virtual desktop installation and Chrome startup.
169
172
  const ipcResult = await cliIpcCall<BrowserExecuteResult>(
170
173
  "browser_execute",
171
174
  {
@@ -173,10 +176,11 @@ function attachOperationAction(
173
176
  operation: meta.operation,
174
177
  input,
175
178
  sessionId,
179
+ ...(parentOpts.desktop ? { desktop: true } : {}),
176
180
  ...(conversationId ? { conversationId } : {}),
177
181
  },
178
182
  },
179
- { timeoutMs: 180_000 },
183
+ { timeoutMs: BROWSER_REQUEST_TIMEOUT_MS, cancelOnDisconnect: true },
180
184
  );
181
185
 
182
186
  if (!ipcResult.ok) {
@@ -328,6 +332,22 @@ export function registerBrowserCommand(program: Command): void {
328
332
  description: browserHelp.description,
329
333
  build: (browser) => {
330
334
  applyCommandHelp(browser, browserHelp);
335
+ browser.hook("preAction", () => {
336
+ if (browser.opts().virtualDesktop) {
337
+ browser.setOptionValue("desktop", true);
338
+ }
339
+ const options = browser.opts();
340
+ if (
341
+ options.desktop &&
342
+ (options.targetClientId ||
343
+ (options.browserMode &&
344
+ !["auto", "cdp-inspect"].includes(options.browserMode)))
345
+ ) {
346
+ throw new Error(
347
+ "--virtual-desktop cannot be combined with a personal browser target or another browser mode",
348
+ );
349
+ }
350
+ });
331
351
 
332
352
  // Attach one action per browser operation
333
353
  for (const meta of BROWSER_OPERATION_META) {
@@ -339,6 +359,8 @@ export function registerBrowserCommand(program: Command): void {
339
359
 
340
360
  subcommand(tabs, "list").action(async (opts: { pretty?: boolean }) => {
341
361
  const parentOpts = browser.opts() as {
362
+ desktop?: boolean;
363
+ browserMode?: string;
342
364
  session?: string;
343
365
  json?: boolean;
344
366
  targetClientId?: string;
@@ -364,11 +386,15 @@ export function registerBrowserCommand(program: Command): void {
364
386
  body: {
365
387
  command: "list",
366
388
  sessionId,
389
+ ...(parentOpts.desktop ? { desktop: true } : {}),
390
+ ...(parentOpts.browserMode
391
+ ? { browserMode: parentOpts.browserMode }
392
+ : {}),
367
393
  ...(conversationId ? { conversationId } : {}),
368
394
  ...(targetClientId ? { targetClientId } : {}),
369
395
  },
370
396
  },
371
- { timeoutMs: 30_000 },
397
+ { timeoutMs: BROWSER_REQUEST_TIMEOUT_MS, cancelOnDisconnect: true },
372
398
  );
373
399
 
374
400
  if (!ipcResult.ok) {
@@ -409,6 +435,8 @@ export function registerBrowserCommand(program: Command): void {
409
435
 
410
436
  subcommand(tabs, "select").action(async (opts: { tabId: string }) => {
411
437
  const parentOpts = browser.opts() as {
438
+ desktop?: boolean;
439
+ browserMode?: string;
412
440
  session?: string;
413
441
  json?: boolean;
414
442
  targetClientId?: string;
@@ -425,12 +453,16 @@ export function registerBrowserCommand(program: Command): void {
425
453
  body: {
426
454
  command: "select",
427
455
  sessionId,
456
+ ...(parentOpts.desktop ? { desktop: true } : {}),
457
+ ...(parentOpts.browserMode
458
+ ? { browserMode: parentOpts.browserMode }
459
+ : {}),
428
460
  tabId,
429
461
  ...(conversationId ? { conversationId } : {}),
430
462
  ...(targetClientId ? { targetClientId } : {}),
431
463
  },
432
464
  },
433
- { timeoutMs: 30_000 },
465
+ { timeoutMs: BROWSER_REQUEST_TIMEOUT_MS, cancelOnDisconnect: true },
434
466
  );
435
467
 
436
468
  if (!ipcResult.ok) {
@@ -456,6 +488,8 @@ export function registerBrowserCommand(program: Command): void {
456
488
 
457
489
  subcommand(tabs, "new").action(async (opts: { url?: string }) => {
458
490
  const parentOpts = browser.opts() as {
491
+ desktop?: boolean;
492
+ browserMode?: string;
459
493
  session?: string;
460
494
  json?: boolean;
461
495
  targetClientId?: string;
@@ -475,12 +509,16 @@ export function registerBrowserCommand(program: Command): void {
475
509
  body: {
476
510
  command: "new",
477
511
  sessionId,
512
+ ...(parentOpts.desktop ? { desktop: true } : {}),
513
+ ...(parentOpts.browserMode
514
+ ? { browserMode: parentOpts.browserMode }
515
+ : {}),
478
516
  ...(opts.url ? { url: opts.url } : {}),
479
517
  ...(conversationId ? { conversationId } : {}),
480
518
  ...(targetClientId ? { targetClientId } : {}),
481
519
  },
482
520
  },
483
- { timeoutMs: 30_000 },
521
+ { timeoutMs: BROWSER_REQUEST_TIMEOUT_MS, cancelOnDisconnect: true },
484
522
  );
485
523
 
486
524
  if (!ipcResult.ok) {
@@ -512,6 +550,8 @@ export function registerBrowserCommand(program: Command): void {
512
550
 
513
551
  subcommand(tabs, "close").action(async (opts: { tabId: string }) => {
514
552
  const parentOpts = browser.opts() as {
553
+ desktop?: boolean;
554
+ browserMode?: string;
515
555
  session?: string;
516
556
  json?: boolean;
517
557
  targetClientId?: string;
@@ -532,12 +572,16 @@ export function registerBrowserCommand(program: Command): void {
532
572
  body: {
533
573
  command: "close",
534
574
  sessionId,
575
+ ...(parentOpts.desktop ? { desktop: true } : {}),
576
+ ...(parentOpts.browserMode
577
+ ? { browserMode: parentOpts.browserMode }
578
+ : {}),
535
579
  tabId,
536
580
  ...(conversationId ? { conversationId } : {}),
537
581
  ...(targetClientId ? { targetClientId } : {}),
538
582
  },
539
583
  },
540
- { timeoutMs: 30_000 },
584
+ { timeoutMs: BROWSER_REQUEST_TIMEOUT_MS, cancelOnDisconnect: true },
541
585
  );
542
586
 
543
587
  if (!ipcResult.ok) {
@@ -53,6 +53,7 @@ import {
53
53
  PluginAlreadyInStateException,
54
54
  PluginDirectoryNotFoundError,
55
55
  } from "../lib/toggle-plugin.js";
56
+ import type { PluginUninstallWarningKey } from "../lib/uninstall-plugin.js";
56
57
  import type { PluginUpgradeResult } from "../lib/upgrade-plugin.js";
57
58
  import { getCliLogger } from "../logger.js";
58
59
  import { PLUGINS_SEARCH_INSTALL_HINT, pluginsHelp } from "./plugins.help.js";
@@ -666,11 +667,16 @@ export function registerPluginsCommand(program: Command): void {
666
667
  // when the daemon is unreachable (a transport error carries no
667
668
  // `statusCode`); an operator can still uninstall while it's stopped,
668
669
  // and `shutdown` then runs in this process, the only one available.
669
- const daemon = await cliIpcCall<{ name: string; target: string }>(
670
- "plugins_uninstall",
671
- { pathParams: { name } },
672
- );
673
- let result: { name: string; target: string };
670
+ const daemon = await cliIpcCall<{
671
+ name: string;
672
+ target: string;
673
+ warnings?: PluginUninstallWarningKey[];
674
+ }>("plugins_uninstall", { pathParams: { name } });
675
+ let result: {
676
+ name: string;
677
+ target: string;
678
+ warnings?: PluginUninstallWarningKey[];
679
+ };
674
680
  if (daemon.ok && daemon.result) {
675
681
  result = daemon.result;
676
682
  } else if (daemon.statusCode === undefined) {
@@ -693,6 +699,11 @@ export function registerPluginsCommand(program: Command): void {
693
699
  console.log(
694
700
  `Uninstalled plugin "${result.name}" from ${result.target}`,
695
701
  );
702
+ for (const warning of result.warnings ?? []) {
703
+ console.warn(
704
+ `Warning: ${libs.uninstall.resolvePluginUninstallWarning(warning)}`,
705
+ );
706
+ }
696
707
  } catch (err) {
697
708
  if (err instanceof libs.installGitHub.InvalidPluginNameError) {
698
709
  console.error(err.message);
@@ -22,6 +22,8 @@ import { tmpdir } from "node:os";
22
22
  import { dirname, join } from "node:path";
23
23
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
24
24
 
25
+ import { readPluginMcpServers } from "../../../plugins/mcp-servers.js";
26
+ import { PluginManifestError } from "../../../util/plugin-manifest.js";
25
27
  import type { FetchLike } from "../fetch-like.js";
26
28
  import {
27
29
  type GitRunner,
@@ -1225,6 +1227,115 @@ describe("installPlugin — direct (untrusted) install", () => {
1225
1227
  expect(meta?.commit).toBe("a".repeat(40));
1226
1228
  });
1227
1229
 
1230
+ test("installs a standard-only plugin without synthesizing package.json", async () => {
1231
+ const fetch = makeContentsFetch({ tree: {} });
1232
+ const runGit = fakeGitRunner({
1233
+ tree: {
1234
+ "plugin.json": JSON.stringify({
1235
+ $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
1236
+ name: "standard-plugin",
1237
+ version: "1.4.0",
1238
+ }),
1239
+ "mcp.json": JSON.stringify({
1240
+ $schema: "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
1241
+ mcpServers: {
1242
+ "standard-plugin": {
1243
+ type: "streamable-http",
1244
+ url: "https://mcp.example.com",
1245
+ },
1246
+ },
1247
+ }),
1248
+ },
1249
+ commit: "9".repeat(40),
1250
+ });
1251
+
1252
+ const result = await installPlugin(
1253
+ {
1254
+ name: "standard-plugin",
1255
+ directSource: {
1256
+ owner: "owner",
1257
+ repo: "standard-plugin",
1258
+ rootPath: "",
1259
+ ref: "HEAD",
1260
+ },
1261
+ },
1262
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
1263
+ );
1264
+
1265
+ expect(existsSync(join(result.target, "plugin.json"))).toBe(true);
1266
+ expect(existsSync(join(result.target, "package.json"))).toBe(false);
1267
+ expect(readInstallMeta(result.target)?.version).toBe("1.4.0");
1268
+ const mcp = readPluginMcpServers({ workspacePluginsDir: pluginsDir });
1269
+ expect(mcp.issues).toEqual([]);
1270
+ expect(mcp.servers.map((server) => server.id)).toEqual(["standard-plugin"]);
1271
+ });
1272
+
1273
+ test("synthesizes package.json beside a foreign plugin.json", async () => {
1274
+ const fetch = makeContentsFetch({ tree: {} });
1275
+ const runGit = fakeGitRunner({
1276
+ tree: {
1277
+ "plugin.json": JSON.stringify({ name: "foreign-plugin" }),
1278
+ "mcp.json": JSON.stringify({
1279
+ mcpServers: {
1280
+ "foreign-plugin": {
1281
+ type: "streamable-http",
1282
+ url: "https://mcp.example.com",
1283
+ },
1284
+ },
1285
+ }),
1286
+ },
1287
+ commit: "8".repeat(40),
1288
+ });
1289
+
1290
+ const result = await installPlugin(
1291
+ {
1292
+ name: "foreign-plugin",
1293
+ directSource: {
1294
+ owner: "owner",
1295
+ repo: "foreign-plugin",
1296
+ rootPath: "",
1297
+ ref: "HEAD",
1298
+ },
1299
+ },
1300
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
1301
+ );
1302
+
1303
+ expect(existsSync(join(result.target, "plugin.json"))).toBe(true);
1304
+ expect(existsSync(join(result.target, "package.json"))).toBe(true);
1305
+ const mcp = readPluginMcpServers({ workspacePluginsDir: pluginsDir });
1306
+ expect(mcp.issues).toEqual([]);
1307
+ expect(mcp.servers.map((server) => server.id)).toEqual(["foreign-plugin"]);
1308
+ });
1309
+
1310
+ test("rejects a malformed claimed standard manifest before install", async () => {
1311
+ const fetch = makeContentsFetch({ tree: {} });
1312
+ const runGit = fakeGitRunner({
1313
+ tree: {
1314
+ "plugin.json": JSON.stringify({
1315
+ $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
1316
+ name: "Invalid Standard Name",
1317
+ }),
1318
+ },
1319
+ commit: "7".repeat(40),
1320
+ });
1321
+
1322
+ await expect(
1323
+ installPlugin(
1324
+ {
1325
+ name: "invalid-standard",
1326
+ directSource: {
1327
+ owner: "owner",
1328
+ repo: "invalid-standard",
1329
+ rootPath: "",
1330
+ ref: "HEAD",
1331
+ },
1332
+ },
1333
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
1334
+ ),
1335
+ ).rejects.toBeInstanceOf(PluginManifestError);
1336
+ expect(existsSync(join(pluginsDir, "invalid-standard"))).toBe(false);
1337
+ });
1338
+
1228
1339
  test("never overlays a curated adapter stub, even if one matches the name", async () => {
1229
1340
  // GIVEN a fetch that WOULD serve an adapter stub for the name, and a
1230
1341
  // postinstall runner that fails the test if it is ever invoked
@@ -14,6 +14,8 @@ import { join } from "node:path";
14
14
  import { gzipSync } from "node:zlib";
15
15
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
16
16
 
17
+ import { readPluginMcpServers } from "../../../plugins/mcp-servers.js";
18
+ import { PluginManifestError } from "../../../util/plugin-manifest.js";
17
19
  import type { FetchLike } from "../fetch-like.js";
18
20
  import {
19
21
  PluginInstallDeclinedError,
@@ -145,7 +147,13 @@ const noSleep = async () => {};
145
147
  describe("installPluginFromPlatform — success", () => {
146
148
  test("downloads, verifies, and extracts files at the plugin root", async () => {
147
149
  const entries: TarEntry[] = [
148
- { name: "plugin.json", content: '{"name":"reading-pal"}' },
150
+ {
151
+ name: "plugin.json",
152
+ content: JSON.stringify({
153
+ $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
154
+ name: "reading-pal",
155
+ }),
156
+ },
149
157
  { name: "README.md", content: "# reading pal" },
150
158
  { name: "skills/read/SKILL.md", content: "skill body" },
151
159
  ];
@@ -177,7 +185,7 @@ describe("installPluginFromPlatform — success", () => {
177
185
  );
178
186
  expect(existsSync(join(target, "README.md"))).toBe(true);
179
187
  expect(existsSync(join(target, "skills", "read", "SKILL.md"))).toBe(true);
180
- expect(existsSync(join(target, "package.json"))).toBe(true);
188
+ expect(existsSync(join(target, "package.json"))).toBe(false);
181
189
 
182
190
  // Provenance records the pinned commit and the verified ETag.
183
191
  const meta = readInstallMeta(target);
@@ -227,6 +235,73 @@ describe("installPluginFromPlatform — success", () => {
227
235
  expect(pkg.peerDependencies["@vellumai/plugin-api"]).toBeDefined();
228
236
  });
229
237
 
238
+ test("synthesizes package.json beside a foreign plugin.json", async () => {
239
+ const fetchFn = makeInstallFetch({
240
+ entries: [
241
+ {
242
+ name: "plugin.json",
243
+ content: JSON.stringify({ name: "foreign-plugin" }),
244
+ },
245
+ {
246
+ name: "mcp.json",
247
+ content: JSON.stringify({
248
+ mcpServers: {
249
+ "foreign-plugin": {
250
+ type: "streamable-http",
251
+ url: "https://mcp.example.com",
252
+ },
253
+ },
254
+ }),
255
+ },
256
+ ],
257
+ ref: "d".repeat(40),
258
+ repo: "example-org/foreign-plugin",
259
+ });
260
+
261
+ const result = await installPluginFromPlatform(
262
+ { name: "foreign-plugin" },
263
+ {
264
+ fetch: fetchFn,
265
+ platformBaseUrl: PLATFORM,
266
+ workspacePluginsDir: pluginsDir,
267
+ },
268
+ );
269
+
270
+ expect(existsSync(join(result.target, "plugin.json"))).toBe(true);
271
+ expect(existsSync(join(result.target, "package.json"))).toBe(true);
272
+ const mcp = readPluginMcpServers({ workspacePluginsDir: pluginsDir });
273
+ expect(mcp.issues).toEqual([]);
274
+ expect(mcp.servers.map((server) => server.id)).toEqual(["foreign-plugin"]);
275
+ });
276
+
277
+ test("rejects a malformed claimed standard manifest before install", async () => {
278
+ const fetchFn = makeInstallFetch({
279
+ entries: [
280
+ {
281
+ name: "plugin.json",
282
+ content: JSON.stringify({
283
+ $schema:
284
+ "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
285
+ }),
286
+ },
287
+ ],
288
+ ref: "e".repeat(40),
289
+ repo: "example-org/invalid-standard",
290
+ });
291
+
292
+ await expect(
293
+ installPluginFromPlatform(
294
+ { name: "invalid-standard" },
295
+ {
296
+ fetch: fetchFn,
297
+ platformBaseUrl: PLATFORM,
298
+ workspacePluginsDir: pluginsDir,
299
+ },
300
+ ),
301
+ ).rejects.toBeInstanceOf(PluginManifestError);
302
+ expect(existsSync(join(pluginsDir, "invalid-standard"))).toBe(false);
303
+ });
304
+
230
305
  test("leaves an upstream package.json in place", async () => {
231
306
  const fetchFn = makeInstallFetch({
232
307
  entries: [
@@ -93,6 +93,46 @@ describe("listInstalledPlugins", () => {
93
93
  expect(result[0]!.packageJson?.icon).toBe("🚀");
94
94
  });
95
95
 
96
+ test("lists metadata from a standard-only plugin.json", () => {
97
+ mkdirSync(join(pluginsDir, "standard"));
98
+ writeFileSync(
99
+ join(pluginsDir, "standard", "plugin.json"),
100
+ JSON.stringify({
101
+ $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
102
+ name: "standard",
103
+ version: "3.0.0",
104
+ description: "standard plugin",
105
+ }),
106
+ );
107
+
108
+ const result = listInstalledPlugins({ workspacePluginsDir: pluginsDir });
109
+
110
+ expect(result[0]!.packageJson).toEqual({
111
+ name: "standard",
112
+ version: "3.0.0",
113
+ description: "standard plugin",
114
+ peerDependencies: undefined,
115
+ });
116
+ expect(result[0]!.issues).toEqual([]);
117
+ });
118
+
119
+ test("does not validate an unselected plugin.json beside package.json", () => {
120
+ mkdirSync(join(pluginsDir, "legacy"));
121
+ writeFileSync(
122
+ join(pluginsDir, "legacy", "package.json"),
123
+ JSON.stringify({ name: "legacy", version: "1.0.0" }),
124
+ );
125
+ writeFileSync(
126
+ join(pluginsDir, "legacy", "plugin.json"),
127
+ "{ foreign and malformed }",
128
+ );
129
+
130
+ const result = listInstalledPlugins({ workspacePluginsDir: pluginsDir });
131
+
132
+ expect(result[0]!.packageJson?.version).toBe("1.0.0");
133
+ expect(result[0]!.issues).toEqual([]);
134
+ });
135
+
96
136
  test.each([
97
137
  ["missing vellum block", { name: "p", version: "1.0.0" }],
98
138
  ["non-string icon", { name: "p", version: "1.0.0", vellum: { icon: 42 } }],
@@ -120,7 +160,9 @@ describe("listInstalledPlugins", () => {
120
160
  const result = listInstalledPlugins({ workspacePluginsDir: pluginsDir });
121
161
  expect(result).toHaveLength(1);
122
162
  expect(result[0]!.packageJson).toBeNull();
123
- expect(result[0]!.issues).toEqual(["missing package.json"]);
163
+ expect(result[0]!.issues[0]).toContain(
164
+ "missing package.json and plugin.json",
165
+ );
124
166
  });
125
167
 
126
168
  test("reports malformed JSON as an issue rather than failing", () => {
@@ -130,7 +172,7 @@ describe("listInstalledPlugins", () => {
130
172
  const result = listInstalledPlugins({ workspacePluginsDir: pluginsDir });
131
173
  expect(result).toHaveLength(1);
132
174
  expect(result[0]!.packageJson).toBeNull();
133
- expect(result[0]!.issues[0]).toMatch(/invalid JSON/);
175
+ expect(result[0]!.issues[0]).toContain("could not be read or parsed");
134
176
  });
135
177
 
136
178
  test("reports non-object package.json as an issue", () => {
@@ -143,7 +185,7 @@ describe("listInstalledPlugins", () => {
143
185
  const result = listInstalledPlugins({ workspacePluginsDir: pluginsDir });
144
186
  expect(result).toHaveLength(1);
145
187
  expect(result[0]!.packageJson).toBeNull();
146
- expect(result[0]!.issues).toContain("package.json is not an object");
188
+ expect(result[0]!.issues[0]).toContain("failed schema validation");
147
189
  });
148
190
 
149
191
  test("skips hidden entries and non-directories", () => {