@vellumai/assistant 0.12.2-staging.5 → 0.12.2-staging.7

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 (169) hide show
  1. package/Dockerfile +7 -7
  2. package/docs/architecture/memory.md +11 -2
  3. package/docs/desktop-browser-cli.md +4 -2
  4. package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
  5. package/node_modules/@vellumai/environments/src/shell.ts +24 -0
  6. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
  7. package/openapi.yaml +157 -55
  8. package/package.json +5 -4
  9. package/scripts/bundled-plugin-packages.ts +154 -0
  10. package/scripts/generate-bundled-plugin-packages.ts +29 -0
  11. package/scripts/postinstall.ts +33 -0
  12. package/scripts/smoke-desktop-browser-cli.ts +1 -0
  13. package/scripts/test.ts +15 -13
  14. package/src/__tests__/agent-loop.test.ts +124 -0
  15. package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
  16. package/src/__tests__/channel-approval.test.ts +9 -14
  17. package/src/__tests__/conversation-agent-loop.test.ts +25 -0
  18. package/src/__tests__/db-conversation-tool-surface.test.ts +144 -0
  19. package/src/__tests__/managed-store.test.ts +121 -0
  20. package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
  21. package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
  22. package/src/__tests__/scaffold-managed-skill-tool.test.ts +88 -0
  23. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  24. package/src/__tests__/subagent-tool-gate-mode.test.ts +169 -0
  25. package/src/__tests__/terminal-tools.test.ts +8 -0
  26. package/src/__tests__/unicode.test.ts +36 -0
  27. package/src/agent/loop.ts +19 -0
  28. package/src/api/events/desktop-activity-changed.ts +10 -0
  29. package/src/api/index.ts +6 -0
  30. package/src/approvals/approval-primitive.ts +5 -2
  31. package/src/approvals/scoped-approval-grants.ts +6 -2
  32. package/src/calls/__tests__/voice-control-protocol.test.ts +24 -2
  33. package/src/calls/__tests__/voice-session-bridge.test.ts +22 -2
  34. package/src/calls/voice-control-protocol.ts +13 -4
  35. package/src/calls/voice-session-bridge.ts +27 -6
  36. package/src/cli/commands/__tests__/plugins.test.ts +66 -0
  37. package/src/cli/commands/plugins.ts +50 -18
  38. package/src/cli/lib/__tests__/install-from-github.test.ts +67 -0
  39. package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +169 -0
  40. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +57 -0
  41. package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +14 -0
  42. package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +27 -1
  43. package/src/cli/lib/__tests__/plugin-details.test.ts +9 -2
  44. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +20 -0
  45. package/src/cli/lib/__tests__/plugins-install-offline.test.ts +43 -0
  46. package/src/cli/lib/__tests__/search-plugins.test.ts +31 -0
  47. package/src/cli/lib/bundled-plugin-packages.json +4 -0
  48. package/src/cli/lib/bundled-plugin-packages.ts +87 -0
  49. package/src/cli/lib/diff-plugin.ts +1 -1
  50. package/src/cli/lib/inspect-plugin.ts +80 -12
  51. package/src/cli/lib/install-from-github.ts +133 -76
  52. package/src/cli/lib/plugin-catalog-cache.ts +22 -3
  53. package/src/cli/lib/plugin-catalog-local.ts +13 -3
  54. package/src/cli/lib/plugin-catalog-platform.ts +6 -1
  55. package/src/cli/lib/plugin-catalog-resolve.ts +20 -0
  56. package/src/cli/lib/plugin-details.ts +12 -0
  57. package/src/cli/lib/plugin-marketplace.ts +121 -21
  58. package/src/cli/lib/plugin-pin-history.ts +5 -2
  59. package/src/cli/lib/search-plugins.ts +58 -16
  60. package/src/cli/lib/upgrade-plugin.ts +48 -3
  61. package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
  62. package/src/config/bundled-skills/skill-management/TOOLS.json +6 -6
  63. package/src/daemon/__tests__/conversation-tool-setup.test.ts +43 -0
  64. package/src/daemon/conversation-agent-loop.ts +2 -0
  65. package/src/daemon/conversation-tool-setup.ts +61 -23
  66. package/src/daemon/conversation.ts +17 -0
  67. package/src/daemon/daemon-control.ts +2 -6
  68. package/src/daemon/orphan-reaper.ts +4 -3
  69. package/src/daemon/tool-setup-types.ts +6 -0
  70. package/src/daemon/wake-conversation-ops.ts +38 -15
  71. package/src/desktop/desktop-automation-lease.test.ts +143 -0
  72. package/src/desktop/desktop-automation-lease.ts +39 -3
  73. package/src/live-voice/__tests__/live-voice-vad.test.ts +624 -3
  74. package/src/live-voice/__tests__/session-controls.test.ts +18 -0
  75. package/src/live-voice/live-voice-session.ts +575 -53
  76. package/src/live-voice/session-controls.ts +7 -3
  77. package/src/messaging/provider-message-metadata.ts +3 -3
  78. package/src/monitoring/plugin-auto-update.ts +6 -0
  79. package/src/notifications/__tests__/copy-composer.test.ts +70 -0
  80. package/src/notifications/copy-composer.ts +11 -3
  81. package/src/persistence/conversation-plugin-facade.ts +13 -0
  82. package/src/persistence/conversation-tool-surface.ts +86 -0
  83. package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +78 -0
  84. package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +29 -0
  85. package/src/persistence/schema/conversation-tool-surfaces.ts +22 -0
  86. package/src/persistence/schema/index.ts +1 -0
  87. package/src/persistence/steps.ts +2 -0
  88. package/src/plugin-api/conversation-turn.ts +31 -7
  89. package/src/plugin-api/index.ts +9 -1
  90. package/src/plugin-api/plugin-channel-turn-trust.test.ts +133 -0
  91. package/src/plugin-api/plugin-channel-turn-trust.ts +71 -0
  92. package/src/plugins/defaults/memory/AGENTS.md +14 -2
  93. package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +320 -0
  94. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +43 -0
  95. package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +17 -0
  96. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +46 -0
  97. package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +5 -0
  98. package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +161 -0
  99. package/src/plugins/defaults/memory/buffer-file.ts +354 -0
  100. package/src/plugins/defaults/memory/buffer-format.ts +40 -0
  101. package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -2
  102. package/src/plugins/defaults/memory/context-search/format.ts +2 -1
  103. package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -1
  104. package/src/plugins/defaults/memory/context-search/sources/workspace.ts +2 -1
  105. package/src/plugins/defaults/memory/graph/capability-seed.ts +1 -2
  106. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -42
  107. package/src/plugins/defaults/memory/host-utils.ts +0 -10
  108. package/src/plugins/defaults/memory/injectors.ts +4 -3
  109. package/src/plugins/defaults/memory/memory-retrospective-job.ts +55 -181
  110. package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
  111. package/src/plugins/defaults/memory/memory-run-evidence.ts +213 -0
  112. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +407 -99
  113. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +10 -0
  114. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +107 -7
  115. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +307 -86
  116. package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +34 -0
  117. package/src/plugins/defaults/memory/substrate/page-index.ts +2 -1
  118. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +89 -49
  119. package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
  120. package/src/plugins/defaults/memory/tools.ts +1 -1
  121. package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
  122. package/src/plugins/defaults/memory/v1/graph/extraction.ts +2 -1
  123. package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
  124. package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +5 -0
  125. package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +5 -2
  126. package/src/plugins/defaults/memory/v2/reranker.ts +2 -1
  127. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +81 -1
  128. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +87 -0
  129. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +21 -0
  130. package/src/plugins/defaults/memory/v3/card.ts +2 -1
  131. package/src/plugins/defaults/memory/v3/injector.ts +212 -178
  132. package/src/plugins/defaults/memory/v3/orchestrate.ts +86 -22
  133. package/src/plugins/defaults/memory/v3/pool-select.ts +10 -7
  134. package/src/plugins/defaults/memory/v3/sections.ts +2 -1
  135. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +10 -1
  136. package/src/plugins/defaults/tool-result-truncate/terminal.ts +1 -46
  137. package/src/runtime/AGENTS.md +1 -1
  138. package/src/runtime/__tests__/agent-wake.test.ts +86 -1
  139. package/src/runtime/agent-wake.ts +20 -4
  140. package/src/runtime/guardian-action-service.ts +2 -17
  141. package/src/runtime/guardian-reply-router.ts +1 -8
  142. package/src/runtime/routes/__tests__/plugins-routes.test.ts +102 -0
  143. package/src/runtime/routes/channel-route-shared.ts +1 -9
  144. package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
  145. package/src/runtime/routes/desktop-setup-routes.ts +7 -3
  146. package/src/runtime/routes/guardian-approval-interception.ts +24 -0
  147. package/src/runtime/routes/inbound-message-handler.ts +2 -3
  148. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
  149. package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -4
  150. package/src/runtime/routes/plugins-routes.ts +69 -49
  151. package/src/schedule/run-script.ts +2 -2
  152. package/src/skills/managed-store.ts +98 -32
  153. package/src/tools/host-terminal/host-shell.ts +12 -6
  154. package/src/tools/shared/filesystem/file-ops-service.ts +1 -31
  155. package/src/tools/shared/shell-output.test.ts +10 -0
  156. package/src/tools/shared/shell-output.ts +14 -2
  157. package/src/tools/skills/find-similar-skills.test.ts +3 -0
  158. package/src/tools/skills/resolve-execute-invocation.ts +24 -0
  159. package/src/tools/skills/sandbox-runner.ts +13 -2
  160. package/src/tools/skills/scaffold-managed.ts +18 -14
  161. package/src/tools/terminal/__tests__/safe-env.test.ts +29 -0
  162. package/src/tools/terminal/safe-env.ts +30 -1
  163. package/src/tools/terminal/sanitized-bash.ts +15 -2
  164. package/src/tools/terminal/shell-launch.test.ts +162 -0
  165. package/src/tools/terminal/shell.test.ts +29 -0
  166. package/src/tools/terminal/shell.ts +13 -7
  167. package/src/util/host-process.test.ts +17 -1
  168. package/src/util/host-process.ts +24 -0
  169. package/src/util/unicode.ts +29 -0
