@vellumai/assistant 0.9.0 → 0.9.1-staging.1

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 (222) hide show
  1. package/ARCHITECTURE.md +18 -34
  2. package/bun.lock +7 -8
  3. package/docs/activation-funnel-telemetry.md +4 -4
  4. package/docs/architecture/security.md +29 -28
  5. package/docs/stt-provider-onboarding.md +3 -5
  6. package/docs/workflows-testing.md +13 -44
  7. package/docs/workflows.md +3 -5
  8. package/node_modules/@vellumai/ces-client/src/__tests__/ces-client.test.ts +47 -0
  9. package/node_modules/@vellumai/ces-client/src/rpc-client.ts +28 -5
  10. package/node_modules/@vellumai/environments/src/seeds.ts +2 -5
  11. package/node_modules/@vellumai/gateway-client/src/index.ts +17 -6
  12. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +119 -0
  13. package/node_modules/@vellumai/gateway-client/src/types.ts +15 -84
  14. package/openapi.yaml +135 -59
  15. package/package.json +2 -1
  16. package/scripts/sync-llm-catalog.ts +6 -15
  17. package/scripts/sync-web-search-catalog.ts +3 -11
  18. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +14 -26
  19. package/src/__tests__/agent-loop-compaction-strip.test.ts +240 -0
  20. package/src/__tests__/agent-loop-output-hooks.test.ts +69 -0
  21. package/src/__tests__/agent-loop-override-profile.test.ts +25 -0
  22. package/src/__tests__/always-loaded-tools-guard.test.ts +2 -3
  23. package/src/__tests__/app-dir-path-guard.test.ts +0 -1
  24. package/src/__tests__/assistant-feature-flag-guard.test.ts +1 -4
  25. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +0 -2
  26. package/src/__tests__/avatar-identity-sync.test.ts +2 -27
  27. package/src/__tests__/btw-routes.test.ts +6 -8
  28. package/src/__tests__/checker.test.ts +0 -3
  29. package/src/__tests__/config-loader-backfill.test.ts +103 -6
  30. package/src/__tests__/config-watcher.test.ts +0 -18
  31. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +22 -0
  32. package/src/__tests__/credential-broker.test.ts +449 -1
  33. package/src/__tests__/credential-execution-tools.test.ts +0 -1
  34. package/src/__tests__/credential-prompt-route.test.ts +3 -4
  35. package/src/__tests__/credential-routes.test.ts +360 -0
  36. package/src/__tests__/credential-security-invariants.test.ts +2 -13
  37. package/src/__tests__/dynamic-page-surface.test.ts +101 -1
  38. package/src/__tests__/fixtures/credential-security-fixtures.ts +2 -33
  39. package/src/__tests__/gateway-only-guard.test.ts +3 -7
  40. package/src/__tests__/identity-routes.test.ts +0 -189
  41. package/src/__tests__/inbound-invite-redemption.test.ts +4 -4
  42. package/src/__tests__/invite-redemption-service.test.ts +4 -4
  43. package/src/__tests__/llm-callsite-catalog.test.ts +5 -6
  44. package/src/__tests__/llm-catalog-parity.test.ts +0 -22
  45. package/src/__tests__/llm-resolver.test.ts +49 -24
  46. package/src/__tests__/oauth-provider-seed-logos.test.ts +4 -6
  47. package/src/__tests__/onboarding-persona-write.test.ts +1 -1
  48. package/src/__tests__/persona-resolver.test.ts +11 -14
  49. package/src/__tests__/plugin-api-model-profiles.test.ts +178 -0
  50. package/src/__tests__/registry.test.ts +2 -7
  51. package/src/__tests__/schedule-routes-workflow-validation.test.ts +1 -10
  52. package/src/__tests__/schedule-routes.test.ts +0 -30
  53. package/src/__tests__/schedule-tools.test.ts +2 -18
  54. package/src/__tests__/skill-execute-input.test.ts +46 -1
  55. package/src/__tests__/skill-runtime-path.test.ts +2 -3
  56. package/src/__tests__/subagent-tools.test.ts +116 -0
  57. package/src/__tests__/surface-completion-nudge-hook.test.ts +367 -0
  58. package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +1 -29
  59. package/src/__tests__/token-manager.test.ts +519 -0
  60. package/src/__tests__/tool-executor.test.ts +0 -79
  61. package/src/__tests__/trusted-contact-multichannel.test.ts +3 -3
  62. package/src/__tests__/trusted-contact-verification.test.ts +6 -6
  63. package/src/__tests__/voice-invite-redemption.test.ts +2 -2
  64. package/src/__tests__/web-search-catalog-parity.test.ts +6 -25
  65. package/src/__tests__/workspace-greetings.test.ts +152 -0
  66. package/src/agent/loop.ts +25 -5
  67. package/src/api/README.md +6 -6
  68. package/src/api/responses/conversation-message.ts +2 -4
  69. package/src/api/responses/home.ts +0 -4
  70. package/src/approvals/guardian-request-resolvers.ts +2 -2
  71. package/src/calls/relay-access-wait.ts +1 -1
  72. package/src/calls/voice-session-bridge.ts +2 -2
  73. package/src/cli/commands/plugins.ts +143 -2
  74. package/src/cli/lib/__tests__/diff-plugin.test.ts +443 -0
  75. package/src/cli/lib/__tests__/merge-plugin-tree.test.ts +313 -0
  76. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +253 -2
  77. package/src/cli/lib/diff-plugin.ts +346 -0
  78. package/src/cli/lib/install-from-github.ts +105 -17
  79. package/src/cli/lib/merge-plugin-tree.ts +228 -0
  80. package/src/cli/lib/plugin-fingerprint.ts +14 -0
  81. package/src/cli/lib/upgrade-plugin.ts +270 -10
  82. package/src/cli/program.ts +0 -2
  83. package/src/config/bundled-skills/subagent/SKILL.md +4 -0
  84. package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
  85. package/src/config/bundled-skills/workflows/SKILL.md +0 -1
  86. package/src/config/bundled-tool-registry.ts +2 -7
  87. package/src/config/call-site-defaults.ts +12 -2
  88. package/src/config/feature-flag-registry.json +1 -17
  89. package/src/config/inference-profile-validation.ts +26 -0
  90. package/src/config/loader.ts +4 -0
  91. package/src/config/profile-order.ts +28 -0
  92. package/src/config/schemas/elevenlabs.ts +0 -1
  93. package/src/config/schemas/platform.ts +0 -8
  94. package/src/config/seed-inference-profiles.ts +25 -20
  95. package/src/contacts/contact-store.ts +87 -96
  96. package/src/contacts/contacts-write.ts +5 -21
  97. package/src/context/compactor.ts +2 -2
  98. package/src/credential-execution/process-manager.ts +55 -14
  99. package/src/credential-execution/prompted-credential.ts +2 -3
  100. package/src/daemon/config-watcher.ts +0 -4
  101. package/src/daemon/conversation-agent-loop.ts +15 -4
  102. package/src/daemon/conversation-slash.ts +2 -23
  103. package/src/daemon/conversation-tool-setup.ts +11 -3
  104. package/src/daemon/conversation.ts +2 -0
  105. package/src/daemon/handlers/config-channels.ts +20 -16
  106. package/src/daemon/handlers/config-slack-channel.ts +2 -3
  107. package/src/daemon/lifecycle.ts +0 -7
  108. package/src/daemon/message-types/conversations.ts +3 -3
  109. package/src/daemon/message-types/sync.ts +0 -1
  110. package/src/daemon/orphan-reaper.test.ts +0 -19
  111. package/src/daemon/orphan-reaper.ts +2 -24
  112. package/src/daemon/server.ts +0 -10
  113. package/src/home/relationship-state.ts +2 -4
  114. package/src/memory/__tests__/memory-retrospective-job.test.ts +195 -401
  115. package/src/memory/bookmark-crud.ts +1 -2
  116. package/src/memory/db-init.ts +7 -17
  117. package/src/memory/embedding-backend.ts +23 -0
  118. package/src/memory/embedding-billing-breaker.ts +96 -0
  119. package/src/memory/jobs-store.ts +25 -13
  120. package/src/memory/jobs-worker.ts +52 -0
  121. package/src/memory/memory-retrospective-constants.ts +4 -4
  122. package/src/memory/memory-retrospective-job.ts +19 -227
  123. package/src/memory/migrations/291-contact-channels-renormalize-addresses.ts +62 -0
  124. package/src/memory/migrations/__tests__/291-contact-channels-renormalize-addresses.test.ts +311 -0
  125. package/src/memory/migrations/__tests__/run-migrations.test.ts +52 -0
  126. package/src/memory/migrations/index.ts +1 -0
  127. package/src/memory/migrations/run-migrations.ts +41 -0
  128. package/src/memory/migrations/validate-migration-state.ts +1 -1
  129. package/src/memory/schema/contacts.ts +0 -4
  130. package/src/messaging/providers/slack/adapter.ts +1 -1
  131. package/src/notifications/adapters/shared.ts +29 -0
  132. package/src/notifications/adapters/slack.ts +5 -32
  133. package/src/notifications/adapters/telegram.ts +2 -20
  134. package/src/notifications/broadcaster.ts +10 -1
  135. package/src/notifications/home-feed-side-effect.ts +4 -3
  136. package/src/notifications/notification-utils.ts +17 -19
  137. package/src/notifications/types.ts +7 -0
  138. package/src/oauth/AGENTS.md +5 -24
  139. package/src/plugin-api/constants.ts +1 -1
  140. package/src/plugin-api/index.ts +6 -1
  141. package/src/plugin-api/model-profiles.ts +33 -0
  142. package/src/plugin-api/types.ts +50 -2
  143. package/src/plugins/defaults/index.ts +25 -0
  144. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +54 -2
  145. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +107 -7
  146. package/src/plugins/defaults/surface-completion-nudge/hooks/post-model-call.ts +276 -0
  147. package/src/plugins/defaults/surface-completion-nudge/hooks/stop.ts +22 -0
  148. package/src/plugins/defaults/surface-completion-nudge/nudge-state-store.ts +46 -0
  149. package/src/plugins/defaults/surface-completion-nudge/package.json +14 -0
  150. package/src/plugins/defaults/task-progress-nudge/hooks/post-tool-use.ts +1 -1
  151. package/src/prompts/persona-resolver.ts +2 -2
  152. package/src/runtime/AGENTS.md +0 -1
  153. package/src/runtime/actor-trust-resolver.ts +12 -44
  154. package/src/runtime/btw-sidechain.ts +3 -6
  155. package/src/runtime/channel-approval-types.ts +18 -45
  156. package/src/runtime/channel-invite-transports/telegram.ts +4 -4
  157. package/src/runtime/channel-verification-service.ts +4 -3
  158. package/src/runtime/invite-redemption-service.ts +3 -3
  159. package/src/runtime/routes/__tests__/plugins-routes.test.ts +218 -1
  160. package/src/runtime/routes/app-routes.ts +1 -1
  161. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +2 -2
  162. package/src/runtime/routes/assets/vellum-design-system.css +1959 -0
  163. package/src/runtime/routes/btw-routes.ts +1 -27
  164. package/src/runtime/routes/conversation-compaction-routes.ts +1 -1
  165. package/src/runtime/routes/conversation-routes.ts +2 -2
  166. package/src/runtime/routes/credential-routes.ts +40 -16
  167. package/src/runtime/routes/empty-state-greeting-cache.ts +1 -2
  168. package/src/runtime/routes/identity-routes.ts +1 -296
  169. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +1 -1
  170. package/src/runtime/routes/plugins-routes.ts +171 -5
  171. package/src/runtime/routes/schedule-routes.ts +0 -22
  172. package/src/runtime/routes/workflow-routes.test.ts +4 -43
  173. package/src/runtime/routes/workflow-routes.ts +0 -28
  174. package/src/runtime/routes/workspace-greetings.ts +55 -0
  175. package/src/runtime/sync/resource-sync-events.ts +1 -11
  176. package/src/schedule/inference-profile.ts +2 -14
  177. package/src/subagent/manager.ts +6 -0
  178. package/src/subagent/types.ts +6 -0
  179. package/src/tools/AGENTS.md +3 -3
  180. package/src/tools/browser/browser-execution.ts +1 -1
  181. package/src/tools/network/web-search-error.ts +1 -1
  182. package/src/tools/permission-checker.ts +1 -1
  183. package/src/tools/schedule/create.ts +3 -9
  184. package/src/tools/schedule/update.ts +2 -10
  185. package/src/tools/side-effects.ts +2 -17
  186. package/src/tools/skills/execute.ts +34 -0
  187. package/src/tools/subagent/spawn.ts +34 -9
  188. package/src/tools/tool-approval-handler.ts +1 -3
  189. package/src/tools/tool-manifest.ts +0 -2
  190. package/src/tools/ui-surface/definitions.ts +39 -1
  191. package/src/tools/workflows/run-workflow.test.ts +8 -18
  192. package/src/util/platform.ts +2 -2
  193. package/src/workflows/capabilities.ts +2 -3
  194. package/src/workflows/run-manager.test.ts +0 -25
  195. package/src/workflows/run-manager.ts +2 -24
  196. package/src/__tests__/app-control-no-global-cgevent.test.ts +0 -98
  197. package/src/__tests__/credential-security-e2e.test.ts +0 -362
  198. package/src/__tests__/credential-vault-unit.test.ts +0 -1528
  199. package/src/__tests__/credential-vault.test.ts +0 -1706
  200. package/src/__tests__/identity-intro-cache.test.ts +0 -315
  201. package/src/__tests__/secret-onetime-send.test.ts +0 -182
  202. package/src/cli/commands/__tests__/task.test.ts +0 -914
  203. package/src/cli/commands/task.ts +0 -771
  204. package/src/config/bundled-skills/personal-page/SKILL.md +0 -57
  205. package/src/config/bundled-skills/personal-page/TOOLS.json +0 -27
  206. package/src/config/bundled-skills/personal-page/tools/app-refresh.ts +0 -17
  207. package/src/config/preloaded-apps/personal-page/src/components/About.tsx +0 -22
  208. package/src/config/preloaded-apps/personal-page/src/components/App.tsx +0 -16
  209. package/src/config/preloaded-apps/personal-page/src/components/Features.tsx +0 -77
  210. package/src/config/preloaded-apps/personal-page/src/components/Hero.tsx +0 -57
  211. package/src/config/preloaded-apps/personal-page/src/components/Pending.tsx +0 -28
  212. package/src/config/preloaded-apps/personal-page/src/components/animations.tsx +0 -234
  213. package/src/config/preloaded-apps/personal-page/src/components/icons.tsx +0 -48
  214. package/src/config/preloaded-apps/personal-page/src/components/media.ts +0 -16
  215. package/src/config/preloaded-apps/personal-page/src/index.html +0 -20
  216. package/src/config/preloaded-apps/personal-page/src/main.tsx +0 -7
  217. package/src/config/preloaded-apps/personal-page/src/profile-data.ts +0 -82
  218. package/src/config/preloaded-apps/personal-page/src/styles.css +0 -759
  219. package/src/memory/__tests__/preloaded-apps.test.ts +0 -85
  220. package/src/memory/preloaded-apps.ts +0 -116
  221. package/src/runtime/routes/identity-intro-cache.ts +0 -172
  222. package/src/tools/credentials/vault.ts +0 -712
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Three-way merge of a plugin tree, used by `plugins upgrade --strategy` to
3
+ * carry local edits forward across an upgrade instead of discarding them.
4
+ *
5
+ * An upgrade has three inputs, exactly like a git merge:
6
+ * - **base** — the tree the plugin was installed at (the recorded commit,
7
+ * re-materialized through the install pipeline; see {@link ./diff-plugin}).
8
+ * - **ours** — the current on-disk install, carrying any local edits.
9
+ * - **theirs** — the marketplace's current pin, the tree being upgraded to.
10
+ *
11
+ * Per file the merge is delegated to `git merge-file`, the same line-level
12
+ * three-way algorithm git itself uses: a hunk edited on only one side is taken
13
+ * from that side, so non-conflicting edits from *both* sides survive. Only a
14
+ * hunk edited differently on both sides is a true conflict, resolved by the
15
+ * caller's strategy — `--ours` keeps the local hunk, `--theirs` the pinned one.
16
+ * File-level add/delete divergence (a file added, removed, or modified on only
17
+ * one side) is resolved here before any line merge, since `git merge-file`
18
+ * operates on three existing blobs.
19
+ *
20
+ * Binary files cannot be line-merged, so a binary file that diverged on both
21
+ * sides is resolved whole-file by the strategy rather than corrupted with
22
+ * conflict markers.
23
+ *
24
+ * The `overwrite` strategy never reaches here — it discards local edits and is
25
+ * a plain re-install at the pin. The `assistant` strategy is not yet supported.
26
+ */
27
+
28
+ import { execFile } from "node:child_process";
29
+ import {
30
+ mkdirSync,
31
+ mkdtempSync,
32
+ readFileSync,
33
+ rmSync,
34
+ writeFileSync,
35
+ } from "node:fs";
36
+ import { tmpdir } from "node:os";
37
+ import { dirname, join } from "node:path";
38
+ import { promisify } from "node:util";
39
+
40
+ import { INSTALL_META_FILENAME } from "./install-from-github.js";
41
+ import { computeFingerprint } from "./plugin-fingerprint.js";
42
+
43
+ const execFileAsync = promisify(execFile);
44
+
45
+ /** Cap on a single `git merge-file`; a per-file line merge is near-instant. */
46
+ const MERGE_TIMEOUT_MS = 30_000;
47
+
48
+ /**
49
+ * Conflict-resolution strategy for the hunks `git merge-file` cannot
50
+ * auto-resolve. `overwrite` and `assistant` are not merge strategies and are
51
+ * handled by the caller before a merge is attempted.
52
+ */
53
+ export type MergeConflictStrategy = "ours" | "theirs";
54
+
55
+ /** Inputs for a three-way plugin-tree merge. */
56
+ export interface MergePluginTreeOptions {
57
+ /** Re-materialized install-commit tree (the merge base). */
58
+ readonly baseDir: string;
59
+ /** Current on-disk install, carrying local edits (`ours`). */
60
+ readonly oursDir: string;
61
+ /** Marketplace-pinned tree being upgraded to (`theirs`). */
62
+ readonly theirsDir: string;
63
+ /** Empty directory the merged tree is written into. */
64
+ readonly destDir: string;
65
+ /** How to resolve hunks edited differently on both sides. */
66
+ readonly strategy: MergeConflictStrategy;
67
+ }
68
+
69
+ /** A NUL byte in the leading bytes is the heuristic git uses to flag a blob as binary. */
70
+ function isBinary(buf: Buffer): boolean {
71
+ const len = Math.min(buf.length, 8000);
72
+ for (let i = 0; i < len; i++) {
73
+ if (buf[i] === 0) return true;
74
+ }
75
+ return false;
76
+ }
77
+
78
+ /** Write `content` to `destDir/rel`, creating parent directories as needed. */
79
+ function writeInto(destDir: string, rel: string, content: Buffer): void {
80
+ const abs = join(destDir, rel);
81
+ mkdirSync(dirname(abs), { recursive: true });
82
+ writeFileSync(abs, content);
83
+ }
84
+
85
+ /**
86
+ * Line-merge three blobs with `git merge-file`, resolving conflicting hunks
87
+ * toward `strategy`. Non-conflicting hunks from both sides are always kept.
88
+ * Binary input cannot be line-merged: a side that matches the base did not
89
+ * change, so the other side's edit is taken; only a blob changed on *both*
90
+ * sides is a true conflict resolved whole-file by `strategy`, avoiding a
91
+ * marker-corrupted blob.
92
+ */
93
+ async function threeWayMergeFile(
94
+ ours: Buffer,
95
+ base: Buffer,
96
+ theirs: Buffer,
97
+ strategy: MergeConflictStrategy,
98
+ ): Promise<Buffer> {
99
+ if (isBinary(ours) || isBinary(base) || isBinary(theirs)) {
100
+ if (ours.equals(base)) return theirs;
101
+ if (theirs.equals(base)) return ours;
102
+ return strategy === "ours" ? ours : theirs;
103
+ }
104
+
105
+ const scratch = mkdtempSync(join(tmpdir(), "plugin-merge-file-"));
106
+ try {
107
+ const oursPath = join(scratch, "ours");
108
+ const basePath = join(scratch, "base");
109
+ const theirsPath = join(scratch, "theirs");
110
+ writeFileSync(oursPath, ours);
111
+ writeFileSync(basePath, base);
112
+ writeFileSync(theirsPath, theirs);
113
+
114
+ // `-p` prints the merged result to stdout instead of editing `ours` in
115
+ // place; `--ours`/`--theirs` auto-resolve conflicting hunks toward that
116
+ // side (so no markers are ever written). `git merge-file` exits non-zero
117
+ // when conflicts remained — impossible with a resolving flag, but stdout
118
+ // still carries the merged bytes, so capture it from the error too.
119
+ const args = [
120
+ "merge-file",
121
+ "-p",
122
+ `--${strategy}`,
123
+ oursPath,
124
+ basePath,
125
+ theirsPath,
126
+ ];
127
+ try {
128
+ const { stdout } = await execFileAsync("git", args, {
129
+ cwd: scratch,
130
+ encoding: "buffer",
131
+ timeout: MERGE_TIMEOUT_MS,
132
+ maxBuffer: 64 * 1024 * 1024,
133
+ });
134
+ return stdout;
135
+ } catch (err) {
136
+ const stdout = (err as { stdout?: Buffer }).stdout;
137
+ if (Buffer.isBuffer(stdout)) return stdout;
138
+ throw err;
139
+ }
140
+ } finally {
141
+ rmSync(scratch, { recursive: true, force: true });
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Three-way merge `oursDir`/`theirsDir` against `baseDir` into `destDir`,
147
+ * resolving conflicts toward `strategy`. The provenance sidecar is excluded on
148
+ * every side — it is rewritten by the caller after the swap and must not be
149
+ * carried through the merge.
150
+ *
151
+ * Returns the number of files written into `destDir`.
152
+ */
153
+ export async function mergePluginTree({
154
+ baseDir,
155
+ oursDir,
156
+ theirsDir,
157
+ destDir,
158
+ strategy,
159
+ }: MergePluginTreeOptions): Promise<number> {
160
+ const exclude = [INSTALL_META_FILENAME];
161
+ const base = computeFingerprint(baseDir, exclude).files;
162
+ const ours = computeFingerprint(oursDir, exclude).files;
163
+ const theirs = computeFingerprint(theirsDir, exclude).files;
164
+
165
+ const paths = new Set([
166
+ ...Object.keys(base),
167
+ ...Object.keys(ours),
168
+ ...Object.keys(theirs),
169
+ ]);
170
+
171
+ const readBase = (rel: string) => readFileSync(join(baseDir, rel));
172
+ const readOurs = (rel: string) => readFileSync(join(oursDir, rel));
173
+ const readTheirs = (rel: string) => readFileSync(join(theirsDir, rel));
174
+
175
+ let fileCount = 0;
176
+ const keep = (rel: string, content: Buffer): void => {
177
+ writeInto(destDir, rel, content);
178
+ fileCount++;
179
+ };
180
+
181
+ for (const rel of paths) {
182
+ const b = base[rel];
183
+ const o = ours[rel];
184
+ const t = theirs[rel];
185
+
186
+ if (o !== undefined && t !== undefined) {
187
+ // Present on both sides: identical needs no merge; otherwise line-merge
188
+ // (an empty base when the file was added on both sides).
189
+ if (o === t) {
190
+ keep(rel, readOurs(rel));
191
+ } else {
192
+ const merged = await threeWayMergeFile(
193
+ readOurs(rel),
194
+ b !== undefined ? readBase(rel) : Buffer.alloc(0),
195
+ readTheirs(rel),
196
+ strategy,
197
+ );
198
+ keep(rel, merged);
199
+ }
200
+ continue;
201
+ }
202
+
203
+ if (o !== undefined) {
204
+ // Present only locally. A local-only addition (no base) always survives.
205
+ // A file deleted upstream is a delete: drop it when unchanged locally,
206
+ // and on a modify/delete conflict let the strategy decide.
207
+ if (b === undefined || (b !== o && strategy === "ours")) {
208
+ keep(rel, readOurs(rel));
209
+ }
210
+ continue;
211
+ }
212
+
213
+ if (t !== undefined) {
214
+ // Present only at the pin. A remote-only addition (no base) always lands.
215
+ // A file deleted locally is a delete: keep upstream's removal when the
216
+ // pin left it unchanged, and on a delete/modify conflict let the strategy
217
+ // decide.
218
+ if (b === undefined || (b !== t && strategy === "theirs")) {
219
+ keep(rel, readTheirs(rel));
220
+ }
221
+ continue;
222
+ }
223
+
224
+ // Present only in the base: removed on both sides, so it stays removed.
225
+ }
226
+
227
+ return fileCount;
228
+ }
@@ -120,6 +120,20 @@ export function compareFingerprint(
120
120
  };
121
121
  }
122
122
 
123
+ /**
124
+ * Whether two fingerprints cover the same files with identical digests. Used to
125
+ * confirm a re-materialized tree faithfully reproduces a recorded baseline
126
+ * before it is trusted as a merge base.
127
+ */
128
+ export function fingerprintsEqual(a: Fingerprint, b: Fingerprint): boolean {
129
+ const aKeys = Object.keys(a.files);
130
+ if (aKeys.length !== Object.keys(b.files).length) return false;
131
+ for (const key of aKeys) {
132
+ if (a.files[key] !== b.files[key]) return false;
133
+ }
134
+ return true;
135
+ }
136
+
123
137
  /**
124
138
  * Parse a fingerprint from already-decoded JSON. Lenient by design — any shape
125
139
  * problem yields `null` so an older or hand-edited sidecar simply reports "no
@@ -10,14 +10,17 @@
10
10
  * This is deliberately a distinct operation from install: `install` is
11
11
  * first-time materialization (and errors on an existing install unless
12
12
  * `--force` is passed), whereas `upgrade` moves an existing install forward.
13
- * Mechanically the move is a forced re-install at the current pin, which the
14
- * underlying {@link ./install-from-github.installPlugin} performs atomically —
15
- * the previously installed copy is preserved until the fetch succeeds.
16
13
  *
17
- * Conflict resolution for locally-modified plugins is intentionally out of
18
- * scope here: this overwrites the install with the pinned tree. A future
19
- * iteration will detect local edits (installed SHA = merge base) and resolve
20
- * them before the swap.
14
+ * How local edits are reconciled with the pin is controlled by the
15
+ * {@link PluginUpgradeStrategy}. `overwrite` (the default) moves via a forced
16
+ * re-install at the current pin — the underlying
17
+ * {@link ./install-from-github.installPlugin} performs that atomically, and the
18
+ * previously installed copy (including any local edits) is replaced wholesale.
19
+ * `ours`/`theirs` instead three-way merge the on-disk tree and the pin against
20
+ * the re-materialized install commit so non-conflicting local edits survive,
21
+ * resolving conflicting hunks toward the local edit or the pin respectively.
22
+ * `assistant` is reserved for assistant-driven conflict resolution and is not
23
+ * yet implemented.
21
24
  *
22
25
  * Designed for direct programmatic use with injected dependencies, mirroring
23
26
  * the sibling plugin libraries. The CLI command `assistant plugins upgrade
@@ -25,24 +28,66 @@
25
28
  * result.
26
29
  */
27
30
 
28
- import { join } from "node:path";
31
+ import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
32
+ import { tmpdir } from "node:os";
33
+ import { dirname, join } from "node:path";
29
34
 
30
35
  import { getWorkspacePluginsDir } from "../../util/platform.js";
31
36
  import {
32
37
  inspectPlugin,
33
38
  type PluginInspection,
34
39
  PluginInspectNotFoundError,
40
+ type PluginLocalInfo,
41
+ type PluginRemoteInfo,
35
42
  } from "./inspect-plugin.js";
36
43
  import {
44
+ DEFAULT_PLUGIN_REF,
37
45
  type FetchLike,
46
+ finalizeStagedInstall,
38
47
  type GitRunner,
48
+ INSTALL_META_FILENAME,
39
49
  installPlugin,
50
+ materializePluginTree,
51
+ type PluginFetchSource,
52
+ PluginNotFoundError,
40
53
  PluginSourceUnavailableError,
41
54
  type PostinstallRunner,
55
+ readInstallMeta,
42
56
  sanitizePluginName,
43
57
  } from "./install-from-github.js";
58
+ import { mergePluginTree } from "./merge-plugin-tree.js";
59
+ import { computeFingerprint, fingerprintsEqual } from "./plugin-fingerprint.js";
44
60
  import { PluginNotInstalledError } from "./uninstall-plugin.js";
45
61
 
62
+ /**
63
+ * How local edits to an installed plugin are reconciled with the marketplace
64
+ * pin during an upgrade.
65
+ *
66
+ * - `overwrite` (default) — discard all local edits and re-install the pin
67
+ * wholesale. Matches the historical upgrade behavior.
68
+ * - `ours` — three-way merge; conflicting hunks resolve toward the local edit.
69
+ * - `theirs` — three-way merge; conflicting hunks resolve toward the pin.
70
+ * - `assistant` — hand conflicts to the assistant to resolve (not yet
71
+ * implemented).
72
+ */
73
+ export type PluginUpgradeStrategy =
74
+ | "ours"
75
+ | "theirs"
76
+ | "overwrite"
77
+ | "assistant";
78
+
79
+ /** The set of accepted `--strategy` values, for validation and help text. */
80
+ export const PLUGIN_UPGRADE_STRATEGIES: readonly PluginUpgradeStrategy[] = [
81
+ "ours",
82
+ "theirs",
83
+ "overwrite",
84
+ "assistant",
85
+ ];
86
+
87
+ /** The strategy applied when a caller omits `--strategy`. */
88
+ export const DEFAULT_PLUGIN_UPGRADE_STRATEGY: PluginUpgradeStrategy =
89
+ "overwrite";
90
+
46
91
  /**
47
92
  * Outcome of an upgrade attempt.
48
93
  *
@@ -61,6 +106,11 @@ export interface UpgradePluginOptions {
61
106
  readonly name: string;
62
107
  /** Report what would change without modifying the install. */
63
108
  readonly dryRun?: boolean;
109
+ /**
110
+ * How to reconcile local edits with the pin. Defaults to
111
+ * {@link DEFAULT_PLUGIN_UPGRADE_STRATEGY}.
112
+ */
113
+ readonly strategy?: PluginUpgradeStrategy;
64
114
  }
