@vellumai/assistant 0.8.9-staging.2 → 0.8.9-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 (111) hide show
  1. package/docs/activation-funnel-telemetry.md +310 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/activation-early-marking.test.ts +120 -0
  4. package/src/__tests__/agent-loop-output-hooks.test.ts +13 -13
  5. package/src/__tests__/approval-cascade.test.ts +1 -1
  6. package/src/__tests__/compaction-direct.test.ts +32 -18
  7. package/src/__tests__/compaction-events.test.ts +2 -2
  8. package/src/__tests__/compaction.benchmark.test.ts +1 -1
  9. package/src/__tests__/context-overflow-reducer.test.ts +5 -5
  10. package/src/__tests__/context-window-manager-compact-retry.test.ts +1 -1
  11. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
  12. package/src/__tests__/conversation-confirmation-signals.test.ts +1 -1
  13. package/src/__tests__/conversation-error.test.ts +15 -1
  14. package/src/__tests__/conversation-history-web-search.test.ts +5 -0
  15. package/src/__tests__/conversation-media-retry.test.ts +1 -1
  16. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +40 -0
  17. package/src/__tests__/conversation-process-callsite.test.ts +1 -1
  18. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -1
  19. package/src/__tests__/conversation-queue.test.ts +1 -1
  20. package/src/__tests__/conversation-runtime-assembly.test.ts +71 -0
  21. package/src/__tests__/conversation-slash-queue.test.ts +1 -1
  22. package/src/__tests__/conversation-slash-unknown.test.ts +1 -1
  23. package/src/__tests__/conversation-speed-override.test.ts +1 -1
  24. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
  25. package/src/__tests__/conversation-surfaces-app-control.test.ts +44 -0
  26. package/src/__tests__/conversation-undo.test.ts +2 -2
  27. package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -1
  28. package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
  29. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
  30. package/src/__tests__/cu-unified-flow.test.ts +36 -0
  31. package/src/__tests__/history-repair-hook.test.ts +2 -0
  32. package/src/__tests__/memory-retrieval-hook.test.ts +1 -2
  33. package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
  34. package/src/__tests__/persist-unsendable-image.test.ts +97 -1
  35. package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
  36. package/src/__tests__/skill-feature-flags-integration.test.ts +5 -7
  37. package/src/__tests__/title-generate-hook.test.ts +2 -0
  38. package/src/__tests__/web-fetch.test.ts +45 -0
  39. package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
  40. package/src/acp/resolve-agent.test.ts +0 -56
  41. package/src/acp/resolve-agent.ts +10 -38
  42. package/src/agent/loop.ts +13 -27
  43. package/src/cli/lib/__tests__/install-from-github.test.ts +232 -29
  44. package/src/cli/lib/__tests__/plugin-details.test.ts +28 -19
  45. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +57 -7
  46. package/src/cli/lib/__tests__/search-plugins.test.ts +17 -10
  47. package/src/cli/lib/install-from-github.ts +258 -41
  48. package/src/cli/lib/plugin-details.ts +20 -13
  49. package/src/cli/lib/plugin-marketplace.ts +23 -5
  50. package/src/cli/lib/search-plugins.ts +14 -8
  51. package/src/config/acp-defaults.ts +3 -3
  52. package/src/config/acp-schema.ts +1 -7
  53. package/src/config/bundled-skills/acp/SKILL.md +4 -17
  54. package/src/config/bundled-skills/acp/TOOLS.json +2 -2
  55. package/src/config/feature-flag-registry.json +3 -18
  56. package/src/context/post-turn-tool-result-truncation.ts +39 -1
  57. package/src/daemon/conversation-agent-loop-handlers.ts +8 -1
  58. package/src/daemon/conversation-agent-loop.ts +78 -78
  59. package/src/daemon/conversation-error.ts +31 -4
  60. package/src/daemon/conversation-history.ts +1 -1
  61. package/src/daemon/conversation-media-retry.ts +19 -6
  62. package/src/daemon/conversation-messaging.ts +17 -0
  63. package/src/daemon/conversation-process.ts +14 -5
  64. package/src/daemon/conversation-queue-manager.ts +8 -0
  65. package/src/daemon/conversation-runtime-assembly.ts +37 -1
  66. package/src/daemon/conversation-surfaces.ts +141 -3
  67. package/src/daemon/conversation.ts +48 -13
  68. package/src/daemon/persist-unsendable-image.ts +62 -25
  69. package/src/daemon/process-message.ts +1 -1
  70. package/src/memory/__tests__/activation-session-store.test.ts +41 -0
  71. package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
  72. package/src/memory/activation-session-store.ts +43 -0
  73. package/src/memory/db-init.ts +4 -0
  74. package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
  75. package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
  76. package/src/memory/migrations/index.ts +2 -0
  77. package/src/memory/onboarding-events-store.ts +66 -18
  78. package/src/memory/schema/infrastructure.ts +13 -0
  79. package/src/messaging/providers/telegram-bot/api.ts +14 -5
  80. package/src/notifications/adapters/telegram.ts +7 -1
  81. package/src/plugin-api/constants.ts +2 -2
  82. package/src/plugin-api/index.ts +2 -2
  83. package/src/plugin-api/types.ts +19 -5
  84. package/src/plugins/defaults/compaction/compact.ts +24 -15
  85. package/src/plugins/defaults/compaction/context-overflow-reducer.ts +4 -4
  86. package/src/plugins/defaults/compaction/manager-store.ts +1 -1
  87. package/src/{context → plugins/defaults/compaction}/window-manager.ts +12 -12
  88. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +12 -18
  89. package/src/prompts/system-prompt.ts +61 -10
  90. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +37 -2
  91. package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
  92. package/src/providers/openai/chat-completions-provider.ts +25 -0
  93. package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
  94. package/src/runtime/routes/acp-routes.test.ts +3 -20
  95. package/src/runtime/routes/conversation-routes.ts +2 -0
  96. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
  97. package/src/runtime/routes/stt-routes.ts +45 -12
  98. package/src/runtime/routes/workspace-routes.ts +50 -15
  99. package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
  100. package/src/telemetry/activation-funnel.ts +167 -0
  101. package/src/telemetry/types.ts +13 -0
  102. package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
  103. package/src/telemetry/usage-telemetry-reporter.ts +26 -1
  104. package/src/tools/acp/list-agents.test.ts +2 -18
  105. package/src/tools/acp/list-agents.ts +3 -15
  106. package/src/tools/acp/spawn.test.ts +0 -10
  107. package/src/tools/browser/browser-execution.ts +12 -2
  108. package/src/tools/network/web-fetch.ts +65 -24
  109. package/src/tools/ui-surface/definitions.ts +7 -0
  110. package/src/acp/feature-gate.test.ts +0 -48
  111. package/src/acp/feature-gate.ts +0 -34