@@ -2,7 +2,7 @@
2
2
  * Route handlers for the assistant plugins surface.
3
3
  *
4
4
  * GET /v1/plugins — list installed plugins under `<workspaceDir>/plugins/`.
5
- * GET /v1/plugins/search — search the canonical GitHub catalog of installable plugins.
5
+ * GET /v1/plugins/search - search the reviewed catalog of installable plugins.
6
6
  * GET /v1/plugins/:name — resolve a single plugin's detail view (metadata + README).
7
7
  * POST /v1/plugins/install — install a plugin by name from the canonical source.
8
8
  * DELETE /v1/plugins/:name — uninstall a plugin from `<workspaceDir>/plugins/<name>/`.
@@ -192,30 +192,47 @@ const pluginsListResponseSchema = z.object({
192
192
  });
193
193
 
194
194
  const pluginMatchSourceSchema = z
195
- .object({
196
- kind: z.literal("github"),
197
- repo: z
198
- .string()
199
- .describe("`owner/repo` of the external plugin repository."),
200
- path: z
201
- .string()
202
- .optional()
203
- .describe(
204
- "Directory within the repo, when the plugin is not at the root.",
205
- ),
206
- ref: z.string().describe("Pinned git ref the plugin is fetched from."),
207
- })
208
- .describe("Origin of the match: a whitelisted external plugin repository.");
195
+ .discriminatedUnion("kind", [
196
+ z.object({
197
+ kind: z.literal("github"),
198
+ repo: z
199
+ .string()
200
+ .describe("`owner/repo` of the external plugin repository."),
201
+ path: z
202
+ .string()
203
+ .optional()
204
+ .describe(
205
+ "Directory within the repo, when the plugin is not at the root.",
206
+ ),
207
+ ref: z.string().describe("Pinned git ref the plugin is fetched from."),
208
+ }),
209
+ z.object({
210
+ kind: z.literal("local"),
211
+ path: z.string().describe("Package key in the assistant bundle."),
212
+ version: z.string().describe("Bundled package version."),
213
+ }),
214
+ ])
215
+ .describe("Reviewed source of the marketplace entry.");
216
+
217
+ const mcpPluginIntegrationSchema = z.object({
218
+ kind: z.literal("mcp"),
219
+ displayName: z.string(),
220
+ documentationUrl: z.string(),
221
+ verifiedAt: z.string(),
222
+ verification: z.literal("documentation-only"),
223
+ setup: z.object({
224
+ mode: z.enum(["oauth", "manual"]),
225
+ instructions: z.string(),
226
+ }),
227
+ logo: z.string(),
228
+ oauthProvider: z.string().optional(),
229
+ });
209
230
 
