@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
@@ -45,10 +45,23 @@ beforeEach(() => {
45
45
  describe("listInstalledPluginDirs", () => {
46
46
  test("lists real plugin directories and skips dot entries", () => {
47
47
  makePluginDir(pluginsDir, "alpha");
48
+ const standard = join(pluginsDir, "standard");
49
+ mkdirSync(standard, { recursive: true });
50
+ writeFileSync(
51
+ join(standard, "plugin.json"),
52
+ JSON.stringify({
53
+ $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
54
+ name: "standard",
55
+ }),
56
+ );
48
57
  makePluginDir(pluginsDir, ".hidden");
49
58
  mkdirSync(join(pluginsDir, "no-manifest"), { recursive: true });
50
59
 
51
- expect(listInstalledPluginDirs().map((p) => p.name)).toEqual(["alpha"]);
60
+ expect(
61
+ listInstalledPluginDirs()
62
+ .map((p) => p.name)
63
+ .sort(),
64
+ ).toEqual(["alpha", "standard"]);
52
65
  });
53
66
 
54
67
  test("skips a symlinked plugin root that points outside the plugins dir", () => {
@@ -24,17 +24,27 @@ import {
24
24
  function makePluginsDir(
25
25
  plugins: Record<
26
26
  string,
27
- { mcpJson?: string; packageJson?: string; disabled?: boolean }
27
+ {
28
+ mcpJson?: string;
29
+ packageJson?: string | null;
30
+ pluginJson?: string;
31
+ disabled?: boolean;
32
+ }
28
33
  >,
29
34
  ): string {
30
35
  const root = mkdtempSync(join(tmpdir(), "vellum-plugin-mcp-"));
31
36
  for (const [name, spec] of Object.entries(plugins)) {
32
37
  const dir = join(root, name);
33
38
  mkdirSync(dir, { recursive: true });
34
- writeFileSync(
35
- join(dir, "package.json"),
36
- spec.packageJson ?? JSON.stringify({ name, version: "1.0.0" }),
37
- );
39
+ if (spec.packageJson !== null) {
40
+ writeFileSync(
41
+ join(dir, "package.json"),
42
+ spec.packageJson ?? JSON.stringify({ name, version: "1.0.0" }),
43
+ );
44
+ }
45
+ if (spec.pluginJson !== undefined) {
46
+ writeFileSync(join(dir, "plugin.json"), spec.pluginJson);
47
+ }
38
48
  if (spec.mcpJson !== undefined) {
39
49
  writeFileSync(join(dir, "mcp.json"), spec.mcpJson);
40
50
  }
@@ -137,6 +147,26 @@ describe("readPluginMcpServers", () => {
137
147
  });
138
148
  });
139
149
 
150
+ test("reads MCP servers from a standard-only plugin", () => {
151
+ const dir = makePluginsDir({
152
+ unabyss: {
153
+ packageJson: null,
154
+ pluginJson: JSON.stringify({
155
+ $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
156
+ name: "unabyss",
157
+ }),
158
+ mcpJson: VALID_MANIFEST,
159
+ },
160
+ });
161
+
162
+ const { servers, issues } = readPluginMcpServers({
163
+ workspacePluginsDir: dir,
164
+ });
165
+
166
+ expect(issues).toEqual([]);
167
+ expect(servers.map((server) => server.id)).toEqual(["unabyss"]);
168
+ });
169
+
140
170
  test("defaults an omitted type to streamable-http", () => {
141
171
  const dir = makePluginsDir({
142
172
  openseo: {
@@ -205,7 +235,7 @@ describe("readPluginMcpServers", () => {
205
235
  workspacePluginsDir: root,
206
236
  });
207
237
  expect(servers).toEqual([]);
208
- expect(issues[0].message).toContain("package.json");
238
+ expect(issues[0].message).toContain("selected plugin manifest");
209
239
  });
210
240
 
211
241
  test("ignores a directory whose package.json is unparseable", () => {
@@ -216,7 +246,7 @@ describe("readPluginMcpServers", () => {
216
246
  workspacePluginsDir: dir,
217
247
  });
218
248
  expect(servers).toEqual([]);
219
- expect(issues[0].message).toContain("package.json");
249
+ expect(issues[0].message).toContain("selected plugin manifest");
220
250
  });
221
251
 
222
252
  test("ignores a directory whose package.json has no name", () => {
@@ -230,7 +260,7 @@ describe("readPluginMcpServers", () => {
230
260
  workspacePluginsDir: dir,
231
261
  });
232
262
  expect(servers).toEqual([]);
233
- expect(issues[0].message).toContain("package.json");
263
+ expect(issues[0].message).toContain("selected plugin manifest");
234
264
  });
235
265
 
236
266
  test("skips plugins with no mcp.json without complaining", () => {
@@ -10,12 +10,12 @@
10
10
  * need to be renamed when the convention shifts.
11
11
  *
12
12
  * <pluginDir>/
13
- * package.json ← manifest.name comes from `name`
14
- * (npm scope stripped);
13
+ * package.json ← legacy Vellum manifest, selected first;
15
14
  * peerDependencies["@vellumai/plugin-api"]
16
15
  * semver range is checked against the
17
16
  * running assistant version and rejects
18
17
  * the plugin if unsatisfied
18
+ * plugin.json ← standard fallback when package.json is absent
19
19
  * hooks/
20
20
  * <name>.ts ← default export → plugin.hooks[<name>]
21
21
  * (today the runtime invokes "init" at
@@ -41,7 +41,6 @@
41
41
  */
42
42
 
43
43
  import { existsSync, readdirSync, statSync } from "node:fs";
44
- import { readFile } from "node:fs/promises";
45
44
  import { basename, join } from "node:path";
46
45
  import { pathToFileURL } from "node:url";
47
46
 
@@ -53,6 +52,10 @@ import { PLUGIN_SECRET_PATTERN_LIMITS } from "../security/plugin-secret-patterns
53
52
  import { finalizeTool } from "../tools/tool-defaults.js";
54
53
  import type { Tool, ToolDefinition } from "../tools/types.js";
55
54
  import { getLogger } from "../util/logger.js";
55
+ import {
56
+ LEGACY_PLUGIN_MANIFEST,
57
+ readPluginManifest,
58
+ } from "../util/plugin-manifest.js";
56
59
  import { registerPlugin } from "./registry.js";
57
60
  import type {
58
61
  HookFunction,
@@ -69,39 +72,12 @@ const log = getLogger("external-plugin-loader");
69
72
  /** Default upper bound on how long a single plugin load may take. */
70
73
  const DEFAULT_IMPORT_TIMEOUT_MS = 10_000;
71
74
 
72
- /**
73
- * Zod schema for the subset of `package.json` the external loader reads.
74
- *
75
- * - `name` is the only required field; everything else is best-effort.
76
- * - `peerDependencies["@vellumai/plugin-api"]` is the canonical host-compat
77
- * declaration. If present, the loader checks `semver.satisfies(host, range)`
78
- * against the running assistant version and rejects the plugin on
79
- * mismatch. If absent, the plugin loads without a host-compat claim
80
- * (with a warning).
81
- * - `credentialKeyPatterns` is typed `unknown` here and shape-validated
82
- * separately ({@link parseCredentialKeyPatterns}) so a malformed
83
- * declaration degrades to `undefined` instead of failing the whole
84
- * `safeParse` and blocking plugin load.
85
- * - Unknown fields pass through (`passthrough`) so the loader does not
86
- * destructively reshape the file when the rest of the npm ecosystem
87
- * writes to it.
88
- */
89
- const PluginPackageJsonSchema = z
90
- .object({
91
- name: z.string().min(1, "package.json `name` must be a non-empty string"),
92
- version: z.string().optional(),
93
- peerDependencies: z.record(z.string(), z.string()).optional(),
94
- credentialKeyPatterns: z.unknown().optional(),
95
- /** Human title, when the package name is not one ("@vellumai/imessage"). */
96
- displayName: z.string().min(1).optional(),
97
- /** Standard npm field, reused as the one-line description clients show. */
98
- description: z.string().min(1).optional(),
99
- /** Lucide icon name without the `lucide-` prefix, matching `ChannelInfo`. */
100
- icon: z.string().min(1).optional(),
101
- })
102
- .passthrough();
103
-
104
- type PluginPackageJson = z.infer<typeof PluginPackageJsonSchema>;
75
+ interface LegacyPluginFields extends Record<string, unknown> {
76
+ readonly peerDependencies?: Record<string, string>;
77
+ readonly credentialKeyPatterns?: unknown;
78
+ readonly displayName?: string;
79
+ readonly icon?: string;
80
+ }
105
81
 
106
82
  /**
107
83
  * Shape-level caps for the `credentialKeyPatterns` manifest field. The
@@ -270,23 +246,8 @@ async function loadHooks(
270
246
  * timeout/try-catch/register triple.
271
247
  */
272
248
  async function buildPluginFromDir(pluginDir: string): Promise<Plugin> {
273
- const pkgPath = join(pluginDir, "package.json");
274
- let rawPkg: unknown;
275
- try {
276
- rawPkg = JSON.parse(await readFile(pkgPath, "utf8"));
277
- } catch (err) {
278
- const reason = err instanceof Error ? err.message : String(err);
279
- throw new Error(
280
- `package.json at ${pluginDir} could not be read or parsed: ${reason}`,
281
- );
282
- }
283
- const parsed = PluginPackageJsonSchema.safeParse(rawPkg);
284
- if (!parsed.success) {
285
- throw new Error(
286
- `package.json at ${pluginDir} failed schema validation: ${parsed.error.message}`,
287
- );
288
- }
289
- const pkg: PluginPackageJson = parsed.data;
249
+ const resolvedManifest = readPluginManifest(pluginDir);
250
+ const legacyFields = resolvedManifest.raw as LegacyPluginFields;
290
251
  // A plugin's identity is its install directory name — the slug the plugin
291
252
  // was installed under (a marketplace slug or a GitHub path leaf). This is
292
253
  // the identity every other surface uses: `plugins list`, enable/disable,
@@ -296,7 +257,10 @@ async function buildPluginFromDir(pluginDir: string): Promise<Plugin> {
296
257
  // registry from the identity the user toggles and the scope filters match.
297
258
  // The manifest is still required (schema-validated above) as the load gate.
298
259
  const name = basename(pluginDir);
299
- const version = pkg.version && pkg.version.length > 0 ? pkg.version : "0.0.0";
260
+ const version =
261
+ resolvedManifest.version && resolvedManifest.version.length > 0
262
+ ? resolvedManifest.version
263
+ : "0.0.0";
300
264
 
301
265
  // Host-compat negotiation: plugins declare their plugin-api version
302
266
  // range via standard `peerDependencies["@vellumai/plugin-api"]`. We
@@ -310,7 +274,7 @@ async function buildPluginFromDir(pluginDir: string): Promise<Plugin> {
310
274
  //
311
275
  // If the peerDep is absent, the plugin loads without a host-compat
312
276
  // claim; we log a warning so the omission is visible at boot.
313
- const range = pkg.peerDependencies?.[PLUGIN_API_PEER_DEP];
277
+ const range = legacyFields.peerDependencies?.[PLUGIN_API_PEER_DEP];
314
278
  if (range !== undefined) {
315
279
  if (!semver.validRange(range)) {
316
280
  log.error(
@@ -333,7 +297,7 @@ async function buildPluginFromDir(pluginDir: string): Promise<Plugin> {
333
297
  `external plugin ${name}: peerDependencies["${PLUGIN_API_PEER_DEP}"] requires "${range}" but assistant is ${assistantPkg.version} — loading anyway`,
334
298
  );
335
299
  }
336
- } else {
300
+ } else if (resolvedManifest.source === LEGACY_PLUGIN_MANIFEST) {
337
301
  log.warn(
338
302
  { pluginDir, plugin: name, peerDep: PLUGIN_API_PEER_DEP },
339
303
  "external plugin missing plugin-api peerDependency — loading without host-compat claim",
@@ -342,7 +306,9 @@ async function buildPluginFromDir(pluginDir: string): Promise<Plugin> {
342
306
 
343
307
  const manifest: PluginManifest = { name, version };
344
308
  const credentialKeyPatterns = parseCredentialKeyPatterns(
345
- pkg.credentialKeyPatterns,
309
+ resolvedManifest.source === LEGACY_PLUGIN_MANIFEST
310
+ ? legacyFields.credentialKeyPatterns
311
+ : undefined,
346
312
  pluginDir,
347
313
  );
348
314
  if (credentialKeyPatterns !== undefined) {
@@ -459,9 +425,9 @@ export async function loadExternalPlugin(
459
425
  }
460
426
 
461
427
  /**
462
- * Parse a plugin's `package.json` manifest from disk. Returns the plugin
428
+ * Parse a plugin's selected manifest from disk. Returns the plugin
463
429
  * identity (its install directory name), version, and any declared
464
- * `credentialKeyPatterns`, or `undefined` when the `package.json` is
430
+ * legacy `credentialKeyPatterns`, or `undefined` when the manifest is
465
431
  * missing, unparseable, or fails schema validation.
466
432
  *
467
433
  * Exported so the mtime cache can discover plugin identity without going
@@ -497,20 +463,23 @@ export interface PluginPresentation {
497
463
  export async function parsePluginPresentation(
498
464
  pluginDir: string,
499
465
  ): Promise<PluginPresentation | undefined> {
500
- let rawPkg: unknown;
501
466
  try {
502
- rawPkg = JSON.parse(
503
- await readFile(join(pluginDir, "package.json"), "utf8"),
504
- );
467
+ const manifest = readPluginManifest(pluginDir);
468
+ const legacyFields = manifest.raw as LegacyPluginFields;
469
+ return {
470
+ displayName:
471
+ manifest.source === LEGACY_PLUGIN_MANIFEST
472
+ ? legacyFields.displayName
473
+ : manifest.name,
474
+ description: manifest.description,
475
+ icon:
476
+ manifest.source === LEGACY_PLUGIN_MANIFEST
477
+ ? legacyFields.icon
478
+ : undefined,
479
+ };
505
480
  } catch {
506
481
  return undefined;
507
482
  }
508
- const parsed = PluginPackageJsonSchema.safeParse(rawPkg);
509
- if (!parsed.success) {
510
- return undefined;
511
- }
512
- const { displayName, description, icon } = parsed.data;
513
- return { displayName, description, icon };
514
483
  }
515
484
 
516
485
  export async function parsePluginManifest(
@@ -519,35 +488,29 @@ export async function parsePluginManifest(
519
488
  ): Promise<
520
489
  Pick<PluginManifest, "name" | "version" | "credentialKeyPatterns"> | undefined
521
490
  > {
522
- const pkgPath = join(pluginDir, "package.json");
523
- let rawPkg: unknown;
491
+ let resolvedManifest: ReturnType<typeof readPluginManifest>;
524
492
  try {
525
- rawPkg = JSON.parse(await readFile(pkgPath, "utf8"));
493
+ resolvedManifest = readPluginManifest(pluginDir);
526
494
  } catch (err) {
527
495
  const reason = err instanceof Error ? err.message : String(err);
528
496
  if (!opts.quiet) {
529
497
  log.error(
530
498
  { err, pluginDir },
531
- `package.json at ${pluginDir} could not be read or parsed: ${reason}`,
532
- );
533
- }
534
- return undefined;
535
- }
536
- const parsed = PluginPackageJsonSchema.safeParse(rawPkg);
537
- if (!parsed.success) {
538
- if (!opts.quiet) {
539
- log.error(
540
- { err: parsed.error, pluginDir },
541
- `package.json at ${pluginDir} failed schema validation: ${parsed.error.message}`,
499
+ `Plugin manifest at ${pluginDir} could not be read or validated: ${reason}`,
542
500
  );
543
501
  }
544
502
  return undefined;
545
503
  }
546
- const pkg: PluginPackageJson = parsed.data;
504
+ const legacyFields = resolvedManifest.raw as LegacyPluginFields;
547
505
  const name = basename(pluginDir);
548
- const version = pkg.version && pkg.version.length > 0 ? pkg.version : "0.0.0";
506
+ const version =
507
+ resolvedManifest.version && resolvedManifest.version.length > 0
508
+ ? resolvedManifest.version
509
+ : "0.0.0";
549
510
  const credentialKeyPatterns = parseCredentialKeyPatterns(
550
- pkg.credentialKeyPatterns,
511
+ resolvedManifest.source === LEGACY_PLUGIN_MANIFEST
512
+ ? legacyFields.credentialKeyPatterns
513
+ : undefined,
551
514
  pluginDir,
552
515
  );
553
516
  if (credentialKeyPatterns !== undefined) {
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Enumerate installed plugin directories under the workspace plugins dir.
3
3
  *
4
- * An installed plugin is a non-hidden directory carrying a `package.json`,
5
- * whose realpath stays under the realpath of the plugins directory. This walk
4
+ * An installed plugin is a non-hidden directory carrying a `package.json` or
5
+ * root `plugin.json` whose realpath stays under the realpath of the plugins
6
+ * directory. This walk
6
7
  * is shared by the plugin source-version collector
7
8
  * (`./collect-source-versions.ts`) and the schedule reconciler
8
9
  * (`../schedule/plugin-schedule-reconciler.ts`) so both agree on what counts
@@ -17,10 +18,11 @@
17
18
  * declaration is available to run.
18
19
  */
19
20
 
20
- import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
21
+ import { readdirSync, realpathSync, statSync } from "node:fs";
21
22
  import { join, sep } from "node:path";
22
23
 
23
24
  import { getWorkspacePluginsDir } from "../util/platform.js";
25
+ import { hasPluginManifest } from "../util/plugin-manifest.js";
24
26
 
25
27
  export interface InstalledPluginDir {
26
28
  /** Directory basename: the plugin's install identity. */
@@ -92,7 +94,7 @@ export function listInstalledPluginDirs(): InstalledPluginDir[] {
92
94
  if (!isInsidePluginRoot(dir, pluginsDir)) {
93
95
  continue;
94
96
  }
95
- if (!existsSync(join(dir, "package.json"))) {
97
+ if (!hasPluginManifest(dir)) {
96
98
  continue;
97
99
  }
98
100
  out.push({ name: entry, dir });
@@ -21,19 +21,11 @@
21
21
  * disables only that entry. Neither ever throws, because a malformed file
22
22
  * in one plugin must not remove another plugin's servers from a listing.
23
23
  *
24
- * Authentication is deliberately absent. Agent Plugins 1.0.0 defines no
25
- * portable OAuth or credential-reference fields; authentication is
26
- * client-managed, and any `headers` in the file are literal package data.
27
- * A plugin therefore cannot ship a credential, and the assistant's own
28
- * credential store stays the only place secrets live.
29
- *
30
- * Consumers must not resolve a plugin server's id against the
31
- * `mcp:<serverId>:*` credential namespace. Those keys belong to
32
- * workspace-configured servers, and a plugin controls both its server key
33
- * and its URL, so honoring them for a plugin-declared server would send a
34
- * workspace credential to an endpoint the plugin chose. Every config built
35
- * here carries `source: "plugin"`, which is what `McpClient` reads to
36
- * decide, so the rule travels with the server rather than with the caller.
24
+ * Authentication is client-managed. Agent Plugins 1.0.0 defines no portable
25
+ * OAuth or credential-reference fields, and any `headers` in the file are
26
+ * literal package data. Runtime configs carry the declaring plugin directory
27
+ * name and original server key so OAuth credentials can be isolated from the
28
+ * public server id and from workspace credentials.
37
29
  */
38
30
 
39
31
  import { existsSync, readFileSync } from "node:fs";
@@ -141,7 +133,7 @@ export function interpolatePluginPaths(
141
133
  *
142
134
  * `listAllPlugins` is an inventory of directories and reports a malformed
143
135
  * entry rather than dropping it, so it happily returns a directory with no
144
- * usable `package.json`. The runtime loader rejects those in
136
+ * usable selected manifest. The runtime loader rejects those in
145
137
  * `parsePluginManifest`: the manifest must parse and carry a non-empty
146
138
  * `name`. Applying the same gate here keeps `mcp.json` from being honored
147
139
  * for a directory that will never load as a plugin.
@@ -192,7 +184,7 @@ export function readPluginMcpServers(
192
184
  if (!hasLoadableManifest(plugin)) {
193
185
  issues.push({
194
186
  pluginName: plugin.name,
195
- message: `${PLUGIN_MCP_MANIFEST} ignored: package.json is missing, unparseable, or has no name, so the runtime will not load this directory as a plugin`,
187
+ message: `${PLUGIN_MCP_MANIFEST} ignored: the selected plugin manifest is missing or invalid, so the runtime will not load this directory as a plugin`,
196
188
  });
197
189
  continue;
198
190
  }
@@ -257,6 +249,8 @@ export function readPluginMcpServers(
257
249
  config: {
258
250
  transport: projectTransport(entry.data, plugin.target),
259
251
  source: "plugin",
252
+ pluginName: plugin.name,
253
+ serverKey,
260
254
  },
261
255
  });
262
256
  }
@@ -59,6 +59,7 @@ import {
59
59
  getWorkspaceHooksDir,
60
60
  getWorkspacePluginsDir,
61
61
  } from "../util/platform.js";
62
+ import { hasPluginManifest } from "../util/plugin-manifest.js";
62
63
  import { collectSourceVersions } from "./collect-source-versions.js";
63
64
  import {
64
65
  deriveToolName,
@@ -768,7 +769,7 @@ async function scanPlugins(): Promise<void> {
768
769
  );
769
770
  continue;
770
771
  }
771
- if (!existsSync(join(pluginDir, "package.json"))) {
772
+ if (!hasPluginManifest(pluginDir)) {
772
773
  continue;
773
774
  }
774
775
 
@@ -2,7 +2,8 @@
2
2
  * User plugin loader — discovers plugins under `<workspaceDir>/plugins/*` and
3
3
  * populates the mtime cache.
4
4
  *
5
- * A plugin directory is recognized by a `package.json` manifest. The loader
5
+ * A plugin directory is recognized by a legacy `package.json` or standard
6
+ * `plugin.json` manifest. The loader
6
7
  * delegates to the mtime cache's `populateCacheAtBoot`, which builds each
7
8
  * plugin via `buildExternalPlugin` and stores it keyed by mtime. The cache
8
9
  * is the single source of truth for user plugin state — the registry is no
@@ -107,6 +107,51 @@ describe("testInferenceConnection", () => {
107
107
  expect(calls[0].body).not.toHaveProperty(OPENCODE_SESSION_HEADER);
108
108
  });
109
109
 
110
+ test("probes /responses for an OpenCode responses-only model", async () => {
111
+ const calls: ProbeCall[] = [];
112
+ const result = await testInferenceConnection(
113
+ {
114
+ provider: "opencode",
115
+ auth: { type: "none" },
116
+ baseUrl: "https://opencode.ai/zen/v1",
117
+ models: [{ id: "muse-spark-1.2-contributor-free" }],
118
+ },
119
+ stubFetch(200, calls),
120
+ );
121
+
122
+ expect(result).toMatchObject({
123
+ ok: true,
124
+ resolved_url: "https://opencode.ai/zen/v1/responses",
125
+ });
126
+ expect(calls[0].body).toMatchObject({
127
+ model: "muse-spark-1.2-contributor-free",
128
+ input: "ping",
129
+ max_output_tokens: 16,
130
+ });
131
+ expect(calls[0].headers[OPENCODE_SESSION_HEADER]).toBeTruthy();
132
+ });
133
+
134
+ test("keeps other OpenCode models on /chat/completions", async () => {
135
+ const calls: ProbeCall[] = [];
136
+ const result = await testInferenceConnection(
137
+ {
138
+ provider: "opencode",
139
+ auth: { type: "none" },
140
+ baseUrl: "https://opencode.ai/zen/v1",
141
+ models: [{ id: "mimo-v2.5-free" }],
142
+ },
143
+ stubFetch(200, calls),
144
+ );
145
+
146
+ expect(result?.resolved_url).toBe(
147
+ "https://opencode.ai/zen/v1/chat/completions",
148
+ );
149
+ expect(calls[0].body).toMatchObject({
150
+ model: "mimo-v2.5-free",
151
+ max_tokens: 1,
152
+ });
153
+ });
154
+
110
155
  test("does not send OpenCode headers to other providers", async () => {
111
156
  const calls: ProbeCall[] = [];
112
157
  await testInferenceConnection(CONNECTION, stubFetch(200, calls));
@@ -10,7 +10,10 @@
10
10
  import { z } from "zod";
11
11
 
12
12
  import { getLogger } from "../../util/logger.js";
13
- import { resolveOpenCodeRequestHeaders } from "../opencode/client.js";
13
+ import {
14
+ OPENCODE_RESPONSES_ONLY_MODELS,
15
+ resolveOpenCodeRequestHeaders,
16
+ } from "../opencode/client.js";
14
17
  import type { Auth, ConnectionModel } from "./auth.js";
15
18
  import { resolveAuth } from "./resolve-auth.js";
16
19
 
@@ -46,10 +49,12 @@ function hintForStatus(status: number): string {
46
49
  }
47
50
 
48
51
  /**
49
- * Probe a connection's custom endpoint with a minimal chat-completions
50
- * request (`max_tokens: 1`). Returns `null` when there is nothing to probe:
51
- * no custom base URL, no model id to send, or auth that cannot be resolved
52
- * (a missing credential surfaces through its own error path).
52
+ * Probe a connection's custom endpoint with a minimal request on the wire
53
+ * its first model is served over: chat completions (`max_tokens: 1`), or
54
+ * the Responses API for OpenCode's responses-only models, which answer
55
+ * `/chat/completions` with HTTP 500. Returns `null` when there is nothing
56
+ * to probe: no custom base URL, no model id to send, or auth that cannot be
57
+ * resolved (a missing credential surfaces through its own error path).
53
58
  */
54
59
  export async function testInferenceConnection(
55
60
  connection: {
@@ -78,7 +83,12 @@ export async function testInferenceConnection(
78
83
  const providerHeaders =
79
84
  connection.provider === "opencode" ? resolveOpenCodeRequestHeaders() : {};
80
85
 
81
- const url = `${connection.baseUrl.replace(/\/+$/, "")}/chat/completions`;
86
+ const useResponses =
87
+ connection.provider === "opencode" &&
88
+ OPENCODE_RESPONSES_ONLY_MODELS.has(model);
89
+ const url = `${connection.baseUrl.replace(/\/+$/, "")}${
90
+ useResponses ? "/responses" : "/chat/completions"
91
+ }`;
82
92
  try {
83
93
  const res = await fetchImpl(url, {
84
94
  method: "POST",
@@ -87,12 +97,17 @@ export async function testInferenceConnection(
87
97
  ...providerHeaders,
88
98
  "Content-Type": "application/json",
89
99
  },
90
- body: JSON.stringify({
91
- model,
92
- messages: [{ role: "user", content: "ping" }],
93
- max_tokens: 1,
94
- stream: false,
95
- }),
100
+ body: JSON.stringify(
101
+ useResponses
102
+ ? // The Responses API rejects output budgets below 16 tokens.
103
+ { model, input: "ping", max_output_tokens: 16, stream: false }
104
+ : {
105
+ model,
106
+ messages: [{ role: "user", content: "ping" }],
107
+ max_tokens: 1,
108
+ stream: false,
109
+ },
110
+ ),
96
111
  signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
97
112
  });
98
113
  void res.body?.cancel();
@@ -109,7 +109,9 @@ function mapResponsesReasoningEffort(
109
109
  return "none";
110
110
  }
111
111
  const clamped = clampReasoningEffort(raw, effortCeilingForModel(model));
112
- return supported ? snapReasoningEffortToSupported(clamped, supported) : clamped;
112
+ return supported
113
+ ? snapReasoningEffortToSupported(clamped, supported)
114
+ : clamped;
113
115
  }
114
116
 
115
117
  /** Values accepted by the Responses API `text.verbosity` parameter. */
@@ -280,7 +282,11 @@ export class OpenAIResponsesProvider implements Provider {
280
282
  const effort = configObj?.effort as string | undefined;
281
283
  const verbosity = configObj?.verbosity as string | undefined;
282
284
  const usageAttributionHeaders = configObj?.usageAttributionHeaders as
283
- Record<string, string> | undefined;
285
+ | Record<string, string>
286
+ | undefined;
287
+ const perRequestHeaders = configObj?.requestHeaders as
288
+ | Record<string, string>
289
+ | undefined;
284
290
  const disableCache = configObj?.disableCache === true;
285
291
  const disableTurnStartCache = configObj?.disableTurnStartCache === true;
286
292
  const promptCacheKey =
@@ -463,6 +469,7 @@ export class OpenAIResponsesProvider implements Provider {
463
469
  const requestHeaders = {
464
470
  ...this.requestHeaders,
465
471
  ...(usageAttributionHeaders ?? {}),
472
+ ...(perRequestHeaders ?? {}),
466
473
  };
467
474
  const stream = await responsesApi.create(
468
475
  { ...params, stream: true },