@@ -18,6 +18,11 @@ import {
18
18
  const MANIFEST_URL_PREFIX =
19
19
  "https://api.github.com/repos/vellum-ai/vellum-assistant/contents/experimental/plugins/marketplace.json";
20
20
 
21
+ // External marketplace refs must be full commit SHAs (immutable). Fixtures use
22
+ // realistic 40-char hex object names rather than tags/branches.
23
+ const CAVEMAN_SHA = "63a91ecadbf4c4719a4602a5abb00883f9966034";
24
+ const NESTED_SHA = "0123456789abcdef0123456789abcdef01234567";
25
+
21
26
  /** Serve `body` (any value) as the raw manifest file at the manifest URL. */
22
27
  function manifestFetch(body: unknown, status = 200, raw?: string): FetchLike {
23
28
  return (async (input: RequestInfo | URL) => {
@@ -41,7 +46,7 @@ const VALID_MANIFEST = {
41
46
  source: {
42
47
  source: "github",
43
48
  repo: "JuliusBrussee/caveman",
44
- ref: "v1.8.2",
49
+ ref: CAVEMAN_SHA,
45
50
  },
46
51
  description: "Ultra-compressed communication mode.",
47
52
  category: "productivity",
@@ -52,7 +57,7 @@ const VALID_MANIFEST = {
52
57
  source: "github",
53
58
  repo: "acme/monorepo",
54
59
  path: "packages/nested",
55
- ref: "abc123",
60
+ ref: NESTED_SHA,
56
61
  },
57
62
  },
58
63
  ],
@@ -71,7 +76,7 @@ describe("fetchMarketplaceEntries", () => {
71
76
  expect(entries[0]!.source).toEqual({
72
77
  source: "github",
73
78
  repo: "JuliusBrussee/caveman",
74
- ref: "v1.8.2",
79
+ ref: CAVEMAN_SHA,
75
80
  });
76
81
  expect(entries[1]!.source.path).toBe("packages/nested");
77
82
  });
@@ -123,6 +128,51 @@ describe("fetchMarketplaceEntries", () => {
123
128
  ).rejects.toBeInstanceOf(MarketplaceFetchError);
124
129
  });
125
130
 
131
+ test("rejects a mutable tag or branch ref (must be a full commit SHA)", async () => {
132
+ // GIVEN an entry whose source pins a mutable tag rather than a commit SHA
133
+ const fetch = manifestFetch({
134
+ name: "x",
135
+ plugins: [
136
+ {
137
+ name: "caveman",
138
+ source: {
139
+ source: "github",
140
+ repo: "JuliusBrussee/caveman",
141
+ ref: "v1.8.2",
142
+ },
143
+ },
144
+ ],
145
+ });
146
+
147
+ // WHEN / THEN the mutable ref is rejected: a retag could repoint it at
148
+ // attacker code the daemon later dynamically imports.
149
+ await expect(
150
+ fetchMarketplaceEntries({ fetch }, { ref: "main" }),
151
+ ).rejects.toBeInstanceOf(MarketplaceFetchError);
152
+ });
153
+
154
+ test("rejects an abbreviated commit SHA", async () => {
155
+ // GIVEN an entry whose source uses a short (ambiguous, non-pinning) SHA
156
+ const fetch = manifestFetch({
157
+ name: "x",
158
+ plugins: [
159
+ {
160
+ name: "caveman",
161
+ source: {
162
+ source: "github",
163
+ repo: "JuliusBrussee/caveman",
164
+ ref: "63a91ec",
165
+ },
166
+ },
167
+ ],
168
+ });
169
+
170
+ // WHEN / THEN only a full object name pins the install immutably
171
+ await expect(
172
+ fetchMarketplaceEntries({ fetch }, { ref: "main" }),
173
+ ).rejects.toBeInstanceOf(MarketplaceFetchError);
174
+ });
175
+
126
176
  test("rejects a manifest entry missing a pinned ref", async () => {
127
177
  // GIVEN an entry whose source omits the required ref
128
178
  const fetch = manifestFetch({
@@ -169,7 +219,7 @@ describe("resolveMarketplaceSource", () => {
169
219
  source: {
170
220
  source: "github",
171
221
  repo: "JuliusBrussee/caveman",
172
- ref: "v1.8.2",
222
+ ref: CAVEMAN_SHA,
173
223
  },
174
224
  },
175
225
  {
@@ -178,7 +228,7 @@ describe("resolveMarketplaceSource", () => {
178
228
  source: "github",
179
229
  repo: "acme/monorepo",
180
230
  path: "packages/nested",
181
- ref: "abc123",
231
+ ref: NESTED_SHA,
182
232
  },
183
233
  },
184
234
  ];
@@ -193,7 +243,7 @@ describe("resolveMarketplaceSource", () => {
193
243
  owner: "JuliusBrussee",
194
244
  repo: "caveman",
195
245
  path: "",
196
- ref: "v1.8.2",
246
+ ref: CAVEMAN_SHA,
197
247
  });
198
248
  });
199
249
 
@@ -207,7 +257,7 @@ describe("resolveMarketplaceSource", () => {
207
257
  owner: "acme",
208
258
  repo: "monorepo",
209
259
  path: "packages/nested",
210
- ref: "abc123",
260
+ ref: NESTED_SHA,
211
261
  });
212
262
  });
213
263
 
@@ -279,7 +279,7 @@ describe("searchPlugins", () => {
279
279
  source: {
280
280
  source: "github",
281
281
  repo: "JuliusBrussee/caveman",
282
- ref: "v1.8.2",
282
+ ref: "63a91ecadbf4c4719a4602a5abb00883f9966034",
283
283
  },
284
284
  description: "Ultra-compressed communication mode.",
285
285
  },
@@ -311,12 +311,12 @@ describe("searchPlugins", () => {
311
311
  expect(result.matches).toEqual([
312
312
  {
313
313
  name: "caveman",
314
- path: "github:JuliusBrussee/caveman@v1.8.2",
314
+ path: "github:JuliusBrussee/caveman@63a91ecadbf4c4719a4602a5abb00883f9966034",
315
315
  description: "Ultra-compressed communication mode.",
316
316
  source: {
317
317
  kind: "github",
318
318
  repo: "JuliusBrussee/caveman",
319
- ref: "v1.8.2",
319
+ ref: "63a91ecadbf4c4719a4602a5abb00883f9966034",
320
320
  },
321
321
  },
322
322
  {
@@ -337,7 +337,7 @@ describe("searchPlugins", () => {
337
337
  source: {
338
338
  source: "github",
339
339
  repo: "JuliusBrussee/caveman",
340
- ref: "v1.8.2",
340
+ ref: "63a91ecadbf4c4719a4602a5abb00883f9966034",
341
341
  },
342
342
  },
343
343
  ],
@@ -357,8 +357,8 @@ describe("searchPlugins", () => {
357
357
  expect(result.matches).toEqual([]);
358
358
  });
359
359
 
360
- test("first-party dirs win a name collision with the marketplace", async () => {
361
- // GIVEN both a first-party dir and a marketplace entry named "caveman"
360
+ test("a marketplace entry owns a name shared by an in-repo stub dir", async () => {
361
+ // GIVEN a marketplace entry named "caveman"
362
362
  const manifest = {
363
363
  name: "vellum-assistant",
364
364
  plugins: [
@@ -367,11 +367,13 @@ describe("searchPlugins", () => {
367
367
  source: {
368
368
  source: "github",
369
369
  repo: "JuliusBrussee/caveman",
370
- ref: "v1.8.2",
370
+ ref: "63a91ecadbf4c4719a4602a5abb00883f9966034",
371
371
  },
372
372
  },
373
373
  ],
374
374
  };
375
+ // AND a same-named in-repo directory, which is caveman's adapter stub
376
+ // rather than a standalone first-party plugin
375
377
  const fetch: FetchLike = (async (input: RequestInfo | URL) => {
376
378
  const url = typeof input === "string" ? input : input.toString();
377
379
  if (url.includes("marketplace.json")) {
@@ -394,12 +396,17 @@ describe("searchPlugins", () => {
394
396
  // WHEN we search
395
397
  const result = await searchPlugins({ query: "caveman" }, { fetch });
396
398
 
397
- // THEN only the first-party entry surfaces — the manifest is additive
399
+ // THEN the name surfaces once, as the external marketplace entry — the
400
+ // stub dir is its adapter overlay, not a competing first-party listing
398
401
  expect(result.matches).toEqual([
399
402
  {
400
403
  name: "caveman",
401
- path: "experimental/plugins/caveman",
402
- source: { kind: "first-party" },
404
+ path: "github:JuliusBrussee/caveman@63a91ecadbf4c4719a4602a5abb00883f9966034",
405
+ source: {
406
+ kind: "github",
407
+ repo: "JuliusBrussee/caveman",
408
+ ref: "63a91ecadbf4c4719a4602a5abb00883f9966034",
409
+ },
403
410
  },
404
411
  ]);
405
412
  });
@@ -9,6 +9,11 @@
9
9
  * fetched with a shallow `git` clone at that ref — one network operation
10
10
  * regardless of repo size, immune to GitHub's unauthenticated API
11
11
  * rate-limit, and recording the exact resolved commit for provenance.
12
+ * When we curate an adapter stub for the plugin (an
13
+ * `experimental/plugins/<name>/` directory in this repo with a
14
+ * `scripts.postinstall` command), the stub is overlaid onto the clone and
15
+ * its postinstall runs to translate a foreign-ecosystem layout into the
16
+ * shape Vellum's loader runs (see {@link applyAdapterStub}).
12
17
  * 2. Otherwise the first-party convention
13
18
  * `vellum-ai/vellum-assistant/experimental/plugins/<name>/` at the
14
19
  * configured ref, fetched via the GitHub Contents API (a small handful of
@@ -29,14 +34,16 @@ import {
29
34
  existsSync,
30
35
  mkdirSync,
31
36
  readdirSync,
37
+ readFileSync,
32
38
  renameSync,
33
39
  rmSync,
34
40
  statSync,
35
41
  writeFileSync,
36
42
  } from "node:fs";
37
- import { dirname, join } from "node:path";
43
+ import { dirname, join, resolve, sep } from "node:path";
38
44
  import { promisify } from "node:util";
39
45
 
46
+ import { ensureBun } from "../../util/bun-runtime.js";
40
47
  import { getWorkspacePluginsDir } from "../../util/platform.js";
41
48
  import {
42
49
  fetchMarketplaceEntries,
@@ -82,6 +89,18 @@ export type GitRunner = (
82
89
  opts: { readonly cwd: string },
83
90
  ) => Promise<{ readonly stdout: string }>;
84
91
 
92
+ /**
93
+ * Runs a plugin's postinstall adapter script in `cwd`. Injected so tests can
94
+ * assert the adapter is invoked (and simulate its effects) without spawning a
95
+ * real subprocess; production callers fall back to {@link defaultPostinstallRunner}.
96
+ */
97
+ export type PostinstallRunner = (opts: {
98
+ /** The staged install directory the adapter transforms in place. */
99
+ readonly cwd: string;
100
+ /** Absolute path to the adapter script to execute. */
101
+ readonly script: string;
102
+ }) => Promise<void>;
103
+
85
104
  /** Options that control which plugin to install and how. */
86
105
  export interface InstallPluginOptions {
87
106
  readonly name: string;
@@ -100,6 +119,8 @@ export interface InstallPluginDeps {
100
119
  readonly workspacePluginsDir?: string;
101
120
  /** Override the git runner used to clone external plugin sources. Falls back to {@link defaultGitRunner}. */
102
121
  readonly runGit?: GitRunner;
122
+ /** Override the runner used to execute a plugin's postinstall adapter. Falls back to {@link defaultPostinstallRunner}. */
123
+ readonly runPostinstall?: PostinstallRunner;
103
124
  }
104
125
 
105
126
  /** Successful install result. */
@@ -123,6 +144,22 @@ export class InvalidPluginNameError extends Error {
123
144
  }
124
145
  }
125
146
 
147
+ /**
148
+ * A plugin's curated postinstall adapter failed — its `scripts.postinstall`
149
+ * command was malformed/unsupported, its script was missing, or the script
150
+ * exited non-zero. The install is aborted and rolled back rather than
151
+ * materializing a half-transformed, non-functional plugin.
152
+ */
153
+ export class PluginPostinstallError extends Error {
154
+ constructor(
155
+ readonly pluginName: string,
156
+ detail: string,
157
+ ) {
158
+ super(`Postinstall adapter for "${pluginName}" failed: ${detail}`);
159
+ this.name = "PluginPostinstallError";
160
+ }
161
+ }
162
+
126
163
  /** A plugin with the same name is already installed and `--force` was not passed. */
127
164
  export class PluginAlreadyInstalledError extends Error {
128
165
  constructor(
@@ -204,40 +241,18 @@ function firstPartySource(name: string, ref: string): PluginFetchSource {
204
241
  };
205
242
  }
206
243
 
207
- /**
208
- * Probe whether a first-party plugin directory exists at the given source.
209
- *
210
- * A transient listing failure resolves to `false` so a marketplace-claimed
211
- * name still reaches its external source — the rare collision guarantee gives
212
- * way to keeping the common external-only install path working under flaky
213
- * network conditions.
214
- */
215
- async function firstPartyPluginExists(
216
- source: PluginFetchSource,
217
- fetchFn: FetchLike,
218
- ): Promise<boolean> {
219
- try {
220
- const entries = await listDir(
221
- source.owner,
222
- source.repo,
223
- source.rootPath,
224
- source.ref,
225
- fetchFn,
226
- );
227
- return entries !== null && entries.length > 0;
228
- } catch {
229
- return false;
230
- }
231
- }
232
-
233
244
  /**
234
245
  * Resolve a plugin name to concrete GitHub coordinates.
235
246
  *
236
- * First-party plugins win a name collision: a name claimed by the curated
237
- * marketplace is fetched from its pinned external repo only when no
238
- * `experimental/plugins/<name>` directory exists in-repo. This mirrors the
239
- * search catalog, where an in-repo plugin suppresses a same-named marketplace
240
- * entry — install must advertise and install the same source.
247
+ * A name claimed by the curated marketplace resolves to its pinned external
248
+ * repo; any other name resolves to the first-party `experimental/plugins/<name>`
249
+ * convention. The marketplace is external-only by construction — a same-named
250
+ * `experimental/plugins/<name>` directory is the plugin's optional *adapter
251
+ * stub* (a curated `package.json` + postinstall script overlaid onto the clone
252
+ * to translate it into Vellum's shape; see {@link applyAdapterStub}), not a
253
+ * standalone first-party plugin. So letting the marketplace win the name is
254
+ * what makes the stub apply to the external clone, and the search catalog
255
+ * surfaces the same name as external — install and search stay in agreement.
241
256
  *
242
257
  * A missing or malformed manifest degrades to first-party resolution — the
243
258
  * whitelist is supplementary and must never block installing a first-party
@@ -259,12 +274,7 @@ async function resolvePluginSource(
259
274
  // Degrade to first-party resolution below.
260
275
  }
261
276
 
262
- const firstParty = firstPartySource(name, marketplaceRef);
263
- if (!resolved) return firstParty;
264
-
265
- if (await firstPartyPluginExists(firstParty, fetchFn)) {
266
- return firstParty;
267
- }
277
+ if (!resolved) return firstPartySource(name, marketplaceRef);
268
278
 
269
279
  return {
270
280
  kind: "external",
@@ -363,6 +373,14 @@ export async function installPlugin(
363
373
  );
364
374
  fileCount = cloned.fileCount;
365
375
  commit = cloned.commit;
376
+ // An external clone is often a foreign-ecosystem plugin (e.g. a Claude
377
+ // Code plugin) that the Vellum loader can't run as-is. When we curate an
378
+ // adapter stub for it, overlay the stub and run its transform so the
379
+ // materialized tree is a valid Vellum plugin. Raw clones (no stub) are
380
+ // left untouched.
381
+ if (fileCount > 0) {
382
+ await applyAdapterStub(name, marketplaceRef, stagingDir, deps);
383
+ }
366
384
  } else {
367
385
  fileCount = await copyDir(
368
386
  source.owner,
@@ -372,6 +390,27 @@ export async function installPlugin(
372
390
  stagingDir,
373
391
  deps.fetch,
374
392
  );
393
+ // We only land in the first-party branch for this name when the
394
+ // marketplace lookup returned no claim. A *healthy* marketplace that
395
+ // claims the name routes to the external+adapter branch above; reaching
396
+ // here for a directory that is actually an adapter stub (declares a
397
+ // `scripts.postinstall`) therefore means the marketplace failed to load
398
+ // (rate-limit / 5xx / malformed) and we degraded past it. A stub has no
399
+ // hooks/tools of its own — it only transforms an external clone — so
400
+ // installing it alone would materialize a non-functional plugin. Fail
401
+ // loudly and retryably instead of silently shipping a broken plugin.
402
+ // Genuine first-party plugins (no postinstall) install normally.
403
+ if (
404
+ fileCount > 0 &&
405
+ resolvePostinstallScript(name, stagingDir) !== null
406
+ ) {
407
+ throw new PluginPostinstallError(
408
+ name,
409
+ "resolved to a first-party adapter stub, but its marketplace entry " +
410
+ "could not be read to locate the external source it adapts — the " +
411
+ "marketplace lookup likely failed transiently. Retry the install.",
412
+ );
413
+ }
375
414
  }
376
415
  } catch (err) {
377
416
  rmSync(stagingDir, { recursive: true, force: true });
@@ -446,7 +485,7 @@ async function copyExternalViaGit(
446
485
  // transient GitHub outage — is retryable, so map it to a 503.
447
486
  if (isGitRefNotFound(err)) return { fileCount: 0, commit: null };
448
487
  throw new PluginSourceUnavailableError(
449
- `git clone failed for ${sourceLabel(source)} @ ${source.ref}: ${gitErrorText(err)}`,
488
+ `git clone failed for ${sourceLabel(source)} @ ${source.ref}: ${subprocessErrorText(err)}`,
450
489
  503,
451
490
  );
452
491
  }
@@ -462,6 +501,18 @@ async function copyExternalViaGit(
462
501
  commit = null;
463
502
  }
464
503
 
504
+ // Defense in depth: external marketplace refs are full commit SHAs (the
505
+ // manifest schema rejects mutable tags/branches), so the checked-out
506
+ // commit must equal the requested ref. If it ever diverges, refuse the
507
+ // install rather than materialize and `import()` unexpected code.
508
+ if (commit && commit.toLowerCase() !== source.ref.toLowerCase()) {
509
+ throw new PluginSourceUnavailableError(
510
+ `git checkout of ${sourceLabel(source)} resolved to ${commit}, ` +
511
+ `which does not match the pinned commit ${source.ref}`,
512
+ 502,
513
+ );
514
+ }
515
+
465
516
  const srcRoot = source.rootPath
466
517
  ? join(cloneDir, source.rootPath)
467
518
  : cloneDir;
@@ -476,6 +527,172 @@ async function copyExternalViaGit(
476
527
  }
477
528
  }
478
529
 
530
+ /** Cap on a postinstall adapter; the curated transforms are fast and file-only. */
531
+ const POSTINSTALL_TIMEOUT_MS = 60_000;
532
+
533
+ /**
534
+ * Overlay our curated adapter stub onto a freshly cloned external plugin and
535
+ * run its postinstall transform, returning whether a transform ran.
536
+ *
537
+ * The stub lives at `experimental/plugins/<name>/` in our own repo and carries
538
+ * a `package.json` (with a `scripts.postinstall` adapter command) plus the
539
+ * adapter script it names. We fetch it via the Contents API — a couple of
540
+ * small files, well within the rate limit — and copy it over the clone, which
541
+ * deliberately overwrites the clone's `package.json` so the postinstall we run
542
+ * is ours, never the upstream repo's lifecycle script. Absent a stub (the
543
+ * common case for a plugin already in Vellum shape), nothing is overlaid and
544
+ * the clone is installed as-is.
545
+ *
546
+ * On any adapter failure the error propagates so {@link installPlugin} rolls
547
+ * back staging — better to fail loudly than ship a half-transformed plugin.
548
+ */
549
+ async function applyAdapterStub(
550
+ name: string,
551
+ ref: string,
552
+ stagingDir: string,
553
+ deps: InstallPluginDeps,
554
+ ): Promise<boolean> {
555
+ const stubFileCount = await copyDir(
556
+ PLUGIN_SOURCE_OWNER,
557
+ PLUGIN_SOURCE_REPO,
558
+ `${PLUGIN_SOURCE_PATH_PREFIX}/${name}`,
559
+ ref,
560
+ stagingDir,
561
+ deps.fetch,
562
+ );
563
+ if (stubFileCount === 0) return false;
564
+
565
+ const script = resolvePostinstallScript(name, stagingDir);
566
+ if (script === null) return false;
567
+
568
+ const run = deps.runPostinstall ?? defaultPostinstallRunner;
569
+ try {
570
+ await run({ cwd: stagingDir, script });
571
+ } catch (err) {
572
+ throw new PluginPostinstallError(name, subprocessErrorText(err));
573
+ }
574
+ return true;
575
+ }
576
+
577
+ /**
578
+ * Resolve the absolute path of the adapter script named by the (overlaid stub)
579
+ * `package.json`'s `scripts.postinstall`, or `null` when there is no stub
580
+ * package.json / postinstall script.
581
+ *
582
+ * Curated adapters declare a single `bun <script>` invocation; bun is resolved
583
+ * via {@link ensureBun} at execution time (see {@link defaultPostinstallRunner})
584
+ * so the `bun` token marks the convention without hard-coding the binary path.
585
+ * Anything else — extra args, a shell pipeline, a non-script file — is rejected
586
+ * rather than executed, and the script path is constrained to a file inside the
587
+ * staging dir so a stub can never escape it.
588
+ */
589
+ function resolvePostinstallScript(
590
+ name: string,
591
+ stagingDir: string,
592
+ ): string | null {
593
+ const pkgPath = join(stagingDir, "package.json");
594
+ if (!existsSync(pkgPath)) return null;
595
+
596
+ let parsed: unknown;
597
+ try {
598
+ parsed = JSON.parse(readFileSync(pkgPath, "utf8"));
599
+ } catch {
600
+ return null;
601
+ }
602
+
603
+ const scripts =
604
+ typeof parsed === "object" && parsed !== null && "scripts" in parsed
605
+ ? (parsed as { scripts?: unknown }).scripts
606
+ : undefined;
607
+ const command =
608
+ typeof scripts === "object" && scripts !== null && "postinstall" in scripts
609
+ ? (scripts as { postinstall?: unknown }).postinstall
610
+ : undefined;
611
+ if (typeof command !== "string" || command.trim() === "") return null;
612
+
613
+ const match = /^bun\s+(\S+)$/.exec(command.trim());
614
+ if (!match) {
615
+ throw new PluginPostinstallError(
616
+ name,
617
+ `unsupported postinstall command ${JSON.stringify(command)} — ` +
618
+ "curated adapters must be a single `bun <script>` invocation",
619
+ );
620
+ }
621
+
622
+ let rel = match[1]!;
623
+ if (rel.startsWith("./")) rel = rel.slice(2);
624
+ if (!/\.(?:ts|mts|cts|mjs|cjs|js)$/.test(rel)) {
625
+ throw new PluginPostinstallError(
626
+ name,
627
+ `postinstall script ${JSON.stringify(rel)} must be a ` +
628
+ ".ts/.mts/.cts/.mjs/.cjs/.js file",
629
+ );
630
+ }
631
+ for (const segment of rel.split("/")) {
632
+ assertSafeFilename("postinstall script segment", segment);
633
+ }
634
+
635
+ const abs = resolve(stagingDir, rel);
636
+ if (
637
+ abs !== resolve(stagingDir) &&
638
+ !abs.startsWith(`${resolve(stagingDir)}${sep}`)
639
+ ) {
640
+ throw new PluginPostinstallError(
641
+ name,
642
+ `postinstall script ${JSON.stringify(rel)} escapes the plugin directory`,
643
+ );
644
+ }
645
+ if (!existsSync(abs)) {
646
+ throw new PluginPostinstallError(
647
+ name,
648
+ `postinstall script ${JSON.stringify(rel)} was not found in the plugin`,
649
+ );
650
+ }
651
+ return abs;
652
+ }
653
+
654
+ /**
655
+ * Production postinstall runner: executes the adapter with a real `bun` binary
656
+ * resolved via {@link ensureBun}, under a stripped environment and a timeout.
657
+ *
658
+ * `process.execPath` is unusable here: inside a `bun build --compile` binary it
659
+ * is the compiled assistant app, not the bun CLI (see `util/bun-runtime.ts`),
660
+ * so passing the adapter script to it would launch the daemon rather than
661
+ * interpret the script. `ensureBun()` locates (or downloads) a standalone bun
662
+ * the same way every other subsystem that spawns bun does. The minimal env
663
+ * (bun's dir + standard bins, `HOME` only) keeps the adapter from inheriting
664
+ * surprising config while still finding the runtime.
665
+ */
666
+ export const defaultPostinstallRunner: PostinstallRunner = async ({
667
+ cwd,
668
+ script,
669
+ }) => {
670
+ const bun = await ensureBun();
671
+ await execFileAsync(bun, [script], {
672
+ cwd,
673
+ encoding: "utf8",
674
+ timeout: POSTINSTALL_TIMEOUT_MS,
675
+ maxBuffer: 16 * 1024 * 1024,
676
+ env: pluginPostinstallEnv(bun),
677
+ });
678
+ };
679
+
680
+ function pluginPostinstallEnv(bun: string): NodeJS.ProcessEnv {
681
+ const env: NodeJS.ProcessEnv = {
682
+ PATH: [
683
+ dirname(bun),
684
+ "/opt/homebrew/bin",
685
+ "/usr/local/bin",
686
+ "/usr/bin",
687
+ "/bin",
688
+ ]
689
+ .filter(Boolean)
690
+ .join(":"),
691
+ };
692
+ if (process.env.HOME) env.HOME = process.env.HOME;
693
+ return env;
694
+ }
695
+
479
696
  /**
480
697
  * Recursively copy regular files from `srcRoot` into `destDir`, skipping the
481
698
  * top-level `.git` directory and any symlinks. Returns the file count.
@@ -509,7 +726,7 @@ function copyTreeSkippingGit(srcRoot: string, destDir: string): number {
509
726
 
510
727
  /** True when a git fetch failed because the repo or ref is unreachable. */
511
728
  function isGitRefNotFound(err: unknown): boolean {
512
- const text = gitErrorText(err).toLowerCase();
729
+ const text = subprocessErrorText(err).toLowerCase();
513
730
  return [
514
731
  "could not find remote ref",
515
732
  "couldn't find remote ref",
@@ -523,7 +740,7 @@ function isGitRefNotFound(err: unknown): boolean {
523
740
  }
524
741
 
525
742
  /** Extract a stderr/message blob from a spawn error for classification/logging. */
526
- function gitErrorText(err: unknown): string {
743
+ function subprocessErrorText(err: unknown): string {
527
744
  if (err instanceof Error) {
528
745
  const withStreams = err as Error & { stderr?: unknown };
529
746
  const stderr =
@@ -14,9 +14,10 @@
14
14
  * `package.json` fields the manifest doesn't carry.
15
15
  *
16
16
  * Name-collision precedence matches {@link ./search-plugins} and
17
- * {@link ./install-from-github}: a first-party in-repo plugin wins a name also
18
- * claimed by the marketplace, so the detail page advertises the same source the
19
- * catalog and installer would use.
17
+ * {@link ./install-from-github}: a marketplace entry owns its name, so the
18
+ * detail page advertises the external source the catalog and installer use. A
19
+ * same-named `experimental/plugins/<name>/` directory is that plugin's adapter
20
+ * stub, not a standalone first-party plugin, so it does not override the claim.
20
21
  *
21
22
  * Designed for direct programmatic use with an injected `fetch`, mirroring the
22
23
  * sibling plugin libraries.
@@ -130,22 +131,28 @@ export async function getPluginDetails(
130
131
  const local = readLocalPlugin(pluginsDir, name);
131
132
 
132
133
  const marketplaceEntry = await findMarketplaceEntry(name, ref, fetchFn);
133
- const firstPartyEntries = await listDirSafe(
134
- PLUGIN_SOURCE_OWNER,
135
- PLUGIN_SOURCE_REPO,
136
- `${PLUGIN_SOURCE_PATH_PREFIX}/${name}`,
137
- ref,
138
- fetchFn,
139
- );
134
+
135
+ // A marketplace entry owns its name (the same-named in-repo directory, if
136
+ // any, is its adapter stub) — so only probe the first-party directory when
137
+ // the name is unclaimed, which also spares a GitHub request in the common
138
+ // external case.
139
+ const firstPartyEntries =
140
+ marketplaceEntry === null
141
+ ? await listDirSafe(
142
+ PLUGIN_SOURCE_OWNER,
143
+ PLUGIN_SOURCE_REPO,
144
+ `${PLUGIN_SOURCE_PATH_PREFIX}/${name}`,
145
+ ref,
146
+ fetchFn,
147
+ )
148
+ : null;
140
149
  const firstPartyExists = firstPartyEntries !== null;
141
150
 
142
151
  if (!local.installed && !firstPartyExists && !marketplaceEntry) {
143
152
  throw new PluginDetailsNotFoundError(name, ref);
144
153
  }
145
154
 
146
- // First-party wins a name collision, so probe the in-repo directory before
147
- // honouring a marketplace claim — the same precedence the catalog applies.
148
- const useExternal = !firstPartyExists && marketplaceEntry !== null;
155
+ const useExternal = marketplaceEntry !== null;
149
156
 
150
157
  const source: PluginMatchSource = useExternal
151
158
  ? {