210
231
  const pluginSearchMatchSchema = z.object({
211
232
  name: z
212
233
  .string()
213
234
  .describe("Install name. Matches `assistant plugins install <name>`."),
214
- path: z
215
- .string()
216
- .describe(
217
- "Human-readable origin: a `github:owner/repo@ref` locator for the external plugin.",
218
- ),
235
+ path: z.string().describe("Human-readable catalog origin locator."),
219
236
  description: z
220
237
  .string()
221
238
  .optional()
@@ -232,6 +249,7 @@ const pluginSearchMatchSchema = z.object({
232
249
  .describe(
233
250
  "Marketplace category slug (Skills taxonomy); null when the entry declares none.",
234
251
  ),
252
+ integration: mcpPluginIntegrationSchema.optional(),
235
253
  source: pluginMatchSourceSchema,
236
254
  });
237
255
 
@@ -418,22 +436,20 @@ const fingerprintComparisonSchema = z
418
436
  );
419
437
 
420
438
  const installMetaSourceSchema = z
421
- .object({
422
- kind: z.string().describe("Source kind. Only `github` is written today."),
423
- owner: z.string(),
424
- repo: z.string(),
425
- path: z
426
- .string()
427
- .optional()
428
- .describe(
429
- "Repo-relative directory holding the plugin root; absent = repo root.",
430
- ),
431
- ref: z
432
- .string()
433
- .describe(
434
- "Ref the install resolved through (the pinned commit SHA for marketplace installs).",
435
- ),
436
- })
439
+ .discriminatedUnion("kind", [
440
+ z.object({
441
+ kind: z.literal("github"),
442
+ owner: z.string(),
443
+ repo: z.string(),
444
+ path: z.string().optional(),
445
+ ref: z.string(),
446
+ }),
447
+ z.object({
448
+ kind: z.literal("local"),
449
+ path: z.string(),
450
+ version: z.string(),
451
+ }),
452
+ ])
437
453
  .describe(
438
454
  "Source coordinates recorded in the install-time provenance sidecar.",
439
455
  );
@@ -489,6 +505,7 @@ const pluginLocalInfoSchema = z
489
505
 
490
506
  const pluginRemoteInfoSchema = z
491
507
  .object({
508
+ kind: z.literal("local").optional(),
492
509
  repo: z
493
510
  .string()
494
511
  .describe("`owner/repo` of the external plugin repository."),
@@ -517,6 +534,7 @@ const pluginRemoteInfoSchema = z
517
534
  .describe(
518
535
  "Ref of the canonical repo the marketplace manifest was read from.",
519
536
  ),
537
+ version: z.string().optional().describe("Bundled package version."),
520
538
  })
521
539
  .describe("The marketplace's current pin and advertised metadata.");
522
540
 
@@ -870,7 +888,8 @@ interface PluginMatchView {
870
888
  description?: string;
871
889
  icon?: string;
872
890
  category: string | null;
873
- source: { kind: "github"; repo: string; path?: string; ref: string };
891
+ integration?: PluginSearchMatch["integration"];
892
+ source: PluginSearchMatch["source"];
874
893
  }
875
894
 
876
895
  /**
@@ -886,12 +905,7 @@ function projectMatch(
886
905
  name: m.name,
887
906
  path: m.path,
888
907
  category: normalizeMarketplaceCategory(m.category, validSlugs),
889
- source: {
890
- kind: "github",
891
- repo: m.source.repo,
892
- ref: m.source.ref,
893
- ...(m.source.path !== undefined ? { path: m.source.path } : {}),
894
- },
908
+ source: { ...m.source },
895
909
  };
896
910
  if (m.description !== undefined) {
897
911
  view.description = m.description;
@@ -899,6 +913,9 @@ function projectMatch(
899
913
  if (m.icon !== undefined) {
900
914
  view.icon = m.icon;
901
915
  }
916
+ if (m.integration !== undefined) {
917
+ view.integration = m.integration;
918
+ }
902
919
  return view;
903
920
  }
904
921
 
@@ -1276,12 +1293,15 @@ async function handleInstallPlugin({ body = {}, headers }: RouteHandlerArgs) {
1276
1293
  {
1277
1294
  name,
1278
1295
  force,
1279
- trustedSource: {
1280
- owner: source.owner,
1281
- repo: source.repo,
1282
- rootPath: source.path,
1283
- ref: source.ref,
1284
- },
1296
+ trustedSource:
1297
+ source.kind === "local"
1298
+ ? source
1299
+ : {
1300
+ owner: source.owner,
1301
+ repo: source.repo,
1302
+ rootPath: source.path,
1303
+ ref: source.ref,
1304
+ },
1285
1305
  },
1286
1306
  { fetch: globalThis.fetch.bind(globalThis) },
1287
1307
  );
@@ -1,6 +1,7 @@
1
1
  import { buildSanitizedEnv } from "../tools/terminal/safe-env.js";
2
2
  import {
3
3
  buildShellInvocation,
4
+ buildShellSpawnFlags,
4
5
  terminateProcessTree,
5
6
  } from "../util/host-process.js";
6
7
  import { getLogger } from "../util/logger.js";
@@ -64,10 +65,9 @@ export async function runScript(
64
65
  const shell = buildShellInvocation(command);
65
66
  const proc = Bun.spawn([shell.command, ...shell.args], {
66
67
  cwd,
67
- detached: true,
68
+ ...buildShellSpawnFlags(),
68
69
  stdout: "pipe",
69
70
  stderr: "pipe",
70
- windowsHide: true,
71
71
  env: {
72
72
  ...buildSanitizedEnv(),
73
73
  // __SCHEDULE_ID lets a saved command find its own dir; __SCHEDULE_RUN_ID
@@ -18,6 +18,7 @@ import { parseFrontmatter } from "../config/skills.js";
18
18
  import { deleteSkillCapabilityNode } from "../plugins/defaults/memory/graph/capability-seed.js";
19
19
  import { isDeniedBasename } from "../tools/shared/filesystem/path-policy.js";
20
20
  import { getLogger } from "../util/logger.js";
21
+ import { isPlainObject } from "../util/object.js";
21
22
  import { getWorkspaceDir, getWorkspaceSkillsDir } from "../util/platform.js";
22
23
  import { parseFrontmatterFields } from "./frontmatter.js";
23
24
  import { writeInstallMeta } from "./install-meta.js";
@@ -212,11 +213,48 @@ interface BuildSkillMarkdownInput {
212
213
  name: string;
213
214
  description: string;
214
215
  bodyMarkdown: string;
216
+ /**
217
+ * The five fields the scaffold tool owns. `undefined` leaves whatever
218
+ * `preserve` carries for that field (nothing, on a create); an empty value
219
+ * clears it; a value sets it.
220
+ */
215
221
  emoji?: string;
216
222
  includes?: string[];
217
223
  activationHints?: string[];
218
224
  avoidWhen?: string[];
219
225
  category?: string;
226
+ /**
227
+ * The skill's existing frontmatter, as parsed from disk, for an overwrite.
228
+ * Every key survives except `name`, `description`, and the five fields
229
+ * above, so a `platforms` gate, a `display-name`, or custom metadata a
230
+ * person added by hand is not lost to a call that never mentions it.
231
+ */
232
+ preserve?: Record<string, unknown>;
233
+ }
234
+
235
+ /**
236
+ * Apply one tool-owned field to the vellum block: an input left `undefined`
237
+ * keeps the preserved value, an input given but empty (`value` undefined)
238
+ * clears it, and a value sets it.
239
+ */
240
+ function setOrClear(
241
+ target: Record<string, unknown>,
242
+ key: string,
243
+ value: unknown,
244
+ input: unknown,
245
+ ): void {
246
+ if (input === undefined) {
247
+ return;
248
+ }
249
+ if (value === undefined) {
250
+ delete target[key];
251
+ } else {
252
+ target[key] = value;
253
+ }
254
+ }
255
+
256
+ function nonEmptyList(list: string[] | undefined): string[] | undefined {
257
+ return list && list.length > 0 ? list : undefined;
220
258
  }