65
115
 
66
116
  /** Dependencies injected by the caller. */
@@ -99,6 +149,8 @@ export interface PluginUpgradeResult {
99
149
  readonly fileCount: number | null;
100
150
  /** Whether this was a dry run (no changes made). */
101
151
  readonly dryRun: boolean;
152
+ /** Conflict-resolution strategy the upgrade applied. */
153
+ readonly strategy: PluginUpgradeStrategy;
102
154
  /**
103
155
  * Whether the installed copy lacked resolvable provenance before the
104
156
  * upgrade. Such installs are re-pinned to the current SHA, which also
@@ -118,6 +170,32 @@ export class PluginNotUpgradableError extends Error {
118
170
  }
119
171
  }
120
172
 
173
+ /** A requested merge strategy is recognized but not yet implemented. */
174
+ export class PluginUpgradeStrategyUnsupportedError extends Error {
175
+ constructor(readonly strategy: PluginUpgradeStrategy) {
176
+ super(
177
+ `Upgrade strategy "${strategy}" is not yet supported. Use one of: ours, theirs, overwrite.`,
178
+ );
179
+ this.name = "PluginUpgradeStrategyUnsupportedError";
180
+ }
181
+ }
182
+
183
+ /**
184
+ * A merge strategy (`ours`/`theirs`) was requested but the install-time
185
+ * baseline needed for a three-way merge cannot be reconstructed.
186
+ */
187
+ export class PluginMergeBaselineError extends Error {
188
+ constructor(
189
+ readonly pluginName: string,
190
+ reason: string,
191
+ ) {
192
+ super(
193
+ `Plugin "${pluginName}" cannot be merge-upgraded: ${reason}. Use '--strategy overwrite' to take the pin wholesale, or reinstall with 'plugins install ${pluginName} --force'.`,
194
+ );
195
+ this.name = "PluginMergeBaselineError";
196
+ }
197
+ }
198
+
121
199
  function pluginTarget(name: string, deps: UpgradePluginDeps): string {
122
200
  const dir = deps.workspacePluginsDir ?? getWorkspacePluginsDir();
123
201
  return join(dir, name);
@@ -126,10 +204,19 @@ function pluginTarget(name: string, deps: UpgradePluginDeps): string {
126
204
  /**
127
205
  * Move an installed plugin to the marketplace's current pin.
128
206
  *
207
+ * The `strategy` controls how local edits are reconciled with the pin:
208
+ * `overwrite` (default) re-installs the pin wholesale; `ours`/`theirs` do a
209
+ * three-way merge that carries non-conflicting local edits forward, resolving
210
+ * conflicting hunks toward the local edit or the pin respectively; `assistant`
211
+ * is not yet implemented.
212
+ *
129
213
  * Throws {@link PluginNotInstalledError} when no copy is installed,
130
214
  * {@link PluginNotUpgradableError} when the install has no marketplace entry to
131
- * advance to, {@link PluginSourceUnavailableError} when the marketplace catalog
132
- * is temporarily unreachable (a retryable outage, distinct from the permanent
215
+ * advance to, {@link PluginUpgradeStrategyUnsupportedError} for the `assistant`
216
+ * strategy, {@link PluginMergeBaselineError} when a merge strategy is requested
217
+ * but the install-time baseline cannot be reconstructed,
218
+ * {@link PluginSourceUnavailableError} when the marketplace catalog is
219
+ * temporarily unreachable (a retryable outage, distinct from the permanent
133
220
  * no-entry case), and propagates {@link installPlugin}'s errors (e.g. source
134
221
  * unavailable, postinstall failure) when the re-install itself fails.
135
222
  */
@@ -139,6 +226,11 @@ export async function upgradePlugin(
139
226
  ): Promise<PluginUpgradeResult> {
140
227
  const name = sanitizePluginName(opts.name);
141
228
  const dryRun = opts.dryRun ?? false;
229
+ const strategy = opts.strategy ?? DEFAULT_PLUGIN_UPGRADE_STRATEGY;
230
+
231
+ if (strategy === "assistant") {
232
+ throw new PluginUpgradeStrategyUnsupportedError(strategy);
233
+ }
142
234
 
143
235
  let inspection: PluginInspection;
144
236
  try {
@@ -199,6 +291,7 @@ export async function upgradePlugin(
199
291
  target: local.target,
200
292
  fileCount: null,
201
293
  dryRun,
294
+ strategy,
202
295
  provenanceWasUnknown: false,
203
296
  };
204
297
  }
@@ -214,10 +307,28 @@ export async function upgradePlugin(
214
307
  target: local.target,
215
308
  fileCount: null,
216
309
  dryRun: true,
310
+ strategy,
217
311
  provenanceWasUnknown,
218
312
  };
219
313
  }
220
314
 
315
+ // `ours`/`theirs` carry local edits forward via a three-way merge; the
316
+ // default `overwrite` discards them and re-installs the pin wholesale.
317
+ if (strategy === "ours" || strategy === "theirs") {
318
+ return mergeUpgrade(
319
+ {
320
+ name,
321
+ strategy,
322
+ local,
323
+ remote,
324
+ fromCommit,
325
+ fromTimestamp,
326
+ provenanceWasUnknown,
327
+ },
328
+ deps,
329
+ );
330
+ }
331
+
221
332
  const result = await installPlugin(
222
333
  { name, force: true },
223
334
  {
@@ -238,6 +349,155 @@ export async function upgradePlugin(
238
349
  target: result.target,
239
350
  fileCount: result.fileCount,
240
351
  dryRun: false,
352
+ strategy,
241
353
  provenanceWasUnknown,
242
354
  };
243
355
  }
356
+
357
+ /**
358
+ * Carry local edits forward by three-way merging the on-disk install (`ours`)
359
+ * and the marketplace pin (`theirs`) against the re-materialized install commit
360
+ * (`base`), then atomically swapping the merged tree into place pinned at the
361
+ * new commit. Conflicting hunks resolve toward `ours` or `theirs` per the
362
+ * strategy; non-conflicting edits from both sides survive.
363
+ */
364
+ async function mergeUpgrade(
365
+ ctx: {
366
+ readonly name: string;
367
+ readonly strategy: "ours" | "theirs";
368
+ readonly local: PluginLocalInfo;
369
+ readonly remote: PluginRemoteInfo;
370
+ readonly fromCommit: string | null;
371
+ readonly fromTimestamp: string | null;
372
+ readonly provenanceWasUnknown: boolean;
373
+ },
374
+ deps: UpgradePluginDeps,
375
+ ): Promise<PluginUpgradeResult> {
376
+ const { name, strategy, local, remote } = ctx;
377
+
378
+ const meta = readInstallMeta(local.target);
379
+ if (!meta || !meta.commit || !meta.fingerprint) {
380
+ throw new PluginMergeBaselineError(
381
+ name,
382
+ "no install commit or fingerprint was recorded (an older or manually-copied install)",
383
+ );
384
+ }
385
+ const recorded = meta.fingerprint;
386
+
387
+ const baseSource: PluginFetchSource = {
388
+ owner: meta.source.owner,
389
+ repo: meta.source.repo,
390
+ rootPath: meta.source.path ?? "",
391
+ ref: meta.commit,
392
+ };
393
+ const [remoteOwner, remoteRepo] = remote.repo.split("/");
394
+ const theirsSource: PluginFetchSource = {
395
+ owner: remoteOwner ?? "",
396
+ repo: remoteRepo ?? "",
397
+ rootPath: remote.path,
398
+ ref: remote.commit,
399
+ };
400
+
401
+ const pluginsDir = deps.workspacePluginsDir ?? getWorkspacePluginsDir();
402
+ const baseDir = mkdtempSync(join(tmpdir(), `plugin-upgrade-base-${name}-`));
403
+ const theirsDir = mkdtempSync(
404
+ join(tmpdir(), `plugin-upgrade-theirs-${name}-`),
405
+ );
406
+ // Stage outside the served `plugins/` directory and on its filesystem so the
407
+ // final swap is an atomic rename, mirroring `installPlugin`.
408
+ const stagingRoot = join(dirname(pluginsDir), ".plugins-staging");
409
+ mkdirSync(stagingRoot, { recursive: true });
410
+ const stagingDir = join(stagingRoot, `${name}.upgrading.${process.pid}`);
411
+ if (existsSync(stagingDir)) {
412
+ rmSync(stagingDir, { recursive: true, force: true });
413
+ }
414
+ mkdirSync(stagingDir, { recursive: true });
415
+
416
+ try {
417
+ const base = await materializePluginTree(
418
+ {
419
+ source: baseSource,
420
+ name,
421
+ stubRef: DEFAULT_PLUGIN_REF,
422
+ destDir: baseDir,
423
+ },
424
+ deps,
425
+ );
426
+ if (base.fileCount === 0) {
427
+ throw new PluginNotFoundError(
428
+ name,
429
+ baseSource.ref,
430
+ `${baseSource.owner}/${baseSource.repo}`,
431
+ );
432
+ }
433
+ // The merge base must faithfully reproduce what install materialized;
434
+ // otherwise a curated adapter overlay that moved since install would read
435
+ // as base→ours/theirs edits and corrupt the merge. Verify against the
436
+ // recorded fingerprint, exactly as `plugins diff` does.
437
+ const baseFingerprint = computeFingerprint(baseDir, [
438
+ INSTALL_META_FILENAME,
439
+ ]);
440
+ if (!fingerprintsEqual(baseFingerprint, recorded)) {
441
+ throw new PluginMergeBaselineError(
442
+ name,
443
+ "the install-time baseline could not be faithfully reconstructed (a curated adapter overlay it was built from has changed since install)",
444
+ );
445
+ }
446
+
447
+ const theirs = await materializePluginTree(
448
+ {
449
+ source: theirsSource,
450
+ name,
451
+ stubRef: remote.marketplaceRef,
452
+ destDir: theirsDir,
453
+ },
454
+ deps,
455
+ );
456
+ if (theirs.fileCount === 0) {
457
+ throw new PluginNotFoundError(
458
+ name,
459
+ theirsSource.ref,
460
+ `${theirsSource.owner}/${theirsSource.repo}`,
461
+ );
462
+ }
463
+
464
+ const fileCount = await mergePluginTree({
465
+ baseDir,
466
+ oursDir: local.target,
467
+ theirsDir,
468
+ destDir: stagingDir,
469
+ strategy,
470
+ });
471
+
472
+ const toCommit = theirs.commit ?? remote.commit;
473
+ const toTimestamp = theirs.committedAt ?? remote.committedAt;
474
+ finalizeStagedInstall(stagingDir, {
475
+ name,
476
+ source: theirsSource,
477
+ ref: theirsSource.ref,
478
+ commit: toCommit,
479
+ committedAt: toTimestamp,
480
+ pluginsDir,
481
+ });
482
+
483
+ return {
484
+ name,
485
+ outcome: "upgraded",
486
+ fromCommit: ctx.fromCommit,
487
+ fromTimestamp: ctx.fromTimestamp,
488
+ toCommit,
489
+ toTimestamp,
490
+ target: join(pluginsDir, name),
491
+ fileCount,
492
+ dryRun: false,
493
+ strategy,
494
+ provenanceWasUnknown: ctx.provenanceWasUnknown,
495
+ };
496
+ } catch (err) {
497
+ rmSync(stagingDir, { recursive: true, force: true });
498
+ throw err;
499
+ } finally {
500
+ rmSync(baseDir, { recursive: true, force: true });
501
+ rmSync(theirsDir, { recursive: true, force: true });
502
+ }
503
+ }
@@ -47,7 +47,6 @@ import { registerSequenceCommand } from "./commands/sequence.js";
47
47
  import { registerSkillsCommand } from "./commands/skills.js";
48
48
  import { registerStatusCommand } from "./commands/status.js";
49
49
  import { registerSttCommand } from "./commands/stt.js";
50
- import { registerTaskCommand } from "./commands/task.js";
51
50
  import { registerTelemetryCommand } from "./commands/telemetry.js";
52
51
  import { registerToolsCommand } from "./commands/tools.js";
53
52
  import { registerTrustCommand } from "./commands/trust.js";
@@ -146,7 +145,6 @@ Examples:
146
145
  registerStatusCommand(program);
147
146
  registerSkillsCommand(program);
148
147
  registerSttCommand(program);
149
- registerTaskCommand(program);
150
148
  registerTelemetryCommand(program);
151
149
  registerToolsCommand(program);
152
150
  registerTrustCommand(program);
@@ -71,6 +71,10 @@ Only the parent conversation that spawned a subagent can interact with it (check
71
71
 
72
72
  Set `send_result_to_user: false` when spawning a subagent whose result is for internal processing only. The parent will still be notified on completion, but the notification will instruct it to read the result without presenting it to the user.
73
73
 
74
+ ## Inference Profile
75
+
76
+ Set `inference_profile` to an `llm.profiles` key when a subagent should run under a specific model profile. When omitted, the subagent inherits the parent turn's active profile if one exists; otherwise it uses the `subagentSpawn` call site's default model selection.
77
+
74
78
  ## Fork Mode
75
79
 
76
80
  Forks are sub-agents that inherit the parent's full context -- messages, system prompt, and memory -- sharing the KV cache for near-free context inheritance. Use forks when the task benefits from knowing what you've been discussing; use a regular sub-agent when the task is self-contained.
@@ -39,6 +39,10 @@
39
39
  "investigator"
40
40
  ],
41
41
  "description": "Agent specialization that controls tool access. 'researcher': read-only (web, files, memory). 'coder': file and bash access. 'planner': read-only analysis. 'investigator': root-cause analysis — debugging, log forensics, tracing behavior across many files; has bash for read-only investigation (grep/find/log inspection) and returns a compact root-cause report. 'general': full access (default). Ignored when fork: true (forks always use general)."
42
+ },
43
+ "inference_profile": {
44
+ "type": "string",
45
+ "description": "Optional llm.profiles key this subagent should run under. When omitted, the subagent inherits the parent turn's profile when one is active; otherwise it uses the subagentSpawn call site's default model selection."
42
46
  }
43
47
  },
44
48
  "required": ["label", "objective"]
@@ -7,7 +7,6 @@ metadata:
7
7
  vellum:
8
8
  display-name: "Workflows"
9
9
  category: "system"
10
- feature-flag: "workflows"
11
10
  activation-hints:
12
11
  - "A task decomposes into many similar sub-tasks that can run concurrently (score every item, extract a field from each of many documents, draft-then-verify a batch)"
13
12
  - "You want fan-out orchestrated deterministically and the result reported back when the whole run finishes"