221
259
 
222
260
  export function buildSkillMarkdown(input: BuildSkillMarkdownInput): string {
@@ -230,40 +268,55 @@ export function buildSkillMarkdown(input: BuildSkillMarkdownInput): string {
230
268
  lines.push(`name: "${esc(input.name)}"`);
231
269
  lines.push(`description: "${esc(input.description)}"`);
232
270
 
233
- // Build metadata object matching the format parseFrontmatter expects:
234
- // metadata:
235
- // vellum:
236
- // emoji: "..."
237
- const vellum: Record<string, unknown> = {};
238
- if (input.emoji) {
239
- vellum.emoji = input.emoji;
240
- }
241
- if (input.includes && input.includes.length > 0) {
242
- vellum.includes = input.includes;
243
- }
244
- // Kebab-case keys match what parseFrontmatter reads back
245
- // (config/skills.ts: vellum["activation-hints"] / vellum["avoid-when"]).
246
- // These flow through stringifyYaml below, which escapes/quotes values, so no
271
+ // Everything but name and description is emitted from one object: the
272
+ // preserved frontmatter with the tool-owned fields applied over it, under
273
+ // `metadata.vellum` where parseFrontmatter reads them back (kebab-case for
274
+ // the two list fields). stringifyYaml quotes and escapes values, so no
247
275
  // manual sanitization is needed here.
248
- if (input.activationHints && input.activationHints.length > 0) {
249
- vellum["activation-hints"] = input.activationHints;
276
+ const rest: Record<string, unknown> = structuredClone(input.preserve ?? {});
277
+ delete rest.name;
278
+ delete rest.description;
279
+ const metadata = isPlainObject(rest.metadata) ? rest.metadata : {};
280
+ const vellum = isPlainObject(metadata.vellum) ? metadata.vellum : {};
281
+ setOrClear(vellum, "emoji", input.emoji?.trim() || undefined, input.emoji);
282
+ // An emoji at the legacy `metadata.emoji` location wins over an absent
283
+ // vellum one on read, so a call that states the emoji retires it.
284
+ if (input.emoji !== undefined) {
285
+ delete metadata.emoji;
250
286
  }
251
- if (input.avoidWhen && input.avoidWhen.length > 0) {
252
- vellum["avoid-when"] = input.avoidWhen;
287
+ setOrClear(vellum, "includes", nonEmptyList(input.includes), input.includes);
288
+ setOrClear(
289
+ vellum,
290
+ "activation-hints",
291
+ nonEmptyList(input.activationHints),
292
+ input.activationHints,
293
+ );
294
+ setOrClear(
295
+ vellum,
296
+ "avoid-when",
297
+ nonEmptyList(input.avoidWhen),
298
+ input.avoidWhen,
299
+ );
300
+ // The web Skills UI buckets skills by this value; a blank one is a clear,
301
+ // never an empty bucket in the file.
302
+ setOrClear(
303
+ vellum,
304
+ "category",
305
+ input.category?.trim() || undefined,
306
+ input.category,
307
+ );
308
+ if (Object.keys(vellum).length > 0) {
309
+ metadata.vellum = vellum;
310
+ } else {
311
+ delete metadata.vellum;
253
312
  }
254
- // The web Skills UI groups skills into a category sidebar by this value;
255
- // skip it when blank so an empty bucket assignment never lands in frontmatter.
256
- if (input.category?.trim()) {
257
- vellum.category = input.category.trim();
313
+ if (Object.keys(metadata).length > 0) {
314
+ rest.metadata = metadata;
315
+ } else {
316
+ delete rest.metadata;
258
317
  }
259
-
260
- if (Object.keys(vellum).length > 0) {
261
- const metadata = { vellum };
262
- const yamlBlock = stringifyYaml(metadata, { indent: 2 });
263
- lines.push("metadata:");
264
- for (const yamlLine of yamlBlock.trimEnd().split("\n")) {
265
- lines.push(` ${yamlLine}`);
266
- }
318
+ if (Object.keys(rest).length > 0) {
319
+ lines.push(stringifyYaml(rest, { indent: 2 }).trimEnd());
267
320
  }
268
321
 
269
322
  lines.push("---");
@@ -350,7 +403,8 @@ export function createManagedSkill(
350
403
  const skillDir = getManagedSkillDir(params.id);
351
404
  const skillFilePath = join(skillDir, "SKILL.md");
352
405
 
353
- if (existsSync(skillFilePath) && !params.overwrite) {
406
+ const skillExists = existsSync(skillFilePath);
407
+ if (skillExists && !params.overwrite) {
354
408
  return {
355
409
  created: false,
356
410
  path: skillFilePath,
@@ -358,6 +412,14 @@ export function createManagedSkill(
358
412
  };
359
413
  }
360
414
 
415
+ // An overwrite replaces the body and patches the frontmatter: a field the
416
+ // call leaves undefined keeps its current value, an explicit empty value
417
+ // clears it, and frontmatter the tool does not own passes through. Callers
418
+ // rarely hold every field (the retrospective sees a skill through a
419
+ // similarity hit; a user edit is "change step 3"), so a field they do not
420
+ // pass is kept rather than dropped.
421
+ const existing = skillExists ? readStoredManagedSkill(params.id) : null;
422
+
361
423
  // Resolve and validate every companion path before any write so an invalid
362
424
  // path leaves no partial files behind.
363
425
  const companionWrites: Array<{ resolvedPath: string; content: string }> = [];
@@ -413,6 +475,7 @@ export function createManagedSkill(
413
475
  activationHints: params.activationHints,
414
476
  avoidWhen: params.avoidWhen,
415
477
  category: params.category,
478
+ preserve: existing?.frontmatter,
416
479
  });
417
480
 
418
481
  mkdirSync(skillDir, { recursive: true });
@@ -461,11 +524,13 @@ export function createManagedSkill(
461
524
  * `{workspaceDir}` and strips feature-gated sections, and a caller that wrote
462
525
  * that output back would bake absolute paths into the skill; and a first line
463
526
  * that opens an indented code block must keep its indentation or a copy turns
464
- * it into prose.
527
+ * it into prose. `frontmatter` is the whole block as written, for an
528
+ * overwrite to carry keys through that the typed fields do not cover.
465
529
  */
466
530
  export interface StoredManagedSkill {
467
531
  name: string;
468
532
  description: string;
533
+ frontmatter: Record<string, unknown>;
469
534
  emoji?: string;
470
535
  includes?: string[];
471
536
  activationHints?: string[];
@@ -493,6 +558,7 @@ export function readStoredManagedSkill(
493
558
  return {
494
559
  name: parsed.name,
495
560
  description: parsed.description,
561
+ frontmatter: raw.fields,
496
562
  emoji: parsed.emoji,
497
563
  includes: parsed.includes,
498
564
  activationHints: parsed.activationHints,
@@ -23,8 +23,10 @@ import { conversationRevealNonce } from "../../runtime/reveal-nonce.js";
23
23
  import { redactSecrets } from "../../security/secret-scanner.js";
24
24
  import {
25
25
  buildShellInvocation,
26
+ buildShellSpawnFlags,
26
27
  prependUniquePathEntries,
27
28
  terminateProcessTree,
29
+ watchShellProcessStart,
28
30
  } from "../../util/host-process.js";
29
31
  import { getLogger } from "../../util/logger.js";
30
32
  import type { CompletedBackgroundTool } from "../background-tool-registry.js";
@@ -455,9 +457,9 @@ export const hostShellTool = {
455
457
  cwd: workingDir,
456
458
  env: hostEnv,
457
459
  stdio: ["ignore", "pipe", "pipe"],
458
- detached: true,
459
- windowsHide: true,
460
+ ...buildShellSpawnFlags(),
460
461
  });
462
+ const launch = watchShellProcessStart(child);
461
463
 
462
464
  const collector = attachBoundedStdio(child);
463
465
  let timedOut = false;
@@ -483,7 +485,9 @@ export const hostShellTool = {
483
485
  }
484
486
  completed = true;
485
487
  clearTimeout(timer);
486
- const result = collector.format(code, timedOut, timeoutSec);
488
+ const result = collector.format(code, timedOut, timeoutSec, {
489
+ started: launch.didStart(),
490
+ });
487
491
  // Cancel takes precedence over the SIGKILL-induced error result.
488
492
  const status = aborted
489
493
  ? "cancelled"
@@ -629,9 +633,9 @@ export const hostShellTool = {
629
633
  cwd: workingDir,
630
634
  env: hostEnv,
631
635
  stdio: ["ignore", "pipe", "pipe"],
632
- detached: true,
633
- windowsHide: true,
636
+ ...buildShellSpawnFlags(),
634
637
  });
638
+ const launch = watchShellProcessStart(child);
635
639
  const collector = attachBoundedStdio(child, {
636
640
  onOutput: context.onOutput,
637
641
  });
@@ -657,7 +661,9 @@ export const hostShellTool = {
657
661
  clearTimeout(timer);
658
662
  context.signal?.removeEventListener("abort", onAbort);
659
663
 
660
- const result = collector.format(code, timedOut, timeoutSec);
664
+ const result = collector.format(code, timedOut, timeoutSec, {
665
+ started: launch.didStart(),
666
+ });
661
667
 
662
668
  resolve({
663
669
  content: result.content,
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
4
4
  import { minimatch } from "minimatch";
5
5
 
6
6
  import { ensureDir, pathExists } from "../../../util/fs.js";
7
+ import { surrogateSafeWindow } from "../../../util/unicode.js";
7
8
  import { isAbortLikeError } from "../abort.js";
8
9
  import { applyEdit } from "./edit-engine.js";
9
10
  import * as Err from "./errors.js";
@@ -102,37 +103,6 @@ function truncationNotice(
102
103
  return `\n\n[Truncated: characters ${start}-${end} of ${totalChars}. Read on with start_index=${end}.]`;
103
104
  }
104
105
 
105
- const isHighSurrogate = (code: number): boolean =>
106
- code >= 0xd800 && code <= 0xdbff;
107
- const isLowSurrogate = (code: number): boolean =>
108
- code >= 0xdc00 && code <= 0xdfff;
109
-
110
- /**
111
- * Character window that never splits a surrogate pair. A split leaves a lone
112
- * half at each edge, and each encodes to U+FFFD, so the character is lost from
113
- * both this window and the next one paged in after it.
114
- */
115
- export function surrogateSafeWindow(
116
- total: number,
117
- charCodeAt: (index: number) => number,
118
- requestedStart: number,
119
- maxChars: number,
120
- ): { start: number; end: number } {
121
- let start = Math.max(0, Math.min(requestedStart, total));
122
- if (start > 0 && start < total && isLowSurrogate(charCodeAt(start))) {
123
- start -= 1;
124
- }
125
-
126
- let end = Math.min(total, start + maxChars);
127
- if (end > start && end < total && isHighSurrogate(charCodeAt(end - 1))) {
128
- // Backing off would empty a one-character window, which stalls paging on
129
- // the same offset, so take the whole pair instead.
130
- end = end - 1 > start ? end - 1 : Math.min(total, end + 1);
131
- }
132
-
133
- return { start, end };
134
- }
135
-
136
106
  export class FileSystemOps {
137
107
  private policy: PathPolicy;
138
108
  private sizeLimit: number | undefined;
@@ -7,6 +7,7 @@ import {
7
7
  formatShellOutput,
8
8
  MAX_OUTPUT_LENGTH,
9
9
  OUTPUT_TRUNCATED_TAG,
10
+ SHELL_DID_NOT_START_MESSAGE,
10
11
  } from "./shell-output.js";
11
12
 
12
13
  describe("BoundedStdioCollector", () => {
@@ -85,6 +86,15 @@ describe("attachBoundedStdio", () => {
85
86
  });
86
87
  });
87
88
 
89
+ describe("formatShellOutput launch start", () => {
90
+ test("does not report command_completed when the process never started", () => {
91
+ const result = formatShellOutput("", "", 0, false, 120, { started: false });
92
+ expect(result.isError).toBe(true);
93
+ expect(result.content).toBe(SHELL_DID_NOT_START_MESSAGE);
94
+ expect(result.content).not.toContain("<command_completed />");
95
+ });
96
+ });
97
+
88
98
  describe("formatShellOutput truncation", () => {
89
99
  test("truncates an already-materialized oversized string without a file path", () => {
90
100
  const longOutput = "x".repeat(30_000);
@@ -4,6 +4,9 @@ export const MAX_OUTPUT_LENGTH = 20_000;
4
4
 
5
5
  export const OUTPUT_TRUNCATED_TAG = `<output_truncated limit="20K" />`;
6
6
 
7
+ export const SHELL_DID_NOT_START_MESSAGE =
8
+ "Error: the shell command did not start. No process was created, so the command did not run.";
9
+
7
10
  export interface ShellOutputResult {
8
11
  content: string;
9
12
  status: string | undefined;
@@ -31,8 +34,16 @@ export function formatShellOutput(
31
34
  code: number | null,
32
35
  timedOut: boolean,
33
36
  timeoutSec: number,
34
- options?: { truncated?: boolean },
37
+ options?: { truncated?: boolean; started?: boolean },
35
38
  ): ShellOutputResult {
39
+ if (options?.started === false) {
40
+ return {
41
+ content: SHELL_DID_NOT_START_MESSAGE,
42
+ status: undefined,
43
+ isError: true,
44
+ };
45
+ }
46
+
36
47
  let output = stdout;
37
48
  if (stderr) {
38
49
  output += (output ? "\n" : "") + stderr;
@@ -137,6 +148,7 @@ export class BoundedStdioCollector {
137
148
  code: number | null,
138
149
  timedOut: boolean,
139
150
  timeoutSec: number,
151
+ options?: { started?: boolean },
140
152
  ): ShellOutputResult {
141
153
  return formatShellOutput(
142
154
  Buffer.concat(this.stdoutParts).toString(),
@@ -144,7 +156,7 @@ export class BoundedStdioCollector {
144
156
  code,
145
157
  timedOut,
146
158
  timeoutSec,
147
- { truncated: this.truncated },
159
+ { truncated: this.truncated, started: options?.started },
148
160
  );
149
161
  }
150
162
  }
@@ -257,6 +257,7 @@ describe("find_similar_skills: current skill for a refinable hit", () => {
257
257
  "weekly-export": {
258
258
  name: "Weekly Report Export",
259
259
  description: "Export the weekly usage report",
260
+ frontmatter: {},
260
261
  emoji: "📊",
261
262
  category: "productivity",
262
263
  includes: ["csv-basics"],
@@ -267,11 +268,13 @@ describe("find_similar_skills: current skill for a refinable hit", () => {
267
268
  "user-skill": {
268
269
  name: "User Skill",
269
270
  description: "A person wrote this",
271
+ frontmatter: {},
270
272
  body: "Do the user's thing.",
271
273
  },
272
274
  "clean-disk": {
273
275
  name: "Clean Disk",
274
276
  description: "Free up disk space",
277
+ frontmatter: {},
275
278
  body: "Delete caches.",
276
279
  },
277
280
  };
@@ -0,0 +1,24 @@
1
+ import { getTool } from "../registry.js";
2
+ import { resolveToolInvocationAlias } from "../tool-name-aliases.js";
3
+ import {
4
+ recoverSkillExecuteEnvelope,
5
+ resolveSkillExecuteInput,
6
+ } from "./execute.js";
7
+
8
+ export function resolveSkillExecuteInvocation(
9
+ input: Record<string, unknown>,
10
+ allowedToolNames?: ReadonlySet<string>,
11
+ ): { name: string; input: Record<string, unknown> } {
12
+ const envelope = recoverSkillExecuteEnvelope(input);
13
+ const rawToolName = typeof envelope.tool === "string" ? envelope.tool : "";
14
+ const innerSchema = rawToolName
15
+ ? getTool(rawToolName)?.input_schema
16
+ : undefined;
17
+ const rawToolInput = resolveSkillExecuteInput(envelope, innerSchema);
18
+
19
+ return resolveToolInvocationAlias(
20
+ rawToolName,
21
+ { ...rawToolInput },
22
+ allowedToolNames,
23
+ );
24
+ }
@@ -7,9 +7,12 @@ import { conversationRevealNonce } from "../../runtime/reveal-nonce.js";
7
7
  import { computeSkillVersionHash } from "../../skills/version-hash.js";
8
8
  import {
9
9
  buildShellInvocation,
10
+ buildShellSpawnFlags,
10
11
  terminateProcessTree,
12
+ watchShellProcessStart,
11
13
  } from "../../util/host-process.js";
12
14
  import { safeStringSlice } from "../../util/unicode.js";
15
+ import { SHELL_DID_NOT_START_MESSAGE } from "../shared/shell-output.js";
13
16
  import { buildSanitizedEnv } from "../terminal/safe-env.js";
14
17
  import type { ToolContext, ToolExecutionResult } from "../types.js";
15
18
 
@@ -160,9 +163,9 @@ function spawnRunner(
160
163
  cwd: runDir,
161
164
  env,
162
165
  stdio: ["ignore", "pipe", "pipe"],
163
- detached: true,
164
- windowsHide: true,
166
+ ...buildShellSpawnFlags(),
165
167
  });
168
+ const launch = watchShellProcessStart(child);
166
169
 
167
170
  const timer = setTimeout(() => {
168
171
  timedOut = true;
@@ -188,6 +191,14 @@ function spawnRunner(
188
191
  clearTimeout(timer);
189
192
  context.signal?.removeEventListener("abort", onAbort);
190
193
 
194
+ if (!launch.didStart()) {
195
+ resolve({
196
+ content: `Failed to spawn skill tool script "${executorPath}": ${SHELL_DID_NOT_START_MESSAGE}`,
197
+ isError: true,
198
+ });
199
+ return;
200
+ }
201
+
191
202
  if (timedOut) {
192
203
  resolve({
193
204
  content: `Skill tool script "${executorPath}" timed out after ${timeoutMs}ms`,