@tangle-network/agent-profile-materialize 0.15.2 → 0.17.0

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 (57) hide show
  1. package/dist/candidate-types.d.ts +32 -0
  2. package/dist/candidate-types.js +1 -0
  3. package/dist/candidate.d.ts +9 -0
  4. package/dist/candidate.js +279 -0
  5. package/dist/commands.d.ts +2 -0
  6. package/dist/commands.js +32 -0
  7. package/dist/control-maps.d.ts +34 -0
  8. package/dist/control-maps.js +185 -0
  9. package/dist/execution-controls.d.ts +5 -0
  10. package/dist/execution-controls.js +186 -0
  11. package/dist/harness.d.ts +81 -0
  12. package/dist/harness.js +167 -0
  13. package/dist/hooks.d.ts +6 -0
  14. package/dist/hooks.js +261 -0
  15. package/dist/index.d.ts +24 -623
  16. package/dist/index.js +15 -3733
  17. package/dist/mcp.d.ts +22 -0
  18. package/dist/mcp.js +244 -0
  19. package/dist/pi-models-config.d.ts +30 -0
  20. package/dist/pi-models-config.js +46 -0
  21. package/dist/plan-application.d.ts +10 -0
  22. package/dist/plan-application.js +152 -0
  23. package/dist/plan-digest.d.ts +18 -0
  24. package/dist/plan-digest.js +90 -0
  25. package/dist/plan-json.d.ts +18 -0
  26. package/dist/plan-json.js +91 -0
  27. package/dist/plan-normalize.d.ts +13 -0
  28. package/dist/plan-normalize.js +156 -0
  29. package/dist/plan-prompt.d.ts +38 -0
  30. package/dist/plan-prompt.js +49 -0
  31. package/dist/plan-support.d.ts +9 -0
  32. package/dist/plan-support.js +22 -0
  33. package/dist/plan-types.d.ts +294 -0
  34. package/dist/plan-types.js +106 -0
  35. package/dist/prime-agent-dir.d.ts +47 -0
  36. package/dist/prime-agent-dir.js +164 -0
  37. package/dist/prime-profile.d.ts +88 -0
  38. package/dist/prime-profile.js +284 -0
  39. package/dist/profile-materializer.d.ts +12 -0
  40. package/dist/profile-materializer.js +59 -0
  41. package/dist/profile-plan.d.ts +14 -0
  42. package/dist/profile-plan.js +86 -0
  43. package/dist/profile-prompt.d.ts +4 -0
  44. package/dist/profile-prompt.js +105 -0
  45. package/dist/profile-support.d.ts +8 -0
  46. package/dist/profile-support.js +6 -0
  47. package/dist/resource-lowering.d.ts +7 -0
  48. package/dist/resource-lowering.js +103 -0
  49. package/dist/resource-resolution.d.ts +27 -0
  50. package/dist/resource-resolution.js +241 -0
  51. package/dist/subagents.d.ts +6 -0
  52. package/dist/subagents.js +133 -0
  53. package/dist/workspace-apply.d.ts +12 -0
  54. package/dist/workspace-apply.js +334 -0
  55. package/dist/workspace-files.d.ts +8 -0
  56. package/dist/workspace-files.js +46 -0
  57. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -1,3735 +1,17 @@
1
- /**
2
- * materializeProfile — THE ONE per-harness profile materializer, shared by every
3
- * layer (cli-bridge host spawn, agent-dev-container box adapter, VB). It turns a
4
- * structured {@link AgentProfile} into a {@link WorkspacePlan}: the exact native
5
- * files to write into the run workspace (cwd), plus the env/flags some harnesses
6
- * need because they have NO cwd auto-discovery for certain dimensions.
7
- *
8
- * Routed per the VERIFIED capability matrix (harness-capability-matrix, 2026-06-14)
9
- * across every dimension (context, skills, mcp, hooks, subagents, commands).
10
- *
11
- * FAIL-CLOSED, not silent-drop: when a requested dimension has no path on the
12
- * chosen harness (e.g. kimi MCP is flag-only, hermes hooks are opt-in global), it
13
- * is recorded in `plan.unsupported` with the reason — never silently ignored,
14
- * which is exactly the "looks provisioned, loads nothing" trap.
15
- *
16
- * The ONLY trustworthy proof a dimension works is the in-session effect (skill
17
- * secret word, MCP tool called n>0, hook sentinel file) — see the canary tests.
18
- *
19
- * Types come from `@tangle-network/agent-interface` (the canonical AgentProfile
20
- * contract); this package adds only the harness-specific materialization + the
21
- * `node:fs` IO consumer. It was previously copied byte-for-byte into both
22
- * cli-bridge and agent-dev-container — this is the single source those re-export.
23
- */
1
+ /** Stable public facade for profile materialization and workspace application. */
2
+ export { applyAgentCandidateWorkspacePlan, materializeCandidateProfile, } from "./candidate.js";
3
+ export { OPENCODE_NATIVE_TOOL_IDS, openCodeNativeToolId, } from "./control-maps.js";
4
+ export { HARNESS_SKILL_DIRS, harnessAutoLoadsContextFile, harnessContextFile, isMaterializerHarness, MATERIALIZER_HARNESSES, skillDirForHarness, } from "./harness.js";
5
+ export { openCodeMcpConfig } from "./mcp.js";
24
6
  export { modelIdsMatch, qualifyModelId } from "./model-id.js";
7
+ export { buildPiModelsConfig, buildPiModelsConfigFile, PI_MODELS_CONFIG_PATH, } from "./pi-models-config.js";
8
+ export { applyWorkspacePlan, applyWorkspacePlanWithSecretProvider, } from "./plan-application.js";
9
+ export { agentCandidateProfilePlanMaterial, canonicalizeAgentCandidateProfilePlan, captureAgentCandidateProfilePlan, hashWorkspacePlan, } from "./plan-digest.js";
10
+ export { takeSystemPromptBinding } from "./plan-prompt.js";
11
+ export { assertWorkspacePlanSupported, enforceWorkspacePlanSupport, } from "./plan-support.js";
12
+ export { BINDABLE_LAUNCH_CONTROLS, EXECUTOR_SYSTEM_PROMPT_BINDINGS, isExecutorSystemPromptBinding, isSkippableDimension, SKIPPABLE_DIMENSIONS, WorkspacePlanCleanupError, } from "./plan-types.js";
13
+ export { materializePrimeProfileControls, prunePrimeProfileMaterial, seedPrimeProfileSubagents, writePrimeAgentFile, } from "./prime-agent-dir.js";
25
14
  export { matchPrimeKernelDeadMarker, PRIME_AGENT_DIR_ENV_VAR, PRIME_AGENT_DIR_ENV_VARS, PRIME_FORK_INSTALL, PRIME_FORK_PIN, PRIME_HARNESS_STATE_ENV_DENYLIST, PRIME_KERNEL_DEAD_MARKERS, PRIME_KERNEL_ENV_PASSTHROUGH, PRIME_MCP_UNSUPPORTED_REASON, PRIME_THINKING_LEVELS, PrimeHarnessStateError, PrimeProfileError, parsePrimeModelsJson, primeAgentDirEnv, primeApiKeyEnvNames, primeThinkingLevel, stripPrimeJsonComments, } from "./prime-fork.js";
26
- import { createHash, randomUUID } from "node:crypto";
27
- import { closeSync, existsSync, fchmodSync, constants as fsConstants, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
28
- import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32, } from "node:path";
29
- import { agentCandidateProfileActivationSchema, agentCandidateProfilePlanEvidenceSchema, agentCandidateProfilePlanMaterialSchema, agentCandidateProfileSchema, agentProfileSchema, agentProfileSecretRefSchema, canonicalCandidateBytes, canonicalCandidateDigest, resolveNativeWebTools, } from "@tangle-network/agent-interface";
30
- import { modelIdsMatch, qualifyModelId } from "./model-id.js";
31
- import { PRIME_MCP_UNSUPPORTED_REASON, PrimeHarnessStateError, rejectPrimeControl, rejectPrimeDeclaration, } from "./prime-fork.js";
32
- export const UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER = Object.freeze({
33
- async get() {
34
- return undefined;
35
- },
36
- });
37
- function describeUnresolvedRef(r) {
38
- const name = typeof r.name === "string" ? r.name : undefined;
39
- const path = typeof r.path === "string" ? r.path : undefined;
40
- if (name && path)
41
- return `"${name}" (${path})`;
42
- if (name)
43
- return `"${name}"`;
44
- if (path)
45
- return `"${path}"`;
46
- return "resource";
47
- }
48
- /** Resolve inline bytes already present on the profile. Remote resources must
49
- * be resolved by {@link resolveAgentProfileResources} before materialization.
50
- *
51
- * FAIL-CLOSED: a ref that declares `kind: "inline"` is asserting its bytes are
52
- * already resolved, so missing/non-string `content` is a caller bug, not an
53
- * empty file — silently coercing it (`String(content ?? "")`) would produce a
54
- * file that "looks provisioned, loads nothing", exactly the trap this module's
55
- * docstring warns against. */
56
- function resolveInlineRef(ref) {
57
- const r = ref;
58
- if (typeof r.content === "string") {
59
- return { name: String(r.name ?? r.path ?? "resource"), content: r.content };
60
- }
61
- if (r.kind === "inline") {
62
- throw new Error(`Profile resource ${describeUnresolvedRef(r)} declares kind "inline" but has no string content`);
63
- }
64
- return null; // github / unresolved
65
- }
66
- /**
67
- * The harnesses this materializer can lower an AgentProfile into. `satisfies` binds every id to
68
- * the canonical harness enum, so a rename upstream is a compile error here rather than an
69
- * `isMaterializerHarness` that silently returns false for a harness that still exists.
70
- */
71
- export const MATERIALIZER_HARNESSES = [
72
- "claude-code",
73
- "nanoclaw",
74
- "codex",
75
- "opencode",
76
- "kimi-code",
77
- "pi",
78
- "prime",
79
- "gemini",
80
- "hermes",
81
- "openclaw",
82
- ];
83
- const MATERIALIZER_HARNESS_SET = new Set(MATERIALIZER_HARNESSES);
84
- export function isMaterializerHarness(harness) {
85
- return MATERIALIZER_HARNESS_SET.has(harness);
86
- }
87
- function assertMaterializerHarness(harness) {
88
- if (!isMaterializerHarness(harness)) {
89
- throw new Error(`Unsupported profile materialization harness "${harness}"`);
90
- }
91
- }
92
- /**
93
- * The subset of {@link BINDABLE_LAUNCH_CONTROLS} the plan HANDS OVER rather than
94
- * executes: controls a caller reaches only by composing the harness's own
95
- * configuration, so no file, flag or env var the plan writes can touch them.
96
- *
97
- * - `opencode.agent-prompt` — opencode's `agent.<name>.prompt` config field,
98
- * the one control that replaces its built-in provider prompt. It binds to a
99
- * SINGLE agent, and the agent is chosen at launch. A caller that builds
100
- * opencode's config itself and picks the agent there reads neither
101
- * `plan.files` nor `plan.flags`, so for that one the plan names the control
102
- * and hands over the text in {@link WorkspacePlan.systemPromptBinding}.
103
- *
104
- * Every other bindable control names a capability the plan still lowers itself
105
- * once the caller has declared the plan's launch inputs really reach the spawn.
106
- */
107
- export const EXECUTOR_SYSTEM_PROMPT_BINDINGS = [
108
- "opencode.agent-prompt",
109
- ];
110
- const EXECUTOR_SYSTEM_PROMPT_BINDING_SET = new Set(EXECUTOR_SYSTEM_PROMPT_BINDINGS);
111
- export function isExecutorSystemPromptBinding(value) {
112
- return EXECUTOR_SYSTEM_PROMPT_BINDING_SET.has(value);
113
- }
114
- /**
115
- * The dimensions `materializeProfile`'s `skip` option honors.
116
- *
117
- * Every other dimension is materialized unconditionally: generated plan files are pushed through
118
- * a single site that carries no dimension tag, so there is nothing to gate them on. Naming the
119
- * honored set keeps `skip: ["hooks"]` a loud rejection instead of a silent no-op.
120
- */
121
- export const SKIPPABLE_DIMENSIONS = [
122
- "mcp",
123
- "extensions",
124
- ];
125
- const SKIPPABLE_DIMENSION_SET = new Set(SKIPPABLE_DIMENSIONS);
126
- export function isSkippableDimension(value) {
127
- return SKIPPABLE_DIMENSION_SET.has(value);
128
- }
129
- /** Reject a dimension `materializeProfile` would not actually skip, rather than ignoring it. */
130
- function assertSkippableDimensions(skip) {
131
- if (!skip)
132
- return [];
133
- const unsupportedSkips = skip.filter((dimension) => !isSkippableDimension(dimension));
134
- if (unsupportedSkips.length > 0) {
135
- throw new Error(`materializeProfile: cannot skip ${unsupportedSkips.join(", ")}. ` +
136
- `Skippable dimensions are: ${SKIPPABLE_DIMENSIONS.join(", ")}.`);
137
- }
138
- return skip;
139
- }
140
- /**
141
- * Launcher controls an executor can declare it OWNS via `materializeProfile`'s
142
- * `binds` option.
143
- *
144
- * Some harness controls live outside any workspace: opencode's exact
145
- * system-prompt replacement (`agent.<name>.prompt`) binds to the ONE agent
146
- * whoever starts the process selects, which a plan-forwarding executor cannot
147
- * guarantee — so the plan refuses the intent (harnessSystemPromptIntents in
148
- * @tangle-network/agent-interface answers `replace: false` for opencode).
149
- *
150
- * The two declarations describe the two executor shapes that CAN reach it, and
151
- * each gets the lowering it can actually apply:
152
- *
153
- * - `systemPrompt` — the executor applies the plan's launch flags to the
154
- * spawn it owns, so the plan writes the generated `mode: primary` agent and
155
- * selects it with `--agent`.
156
- * - `opencode.agent-prompt` — the executor composes opencode's own config and
157
- * picks the agent there, reading neither plan files nor plan flags, so the
158
- * plan hands the text over in {@link WorkspacePlan.systemPromptBinding}
159
- * instead of writing a workspace agent that would never be selected.
160
- *
161
- * They are mutually exclusive claims about the same caller: declaring both is a
162
- * caller bug, not a broader capability.
163
- */
164
- export const BINDABLE_LAUNCH_CONTROLS = [
165
- "systemPrompt",
166
- ...EXECUTOR_SYSTEM_PROMPT_BINDINGS,
167
- ];
168
- const BINDABLE_LAUNCH_CONTROL_SET = new Set(BINDABLE_LAUNCH_CONTROLS);
169
- /** Reject a binding declaration this materializer has no lowering for, rather than ignoring it. */
170
- function assertBindableLaunchControls(binds) {
171
- if (!binds)
172
- return new Set();
173
- const unknown = binds.filter((control) => !BINDABLE_LAUNCH_CONTROL_SET.has(control));
174
- if (unknown.length > 0) {
175
- throw new Error(`materializeProfile: cannot bind ${unknown.join(", ")}. ` +
176
- `Bindable launch controls are: ${BINDABLE_LAUNCH_CONTROLS.join(", ")}.`);
177
- }
178
- const declared = new Set(binds);
179
- const handedOver = [...EXECUTOR_SYSTEM_PROMPT_BINDINGS].filter((control) => declared.has(control));
180
- if (declared.has("systemPrompt") && handedOver.length > 0) {
181
- throw new Error(`materializeProfile: cannot bind systemPrompt together with ${handedOver.join(", ")}. ` +
182
- "The first says the plan's launch flags reach the spawn; the second says " +
183
- "the caller composes the harness config itself and reads neither.");
184
- }
185
- return declared;
186
- }
187
- /** The plan was applied, but one or more private staging files remain. */
188
- export class WorkspacePlanCleanupError extends AggregateError {
189
- receipt;
190
- temporaryPaths;
191
- constructor(errors, receipt, temporaryPaths) {
192
- super(errors, "Applied workspace plan but failed to remove temporary files");
193
- this.name = "WorkspacePlanCleanupError";
194
- this.receipt = receipt;
195
- this.temporaryPaths = Object.freeze([...temporaryPaths]);
196
- }
197
- }
198
- /** Fail when a profile asks for controls the chosen harness cannot execute. */
199
- export function assertWorkspacePlanSupported(plan) {
200
- if (plan.unsupported.length > 0) {
201
- throw new Error(formatUnsupported(plan.unsupported));
202
- }
203
- }
204
- /**
205
- * Enforce plan supportability under the profile's `resources.failOnError`
206
- * policy. Strict by default: `failOnError` absent or `true` throws on any
207
- * unsupported dimension. An explicitly best-effort profile
208
- * (`failOnError: false`) materializes the supported subset instead; the
209
- * returned `"dimension: reason"` entries are what the caller must surface
210
- * as a warning so the skipped dimensions stay visible to operators.
211
- */
212
- export function enforceWorkspacePlanSupport(plan, failOnError) {
213
- if (failOnError === false) {
214
- return plan.unsupported.map(({ dimension, reason }) => `${dimension}: ${reason}`);
215
- }
216
- assertWorkspacePlanSupported(plan);
217
- return [];
218
- }
219
- /**
220
- * The two prompt intents a plan carries, projected together onto an outgoing
221
- * object with each field omitted when the source does not carry it.
222
- *
223
- * One helper instead of a pair of inline spreads at each of the digest,
224
- * receipt, candidate, and spawn boundaries: the intents must travel as a pair,
225
- * and a projection that copies the replacement and forgets the addition drops
226
- * it silently — downstream cannot tell "the caller asked for nothing" from
227
- * "this boundary lost it". Adding a third intent later is one edit here rather
228
- * than nine.
229
- */
230
- function promptIntentFields(source, project) {
231
- return {
232
- ...(source.systemPrompt === undefined
233
- ? {}
234
- : { systemPrompt: project(source.systemPrompt) }),
235
- ...(source.appendSystemPrompt === undefined
236
- ? {}
237
- : { appendSystemPrompt: project(source.appendSystemPrompt) }),
238
- };
239
- }
240
- /** Identity projection for boundaries that pass the prompt intents through. */
241
- const samePromptIntentValue = (value) => value;
242
- /**
243
- * The launcher-bound intent, projected onto an outgoing object and omitted when
244
- * the source does not carry one.
245
- *
246
- * Beside {@link promptIntentFields} rather than inside it, because the two
247
- * disagree on both ends: the candidate boundaries that helper also serves have
248
- * no field for a binding — an {@link AgentCandidateWorkspacePlan} is built by a
249
- * caller that declares none, so it can never hold one — and a value projection
250
- * such as `candidatePublicString` has nothing to do to a structured binding.
251
- * Every boundary that carries the plan's own prompt intents calls both.
252
- */
253
- function systemPromptBindingField(source) {
254
- return source.systemPromptBinding === undefined
255
- ? {}
256
- : { systemPromptBinding: { ...source.systemPromptBinding } };
257
- }
258
- /**
259
- * The replacement text this launcher must bind to `control`, or `undefined`
260
- * when the plan carries no launcher-bound intent.
261
- *
262
- * Throws for a binding addressed to a DIFFERENT control: the reader cannot
263
- * execute it, and answering `undefined` would start the agent with the
264
- * harness's own prompt still in force while nothing reported a problem — the
265
- * silent downgrade the binding exists to prevent. This is the seam where an
266
- * executor that cannot bind an intent says so, by name, instead of running a
267
- * profile whose prompt never took effect.
268
- */
269
- export function takeSystemPromptBinding(spawn, control) {
270
- const binding = spawn.systemPromptBinding;
271
- if (!binding)
272
- return undefined;
273
- if (binding.control !== control) {
274
- throw new Error(`Workspace plan carries a system-prompt ${binding.intent} bound to ` +
275
- `${binding.control}, which this executor cannot apply; it binds ${control}`);
276
- }
277
- return binding.systemPrompt;
278
- }
279
- // ─── Verified per-harness conventions (capability matrix) ───────────────────
280
- /**
281
- * Whether the harness LOADS its {@link CONTEXT_FILE} from cwd on its own.
282
- *
283
- * True for every harness whose CLI discovers the file by name (CLAUDE.md,
284
- * AGENTS.md, …). opencode is the exception: it reads only what its
285
- * `opencode.json` `instructions[]` names, so a caller writing context for
286
- * opencode must register the path as well as write it — see the `instructions`
287
- * lowering below, and the interactive TUI's persona fold.
288
- */
289
- export function harnessAutoLoadsContextFile(harness) {
290
- assertMaterializerHarness(harness);
291
- return harness !== "opencode";
292
- }
293
- /**
294
- * The project context file this harness reads from cwd — its stable, documented
295
- * ALWAYS-LOADED surface. Exported so a caller lowering additive context (e.g. a
296
- * bare TUI's persona, which has no per-message system channel) targets the same
297
- * file the materializer writes instructions to, instead of keeping its own copy
298
- * of this per-harness map.
299
- */
300
- export function harnessContextFile(harness) {
301
- assertMaterializerHarness(harness);
302
- // The map is keyed by HarnessId, but callers reach this from runtime strings
303
- // (a registry spec name, a request field). A miss would otherwise return
304
- // undefined behind a `string` return type and surface as an undefined write
305
- // path much later.
306
- const path = CONTEXT_FILE[harness];
307
- if (!path) {
308
- throw new Error(`No project context file is known for harness "${harness}"`);
309
- }
310
- return path;
311
- }
312
- /**
313
- * File codex's `model_instructions_file` config key reads.
314
- *
315
- * cwd-relative on purpose: codex resolves the key against the process working
316
- * directory, which is the same workspace root the plan is applied into, so the
317
- * flag stays correct under an executor that binds the workspace at a different
318
- * absolute path (docker, a jail, a remote box).
319
- */
320
- const CODEX_SYSTEM_PROMPT_FILE = ".codex/system-prompt.md";
321
- /**
322
- * File carrying opencode's ADDITION to its system prompt.
323
- *
324
- * Separate from opencode's instruction file rather than merged into it: the two
325
- * are different privilege levels of the same request, and one file for both
326
- * would leave the plan unable to say which intent produced which bytes.
327
- */
328
- const OPENCODE_APPEND_SYSTEM_PROMPT_FILE = ".opencode/agent-system-prompt.md";
329
- /**
330
- * Why a harness refuses a system-prompt REPLACEMENT.
331
- *
332
- * The reason names the control that does exist, so the refusal is actionable
333
- * without a capability-matrix lookup — a bare "unsupported" sends the caller
334
- * looking for a flag that was never there.
335
- */
336
- function systemPromptRefusal(harness) {
337
- switch (harness) {
338
- case "opencode":
339
- // The control exists, so the reason names the two declarations that reach
340
- // it rather than claiming opencode has none.
341
- return 'opencode: the only system-prompt replacement is per-agent (`agent.<name>.prompt`), which binds to the agent selected at launch and a workspace plan cannot guarantee; an executor that applies the plan\'s "--agent" flag to its own spawn declares binds: ["systemPrompt"], and one that composes opencode\'s config itself declares binds: ["opencode.agent-prompt"]';
342
- default:
343
- return `${harness}: no verified exact system-prompt replacement control`;
344
- }
345
- }
346
- /**
347
- * Why a harness refuses an ADDITION to the system prompt.
348
- *
349
- * gemini puts its context file in the system-prompt position, so the temptation
350
- * is to lower an addition there. That is refused because GEMINI.md is the ONE
351
- * file it auto-loads: an addition written there is byte-identical to the same
352
- * text under `prompt.instructions`, and no consumer could tell which intent the
353
- * caller expressed. opencode is not refused for the same situation, because it
354
- * loads exactly the files its config names and can therefore carry the two
355
- * intents in separate, separately-ordered files.
356
- */
357
- function appendSystemPromptRefusal(harness) {
358
- switch (harness) {
359
- case "gemini":
360
- return "gemini: the only additive system-prompt channel is GEMINI.md memory, the single context file it auto-loads, which `prompt.instructions` already materializes — use it instead";
361
- case "codex":
362
- return "codex: no verified additive system-prompt control — its AGENTS.md lands in a developer/user message, not the system channel";
363
- default:
364
- return `${harness}: no verified additive system-prompt control`;
365
- }
366
- }
367
- /** Project context file each harness actually reads from cwd. NEVER a constant. */
368
- const CONTEXT_FILE = {
369
- "claude-code": "CLAUDE.md",
370
- nanoclaw: "CLAUDE.md",
371
- codex: "AGENTS.md",
372
- opencode: ".opencode/profile-instructions.md",
373
- "kimi-code": "AGENTS.md",
374
- pi: "AGENTS.md",
375
- // prime (PrimeIntellect-ai/prime-agent v0.7.0, be9e2fa0) walks cwd and its
376
- // ancestors for AGENTS.md/CLAUDE.md, cwd first (fork resource-loader.ts:59
377
- // candidates, :94-109 walk from cwd inside loadProjectContextFiles).
378
- prime: "AGENTS.md",
379
- gemini: "GEMINI.md", // current default; AGENTS.md only via .gemini/settings.json context.fileName
380
- hermes: "HERMES.md", // highest-priority native name; only ONE context loads (first match)
381
- openclaw: "AGENTS.md",
382
- };
383
- /**
384
- * The paths whose only possible author is this package, per
385
- * {@link PlanFile.ownership}.
386
- *
387
- * Codex names no system-prompt file of its own — `model_instructions_file`
388
- * takes whatever path it is given — and opencode names none either, since it
389
- * reads exactly the files its config lists. All three names below were chosen
390
- * here, so a file already at one of them is this materializer's own output from
391
- * an earlier application into the same workspace.
392
- *
393
- * That is an inference from the path name, not verified provenance: nothing
394
- * checks the existing bytes are prior materializer output, so a workspace that
395
- * carried a foreign file at one of these invented names is overwritten on the
396
- * first application. Accepted cost of stateless multi-turn re-application,
397
- * bounded by the names being ones no harness or user convention uses.
398
- *
399
- * Deliberately absent: `.gemini/system.md` (gemini's own documented name),
400
- * `GEMINI.md`, `CLAUDE.md`, `AGENTS.md`, `opencode.json`. A user's repository
401
- * can carry any of those, and clobbering one is worse than refusing the turn.
402
- * For `opencode.json` this means a profile change that alters the
403
- * `instructions[]` membership between turns of one workspace refuses loudly
404
- * (the consolidated config no longer matches byte-for-byte) rather than
405
- * replacing a possibly user-authored config.
406
- */
407
- const MATERIALIZER_OWNED_PATHS = new Set([
408
- CODEX_SYSTEM_PROMPT_FILE,
409
- OPENCODE_APPEND_SYSTEM_PROMPT_FILE,
410
- CONTEXT_FILE.opencode,
411
- ]);
412
- /**
413
- * cwd-relative native skill dir per harness, or null if the harness has no
414
- * cwd skill primitive (skills live in a user/global dir the workspace plan
415
- * cannot reach). Public so consumers composing a profile can (a) render the
416
- * exact path the platform will mount a skill at into their prompts instead
417
- * of hardcoding `~/.claude/skills/...`, and (b) branch on skill support
418
- * ahead of materialization instead of catching the materialize-time throw.
419
- */
420
- export const HARNESS_SKILL_DIRS = {
421
- "claude-code": ".claude/skills",
422
- nanoclaw: ".claude/skills",
423
- codex: ".codex/skills",
424
- opencode: ".opencode/skills",
425
- "kimi-code": ".kimi/skills",
426
- pi: ".pi/skills",
427
- // prime auto-discovers project skills from <cwd>/<configDir>/skills where the
428
- // fork ships configDir ".prime/agent" (fork package.json piConfig, config.ts:494;
429
- // package-manager.ts:885 projectBaseDir, :2175-2180 projectDirs.skills,
430
- // :2209-2213 collectAutoSkillEntries). The loader recurses into
431
- // <name>/SKILL.md subdirs (skills.ts:328-372).
432
- prime: ".prime/agent/skills",
433
- gemini: ".gemini/skills",
434
- hermes: null, // skills are ~/.hermes/skills (user) only — no cwd discovery
435
- openclaw: "skills", // <workspace>/skills/<name>/SKILL.md
436
- };
437
- const SKILL_DIR = HARNESS_SKILL_DIRS;
438
- /**
439
- * Resolve the cwd-relative directory `resources.skills` refs materialize
440
- * into on `harness`. `null` means the harness cannot receive cwd skills, the
441
- * same condition that surfaces as an `unsupported`
442
- * "skills" entry (and an {@link assertWorkspacePlanSupported} throw) at
443
- * materialize time, exposed here as branchable data.
444
- */
445
- export function skillDirForHarness(harness) {
446
- assertMaterializerHarness(harness);
447
- return HARNESS_SKILL_DIRS[harness];
448
- }
449
- // ─── Helpers ────────────────────────────────────────────────────────────────
450
- const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/;
451
- const collapse = (s) => s.replace(/\s+/g, " ").trim();
452
- const compareStrings = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
453
- const DEFAULT_FILE_MODE = 0o644;
454
- const PRIVATE_FILE_MODE = 0o600;
455
- const EXECUTABLE_FILE_MODE = 0o755;
456
- const DEFAULT_RESOURCE_FETCH_TIMEOUT_MS = 30_000;
457
- const OPENCODE_PRIMARY_AGENT_PREFIX = "tangle-profile-";
458
- const MAX_RESOURCE_BYTES = 10 * 1024 * 1024;
459
- const MAX_PROFILE_RESOURCE_BYTES = 32 * 1024 * 1024;
460
- const MAX_PROFILE_RESOURCE_REFS = 128;
461
- const MAX_CONCURRENT_RESOURCE_FETCHES = 8;
462
- const CLAUDE_SETTINGS_PATH = ".tangle/claude-settings.json";
463
- const SAFE_WRITE_FLAGS = fsConstants.O_WRONLY |
464
- fsConstants.O_CREAT |
465
- fsConstants.O_EXCL |
466
- (typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0);
467
- async function readResourceBody(response, description, consumeBytes) {
468
- if (!response.body)
469
- return "";
470
- const reader = response.body.getReader();
471
- const chunks = [];
472
- let totalBytes = 0;
473
- try {
474
- while (true) {
475
- const { done, value } = await reader.read();
476
- if (done)
477
- break;
478
- if (!value)
479
- continue;
480
- totalBytes += value.byteLength;
481
- if (totalBytes > MAX_RESOURCE_BYTES) {
482
- await reader.cancel().catch(() => undefined);
483
- throw new Error(`GitHub profile resource exceeds ${MAX_RESOURCE_BYTES} bytes: ${description}`);
484
- }
485
- try {
486
- consumeBytes(value.byteLength);
487
- }
488
- catch (error) {
489
- await reader.cancel().catch(() => undefined);
490
- throw error;
491
- }
492
- chunks.push(value);
493
- }
494
- }
495
- finally {
496
- reader.releaseLock();
497
- }
498
- return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)), totalBytes).toString("utf8");
499
- }
500
- async function mapWithConcurrency(values, concurrency, controller, transform) {
501
- const results = new Array(values.length);
502
- let nextIndex = 0;
503
- let firstFailure;
504
- const worker = async () => {
505
- while (!firstFailure) {
506
- const index = nextIndex++;
507
- if (index >= values.length)
508
- return;
509
- try {
510
- results[index] = await transform(values[index]);
511
- }
512
- catch (error) {
513
- if (!firstFailure) {
514
- firstFailure = { error };
515
- controller.abort(error);
516
- }
517
- }
518
- }
519
- };
520
- await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, async () => worker()));
521
- if (firstFailure)
522
- throw firstFailure.error;
523
- return results;
524
- }
525
- function normalizeWorkspaceRelativePath(pathValue) {
526
- if (typeof pathValue !== "string" ||
527
- !pathValue ||
528
- hasControlCharacter(pathValue)) {
529
- throw new Error("Workspace file path must be non-empty and contain no control characters");
530
- }
531
- if (isAbsolute(pathValue) ||
532
- win32.isAbsolute(pathValue) ||
533
- /^[A-Za-z]:/.test(pathValue)) {
534
- throw new Error(`Workspace file path must be relative: ${pathValue}`);
535
- }
536
- const segments = pathValue.replace(/\\/g, "/").split("/");
537
- if (segments.some((segment) => !segment ||
538
- segment === "." ||
539
- segment === ".." ||
540
- segment === ".git" ||
541
- segment === ".sidecar")) {
542
- throw new Error(`Workspace file path must be canonical: ${pathValue}`);
543
- }
544
- return segments.join("/");
545
- }
546
- function hasControlCharacter(value) {
547
- for (let index = 0; index < value.length; index++) {
548
- const code = value.charCodeAt(index);
549
- if (code < 0x20 || code === 0x7f)
550
- return true;
551
- }
552
- return false;
553
- }
554
- function validateFileMode(mode) {
555
- const normalized = mode ?? DEFAULT_FILE_MODE;
556
- if (!Number.isInteger(normalized) || normalized < 0 || normalized > 0o777) {
557
- throw new Error(`Workspace file mode must be between 0o000 and 0o777: ${mode}`);
558
- }
559
- return normalized;
560
- }
561
- /**
562
- * Resolve every GitHub resource to exact inline bytes before materialization.
563
- * The profile's default harness does not affect resource resolution.
564
- */
565
- export async function resolveAgentProfileResources(inputProfile, options = {}) {
566
- const profile = agentProfileSchema.parse(inputProfile);
567
- const resources = profile.resources;
568
- if (!resources)
569
- return profile;
570
- const refs = [
571
- ...(resources.files?.map((mount) => mount.resource) ?? []),
572
- ...(resources.tools ?? []),
573
- ...(resources.skills ?? []),
574
- ...(resources.agents ?? []),
575
- ...(resources.commands ?? []),
576
- ...(typeof resources.instructions === "object"
577
- ? [resources.instructions]
578
- : []),
579
- ];
580
- if (refs.length > MAX_PROFILE_RESOURCE_REFS) {
581
- throw new Error(`AgentProfile may contain at most ${MAX_PROFILE_RESOURCE_REFS} resource references`);
582
- }
583
- const batchController = new AbortController();
584
- let fetchedBytes = 0;
585
- const consumeFetchedBytes = (bytes) => {
586
- fetchedBytes += bytes;
587
- if (fetchedBytes > MAX_PROFILE_RESOURCE_BYTES) {
588
- throw new Error(`GitHub profile resources exceed ${MAX_PROFILE_RESOURCE_BYTES} total bytes`);
589
- }
590
- };
591
- const resolveRef = async (ref) => {
592
- if (ref.kind === "inline")
593
- return ref;
594
- const repository = ref.repository ?? options.repository;
595
- if (!repository) {
596
- throw new Error(`GitHub profile resource "${ref.path}" requires repository`);
597
- }
598
- const [owner, repo, ...extraRepositoryParts] = repository.split("/");
599
- if (!owner ||
600
- !repo ||
601
- extraRepositoryParts.length > 0 ||
602
- !/^[A-Za-z0-9_.-]+$/.test(owner) ||
603
- !/^[A-Za-z0-9_.-]+$/.test(repo)) {
604
- throw new Error(`GitHub profile resource repository must be owner/repo: ${repository}`);
605
- }
606
- const resourcePath = normalizeWorkspaceRelativePath(ref.path);
607
- const gitRef = ref.ref ?? "main";
608
- if (!gitRef ||
609
- hasControlCharacter(gitRef) ||
610
- gitRef.split("/").some((segment) => !segment || segment === "..")) {
611
- throw new Error(`Invalid GitHub profile resource ref: ${gitRef}`);
612
- }
613
- const encoded = (value) => value.split("/").map(encodeURIComponent).join("/");
614
- const url = `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encoded(gitRef)}/${encoded(resourcePath)}`;
615
- const controller = new AbortController();
616
- const abortRequest = () => controller.abort(batchController.signal.reason);
617
- batchController.signal.addEventListener("abort", abortRequest, {
618
- once: true,
619
- });
620
- const timeoutError = new Error(`GitHub profile resource timed out: ${repository}/${resourcePath}@${gitRef}`);
621
- const timeout = setTimeout(() => controller.abort(timeoutError), options.timeoutMs ?? DEFAULT_RESOURCE_FETCH_TIMEOUT_MS);
622
- try {
623
- const fetchResource = options.fetch ?? globalThis.fetch;
624
- const response = await fetchResource(url, { signal: controller.signal });
625
- if (!response.ok) {
626
- throw new Error(`Failed to fetch GitHub profile resource ${repository}/${resourcePath}@${gitRef}: ${response.status} ${response.statusText}`);
627
- }
628
- const contentLength = Number(response.headers.get("content-length"));
629
- if (Number.isFinite(contentLength) &&
630
- contentLength > MAX_RESOURCE_BYTES) {
631
- throw new Error(`GitHub profile resource exceeds ${MAX_RESOURCE_BYTES} bytes: ${repository}/${resourcePath}`);
632
- }
633
- const content = await readResourceBody(response, `${repository}/${resourcePath}`, consumeFetchedBytes);
634
- return {
635
- kind: "inline",
636
- name: ref.name ?? basename(resourcePath),
637
- content,
638
- };
639
- }
640
- catch (error) {
641
- if (controller.signal.aborted && controller.signal.reason !== undefined) {
642
- throw controller.signal.reason;
643
- }
644
- throw error;
645
- }
646
- finally {
647
- clearTimeout(timeout);
648
- batchController.signal.removeEventListener("abort", abortRequest);
649
- }
650
- };
651
- const resolvedRefs = await mapWithConcurrency(refs, MAX_CONCURRENT_RESOURCE_FETCHES, batchController, resolveRef);
652
- let resolvedIndex = 0;
653
- const takeRef = () => resolvedRefs[resolvedIndex++];
654
- const takeRefs = (values) => values?.map(takeRef);
655
- const resolvedResources = {
656
- ...resources,
657
- files: resources.files === undefined
658
- ? undefined
659
- : resources.files.map((mount) => ({
660
- ...mount,
661
- resource: takeRef(),
662
- })),
663
- tools: takeRefs(resources.tools),
664
- skills: takeRefs(resources.skills),
665
- agents: takeRefs(resources.agents),
666
- commands: takeRefs(resources.commands),
667
- instructions: typeof resources.instructions === "object"
668
- ? takeRef()
669
- : resources.instructions,
670
- };
671
- return agentProfileSchema.parse({ ...profile, resources: resolvedResources });
672
- }
673
- /** Normalize any skill markdown (VB frontmatter `skill:`/`description:`, or raw) into a
674
- * harness SKILL.md with `name:`/`description:` frontmatter the harnesses require. */
675
- export function normalizeSkillMd(name, raw) {
676
- const m = raw.match(FRONTMATTER_RE);
677
- const fm = m?.[1] ?? "";
678
- const body = (m?.[2] ?? raw).trim();
679
- let desc = `Skill ${name}. Use when the task matches its domain.`;
680
- if (fm) {
681
- const lines = fm.split("\n");
682
- const i = lines.findIndex((l) => /^description:/.test(l));
683
- if (i >= 0) {
684
- const inline = (lines[i] ?? "").replace(/^description:\s*/, "").trim();
685
- if (inline && ![">", "|", ">-", "|-"].includes(inline))
686
- desc = collapse(inline);
687
- else {
688
- const buf = [];
689
- for (let j = i + 1; j < lines.length; j++) {
690
- const ln = lines[j] ?? "";
691
- if (/^\s+\S/.test(ln))
692
- buf.push(ln.trim());
693
- else if (ln.trim() === "")
694
- continue;
695
- else
696
- break;
697
- }
698
- if (buf.length)
699
- desc = collapse(buf.join(" "));
700
- }
701
- }
702
- }
703
- return `---\nname: ${name}\ndescription: ${JSON.stringify(desc)}\n---\n\n${body}\n`;
704
- }
705
- const tomlStr = (s) => JSON.stringify(s); // valid TOML basic string
706
- function isProfilePublicConfigValue(value) {
707
- return (typeof value === "object" &&
708
- value !== null &&
709
- !Array.isArray(value) &&
710
- value.kind === "public" &&
711
- typeof value.value === "string");
712
- }
713
- function isProfileSecretRef(value) {
714
- return (typeof value === "object" &&
715
- value !== null &&
716
- !Array.isArray(value) &&
717
- value.kind === "secret-ref" &&
718
- typeof value.key === "string");
719
- }
720
- function workspacePlanConfigValue(value) {
721
- return isProfilePublicConfigValue(value) ? value.value : { ...value };
722
- }
723
- function serializeWorkspacePlanJson(value) {
724
- const secretSlots = [];
725
- const lower = (current, path, arrayEntry = false) => {
726
- if (isProfilePublicConfigValue(current))
727
- return current.value;
728
- if (isProfileSecretRef(current)) {
729
- secretSlots.push({ path: [...path], ref: { ...current } });
730
- return null;
731
- }
732
- if (Array.isArray(current)) {
733
- return current.map((entry, index) => lower(entry, [...path, index], true));
734
- }
735
- if (current && typeof current === "object") {
736
- const output = Object.create(null);
737
- for (const [key, entry] of Object.entries(current)) {
738
- const lowered = lower(entry, [...path, key]);
739
- if (lowered !== undefined) {
740
- Object.defineProperty(output, key, {
741
- value: lowered,
742
- enumerable: true,
743
- configurable: true,
744
- writable: true,
745
- });
746
- }
747
- }
748
- return output;
749
- }
750
- return current === undefined && arrayEntry ? null : current;
751
- };
752
- const content = JSON.stringify(lower(value, []), null, 2);
753
- if (content === undefined) {
754
- throw new Error("Generated profile JSON must have a serializable root");
755
- }
756
- return {
757
- content,
758
- ...(secretSlots.length === 0 ? {} : { secretSlots }),
759
- };
760
- }
761
- function workspacePlanArgumentSegments(value) {
762
- return typeof value === "string" ? [value] : [...value.segments];
763
- }
764
- function concatWorkspacePlanArguments(...values) {
765
- const segments = values.flatMap(workspacePlanArgumentSegments);
766
- if (segments.every((segment) => typeof segment === "string")) {
767
- return segments.join("");
768
- }
769
- return { kind: "workspace-plan-argument-template", segments };
770
- }
771
- function joinWorkspacePlanArguments(values, separator) {
772
- if (values.length === 0)
773
- return "";
774
- const joined = [];
775
- for (const [index, value] of values.entries()) {
776
- if (index > 0)
777
- joined.push(separator);
778
- joined.push(value);
779
- }
780
- return concatWorkspacePlanArguments(...joined);
781
- }
782
- function tomlProfileConfigValue(value) {
783
- if (isProfilePublicConfigValue(value))
784
- return tomlStr(value.value);
785
- return {
786
- kind: "workspace-plan-argument-template",
787
- segments: [
788
- {
789
- kind: "secret",
790
- ref: { ...value },
791
- encoding: "json-string",
792
- },
793
- ],
794
- };
795
- }
796
- // ─── The materializer ─────────────────────────────────────────────────────────
797
- /**
798
- * Build the WorkspacePlan for `profile` on `harness`. Pure — no IO. The caller
799
- * writes plan.files into the run cwd and applies plan.env/plan.flags to the spawn.
800
- * The explicit target harness takes precedence over the profile's default.
801
- */
802
- export function materializeProfile(inputProfile, harness, opts = {}) {
803
- assertMaterializerHarness(harness);
804
- const profile = agentProfileSchema.parse(inputProfile);
805
- const h = harness;
806
- const skip = new Set(assertSkippableDimensions(opts.skip));
807
- const binds = assertBindableLaunchControls(opts.binds);
808
- const plan = {
809
- harness: h,
810
- files: [],
811
- env: {},
812
- flags: [],
813
- unsupported: [],
814
- requires: [],
815
- };
816
- // Every plan file is tagged by provenance at the single push site: `add`
817
- // (harness-generated native config) vs `addExplicitResource` (a profile
818
- // resource ref's bytes, passed through verbatim). Downstream consumers that
819
- // reinterpret a specific generated file by relPath (e.g. opencode.json as
820
- // the synthetic native config) must gate on `source === "generated"` —
821
- // otherwise a user resource mounted at the same conventional path is
822
- // silently misread as trusted harness config.
823
- const pushPlanFile = (relPath, serialized, source, mode) => {
824
- const normalizedPath = normalizeWorkspaceRelativePath(relPath);
825
- const content = typeof serialized === "string" ? serialized : serialized.content;
826
- const secretSlots = typeof serialized === "string" ? undefined : serialized.secretSlots;
827
- const fileMode = mode ??
828
- (secretSlots !== undefined && secretSlots.length > 0
829
- ? PRIVATE_FILE_MODE
830
- : undefined);
831
- plan.files.push({
832
- relPath: normalizedPath,
833
- content,
834
- source,
835
- // Only a path this package invented AND generated here is its own to
836
- // replace; the same path carrying a caller's `resources` mount is that
837
- // caller's file, whatever its name.
838
- ...(source === "generated" && MATERIALIZER_OWNED_PATHS.has(normalizedPath)
839
- ? { ownership: "materializer" }
840
- : {}),
841
- ...(fileMode === undefined ? {} : { mode: validateFileMode(fileMode) }),
842
- ...(secretSlots === undefined
843
- ? {}
844
- : {
845
- secretSlots: secretSlots.map((slot) => ({
846
- path: [...slot.path],
847
- ref: { ...slot.ref },
848
- })),
849
- }),
850
- });
851
- };
852
- const add = (relPath, content, mode) => pushPlanFile(relPath, content, "generated", mode);
853
- const unsupported = (dimension, reason) => plan.unsupported.push({ dimension, reason });
854
- const explicitResourcePaths = new Map();
855
- const addExplicitResource = (dimension, relPath, content, mode) => {
856
- const normalizedPath = normalizeWorkspaceRelativePath(relPath);
857
- const previousDimension = explicitResourcePaths.get(normalizedPath);
858
- if (previousDimension) {
859
- throw new Error(`Duplicate profile resource path: ${normalizedPath} (${previousDimension}, ${dimension})`);
860
- }
861
- // Refuse at plan time so {@link MATERIALIZER_OWNED_PATHS} stays true: those
862
- // paths are replaced on every re-application, so a mount there would be
863
- // silently overwritten by the next turn rather than kept.
864
- if (MATERIALIZER_OWNED_PATHS.has(normalizedPath)) {
865
- throw new Error(`Profile resource path is reserved by the materializer: ${normalizedPath} (${dimension}). ` +
866
- "This package writes that path itself and replaces it on re-application, so a mount " +
867
- "there cannot survive; choose another path.");
868
- }
869
- explicitResourcePaths.set(normalizedPath, dimension);
870
- pushPlanFile(normalizedPath, content, "user", mode);
871
- };
872
- // 1) GENERIC FILES → exact workspace-relative paths. Inline refs are
873
- // deterministic; remote refs must be resolved by an async caller first.
874
- const fileMountPaths = new Set();
875
- for (const mount of profile.resources?.files ?? []) {
876
- const relPath = normalizeWorkspaceRelativePath(mount.path);
877
- if (fileMountPaths.has(relPath)) {
878
- throw new Error(`Duplicate workspace file mount: ${relPath}`);
879
- }
880
- fileMountPaths.add(relPath);
881
- if (mount.executable !== undefined &&
882
- typeof mount.executable !== "boolean") {
883
- throw new Error(`Workspace file executable must be a boolean: ${relPath}`);
884
- }
885
- const resource = resolveInlineRef(mount.resource);
886
- if (!resource) {
887
- unsupported("files", `file mount "${relPath}" is not inline (github refs need async pre-resolution)`);
888
- continue;
889
- }
890
- addExplicitResource("files", relPath, resource.content, mount.executable ? EXECUTABLE_FILE_MODE : DEFAULT_FILE_MODE);
891
- }
892
- // 2) PROVIDER-NATIVE TOOLS → only OpenCode exposes a verified cwd path.
893
- for (const ref of profile.resources?.tools ?? []) {
894
- const resource = resolveInlineRef(ref);
895
- if (!resource) {
896
- unsupported("tools", "tool ref is not inline (github refs need async pre-resolution)");
897
- continue;
898
- }
899
- if (h !== "opencode") {
900
- unsupported("tools", `${h}: no verified cwd-native file tool path; use MCP or a native extension`);
901
- continue;
902
- }
903
- addExplicitResource("tools", `.opencode/tools/${resource.name}`, resource.content);
904
- }
905
- // 3) PROVIDER-NATIVE AGENTS → raw native definitions at verified cwd paths.
906
- for (const ref of profile.resources?.agents ?? []) {
907
- const resource = resolveInlineRef(ref);
908
- if (!resource) {
909
- unsupported("subagents", "agent ref is not inline (github refs need async pre-resolution)");
910
- continue;
911
- }
912
- const directory = agentResourceDirectory(h);
913
- if (!directory) {
914
- unsupported("subagents", `${h}: agent definitions have no verified cwd-native resource path`);
915
- continue;
916
- }
917
- const relPath = `${directory}/${resource.name}`;
918
- addExplicitResource("subagents", relPath, resource.content);
919
- if (h === "codex") {
920
- const roleName = codexAgentRoleName(resource.name);
921
- if (!roleName) {
922
- unsupported("subagents", `codex: agent resource "${resource.name}" must use a lowercase role name`);
923
- }
924
- else {
925
- registerCodexAgent(plan, roleName, relPath, roleName);
926
- }
927
- }
928
- }
929
- // 4) PROMPT. Replacing the harness's own system prompt and adding to it are
930
- // two intents against one channel, and a harness almost never has both. Each
931
- // lowers ONLY to a control whose measured job is exactly that intent; the
932
- // other is refused. Substituting one for the other is the silent downgrade
933
- // this section exists to prevent — an addition standing in for a replacement
934
- // leaves the instructions the caller asked to delete in force, and a
935
- // replacement standing in for an addition deletes the harness's own prompt.
936
- // Neither is visible in the result, which is why both fail closed instead.
937
- //
938
- // `instructions` is a third, lower-privilege surface (the harness's
939
- // AGENTS.md/CLAUDE.md-style context file). On gemini and opencode that surface
940
- // does reach the model in the system-prompt position, so an addition can be
941
- // lowered there ONLY where the plan can keep the two apart. opencode can: it
942
- // loads exactly the files `opencode.json` `instructions[]` names, so the
943
- // addition gets its own file registered ahead of the instruction file. gemini
944
- // cannot: GEMINI.md is the one context file it auto-loads, so an addition
945
- // lowered there would be byte-indistinguishable from `instructions`, which is
946
- // one-channel-two-meanings recreated a level down.
947
- //
948
- // Which harness owns which intent is the measured harnessSystemPromptIntents
949
- // table in @tangle-network/agent-interface; the mechanics per owned cell live
950
- // here.
951
- if (profile.prompt?.systemPrompt !== undefined) {
952
- if (h === "claude-code" || h === "pi" || h === "prime") {
953
- // claude-code `--system-prompt`, pi `--system-prompt`: both take the
954
- // replacement as a launch argument the executor supplies, and both
955
- // measurably drop the harness's built-in prompt from the wire request.
956
- // prime inherits the same flag pair from its pi line (fork
957
- // cli/args.ts:139-143 → resourceLoaderOptions main.ts:745-746, the
958
- // replacement deletes the built-in prompt: resource-loader.ts:482-485,
959
- // system-prompt.ts:72-73).
960
- plan.systemPrompt = profile.prompt.systemPrompt;
961
- }
962
- else if (h === "codex") {
963
- // codex has no replacement FLAG; its control is the config key
964
- // `model_instructions_file`, which reads a cwd-relative file and becomes
965
- // the request's entire `instructions` field. File + flag keeps the plan
966
- // self-applying for every executor that already forwards plan.flags.
967
- add(CODEX_SYSTEM_PROMPT_FILE, profile.prompt.systemPrompt);
968
- plan.flags.push("-c", `model_instructions_file=${CODEX_SYSTEM_PROMPT_FILE}`);
969
- }
970
- else if (h === "gemini") {
971
- add(".gemini/system.md", profile.prompt.systemPrompt);
972
- plan.env.GEMINI_SYSTEM_MD = "1";
973
- }
974
- else if (h === "opencode") {
975
- // opencode's replacement control is `agent.<name>.prompt`: it binds to
976
- // ONE agent, and the agent is chosen at launch, so executing it means
977
- // writing that agent AND selecting it. There are two shapes of executor
978
- // that can promise one of those, and each gets the lowering it can apply;
979
- // an executor that promises neither gets the refusal, because a plan
980
- // written for an unknown launcher cannot deliver either half.
981
- //
982
- // No shape can express an EMPTY replacement — an agent `prompt` with no
983
- // text falls back to the built-in prompt instead of deleting it — so the
984
- // guard sits above the split and no path bypasses it.
985
- const systemPrompt = profile.prompt.systemPrompt;
986
- if (systemPrompt.trim().length === 0) {
987
- throw new Error("opencode cannot express an empty exact system-prompt replacement");
988
- }
989
- if (binds.has("opencode.agent-prompt")) {
990
- // A caller that composes opencode's own config and picks the agent
991
- // itself owns the control outright; it does not read plan files or
992
- // forward plan flags, so a workspace agent would never be selected.
993
- // Hand it the intent named against the exact control it claimed.
994
- plan.systemPromptBinding = {
995
- intent: "replace",
996
- control: "opencode.agent-prompt",
997
- systemPrompt,
998
- };
999
- }
1000
- else if (binds.has("systemPrompt")) {
1001
- // A caller that applies the plan's launch flags to the spawn it owns
1002
- // lets the plan execute the control itself: a `mode: primary` agent
1003
- // file holding the prompt, plus the `--agent` flag that selects it.
1004
- // Without the flag the file is inert and the built-in prompt the
1005
- // caller asked to delete stays in force, so the two are pushed
1006
- // together or not at all.
1007
- const primaryAgentName = `${OPENCODE_PRIMARY_AGENT_PREFIX}${sha256(systemPrompt).slice(0, 16)}`;
1008
- const primaryAgentPath = `.opencode/agents/${primaryAgentName}.md`;
1009
- if (profile.subagents?.[primaryAgentName] !== undefined) {
1010
- throw new Error(`opencode: subagent name "${primaryAgentName}" is reserved for the profile primary agent`);
1011
- }
1012
- plan.systemPrompt = systemPrompt;
1013
- add(primaryAgentPath, ["---", "mode: primary", "---", systemPrompt, ""].join("\n"));
1014
- plan.flags.push("--agent", primaryAgentName);
1015
- }
1016
- else {
1017
- unsupported("systemPrompt", systemPromptRefusal(h));
1018
- }
1019
- }
1020
- else {
1021
- unsupported("systemPrompt", systemPromptRefusal(h));
1022
- }
1023
- }
1024
- // Ordered registration list for opencode, whose TUI and headless runs read
1025
- // ONLY the files named here. Addition first so it frames the lower-privilege
1026
- // instruction file that follows, matching the order the model receives them.
1027
- const openCodeInstructionFiles = [];
1028
- // A launcher that declared `opencode.agent-prompt` composes opencode's config
1029
- // itself and reads neither plan files nor plan flags, so the workspace
1030
- // `opencode.json` that registers these additive files would never load.
1031
- // Lowering them anyway would be the silent drop this module refuses to
1032
- // perform, so each additive intent is refused by name instead: the launcher
1033
- // owns the `instructions[]` channel too and must compose it in the config it
1034
- // builds.
1035
- const openCodeConfigUnreachable = h === "opencode" && binds.has("opencode.agent-prompt");
1036
- if (profile.prompt?.appendSystemPrompt !== undefined) {
1037
- const appendSystemPrompt = profile.prompt.appendSystemPrompt;
1038
- if (appendSystemPrompt.trim().length === 0) {
1039
- throw new Error("prompt.appendSystemPrompt must be non-empty text");
1040
- }
1041
- if (openCodeConfigUnreachable) {
1042
- unsupported("appendSystemPrompt", "opencode: the launcher that binds opencode.agent-prompt composes opencode's config itself and loads no workspace opencode.json, so an addition registered there would never reach the model; compose it into the launcher's own instructions[]");
1043
- }
1044
- else if (h === "opencode") {
1045
- // opencode composes config-declared `instructions[]` file contents into
1046
- // the same single role:"system" message as its built-in prompt, which
1047
- // stays in place (its replacement control is the separate per-agent
1048
- // `prompt` field). A dedicated file keeps this intent addressable on its
1049
- // own rather than merged into the instruction file.
1050
- add(OPENCODE_APPEND_SYSTEM_PROMPT_FILE, appendSystemPrompt.endsWith("\n")
1051
- ? appendSystemPrompt
1052
- : `${appendSystemPrompt}\n`);
1053
- openCodeInstructionFiles.push(OPENCODE_APPEND_SYSTEM_PROMPT_FILE);
1054
- }
1055
- else if (h === "claude-code" || h === "pi" || h === "prime") {
1056
- // claude-code `--append-system-prompt`, pi `--append-system-prompt`: both
1057
- // add the caller's text to the harness's own system prompt, which stays
1058
- // on the wire intact. prime inherits the same flag from its pi line (fork
1059
- // cli/args.ts:139-143, the addition appends after the built-in prompt:
1060
- // system-prompt.ts:61,109-110). Carried beside the replacement rather
1061
- // than merged into it, so an executor applies each through its own
1062
- // control and the plan's identity distinguishes the two intents.
1063
- plan.appendSystemPrompt = appendSystemPrompt;
1064
- }
1065
- else {
1066
- unsupported("appendSystemPrompt", appendSystemPromptRefusal(h));
1067
- }
1068
- }
1069
- const instr = [];
1070
- if (profile.prompt?.instructions?.length)
1071
- instr.push(...profile.prompt.instructions);
1072
- const resInstr = profile.resources?.instructions;
1073
- if (typeof resInstr === "string")
1074
- instr.push(resInstr);
1075
- else if (resInstr && resInstr.kind === "inline")
1076
- instr.push(resInstr.content);
1077
- else if (resInstr) {
1078
- unsupported("instructions", "instruction ref is not inline (github refs need async pre-resolution)");
1079
- }
1080
- if (instr.length) {
1081
- if (openCodeConfigUnreachable) {
1082
- unsupported("instructions", "opencode: the launcher that binds opencode.agent-prompt composes opencode's config itself and loads no workspace opencode.json, so an instruction file registered there would never reach the model; compose it into the launcher's own instructions[]");
1083
- }
1084
- else {
1085
- const instructionPath = CONTEXT_FILE[h];
1086
- add(instructionPath, `${instr.join("\n\n")}\n`);
1087
- if (h === "opencode")
1088
- openCodeInstructionFiles.push(instructionPath);
1089
- // gemini reads AGENTS.md only via settings; we write GEMINI.md so no extra step.
1090
- }
1091
- }
1092
- // ONE opencode.json write for the whole prompt section: top-level JSON merge
1093
- // is last-wins per key, and two `add` calls at the same relPath are a
1094
- // duplicate-file error, so a second write would drop whichever lost.
1095
- if (openCodeInstructionFiles.length > 0) {
1096
- add("opencode.json", serializeWorkspacePlanJson({
1097
- $schema: "https://opencode.ai/config.json",
1098
- instructions: openCodeInstructionFiles,
1099
- }));
1100
- }
1101
- // 5) ACTIVE EXECUTION CONTROLS. These alter the tools visible to the primary
1102
- // process and its permission policy, so they must be lowered into native
1103
- // launch/config inputs rather than merely retained in profile metadata.
1104
- materializeExecutionControls(h, profile.tools, profile.permissions, profile.modes, plan, add, unsupported);
1105
- if ((profile.connections?.length ?? 0) > 0) {
1106
- unsupported("connections", `${h}: Hub connections must be resolved to authorized MCP servers by the execution runtime`);
1107
- }
1108
- if (profile.confidential !== undefined) {
1109
- unsupported("confidential", `${h}: confidential execution requires runtime placement and attestation, not workspace files`);
1110
- }
1111
- // 6) SKILLS → native skill dir, from resources.skills refs (the box's shape), or
1112
- // fail-closed (hermes has no cwd skill dir; github refs need async pre-resolution).
1113
- for (const ref of profile.resources?.skills ?? []) {
1114
- const r = resolveInlineRef(ref);
1115
- if (!r) {
1116
- unsupported("skills", "skill ref is not inline (github refs need async pre-resolution)");
1117
- continue;
1118
- }
1119
- const dir = SKILL_DIR[h];
1120
- if (!dir) {
1121
- unsupported("skills", `${h}: no cwd skill dir (skills live in a user/global dir); skill "${r.name}" not mounted`);
1122
- continue;
1123
- }
1124
- addExplicitResource("skills", `${dir}/${r.name}/SKILL.md`, normalizeSkillMd(r.name, r.content));
1125
- }
1126
- // 7) MCP -> each harness's native configuration and launch controls.
1127
- const mcp = activeMcpServers(profile.mcp);
1128
- if (!skip.has("mcp") && Object.keys(mcp).length)
1129
- materializeMcp(h, mcp, plan, add, unsupported);
1130
- // 8) HOOKS → per-harness format; cwd-native only for claude/codex/gemini/opencode(plugin).
1131
- const hooks = profile.hooks ?? {};
1132
- if (Object.keys(hooks).length)
1133
- materializeHooks(h, hooks, add, unsupported, plan);
1134
- // 9) SUBAGENTS → cwd-native for claude/codex/opencode/gemini; flag/global elsewhere.
1135
- const subagents = profile.subagents ?? {};
1136
- for (const [name, sa] of Object.entries(subagents))
1137
- materializeSubagent(h, name, sa, plan, add, unsupported);
1138
- if (!skip.has("extensions")) {
1139
- for (const namespace of extensionNamespaces(h)) {
1140
- const extension = profile.extensions?.[namespace];
1141
- if (extension && Object.keys(extension).length > 0) {
1142
- unsupported("extensions", `${h}: extension namespace "${namespace}" requires provider-native handling`);
1143
- }
1144
- }
1145
- }
1146
- // 10) COMMANDS → cwd-native (resources.commands refs); "commands are skills" on kimi.
1147
- for (const ref of profile.resources?.commands ?? []) {
1148
- const r = resolveInlineRef(ref);
1149
- if (!r) {
1150
- unsupported("commands", "command ref is not inline (github needs pre-resolution)");
1151
- continue;
1152
- }
1153
- materializeCommand(h, r.name, r.content, (path, content, mode) => addExplicitResource("commands", path, content, mode), unsupported);
1154
- }
1155
- for (const [relPath, dimension] of explicitResourcePaths) {
1156
- const writers = plan.files.filter((file) => file.relPath === relPath).length;
1157
- if (writers > 1) {
1158
- throw new Error(`Profile ${dimension} resource conflicts with generated harness file: ${relPath}`);
1159
- }
1160
- }
1161
- // Merge native multi-writer JSON files (for example Claude settings from both
1162
- // mcp and hooks) so the second writer cannot clobber the first.
1163
- plan.files = mergeJsonFiles(plan.files).sort((a, b) => compareStrings(a.relPath, b.relPath));
1164
- if (profile.resources?.failOnError && plan.unsupported.length) {
1165
- throw new Error(formatUnsupported(plan.unsupported));
1166
- }
1167
- return plan;
1168
- }
1169
- function activeMcpServers(servers) {
1170
- return Object.fromEntries(Object.entries(servers ?? {}).filter((entry) => entry[1].enabled !== false));
1171
- }
1172
- function extensionNamespaces(harness) {
1173
- return [harness];
1174
- }
1175
- /**
1176
- * Materialize a sealed candidate profile without weakening its process values.
1177
- * Codex and Claude hooks use generated no-shell wrappers to preserve executable,
1178
- * argv, and environment values separately. Other hook formats fail closed.
1179
- */
1180
- export function materializeCandidateProfile(profile, harness, opts = {}) {
1181
- assertMaterializerHarness(harness);
1182
- const candidate = agentCandidateProfileSchema.parse(profile);
1183
- verifyCandidateInlineResources(candidate);
1184
- const hookLowering = lowerCandidateHooks(candidate.hooks, harness);
1185
- const candidateResources = candidate.resources
1186
- ? candidateResourcesForClassicMaterializer(candidate.resources, opts.resolvedResources)
1187
- : {};
1188
- candidateResources.files = [
1189
- ...(candidateResources.files ?? []),
1190
- ...hookLowering.wrapperFiles,
1191
- ];
1192
- const classicProfile = {
1193
- ...candidate,
1194
- resources: candidateResources,
1195
- mcp: candidateMcpRecordForClassicMaterializer(candidate.mcp),
1196
- hooks: hookLowering.hooks,
1197
- };
1198
- const classicPlan = materializeProfile(classicProfile, harness, {
1199
- binds: opts.binds,
1200
- });
1201
- // Canonical candidate evidence has no field for a launcher-addressed binding.
1202
- // Refuse that shape instead of returning a candidate plan whose receipt would
1203
- // omit the exact control still owed by the launcher.
1204
- if (classicPlan.systemPromptBinding !== undefined) {
1205
- throw new Error(`Sealed candidate evidence cannot represent a system-prompt binding to ${classicPlan.systemPromptBinding.control}`);
1206
- }
1207
- if (hookLowering.unsupportedReason) {
1208
- classicPlan.unsupported.push({
1209
- dimension: "hooks",
1210
- reason: hookLowering.unsupportedReason,
1211
- });
1212
- }
1213
- classicPlan.unsupported.sort((left, right) => compareStrings(`${left.dimension}\0${left.reason}`, `${right.dimension}\0${right.reason}`));
1214
- assertWorkspacePlanSupported(classicPlan);
1215
- return {
1216
- sourceProfileDigest: canonicalCandidateDigest(candidate),
1217
- harness: classicPlan.harness,
1218
- files: classicPlan.files,
1219
- env: candidatePublicRecord(classicPlan.env),
1220
- flags: classicPlan.flags.map(candidatePublicArgument),
1221
- ...promptIntentFields(classicPlan, candidatePublicValue),
1222
- unsupported: classicPlan.unsupported,
1223
- };
1224
- }
1225
- function lowerCandidateHooks(hooks, harness) {
1226
- if (!hooks || Object.keys(hooks).length === 0)
1227
- return { wrapperFiles: [] };
1228
- const nativeHarness = harness;
1229
- if (nativeHarness !== "codex" && nativeHarness !== "claude-code") {
1230
- return {
1231
- wrapperFiles: [],
1232
- unsupportedReason: `${nativeHarness} hook configuration requires an unverified command-string mapping; ` +
1233
- "sealed candidate hooks preserve executable, argv, and env separately",
1234
- };
1235
- }
1236
- const wrapperFiles = [];
1237
- const lowered = {};
1238
- for (const [event, commands] of Object.entries(hooks)) {
1239
- lowered[event] = commands.map((command, index) => {
1240
- const wrapper = candidateHookWrapper(event, index, command);
1241
- wrapperFiles.push({
1242
- path: wrapper.path,
1243
- resource: {
1244
- kind: "inline",
1245
- name: wrapper.path,
1246
- content: wrapper.content,
1247
- },
1248
- });
1249
- return {
1250
- command: `node ${wrapper.path}`,
1251
- timeoutMs: command.timeoutMs,
1252
- blocking: command.blocking,
1253
- matcher: command.matcher,
1254
- };
1255
- });
1256
- }
1257
- return { hooks: lowered, wrapperFiles };
1258
- }
1259
- function candidateHookWrapper(event, index, command) {
1260
- const args = (command.args ?? []).map(candidatePublicString);
1261
- const env = Object.fromEntries(Object.entries(command.env ?? {})
1262
- .sort(([left], [right]) => compareStrings(left, right))
1263
- .map(([name, value]) => [name, candidatePublicString(value)]));
1264
- const wrapperId = canonicalCandidateDigest({
1265
- event,
1266
- index,
1267
- executable: command.executable,
1268
- args,
1269
- env,
1270
- timeoutMs: command.timeoutMs ?? null,
1271
- blocking: command.blocking ?? null,
1272
- matcher: command.matcher ?? null,
1273
- }).slice("sha256:".length);
1274
- const path = `.tangle/profile-hooks/${wrapperId}.mjs`;
1275
- const content = hookWrapperSource(command.executable, args, env);
1276
- return { path, content };
1277
- }
1278
- function agentResourceDirectory(harness) {
1279
- switch (harness) {
1280
- case "claude-code":
1281
- case "nanoclaw":
1282
- return ".claude/agents";
1283
- case "codex":
1284
- return ".codex/agents";
1285
- case "opencode":
1286
- return ".opencode/agents";
1287
- case "gemini":
1288
- return ".gemini/agents";
1289
- default:
1290
- return null;
1291
- }
1292
- }
1293
- function candidateMcpRecordForClassicMaterializer(servers) {
1294
- if (!servers)
1295
- return undefined;
1296
- return Object.fromEntries(Object.entries(servers)
1297
- .filter((entry) => entry[1].enabled !== false)
1298
- .map(([name, server]) => [
1299
- name,
1300
- candidateMcpForClassicMaterializer(server),
1301
- ]));
1302
- }
1303
- function candidateMcpForClassicMaterializer(server) {
1304
- return {
1305
- command: server.command,
1306
- ...(server.transport === undefined ? {} : { transport: server.transport }),
1307
- ...(server.args === undefined
1308
- ? {}
1309
- : {
1310
- args: server.args.map((value) => profilePublicValue(candidatePublicString(value))),
1311
- }),
1312
- ...(server.env === undefined
1313
- ? {}
1314
- : {
1315
- env: Object.fromEntries(Object.entries(server.env).map(([name, value]) => [
1316
- name,
1317
- profilePublicValue(candidatePublicString(value)),
1318
- ])),
1319
- }),
1320
- ...(server.cwd === undefined ? {} : { cwd: server.cwd }),
1321
- };
1322
- }
1323
- function candidateResourcesForClassicMaterializer(resources, resolvedResources) {
1324
- const convert = (ref) => {
1325
- if (ref.kind === "inline") {
1326
- return { kind: "inline", name: ref.name, content: ref.content };
1327
- }
1328
- const content = resolvedResources?.get(ref.sha256);
1329
- if (content !== undefined) {
1330
- verifyCandidateResourceBytes(ref, content, ref.name ?? ref.path);
1331
- return {
1332
- kind: "inline",
1333
- name: ref.name ?? ref.path.split("/").at(-1) ?? "resource",
1334
- content,
1335
- };
1336
- }
1337
- return {
1338
- kind: "github",
1339
- repository: `${ref.repository.owner}/${ref.repository.repo}`,
1340
- path: ref.path,
1341
- ref: ref.commit,
1342
- name: ref.name,
1343
- };
1344
- };
1345
- return {
1346
- files: resources.files?.map((mount) => ({
1347
- path: mount.path,
1348
- resource: convert(mount.resource),
1349
- executable: mount.executable,
1350
- })),
1351
- tools: resources.tools?.map(convert),
1352
- skills: resources.skills?.map(convert),
1353
- agents: resources.agents?.map(convert),
1354
- commands: resources.commands?.map(convert),
1355
- instructions: typeof resources.instructions === "string"
1356
- ? resources.instructions
1357
- : resources.instructions
1358
- ? convert(resources.instructions)
1359
- : undefined,
1360
- // The candidate wrapper owns the complete unsupported check after it adds
1361
- // dimensions that the classic materializer cannot represent.
1362
- failOnError: false,
1363
- };
1364
- }
1365
- function candidatePublicString(value) {
1366
- if (value.kind !== "public") {
1367
- throw new Error("Candidate materialization accepts public config values only");
1368
- }
1369
- return value.value;
1370
- }
1371
- function candidatePublicValue(value) {
1372
- return { kind: "public", value };
1373
- }
1374
- function profilePublicValue(value) {
1375
- return { kind: "public", value };
1376
- }
1377
- function candidatePublicArgument(value) {
1378
- if (typeof value !== "string") {
1379
- throw new Error("Candidate materialization cannot emit private launch arguments");
1380
- }
1381
- return candidatePublicValue(value);
1382
- }
1383
- function candidatePublicRecord(values) {
1384
- return Object.fromEntries(Object.entries(values).map(([name, value]) => {
1385
- if (typeof value !== "string") {
1386
- throw new Error("Candidate materialization cannot emit private environment values");
1387
- }
1388
- return [name, candidatePublicValue(value)];
1389
- }));
1390
- }
1391
- function verifyCandidateInlineResources(profile) {
1392
- const resources = profile.resources;
1393
- if (!resources)
1394
- return;
1395
- const refs = [
1396
- ...(resources.files?.map((mount) => mount.resource) ?? []),
1397
- ...(resources.tools ?? []),
1398
- ...(resources.skills ?? []),
1399
- ...(resources.agents ?? []),
1400
- ...(resources.commands ?? []),
1401
- ...(typeof resources.instructions === "object"
1402
- ? [resources.instructions]
1403
- : []),
1404
- ];
1405
- for (const ref of refs) {
1406
- if (ref.kind !== "inline")
1407
- continue;
1408
- verifyCandidateResourceBytes(ref, ref.content, ref.name);
1409
- }
1410
- }
1411
- function verifyCandidateResourceBytes(ref, content, label) {
1412
- const bytes = Buffer.from(content, "utf8");
1413
- const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
1414
- if (bytes.byteLength !== ref.byteLength || digest !== ref.sha256) {
1415
- throw new Error(`Candidate resource integrity mismatch: ${label}`);
1416
- }
1417
- }
1418
- function formatUnsupported(unsupported) {
1419
- return `Unable to materialize ${unsupported.length} profile resource(s):\n${unsupported
1420
- .map(({ dimension, reason }) => `- ${dimension}: ${reason}`)
1421
- .join("\n")}`;
1422
- }
1423
- /** Collapse multiple PlanFiles with the same relPath: top-level-merge JSON, last-wins
1424
- * for non-JSON. settings.json contributors use disjoint top-level keys, so this unions them. */
1425
- function mergeJsonFiles(files) {
1426
- const byPath = new Map();
1427
- for (const f of files) {
1428
- const prev = byPath.get(f.relPath);
1429
- if (!prev) {
1430
- byPath.set(f.relPath, f);
1431
- continue;
1432
- }
1433
- if (f.relPath.endsWith(".json")) {
1434
- try {
1435
- const next = JSON.parse(f.content);
1436
- const merged = {
1437
- ...JSON.parse(prev.content),
1438
- ...next,
1439
- };
1440
- const replacedRoots = new Set(Object.keys(next));
1441
- const secretSlots = [
1442
- ...(prev.secretSlots ?? []).filter((slot) => typeof slot.path[0] === "string" &&
1443
- !replacedRoots.has(slot.path[0])),
1444
- ...(f.secretSlots ?? []),
1445
- ];
1446
- const { secretSlots: _previousSecretSlots, ...previousFile } = prev;
1447
- byPath.set(f.relPath, {
1448
- ...previousFile,
1449
- content: JSON.stringify(merged, null, 2),
1450
- ...(secretSlots.length > 0 ? { mode: PRIVATE_FILE_MODE } : {}),
1451
- ...(secretSlots.length === 0 ? {} : { secretSlots }),
1452
- });
1453
- continue;
1454
- }
1455
- catch {
1456
- /* fall through to last-wins */
1457
- }
1458
- }
1459
- byPath.set(f.relPath, f); // non-JSON or parse-fail: last wins
1460
- }
1461
- return [...byPath.values()];
1462
- }
1463
- function valueAtJsonPath(root, path) {
1464
- let current = root;
1465
- for (const segment of path) {
1466
- if (current === null ||
1467
- typeof current !== "object" ||
1468
- !Object.hasOwn(current, segment)) {
1469
- return undefined;
1470
- }
1471
- current = current[segment];
1472
- }
1473
- return current;
1474
- }
1475
- function normalizePlanFileSecretSlots(file) {
1476
- if (file.secretSlots === undefined)
1477
- return [];
1478
- if (file.source !== "generated" ||
1479
- !file.relPath.endsWith(".json") ||
1480
- file.secretSlots.length === 0) {
1481
- throw new Error(`Workspace secret slots require a generated JSON file: ${file.relPath}`);
1482
- }
1483
- let parsed;
1484
- try {
1485
- parsed = JSON.parse(file.content);
1486
- }
1487
- catch {
1488
- throw new Error(`Workspace secret slots require valid JSON content: ${file.relPath}`);
1489
- }
1490
- const seen = new Set();
1491
- return file.secretSlots.map((slot) => {
1492
- if (!Array.isArray(slot.path) || slot.path.length === 0) {
1493
- throw new Error(`Workspace secret slot path must be non-empty: ${file.relPath}`);
1494
- }
1495
- const path = slot.path.map((segment) => {
1496
- if ((typeof segment !== "string" && typeof segment !== "number") ||
1497
- (typeof segment === "number" &&
1498
- (!Number.isSafeInteger(segment) || segment < 0))) {
1499
- throw new Error(`Workspace secret slot path is invalid: ${file.relPath}`);
1500
- }
1501
- return segment;
1502
- });
1503
- const identity = JSON.stringify(path);
1504
- if (seen.has(identity)) {
1505
- throw new Error(`Duplicate workspace secret slot path: ${file.relPath} ${identity}`);
1506
- }
1507
- seen.add(identity);
1508
- if (valueAtJsonPath(parsed, path) !== null) {
1509
- throw new Error(`Workspace secret slot must point at a null placeholder: ${file.relPath} ${identity}`);
1510
- }
1511
- return {
1512
- path,
1513
- ref: agentProfileSecretRefSchema.parse(slot.ref),
1514
- };
1515
- });
1516
- }
1517
- function normalizePlanFiles(files) {
1518
- const seen = new Set();
1519
- return files
1520
- .map((file) => {
1521
- const relPath = normalizeWorkspaceRelativePath(file.relPath);
1522
- if (seen.has(relPath)) {
1523
- throw new Error(`Duplicate workspace plan file: ${relPath}`);
1524
- }
1525
- seen.add(relPath);
1526
- if (typeof file.content !== "string") {
1527
- throw new Error(`Workspace file content must be a string: ${relPath}`);
1528
- }
1529
- const mode = validateFileMode(file.mode);
1530
- const secretSlots = normalizePlanFileSecretSlots(file);
1531
- if (secretSlots.length > 0 && (mode & 0o077) !== 0) {
1532
- throw new Error(`Workspace secret-bearing file must not grant group or other access: ${relPath}`);
1533
- }
1534
- return {
1535
- relPath,
1536
- content: file.content,
1537
- mode,
1538
- ownership: file.ownership ?? "shared",
1539
- secretSlots,
1540
- };
1541
- })
1542
- .sort((a, b) => compareStrings(a.relPath, b.relPath));
1543
- }
1544
- const sha256 = (value) => createHash("sha256").update(value, "utf8").digest("hex");
1545
- function normalizeWorkspacePlanArgument(value) {
1546
- if (typeof value === "string")
1547
- return value;
1548
- if (value.kind !== "workspace-plan-argument-template" ||
1549
- !Array.isArray(value.segments) ||
1550
- value.segments.length === 0) {
1551
- throw new Error("Workspace plan argument template is malformed");
1552
- }
1553
- const segments = [];
1554
- for (const segment of value.segments) {
1555
- if (typeof segment === "string") {
1556
- const previous = segments.at(-1);
1557
- if (typeof previous === "string") {
1558
- segments[segments.length - 1] = previous + segment;
1559
- }
1560
- else if (segment.length > 0) {
1561
- segments.push(segment);
1562
- }
1563
- continue;
1564
- }
1565
- if (segment.kind !== "secret" ||
1566
- (segment.encoding !== "raw" && segment.encoding !== "json-string")) {
1567
- throw new Error("Workspace plan secret argument segment is malformed");
1568
- }
1569
- segments.push({
1570
- kind: "secret",
1571
- ref: agentProfileSecretRefSchema.parse(segment.ref),
1572
- encoding: segment.encoding,
1573
- });
1574
- }
1575
- if (segments.every((segment) => typeof segment === "string")) {
1576
- return segments.join("");
1577
- }
1578
- return { kind: "workspace-plan-argument-template", segments };
1579
- }
1580
- /**
1581
- * Hash the exact files, modes, flags, environment, and unsupported dimensions
1582
- * represented by a plan. Array order is preserved only where it changes spawn
1583
- * behavior; unordered maps and file records are canonicalized first.
1584
- */
1585
- export function hashWorkspacePlan(plan) {
1586
- const files = normalizePlanFiles(plan.files).map((file) => ({
1587
- relPath: file.relPath,
1588
- mode: file.mode,
1589
- contentSha256: sha256(file.content),
1590
- // Ownership decides whether re-application may replace the file, so two
1591
- // plans identical in bytes but different here behave differently in the
1592
- // same workspace — the digest a receipt proves must say which one ran.
1593
- ownership: file.ownership,
1594
- ...(file.secretSlots.length === 0
1595
- ? {}
1596
- : {
1597
- secretSlots: [...file.secretSlots].sort((left, right) => compareStrings(JSON.stringify(left.path), JSON.stringify(right.path))),
1598
- }),
1599
- }));
1600
- const env = Object.fromEntries(Object.entries(plan.env)
1601
- .sort(([a], [b]) => compareStrings(a, b))
1602
- .map(([name, value]) => [
1603
- name,
1604
- typeof value === "string"
1605
- ? value
1606
- : agentProfileSecretRefSchema.parse(value),
1607
- ]));
1608
- const unsupported = [...plan.unsupported].sort((a, b) => compareStrings(`${a.dimension}\0${a.reason}`, `${b.dimension}\0${b.reason}`));
1609
- // A plan that needs an extension is not the same plan as one that does not, even when every
1610
- // byte it writes is identical — the digest is what a receipt proves, so it must say so.
1611
- const requires = [...(plan.requires ?? [])].sort((a, b) => compareStrings(`${a.kind}\0${a.id}`, `${b.kind}\0${b.id}`));
1612
- const canonical = JSON.stringify({
1613
- harness: plan.harness,
1614
- files,
1615
- env,
1616
- flags: plan.flags.map(normalizeWorkspacePlanArgument),
1617
- ...promptIntentFields(plan, samePromptIntentValue),
1618
- // A plan that owes a launcher a replacement is not the same plan as one
1619
- // that owes nothing, even when every byte it writes is identical.
1620
- ...systemPromptBindingField(plan),
1621
- unsupported,
1622
- ...(requires.length === 0 ? {} : { requires }),
1623
- });
1624
- return `sha256:${sha256(canonical)}`;
1625
- }
1626
- /** Build the exact digest-free identity document required by agent-interface. */
1627
- export function agentCandidateProfilePlanMaterial(plan) {
1628
- const material = {
1629
- sourceProfileDigest: plan.sourceProfileDigest,
1630
- harness: plan.harness,
1631
- files: normalizePlanFiles(plan.files).map((file) => ({
1632
- relPath: file.relPath,
1633
- mode: file.mode,
1634
- contentSha256: `sha256:${sha256(file.content)}`,
1635
- })),
1636
- env: Object.fromEntries(Object.entries(plan.env).sort(([left], [right]) => compareStrings(left, right))),
1637
- flags: [...plan.flags],
1638
- ...promptIntentFields(plan, samePromptIntentValue),
1639
- unsupported: [...plan.unsupported].sort((left, right) => compareStrings(`${left.dimension}\0${left.reason}`, `${right.dimension}\0${right.reason}`)),
1640
- };
1641
- return agentCandidateProfilePlanMaterialSchema.parse(material);
1642
- }
1643
- /**
1644
- * Return the RFC 8785 UTF-8 bytes and SHA-256 identity of a candidate profile
1645
- * plan. The returned bytes are the exact bytes embedded in receipt evidence.
1646
- */
1647
- export function canonicalizeAgentCandidateProfilePlan(plan) {
1648
- const material = agentCandidateProfilePlanMaterial(plan);
1649
- const bytes = canonicalCandidateBytes(material);
1650
- const digest = canonicalCandidateDigest(material);
1651
- return { material, bytes, digest };
1652
- }
1653
- /** Capture portable evidence of the exact canonical profile-plan bytes. */
1654
- export function captureAgentCandidateProfilePlan(plan) {
1655
- const canonical = canonicalizeAgentCandidateProfilePlan(plan);
1656
- const artifact = {
1657
- encoding: "base64",
1658
- content: Buffer.from(canonical.bytes).toString("base64"),
1659
- sha256: canonical.digest,
1660
- byteLength: canonical.bytes.byteLength,
1661
- };
1662
- return agentCandidateProfilePlanEvidenceSchema.parse({
1663
- kind: "agent-profile-workspace-plan",
1664
- digest: canonical.digest,
1665
- material: canonical.material,
1666
- artifact,
1667
- });
1668
- }
1669
- /**
1670
- * Apply a candidate plan and return its spawn values together with captured
1671
- * canonical evidence. No caller needs to re-serialize or reinterpret the plan.
1672
- */
1673
- export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorkspace) {
1674
- assertWorkspacePlanSupported(plan);
1675
- const profilePlan = captureAgentCandidateProfilePlan(plan);
1676
- const activationFiles = normalizePlanFiles(plan.files).map((file) => ({
1677
- path: file.relPath,
1678
- mode: file.mode,
1679
- content: file.content,
1680
- }));
1681
- const activationMaterial = {
1682
- kind: "agent-candidate-profile-activation",
1683
- profilePlan,
1684
- files: activationFiles,
1685
- };
1686
- const profileActivation = agentCandidateProfileActivationSchema.parse({
1687
- ...activationMaterial,
1688
- digest: canonicalCandidateDigest(activationMaterial),
1689
- });
1690
- const applied = applyWorkspacePlan({
1691
- harness: plan.harness,
1692
- files: plan.files,
1693
- env: Object.fromEntries(Object.entries(plan.env).map(([name, value]) => [
1694
- name,
1695
- candidatePublicString(value),
1696
- ])),
1697
- flags: plan.flags.map(candidatePublicString),
1698
- ...promptIntentFields(plan, candidatePublicString),
1699
- unsupported: plan.unsupported,
1700
- }, workspaceDir);
1701
- return {
1702
- profileActivation,
1703
- application: {
1704
- planDigest: profilePlan.digest,
1705
- targetWorkspace,
1706
- mountPaths: applied.written,
1707
- },
1708
- env: { ...plan.env },
1709
- flags: [...plan.flags],
1710
- ...promptIntentFields(plan, samePromptIntentValue),
1711
- unsupported: [...plan.unsupported],
1712
- };
1713
- }
1714
- /**
1715
- * Apply a public-only WorkspacePlan. A plan with any secret reference fails
1716
- * before the first write; private executors use
1717
- * {@link applyWorkspacePlanWithSecretProvider} instead.
1718
- */
1719
- export function applyWorkspacePlan(plan, workspaceDir, options = {}) {
1720
- return applyResolvedWorkspacePlan(plan, resolvePublicWorkspacePlan(plan), workspaceDir, options).receipt;
1721
- }
1722
- /**
1723
- * Resolve opaque profile references through an executor-owned provider, then
1724
- * apply the resulting private native files and launch inputs. Resolution
1725
- * completes before any workspace write, and resolved values never enter the
1726
- * public plan, digest, receipt, logs, or error messages.
1727
- */
1728
- export async function applyWorkspacePlanWithSecretProvider(plan, workspaceDir, secretProvider, options = {}) {
1729
- const planDigest = hashWorkspacePlan(plan);
1730
- const resolved = await resolvePrivateWorkspacePlan(plan, secretProvider);
1731
- return applyResolvedWorkspacePlan(plan, resolved, workspaceDir, options, planDigest);
1732
- }
1733
- function secretRefLabel(ref) {
1734
- return JSON.stringify(ref.key);
1735
- }
1736
- function requireResolvedSecretValue(ref, value) {
1737
- if (typeof value !== "string" || value.trim().length === 0) {
1738
- throw new Error(`AgentProfile secret provider returned no value for ${secretRefLabel(ref)}`);
1739
- }
1740
- return value;
1741
- }
1742
- function setValueAtJsonPath(root, path, value) {
1743
- let current = root;
1744
- for (const segment of path.slice(0, -1)) {
1745
- if (current === null ||
1746
- typeof current !== "object" ||
1747
- !Object.hasOwn(current, segment)) {
1748
- throw new Error("Generated profile JSON secret path no longer exists");
1749
- }
1750
- current = current[segment];
1751
- }
1752
- const finalSegment = path.at(-1);
1753
- if (finalSegment === undefined ||
1754
- current === null ||
1755
- typeof current !== "object" ||
1756
- !Object.hasOwn(current, finalSegment)) {
1757
- throw new Error("Generated profile JSON secret path no longer exists");
1758
- }
1759
- Object.defineProperty(current, finalSegment, {
1760
- value,
1761
- enumerable: true,
1762
- configurable: true,
1763
- writable: true,
1764
- });
1765
- }
1766
- function resolvePublicWorkspacePlan(plan) {
1767
- const files = normalizePlanFiles(plan.files);
1768
- for (const file of files) {
1769
- const ref = file.secretSlots[0]?.ref;
1770
- if (ref) {
1771
- throw new Error(`Workspace plan requires an AgentProfileSecretProvider for ${secretRefLabel(ref)}`);
1772
- }
1773
- }
1774
- const env = Object.fromEntries(Object.entries(plan.env).map(([name, value]) => {
1775
- if (typeof value !== "string") {
1776
- throw new Error(`Workspace plan requires an AgentProfileSecretProvider for ${secretRefLabel(value)}`);
1777
- }
1778
- return [name, value];
1779
- }));
1780
- const flags = plan.flags.map((argument) => {
1781
- const normalized = normalizeWorkspacePlanArgument(argument);
1782
- if (typeof normalized !== "string") {
1783
- const segment = normalized.segments.find((value) => typeof value !== "string");
1784
- if (!segment) {
1785
- throw new Error("Workspace plan argument template is malformed");
1786
- }
1787
- throw new Error(`Workspace plan requires an AgentProfileSecretProvider for ${secretRefLabel(segment.ref)}`);
1788
- }
1789
- return normalized;
1790
- });
1791
- return {
1792
- files,
1793
- env,
1794
- flags,
1795
- ...promptIntentFields(plan, samePromptIntentValue),
1796
- ...systemPromptBindingField(plan),
1797
- };
1798
- }
1799
- async function resolvePrivateWorkspacePlan(plan, secretProvider) {
1800
- if (!secretProvider || typeof secretProvider.get !== "function") {
1801
- throw new Error("AgentProfileSecretProvider.get is required");
1802
- }
1803
- const pending = new Map();
1804
- const resolveRef = async (input) => {
1805
- const ref = agentProfileSecretRefSchema.parse(input);
1806
- let raw = pending.get(ref.key);
1807
- if (!raw) {
1808
- raw = (async () => {
1809
- let value;
1810
- try {
1811
- value = await secretProvider.get(ref.key);
1812
- }
1813
- catch {
1814
- throw new Error(`AgentProfile secret provider failed for ${secretRefLabel(ref)}`);
1815
- }
1816
- return requireResolvedSecretValue(ref, value);
1817
- })();
1818
- pending.set(ref.key, raw);
1819
- }
1820
- const value = await raw;
1821
- return ref.format === "bearer" ? `Bearer ${value}` : value;
1822
- };
1823
- const files = await Promise.all(normalizePlanFiles(plan.files).map(async (file) => {
1824
- if (file.secretSlots.length === 0)
1825
- return file;
1826
- const parsed = JSON.parse(file.content);
1827
- const values = await Promise.all(file.secretSlots.map(async (slot) => ({
1828
- path: slot.path,
1829
- value: await resolveRef(slot.ref),
1830
- })));
1831
- for (const resolved of values) {
1832
- setValueAtJsonPath(parsed, resolved.path, resolved.value);
1833
- }
1834
- return {
1835
- ...file,
1836
- content: JSON.stringify(parsed, null, 2),
1837
- secretSlots: [],
1838
- };
1839
- }));
1840
- const env = Object.fromEntries(await Promise.all(Object.entries(plan.env).map(async ([name, value]) => [
1841
- name,
1842
- typeof value === "string" ? value : await resolveRef(value),
1843
- ])));
1844
- const flags = await Promise.all(plan.flags.map(async (argument) => {
1845
- const normalized = normalizeWorkspacePlanArgument(argument);
1846
- if (typeof normalized === "string")
1847
- return normalized;
1848
- const segments = await Promise.all(normalized.segments.map(async (segment) => {
1849
- if (typeof segment === "string")
1850
- return segment;
1851
- const value = await resolveRef(segment.ref);
1852
- return segment.encoding === "json-string"
1853
- ? JSON.stringify(value)
1854
- : value;
1855
- }));
1856
- return segments.join("");
1857
- }));
1858
- return {
1859
- files,
1860
- env,
1861
- flags,
1862
- ...promptIntentFields(plan, samePromptIntentValue),
1863
- ...systemPromptBindingField(plan),
1864
- };
1865
- }
1866
- function safeWorkspacePlanReceipt(plan, written, workspacePlanDigest = hashWorkspacePlan(plan)) {
1867
- return {
1868
- workspacePlanDigest,
1869
- env: Object.fromEntries(Object.entries(plan.env).map(([name, value]) => [
1870
- name,
1871
- typeof value === "string" ? value : { ...value },
1872
- ])),
1873
- flags: plan.flags.map(normalizeWorkspacePlanArgument),
1874
- ...promptIntentFields(plan, samePromptIntentValue),
1875
- ...systemPromptBindingField(plan),
1876
- unsupported: plan.unsupported.map((entry) => ({ ...entry })),
1877
- written: [...written],
1878
- };
1879
- }
1880
- function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDir, options, publicPlanDigest = hashWorkspacePlan(publicPlan)) {
1881
- const workspaceRoot = resolve(workspaceDir);
1882
- const runtimeOnlyFiles = new Set((options.runtimeOnlyFiles ?? []).map(normalizeWorkspaceRelativePath));
1883
- const existingFiles = options.existingFiles ?? "allow-identical";
1884
- if (existingFiles !== "allow-identical" && existingFiles !== "reject") {
1885
- throw new Error(`Unsupported existing workspace file policy: ${existingFiles}`);
1886
- }
1887
- const resolvedFiles = resolvedPlan.files;
1888
- const privateRuntimeFiles = Object.fromEntries(resolvedFiles
1889
- .filter((file) => runtimeOnlyFiles.has(file.relPath))
1890
- .map((file) => [file.relPath, file.content]));
1891
- const files = resolvedFiles
1892
- .filter((file) => !runtimeOnlyFiles.has(file.relPath))
1893
- .map((file) => ({
1894
- ...file,
1895
- absPath: resolveWorkspaceFilePath(workspaceRoot, file.relPath),
1896
- }));
1897
- // A prior application of THIS EXACT plan makes the workspace the running
1898
- // agent's live state: a file the agent edited or removed since then is the
1899
- // session's own memory, not a conflict, so a byte comparison against the
1900
- // plan would refuse a resume the caller proved is unchanged. The digest the
1901
- // caller passes is its own record of what it already applied here; only the
1902
- // caller can know that, which is why this is an option and not an inference
1903
- // from disk. `reject` is not short-circuited: that policy asserts an empty
1904
- // target and must keep failing on any pre-existing path.
1905
- if (existingFiles === "allow-identical" &&
1906
- options.appliedPlanDigest !== undefined &&
1907
- options.appliedPlanDigest === publicPlanDigest) {
1908
- return {
1909
- receipt: safeWorkspacePlanReceipt(publicPlan, files.map((file) => file.relPath), publicPlanDigest),
1910
- spawn: {
1911
- env: { ...resolvedPlan.env },
1912
- flags: [...resolvedPlan.flags],
1913
- ...promptIntentFields(resolvedPlan, samePromptIntentValue),
1914
- ...systemPromptBindingField(resolvedPlan),
1915
- },
1916
- runtimeOnlyFiles: privateRuntimeFiles,
1917
- };
1918
- }
1919
- // A path this package invented can only hold this package's own earlier
1920
- // output, so re-applying into the same workspace replaces it rather than
1921
- // failing the turn. `reject` still refuses: that policy is "nothing may
1922
- // pre-exist here", which is a statement about the workspace, not authorship.
1923
- const mayReplace = (file) => existingFiles === "allow-identical" && file.ownership === "materializer";
1924
- // Validate the complete plan before the first write so a late traversal or
1925
- // existing symlink cannot leave a partially materialized workspace.
1926
- for (const file of files) {
1927
- assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
1928
- if (workspaceFileExists(file.absPath) &&
1929
- !mayReplace(file) &&
1930
- (existingFiles === "reject" || !workspaceFileMatches(file))) {
1931
- throw new Error(workspaceFileConflictMessage(file, existingFiles, publicPlanDigest));
1932
- }
1933
- }
1934
- // Parent directories remain on failure because they may contain concurrent
1935
- // workspace state that this call does not own.
1936
- for (const file of files) {
1937
- mkdirSync(dirname(file.absPath), { recursive: true });
1938
- }
1939
- for (const file of files) {
1940
- assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
1941
- }
1942
- const prepared = [];
1943
- // Prepared temp links stay alive through publication and rollback. Their
1944
- // inodes distinguish files created here from concurrent replacements.
1945
- try {
1946
- for (const file of files) {
1947
- if (existingFiles === "allow-identical" &&
1948
- workspaceFileExists(file.absPath) &&
1949
- workspaceFileMatches(file)) {
1950
- continue;
1951
- }
1952
- prepared.push(prepareWorkspaceFile(workspaceRoot, file));
1953
- }
1954
- for (const entry of prepared) {
1955
- assertNoSymlinkPathComponents(workspaceRoot, entry.file.absPath);
1956
- try {
1957
- linkSync(entry.temporaryPath, entry.file.absPath);
1958
- entry.published = true;
1959
- }
1960
- catch (error) {
1961
- if (isFsError(error, "EEXIST") &&
1962
- existingFiles === "allow-identical" &&
1963
- workspaceFileMatches(entry.file)) {
1964
- continue;
1965
- }
1966
- if (isFsError(error, "EEXIST") && mayReplace(entry.file)) {
1967
- // rename() replaces in one step, so no window exists in which the
1968
- // path is missing and a concurrent spawn reads nothing. The staged
1969
- // inode moves onto the path, which is what rollback identifies the
1970
- // file by, so an abort still removes exactly what this call
1971
- // published — leaving the path ABSENT rather than restored to the
1972
- // superseded bytes. That is the fail-closed direction: the harness
1973
- // whose flag names a missing file refuses to start, whereas silently
1974
- // restored previous-turn bytes would run the wrong prompt.
1975
- assertNoSymlinkPathComponents(workspaceRoot, entry.file.absPath);
1976
- renameSync(entry.temporaryPath, entry.file.absPath);
1977
- entry.published = true;
1978
- continue;
1979
- }
1980
- if (isFsError(error, "EEXIST")) {
1981
- throw new Error(workspaceFileConflictMessage(entry.file, existingFiles, publicPlanDigest), { cause: error });
1982
- }
1983
- throw error;
1984
- }
1985
- }
1986
- }
1987
- catch (error) {
1988
- const cleanupErrors = [];
1989
- for (const entry of [...prepared].reverse()) {
1990
- try {
1991
- if (entry.published &&
1992
- workspacePathHasIdentity(entry.file.absPath, entry.identity)) {
1993
- unlinkSync(entry.file.absPath);
1994
- }
1995
- }
1996
- catch (cleanupError) {
1997
- cleanupErrors.push(cleanupError);
1998
- }
1999
- try {
2000
- if (workspacePathHasIdentity(entry.temporaryPath, entry.identity)) {
2001
- unlinkSync(entry.temporaryPath);
2002
- }
2003
- }
2004
- catch (cleanupError) {
2005
- cleanupErrors.push(cleanupError);
2006
- }
2007
- }
2008
- if (cleanupErrors.length > 0) {
2009
- throw new AggregateError([error, ...cleanupErrors], "Failed to apply workspace plan and roll back published files");
2010
- }
2011
- throw error;
2012
- }
2013
- const receipt = safeWorkspacePlanReceipt(publicPlan, files.map((file) => file.relPath), publicPlanDigest);
2014
- const cleanupErrors = [];
2015
- const temporaryPaths = [];
2016
- for (const entry of prepared) {
2017
- try {
2018
- unlinkSync(entry.temporaryPath);
2019
- }
2020
- catch (error) {
2021
- if (!isFsError(error, "ENOENT")) {
2022
- cleanupErrors.push(error);
2023
- temporaryPaths.push(entry.temporaryPath);
2024
- }
2025
- }
2026
- }
2027
- if (cleanupErrors.length > 0) {
2028
- throw new WorkspacePlanCleanupError(cleanupErrors, receipt, temporaryPaths);
2029
- }
2030
- return {
2031
- receipt,
2032
- spawn: {
2033
- env: { ...resolvedPlan.env },
2034
- flags: [...resolvedPlan.flags],
2035
- ...promptIntentFields(resolvedPlan, samePromptIntentValue),
2036
- ...systemPromptBindingField(resolvedPlan),
2037
- },
2038
- runtimeOnlyFiles: privateRuntimeFiles,
2039
- };
2040
- }
2041
- function workspaceFileExists(pathValue) {
2042
- try {
2043
- lstatSync(pathValue);
2044
- return true;
2045
- }
2046
- catch (error) {
2047
- if (isFsError(error, "ENOENT"))
2048
- return false;
2049
- throw error;
2050
- }
2051
- }
2052
- function workspaceFileMatches(file) {
2053
- const stats = lstatSync(file.absPath);
2054
- return (stats.isFile() &&
2055
- (stats.mode & 0o777) === file.mode &&
2056
- readFileSync(file.absPath, "utf8") === file.content);
2057
- }
2058
- function sha256Hex(content) {
2059
- return createHash("sha256").update(content).digest("hex");
2060
- }
2061
- /**
2062
- * The refusal names what differs — planned versus existing content digest,
2063
- * mode, or file kind — plus the plan digest, so the operator can tell a
2064
- * mid-session profile swap from an agent-edited workspace file without
2065
- * reproducing the workspace. Reads here are best-effort diagnosis inside an
2066
- * already-failing path; an unreadable existing file is reported, not thrown
2067
- * over.
2068
- */
2069
- function workspaceFileConflictMessage(file, existingFiles, planDigest) {
2070
- const prefix = `Refusing to replace existing workspace file: ${file.relPath}`;
2071
- if (existingFiles === "reject") {
2072
- return `${prefix} — the reject policy forbids any pre-existing plan path (plan ${planDigest})`;
2073
- }
2074
- const planned = `planned sha256:${sha256Hex(file.content)} mode ${file.mode.toString(8)}`;
2075
- let existing;
2076
- try {
2077
- const stats = lstatSync(file.absPath);
2078
- if (!stats.isFile()) {
2079
- existing = stats.isSymbolicLink()
2080
- ? "existing path is a symlink, not a regular file"
2081
- : "existing path is not a regular file";
2082
- }
2083
- else {
2084
- const mode = (stats.mode & 0o777).toString(8);
2085
- existing = `existing sha256:${sha256Hex(readFileSync(file.absPath))} mode ${mode}`;
2086
- }
2087
- }
2088
- catch (error) {
2089
- const message = error instanceof Error ? error.message : String(error);
2090
- existing = `existing file unreadable: ${message}`;
2091
- }
2092
- return `${prefix} — ${planned} != ${existing} (plan ${planDigest})`;
2093
- }
2094
- function prepareWorkspaceFile(workspaceRoot, file) {
2095
- assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
2096
- const temporaryPath = join(dirname(file.absPath), `.agent-profile-${randomUUID()}.tmp`);
2097
- let descriptor;
2098
- try {
2099
- descriptor = openSync(temporaryPath, SAFE_WRITE_FLAGS, 0o600);
2100
- writeFileSync(descriptor, file.content, "utf8");
2101
- fchmodSync(descriptor, file.mode);
2102
- const stats = fstatSync(descriptor);
2103
- const descriptorToClose = descriptor;
2104
- descriptor = undefined;
2105
- closeSync(descriptorToClose);
2106
- return {
2107
- file,
2108
- temporaryPath,
2109
- identity: { device: stats.dev, inode: stats.ino },
2110
- published: false,
2111
- };
2112
- }
2113
- catch (error) {
2114
- const cleanupErrors = [];
2115
- if (descriptor !== undefined) {
2116
- try {
2117
- closeSync(descriptor);
2118
- }
2119
- catch (cleanupError) {
2120
- cleanupErrors.push(cleanupError);
2121
- }
2122
- }
2123
- try {
2124
- unlinkSync(temporaryPath);
2125
- }
2126
- catch (cleanupError) {
2127
- if (!isFsError(cleanupError, "ENOENT"))
2128
- cleanupErrors.push(cleanupError);
2129
- }
2130
- if (cleanupErrors.length > 0) {
2131
- throw new AggregateError([error, ...cleanupErrors], `Failed to prepare workspace file: ${file.relPath}`);
2132
- }
2133
- throw error;
2134
- }
2135
- }
2136
- function workspacePathHasIdentity(pathValue, identity) {
2137
- try {
2138
- const stats = lstatSync(pathValue);
2139
- return stats.dev === identity.device && stats.ino === identity.inode;
2140
- }
2141
- catch (error) {
2142
- if (isFsError(error, "ENOENT"))
2143
- return false;
2144
- throw error;
2145
- }
2146
- }
2147
- function resolveWorkspaceFilePath(workspaceRoot, relativePath) {
2148
- const targetPath = resolve(workspaceRoot, ...relativePath.split("/"));
2149
- const targetRelativePath = relative(workspaceRoot, targetPath);
2150
- if (!targetRelativePath ||
2151
- targetRelativePath === ".." ||
2152
- targetRelativePath.startsWith(`..${sep}`) ||
2153
- isAbsolute(targetRelativePath)) {
2154
- throw new Error(`Refusing to materialize profile file outside workspace: ${relativePath}`);
2155
- }
2156
- return targetPath;
2157
- }
2158
- function assertNoSymlinkPathComponents(workspaceRoot, targetPath) {
2159
- const targetRelativePath = relative(workspaceRoot, targetPath);
2160
- let currentPath = workspaceRoot;
2161
- const components = ["", ...targetRelativePath.split(sep)];
2162
- for (const [index, component] of components.entries()) {
2163
- if (component)
2164
- currentPath = join(currentPath, component);
2165
- let stats;
2166
- try {
2167
- stats = lstatSync(currentPath);
2168
- }
2169
- catch (error) {
2170
- if (isFsError(error, "ENOENT"))
2171
- return;
2172
- if (isFsError(error, "ELOOP")) {
2173
- throw new Error(`Refusing to materialize profile file through symlink path: ${targetRelativePath}`);
2174
- }
2175
- throw error;
2176
- }
2177
- if (stats.isSymbolicLink()) {
2178
- throw new Error(`Refusing to materialize profile file through symlink path: ${targetRelativePath}`);
2179
- }
2180
- const isTarget = index === components.length - 1;
2181
- if ((!isTarget && !stats.isDirectory()) || (isTarget && !stats.isFile())) {
2182
- throw new Error(`Refusing to materialize profile file through non-file path: ${targetRelativePath}`);
2183
- }
2184
- }
2185
- }
2186
- function isFsError(error, code) {
2187
- return (typeof error === "object" &&
2188
- error !== null &&
2189
- "code" in error &&
2190
- error.code === code);
2191
- }
2192
- // ─── Per-dimension routing (verified matrix) ────────────────────────────────
2193
- function materializeExecutionControls(h, tools, permissions, modes, plan, add, unsupported) {
2194
- const toolEntries = Object.entries(tools ?? {}).sort(([left], [right]) => compareStrings(left, right));
2195
- const permissionEntries = Object.entries(permissions ?? {}).sort(([left], [right]) => compareStrings(left, right));
2196
- switch (h) {
2197
- case "claude-code": {
2198
- if (toolEntries.length > 0 || permissionEntries.length > 0) {
2199
- const nativeTools = mapSupportedBooleanEntries(toolEntries, claudeToolName, (name) => unsupported("tools", `claude-code: no verified native tool mapping for "${name}"`), "claude-code tool");
2200
- const nativePermissions = mapSupportedPermissionEntries(permissionEntries, claudeToolName, (name) => unsupported("permissions", `claude-code: no verified native permission mapping for "${name}"`), "claude-code permission");
2201
- const rules = claudePermissionRules(nativePermissions);
2202
- for (const [tool, enabled] of nativeTools) {
2203
- rules[enabled ? "allow" : "deny"].push(tool);
2204
- }
2205
- add(CLAUDE_SETTINGS_PATH, serializeWorkspacePlanJson({
2206
- permissions: sealClaudePermissionRules(rules),
2207
- }));
2208
- registerClaudeSettings(plan);
2209
- }
2210
- break;
2211
- }
2212
- case "opencode": {
2213
- const enforceableTools = toolEntries.filter(([name, enabled]) => {
2214
- if (normalizedControlName(name) !== "lsp" || !enabled)
2215
- return true;
2216
- unsupported("tools", "opencode: the LSP tool is disabled by the runtime and cannot be enabled exactly");
2217
- return false;
2218
- });
2219
- const enforceablePermissions = permissionEntries.filter(([name, policy]) => {
2220
- if (normalizedControlName(name) !== "read")
2221
- return true;
2222
- if (!permissionAllowsEverything(policy)) {
2223
- unsupported("permissions", "opencode: read access cannot be denied or approval-gated");
2224
- }
2225
- return false;
2226
- });
2227
- const nativeTools = mapSupportedBooleanEntries(enforceableTools, openCodeToolName, (name) => unsupported("tools", `opencode: no verified native tool mapping for "${name}"`), "opencode tool");
2228
- const nativePermissions = mapSupportedPermissionEntries(enforceablePermissions, openCodePermissionName, (name) => unsupported("permissions", `opencode: no verified native permission mapping for "${name}"`), "opencode permission");
2229
- if (enforceableTools.length > 0 || permissionEntries.length > 0) {
2230
- add("opencode.json", serializeWorkspacePlanJson({
2231
- $schema: "https://opencode.ai/config.json",
2232
- ...(nativeTools.length > 0
2233
- ? { tools: Object.fromEntries(nativeTools) }
2234
- : {}),
2235
- ...(nativePermissions.length > 0
2236
- ? { permission: Object.fromEntries(nativePermissions) }
2237
- : {}),
2238
- }));
2239
- }
2240
- break;
2241
- }
2242
- case "codex": {
2243
- const searchEntries = [];
2244
- for (const [name, enabled] of toolEntries) {
2245
- const posture = resolveNativeWebTools({ [name]: enabled });
2246
- if (posture.search === enabled && posture.fetch === undefined) {
2247
- searchEntries.push([name, enabled]);
2248
- continue;
2249
- }
2250
- if (posture.fetch === enabled && posture.search === undefined) {
2251
- if (enabled) {
2252
- unsupported("tools", `codex: native web fetch is unavailable for "${name}"`);
2253
- }
2254
- continue;
2255
- }
2256
- unsupported("tools", `codex: no verified primary-process tool mapping for "${name}"`);
2257
- }
2258
- const nativeSearch = mapSupportedBooleanEntries(searchEntries, () => "web_search", () => undefined, "codex tool");
2259
- if (nativeSearch[0]) {
2260
- plan.flags.push("-c", `tools.web_search=${nativeSearch[0][1]}`);
2261
- }
2262
- if (permissionEntries.length > 0) {
2263
- unsupported("permissions", "codex: no verified primary-process permission policy mapping");
2264
- }
2265
- break;
2266
- }
2267
- case "pi": {
2268
- const nativeTools = mapSupportedBooleanEntries(toolEntries, piToolId, (name) => unsupported("tools", `pi: tool name "${name}" is empty after trimming`), "pi tool");
2269
- const nativePermissions = mapSupportedPermissionEntries(permissionEntries, piToolId, (name) => unsupported("permissions", `pi: permission name "${name}" is empty after trimming`), "pi permission");
2270
- const enabled = new Set(nativeTools
2271
- .filter(([, value]) => value)
2272
- .map(([name]) => name)
2273
- .sort(compareStrings));
2274
- const excluded = new Set(nativeTools
2275
- .filter(([, value]) => !value)
2276
- .map(([name]) => name)
2277
- .sort(compareStrings));
2278
- for (const [name, policy] of nativePermissions) {
2279
- if (typeof policy !== "string") {
2280
- unsupported("permissions", `pi: conditional permission policy for "${name}" has no verified headless mapping`);
2281
- continue;
2282
- }
2283
- if (policy === "ask") {
2284
- unsupported("permissions", `pi: permission policy "ask" for "${name}" requires an interactive decision surface`);
2285
- continue;
2286
- }
2287
- if (policy === "allow") {
2288
- if (excluded.has(name)) {
2289
- throw new Error(`pi execution control "${name}" conflicts between disabled and allow`);
2290
- }
2291
- continue;
2292
- }
2293
- if (enabled.has(name)) {
2294
- throw new Error(`pi execution control "${name}" conflicts between enabled and deny`);
2295
- }
2296
- excluded.add(name);
2297
- }
2298
- if (enabled.size > 0 || excluded.size > 0) {
2299
- add(PI_EXECUTION_CONTROL_EXTENSION_PATH, piExecutionControlExtensionSource([...enabled], [...excluded]));
2300
- plan.flags.push("--extension", PI_EXECUTION_CONTROL_EXTENSION_PATH);
2301
- }
2302
- if (excluded.size > 0) {
2303
- plan.flags.push("--exclude-tools", [...excluded].sort(compareStrings).join(","));
2304
- }
2305
- break;
2306
- }
2307
- default:
2308
- if (toolEntries.length > 0) {
2309
- unsupported("tools", `${h}: no verified primary-process tool availability mapping`);
2310
- }
2311
- if (permissionEntries.length > 0) {
2312
- unsupported("permissions", `${h}: no verified primary-process permission policy mapping`);
2313
- }
2314
- }
2315
- if (modes && Object.keys(modes).length > 0) {
2316
- unsupported("modes", `${h}: resolve one named AgentProfile mode before materialization`);
2317
- }
2318
- }
2319
- function piExecutionControlExtensionSource(enabledTools, excludedTools) {
2320
- return [
2321
- 'import { writeSync } from "node:fs";',
2322
- `const enabledTools = Object.freeze(${JSON.stringify(enabledTools)});`,
2323
- `const excludedTools = Object.freeze(${JSON.stringify(excludedTools)});`,
2324
- "",
2325
- "function failProfile(message) {",
2326
- " try {",
2327
- ' writeSync(2, "Pi profile execution controls failed: " + message + "\\n");',
2328
- " } finally {",
2329
- " process.exit(78);",
2330
- " }",
2331
- "}",
2332
- "",
2333
- "function resolveTool(reference, available, required) {",
2334
- " if (available.includes(reference)) return reference;",
2335
- " const folded = reference.toLowerCase();",
2336
- " const matches = available.filter((name) => name.toLowerCase() === folded);",
2337
- " if (matches.length === 1) return matches[0];",
2338
- " if (matches.length > 1) {",
2339
- " failProfile('tool \"' + reference + '\" is ambiguous: ' + matches.join(\", \"));",
2340
- " }",
2341
- " if (required) {",
2342
- " failProfile('requested tool \"' + reference + '\" is unavailable');",
2343
- " }",
2344
- " return undefined;",
2345
- "}",
2346
- "",
2347
- "function applyControls(pi) {",
2348
- " const available = pi.getAllTools().map((tool) => tool.name);",
2349
- " for (const reference of excludedTools) {",
2350
- " const remaining = resolveTool(reference, available, false);",
2351
- " if (remaining !== undefined) {",
2352
- " failProfile(",
2353
- " 'excluded tool \"' + reference + '\" remains available as \"' + remaining +",
2354
- " '\"; pass its exact registered Pi name',",
2355
- " );",
2356
- " }",
2357
- " }",
2358
- " const enabled = enabledTools.map((name) =>",
2359
- " resolveTool(name, available, true),",
2360
- " );",
2361
- " const active = new Set(pi.getActiveTools());",
2362
- " for (const name of enabled) active.add(name);",
2363
- " pi.setActiveTools([...active]);",
2364
- "}",
2365
- "",
2366
- "export default function applyProfileExecutionControls(pi) {",
2367
- ' pi.on("before_agent_start", () => applyControls(pi));',
2368
- ' pi.on("turn_start", () => applyControls(pi));',
2369
- "}",
2370
- "",
2371
- ].join("\n");
2372
- }
2373
- function normalizedControlName(name) {
2374
- return name.replace(/[^a-zA-Z0-9_]/g, "").toLowerCase();
2375
- }
2376
- function nativeWebToolKind(name) {
2377
- const posture = resolveNativeWebTools({ [name]: true });
2378
- if (posture.search === true && posture.fetch === undefined)
2379
- return "search";
2380
- if (posture.fetch === true && posture.search === undefined)
2381
- return "fetch";
2382
- return undefined;
2383
- }
2384
- const CLAUDE_TOOL_NAMES = {
2385
- askuserquestion: "AskUserQuestion",
2386
- bash: "Bash",
2387
- edit: "Edit",
2388
- glob: "Glob",
2389
- grep: "Grep",
2390
- notebookedit: "NotebookEdit",
2391
- read: "Read",
2392
- skill: "Skill",
2393
- task: "Task",
2394
- taskoutput: "TaskOutput",
2395
- todowrite: "TodoWrite",
2396
- webfetch: "WebFetch",
2397
- websearch: "WebSearch",
2398
- write: "Write",
2399
- };
2400
- function claudeToolName(name) {
2401
- const web = nativeWebToolKind(name);
2402
- if (web === "search")
2403
- return "WebSearch";
2404
- if (web === "fetch")
2405
- return "WebFetch";
2406
- return CLAUDE_TOOL_NAMES[normalizedControlName(name)];
2407
- }
2408
- /** Tool IDs reported by the OpenCode binary shipped in the Sandbox image. */
2409
- export const OPENCODE_NATIVE_TOOL_IDS = [
2410
- "apply_patch",
2411
- "bash",
2412
- "codesearch",
2413
- "edit",
2414
- "glob",
2415
- "grep",
2416
- "question",
2417
- "read",
2418
- "skill",
2419
- "task",
2420
- "todowrite",
2421
- "webfetch",
2422
- "websearch",
2423
- "write",
2424
- ];
2425
- const OPENCODE_NATIVE_TOOL_ID_SET = new Set(OPENCODE_NATIVE_TOOL_IDS);
2426
- /** Resolve portable and legacy profile names to current OpenCode tool IDs. */
2427
- export function openCodeNativeToolId(name) {
2428
- const web = nativeWebToolKind(name);
2429
- if (web === "search")
2430
- return "websearch";
2431
- if (web === "fetch")
2432
- return "webfetch";
2433
- const normalized = normalizedControlName(name);
2434
- if (normalized === "patch")
2435
- return "apply_patch";
2436
- return OPENCODE_NATIVE_TOOL_ID_SET.has(normalized) ? normalized : undefined;
2437
- }
2438
- function openCodeToolName(name) {
2439
- return openCodeNativeToolId(name);
2440
- }
2441
- function openCodePermissionName(name) {
2442
- const normalized = normalizedControlName(name);
2443
- if (["write", "edit"].includes(normalized))
2444
- return "edit";
2445
- const web = nativeWebToolKind(name);
2446
- if (web === "search")
2447
- return "websearch";
2448
- if (web === "fetch")
2449
- return "webfetch";
2450
- if (["bash", "mcp", "doom_loop", "external_directory"].includes(normalized)) {
2451
- return normalized;
2452
- }
2453
- return normalized !== "read" && OPENCODE_NATIVE_TOOL_ID_SET.has(normalized)
2454
- ? normalized
2455
- : undefined;
2456
- }
2457
- const PI_EXECUTION_CONTROL_EXTENSION_PATH = ".tangle/pi-execution-controls.ts";
2458
- /**
2459
- * Translate only portable spellings whose Pi equivalent differs.
2460
- *
2461
- * Every other exact name is resolved against Pi's live registry, keeping
2462
- * extension and MCP tools profile-configurable without a static catalog.
2463
- */
2464
- function piToolId(name) {
2465
- const trimmed = name.trim();
2466
- if (trimmed.length === 0)
2467
- return undefined;
2468
- const portableName = trimmed.toLowerCase();
2469
- if (portableName === "shell")
2470
- return "bash";
2471
- if (portableName === "glob")
2472
- return "find";
2473
- return trimmed;
2474
- }
2475
- function mapSupportedBooleanEntries(entries, mapName, onUnsupported, label) {
2476
- return mapControlEntries(entries, mapName, onUnsupported, (left, right, nativeName) => {
2477
- if (left !== right)
2478
- throw new Error(`${label} aliases disagree for "${nativeName}"`);
2479
- return left;
2480
- });
2481
- }
2482
- function permissionAllowsEverything(policy) {
2483
- return typeof policy === "string"
2484
- ? policy === "allow"
2485
- : Object.values(policy).every((value) => value === "allow");
2486
- }
2487
- function mapSupportedPermissionEntries(entries, mapName, onUnsupported, label) {
2488
- return mapControlEntries(entries, mapName, onUnsupported, (left, right, nativeName) => mergePermissionPolicies(left, right, `${label} "${nativeName}"`));
2489
- }
2490
- function mapControlEntries(entries, mapName, onUnsupported, merge) {
2491
- const mapped = new Map();
2492
- for (const [name, value] of entries) {
2493
- const nativeName = mapName(name);
2494
- if (!nativeName) {
2495
- onUnsupported(name);
2496
- continue;
2497
- }
2498
- const previous = mapped.get(nativeName);
2499
- mapped.set(nativeName, previous === undefined ? value : merge(previous, value, nativeName));
2500
- }
2501
- return [...mapped.entries()].sort(([left], [right]) => compareStrings(left, right));
2502
- }
2503
- function mergePermissionPolicies(left, right, label) {
2504
- if (typeof left === "string" || typeof right === "string") {
2505
- if (left !== right)
2506
- throw new Error(`${label} aliases disagree`);
2507
- return left;
2508
- }
2509
- const merged = { ...left };
2510
- for (const [specifier, decision] of Object.entries(right)) {
2511
- const previous = merged[specifier];
2512
- if (previous !== undefined && previous !== decision) {
2513
- throw new Error(`${label} aliases disagree for "${specifier}"`);
2514
- }
2515
- merged[specifier] = decision;
2516
- }
2517
- return Object.fromEntries(Object.entries(merged).sort(([leftKey], [rightKey]) => compareStrings(leftKey, rightKey)));
2518
- }
2519
- function claudePermissionRules(entries) {
2520
- const rules = {
2521
- allow: [],
2522
- ask: [],
2523
- deny: [],
2524
- };
2525
- for (const [tool, policy] of entries) {
2526
- if (typeof policy === "string") {
2527
- rules[policy].push(tool);
2528
- continue;
2529
- }
2530
- for (const [specifier, decision] of Object.entries(policy).sort(([left], [right]) => compareStrings(left, right))) {
2531
- rules[decision].push(specifier === "*" ? tool : `${tool}(${specifier})`);
2532
- }
2533
- }
2534
- rules.allow.sort(compareStrings);
2535
- rules.ask.sort(compareStrings);
2536
- rules.deny.sort(compareStrings);
2537
- return rules;
2538
- }
2539
- function sealClaudePermissionRules(rules) {
2540
- const owner = new Map();
2541
- for (const decision of ["allow", "ask", "deny"]) {
2542
- for (const rule of rules[decision]) {
2543
- const previous = owner.get(rule);
2544
- if (previous && previous !== decision) {
2545
- throw new Error(`claude-code execution control "${rule}" conflicts between ${previous} and ${decision}`);
2546
- }
2547
- owner.set(rule, decision);
2548
- }
2549
- }
2550
- return {
2551
- allow: [...new Set(rules.allow)].sort(compareStrings),
2552
- ask: [...new Set(rules.ask)].sort(compareStrings),
2553
- deny: [...new Set(rules.deny)].sort(compareStrings),
2554
- };
2555
- }
2556
- function materializeMcp(h, mcp, plan, add, unsupported) {
2557
- switch (h) {
2558
- case "claude-code": {
2559
- const servers = {};
2560
- for (const [name, server] of Object.entries(mcp)) {
2561
- if (server.url) {
2562
- servers[name] = {
2563
- type: server.transport ?? "http",
2564
- url: server.url,
2565
- ...(server.headers ? { headers: server.headers } : {}),
2566
- };
2567
- continue;
2568
- }
2569
- if (server.cwd !== undefined) {
2570
- unsupported("mcp", `${h}: local MCP server "${name}" does not support a cwd field`);
2571
- }
2572
- servers[name] = {
2573
- type: "stdio",
2574
- command: server.command,
2575
- ...(server.args ? { args: server.args } : {}),
2576
- ...(server.env ? { env: server.env } : {}),
2577
- };
2578
- }
2579
- const relPath = ".tangle/claude-mcp.json";
2580
- add(relPath, serializeWorkspacePlanJson({ mcpServers: servers }));
2581
- plan.flags.push("--mcp-config", relPath, "--strict-mcp-config");
2582
- break;
2583
- }
2584
- case "nanoclaw":
2585
- unsupported("mcp", "nanoclaw MCP must be injected through options.mcpServers by its SDK adapter");
2586
- break;
2587
- case "codex": {
2588
- const servers = [];
2589
- for (const [name, server] of Object.entries(mcp).sort(([left], [right]) => compareStrings(left, right))) {
2590
- if (server.url && server.transport === "sse") {
2591
- unsupported("mcp", `codex: remote MCP server "${name}" uses unsupported SSE transport`);
2592
- continue;
2593
- }
2594
- servers.push(concatWorkspacePlanArguments(`${tomlStr(name)} = `, codexMcpServerToml(name, server, plan)));
2595
- }
2596
- if (servers.length > 0) {
2597
- // Runtime overrides do not require persisted project trust. Header values
2598
- // ride environment variables so credentials never appear in argv.
2599
- plan.flags.push("-c", concatWorkspacePlanArguments("mcp_servers={ ", joinWorkspacePlanArguments(servers, ", "), " }"));
2600
- }
2601
- break;
2602
- }
2603
- case "opencode":
2604
- // opencode.json "mcp" block (local|remote).
2605
- add("opencode.json", serializeWorkspacePlanJson({
2606
- $schema: "https://opencode.ai/config.json",
2607
- mcp: openCodeMcpConfig(mcp),
2608
- }));
2609
- break;
2610
- case "gemini": {
2611
- const servers = {};
2612
- for (const [name, server] of Object.entries(mcp)) {
2613
- if (server.url) {
2614
- servers[name] = {
2615
- [server.transport === "sse" ? "url" : "httpUrl"]: server.url,
2616
- ...(server.headers ? { headers: server.headers } : {}),
2617
- };
2618
- continue;
2619
- }
2620
- servers[name] = {
2621
- command: server.command,
2622
- ...(server.args ? { args: server.args } : {}),
2623
- ...(server.env ? { env: server.env } : {}),
2624
- ...(server.cwd ? { cwd: server.cwd } : {}),
2625
- };
2626
- }
2627
- add(".gemini/settings.json", serializeWorkspacePlanJson({ mcpServers: servers }));
2628
- break;
2629
- }
2630
- case "kimi-code": {
2631
- // NO cwd auto-discovery — kimi needs an explicit --mcp-config-file FLAG.
2632
- const rel = ".kimi/mcp.json";
2633
- const servers = Object.fromEntries(Object.entries(mcp).map(([name, server]) => [
2634
- name,
2635
- kimiMcpServer(server),
2636
- ]));
2637
- add(rel, serializeWorkspacePlanJson({ mcpServers: servers }));
2638
- plan.flags.push("--mcp-config-file", rel);
2639
- break;
2640
- }
2641
- case "pi": {
2642
- // pi ships NO MCP. It arrives from the `pi-mcp-adapter` extension, which discovers
2643
- // `<cwd>/.pi/mcp.json` (adapter 2.17.0 config.ts:17) and also registers a `--mcp-config`
2644
- // flag (index.ts:252). The flag is registered BY the extension, so passing it when the
2645
- // extension is absent is not a fallback — it is an unknown flag.
2646
- //
2647
- // This was previously marked unsupported because a workspace file cannot install an
2648
- // extension. That reasoning was right about the constraint and wrong about the conclusion:
2649
- // the file half is perfectly materializable, and the half that is not becomes a declared
2650
- // requirement the executor enforces. Marking the whole dimension unsupported pushed both
2651
- // halves onto every consumer, and two of them independently rebuilt it.
2652
- const rel = ".pi/mcp.json";
2653
- const servers = Object.fromEntries(Object.entries(mcp).map(([name, server]) => [
2654
- name,
2655
- // `directTools` promotes this server's verbs to first-class pi tools. Without it the
2656
- // agent gets one generic `mcp` tool and must connect, then describe, then call — three
2657
- // round trips of discovery before it can do anything. Per-server, because the adapter
2658
- // lets a per-server value override the global `settings.directTools` (direct-tools.ts:135).
2659
- { ...piMcpServer(server), directTools: true },
2660
- ]));
2661
- // serializeWorkspacePlanJson, NOT JSON.stringify: it unwraps public config values to their
2662
- // bytes and lifts any secret-ref into a tracked slot the applier resolves. Stringifying
2663
- // straight would write `{"kind":"public","value":"..."}` into the file as an argv entry.
2664
- add(rel, serializeWorkspacePlanJson({ mcpServers: servers }));
2665
- plan.requires?.push({
2666
- kind: "harness-extension",
2667
- dimension: "mcp",
2668
- id: "npm:pi-mcp-adapter",
2669
- reason: "pi has no built-in MCP; without this extension the servers in .pi/mcp.json are never read and the agent starts with none of their tools",
2670
- remedy: "pi install npm:pi-mcp-adapter",
2671
- });
2672
- break;
2673
- }
2674
- case "prime":
2675
- // The fork's MCP config surface (`mcpServers` in <agentDir>/settings.json,
2676
- // fork settings-manager.ts:98-120,219) IS read at startup, but a
2677
- // user-declared server never becomes model-visible: the host McpManager
2678
- // only registers OAuth providers and serves mcp.config/refresh
2679
- // host-requests (fork core/mcp/mcp-manager.ts:62-124,156-181), skips
2680
- // stdio servers entirely (mcp-manager.ts:73), and nothing generates a
2681
- // skill or prompt section for it — materializing profile.mcp would be a
2682
- // silently dropped control.
2683
- unsupported("mcp", "prime: settings.json mcpServers never becomes model-visible (fork mcp-manager.ts:62-124 " +
2684
- "registers OAuth providers only and skips stdio servers) — model-visible MCP is exactly " +
2685
- "the fork's built-in catalog skills");
2686
- break;
2687
- case "hermes":
2688
- unsupported("mcp", "hermes MCP is global config.yaml (mcp_servers), no cwd discovery — write to the hermes global config or pass a config flag");
2689
- break;
2690
- case "openclaw":
2691
- unsupported("mcp", "openclaw MCP is central ~/.openclaw/openclaw.json (mcp.servers), no cwd discovery");
2692
- break;
2693
- }
2694
- }
2695
- function tomlInlineStringTable(values) {
2696
- return `{ ${Object.entries(values)
2697
- .sort(([left], [right]) => compareStrings(left, right))
2698
- .map(([key, value]) => `${tomlStr(key)} = ${tomlStr(value)}`)
2699
- .join(", ")} }`;
2700
- }
2701
- function codexMcpServerToml(name, server, plan) {
2702
- const fields = [];
2703
- if (server.url) {
2704
- fields.push(`url = ${tomlStr(server.url)}`);
2705
- if (server.headers && Object.keys(server.headers).length > 0) {
2706
- const envHeaders = {};
2707
- for (const [header, value] of Object.entries(server.headers).sort(([left], [right]) => compareStrings(left, right))) {
2708
- const envName = `TANGLE_MCP_HEADER_${sha256(`${name}\0${header}`)
2709
- .slice(0, 24)
2710
- .toUpperCase()}`;
2711
- setPlanEnvironment(plan, envName, value);
2712
- envHeaders[header] = envName;
2713
- }
2714
- fields.push(`env_http_headers = ${tomlInlineStringTable(envHeaders)}`);
2715
- }
2716
- }
2717
- else {
2718
- if (!server.command) {
2719
- throw new Error(`Codex MCP server "${name}" must define url or command`);
2720
- }
2721
- fields.push(`command = ${tomlStr(server.command)}`);
2722
- if (server.args?.length) {
2723
- fields.push(concatWorkspacePlanArguments("args = [", joinWorkspacePlanArguments(server.args.map(tomlProfileConfigValue), ", "), "]"));
2724
- }
2725
- if (server.cwd)
2726
- fields.push(`cwd = ${tomlStr(server.cwd)}`);
2727
- if (server.env && Object.keys(server.env).length > 0) {
2728
- const envNames = Object.keys(server.env).sort(compareStrings);
2729
- for (const envName of envNames) {
2730
- setPlanEnvironment(plan, envName, server.env[envName]);
2731
- }
2732
- fields.push(`env_vars = [${envNames.map(tomlStr).join(", ")}]`);
2733
- }
2734
- }
2735
- return concatWorkspacePlanArguments("{ ", joinWorkspacePlanArguments(fields, ", "), " }");
2736
- }
2737
- function setPlanEnvironment(plan, name, value) {
2738
- const normalized = typeof value === "string" ? value : workspacePlanConfigValue(value);
2739
- const previous = plan.env[name];
2740
- if (previous !== undefined &&
2741
- JSON.stringify(previous) !== JSON.stringify(normalized)) {
2742
- throw new Error(`profile environment values disagree for ${name}`);
2743
- }
2744
- plan.env[name] = normalized;
2745
- }
2746
- /**
2747
- * Lower one canonical MCP server to the shape `pi-mcp-adapter` reads from `.pi/mcp.json`.
2748
- *
2749
- * The adapter consumes the same `{mcpServers: {...}}` document Claude and Kimi use, so this
2750
- * mirrors {@link kimiMcpServer} rather than inventing a pi dialect. `directTools` is added by the
2751
- * caller, not here, because it is a policy about tool surfacing rather than a property of the
2752
- * server definition.
2753
- */
2754
- function piMcpServer(server) {
2755
- return kimiMcpServer(server);
2756
- }
2757
- function kimiMcpServer(server) {
2758
- if (server.url) {
2759
- return {
2760
- url: server.url,
2761
- transport: server.transport ?? "http",
2762
- ...(server.headers ? { headers: server.headers } : {}),
2763
- };
2764
- }
2765
- return {
2766
- command: server.command,
2767
- ...(server.args ? { args: server.args } : {}),
2768
- ...(server.env ? { env: server.env } : {}),
2769
- ...(server.cwd ? { cwd: server.cwd } : {}),
2770
- };
2771
- }
2772
- /** Lower canonical MCP definitions to OpenCode's native startup configuration. */
2773
- export function openCodeMcpConfig(mcp) {
2774
- const out = {};
2775
- for (const [name, s] of Object.entries(mcp)) {
2776
- if (s.enabled === false)
2777
- continue;
2778
- if (s.url) {
2779
- if (s.transport === "sse") {
2780
- throw new Error(`OpenCode MCP server "${name}" uses unsupported SSE transport`);
2781
- }
2782
- out[name] = {
2783
- type: "remote",
2784
- url: s.url,
2785
- enabled: true,
2786
- ...(s.headers ? { headers: s.headers } : {}),
2787
- };
2788
- continue;
2789
- }
2790
- if (!s.command) {
2791
- throw new Error(`OpenCode MCP server "${name}" must define either url or command`);
2792
- }
2793
- if (s.cwd !== undefined) {
2794
- throw new Error(`OpenCode MCP server "${name}" does not support a cwd field`);
2795
- }
2796
- out[name] = {
2797
- type: "local",
2798
- command: [s.command, ...(s.args ?? [])],
2799
- enabled: true,
2800
- ...(s.env ? { environment: s.env } : {}),
2801
- };
2802
- }
2803
- return out;
2804
- }
2805
- function materializeHooks(h, hooks, add, unsupported, plan) {
2806
- switch (h) {
2807
- case "claude-code": {
2808
- // Claude settings hooks{} — each event → [{matcher, hooks:[{type:command,command}]}].
2809
- const settingsHooks = {};
2810
- for (const [event, cmds] of Object.entries(hooks)) {
2811
- if (!hookEventSupported("claude-code", event)) {
2812
- unsupported("hooks", `claude-code: unsupported hook event "${event}"`);
2813
- continue;
2814
- }
2815
- settingsHooks[event] = cmds.map((c) => ({
2816
- matcher: c.matcher ?? "*",
2817
- hooks: [
2818
- {
2819
- type: "command",
2820
- command: materializedHookCommand(event, c, add, plan),
2821
- ...(c.timeoutMs ? { timeout: timeoutSeconds(c.timeoutMs) } : {}),
2822
- ...(c.blocking === false ? { async: true } : {}),
2823
- },
2824
- ],
2825
- }));
2826
- }
2827
- add(CLAUDE_SETTINGS_PATH, serializeWorkspacePlanJson({ hooks: settingsHooks }));
2828
- registerClaudeSettings(plan);
2829
- break;
2830
- }
2831
- case "nanoclaw":
2832
- unsupported("hooks", "nanoclaw's chat bridge does not expose Claude Code hook settings to the spawned agent");
2833
- break;
2834
- case "gemini": {
2835
- const settingsHooks = {};
2836
- for (const [event, cmds] of Object.entries(hooks)) {
2837
- if (!hookEventSupported("gemini", event)) {
2838
- unsupported("hooks", `gemini: unsupported hook event "${event}"`);
2839
- continue;
2840
- }
2841
- if (cmds.some((command) => command.blocking === false)) {
2842
- unsupported("hooks", `gemini: hook event "${event}" does not support non-blocking commands`);
2843
- }
2844
- settingsHooks[event] = cmds.map((c) => ({
2845
- matcher: c.matcher ?? "*",
2846
- hooks: [
2847
- {
2848
- type: "command",
2849
- command: materializedHookCommand(event, c, add, plan),
2850
- ...(c.timeoutMs ? { timeout: c.timeoutMs } : {}),
2851
- },
2852
- ],
2853
- }));
2854
- }
2855
- add(".gemini/settings.json", serializeWorkspacePlanJson({ hooks: settingsHooks }));
2856
- break;
2857
- }
2858
- case "codex": {
2859
- for (const [event, cmds] of Object.entries(hooks).sort(([left], [right]) => compareStrings(left, right))) {
2860
- if (!hookEventSupported("codex", event)) {
2861
- unsupported("hooks", `codex: unsupported hook event "${event}"`);
2862
- continue;
2863
- }
2864
- if (cmds.some((command) => command.blocking === false)) {
2865
- unsupported("hooks", `codex: hook event "${event}" cannot execute asynchronous commands`);
2866
- continue;
2867
- }
2868
- const entries = cmds.map((command) => {
2869
- const handler = [
2870
- `type = ${tomlStr("command")}`,
2871
- `command = ${tomlStr(materializedHookCommand(event, command, add, plan))}`,
2872
- ...(command.timeoutMs
2873
- ? [`timeout = ${timeoutSeconds(command.timeoutMs)}`]
2874
- : []),
2875
- ];
2876
- return `{ matcher = ${tomlStr(command.matcher ?? "*")}, hooks = [{ ${handler.join(", ")} }] }`;
2877
- });
2878
- plan.flags.push("-c", `hooks.${event}=[${entries.join(", ")}]`);
2879
- }
2880
- break;
2881
- }
2882
- case "opencode":
2883
- unsupported("hooks", "opencode hooks require executable JS/TS plugins; command hooks cannot be translated exactly");
2884
- break;
2885
- case "kimi-code":
2886
- unsupported("hooks", "kimi hooks are [[hooks]] in the config TOML loaded via --config-file, not cwd — pass --config-file");
2887
- break;
2888
- case "pi":
2889
- unsupported("hooks", "pi hooks are TS extensions (.pi/extensions/*.ts) — build an extension");
2890
- break;
2891
- case "prime":
2892
- unsupported("hooks", "prime hooks are TS extensions (fork core/extensions), not command hooks — build an extension");
2893
- break;
2894
- case "hermes":
2895
- unsupported("hooks", "hermes project plugins do not expose an exact command-hook mapping");
2896
- break;
2897
- case "openclaw":
2898
- unsupported("hooks", "openclaw hooks are DISABLED by default — enable in ~/.openclaw/openclaw.json; cwd hooks/ alone is a no-op");
2899
- break;
2900
- }
2901
- }
2902
- function registerClaudeSettings(plan) {
2903
- for (let index = 0; index < plan.flags.length - 1; index += 1) {
2904
- if (plan.flags[index] === "--settings" &&
2905
- plan.flags[index + 1] === CLAUDE_SETTINGS_PATH) {
2906
- return;
2907
- }
2908
- }
2909
- plan.flags.push("--settings", CLAUDE_SETTINGS_PATH);
2910
- }
2911
- const CLAUDE_HOOK_EVENTS = new Set([
2912
- "ConfigChange",
2913
- "CwdChanged",
2914
- "Elicitation",
2915
- "ElicitationResult",
2916
- "FileChanged",
2917
- "InstructionsLoaded",
2918
- "MessageDisplay",
2919
- "Notification",
2920
- "PermissionDenied",
2921
- "PermissionRequest",
2922
- "PostCompact",
2923
- "PostToolBatch",
2924
- "PostToolUse",
2925
- "PostToolUseFailure",
2926
- "PreCompact",
2927
- "PreToolUse",
2928
- "SessionEnd",
2929
- "SessionStart",
2930
- "Setup",
2931
- "Stop",
2932
- "StopFailure",
2933
- "SubagentStart",
2934
- "SubagentStop",
2935
- "TaskCompleted",
2936
- "TaskCreated",
2937
- "TeammateIdle",
2938
- "UserPromptExpansion",
2939
- "UserPromptSubmit",
2940
- "WorktreeCreate",
2941
- "WorktreeRemove",
2942
- ]);
2943
- const CODEX_HOOK_EVENTS = new Set([
2944
- "PermissionRequest",
2945
- "PostCompact",
2946
- "PostToolUse",
2947
- "PreCompact",
2948
- "PreToolUse",
2949
- "SessionStart",
2950
- "Stop",
2951
- "SubagentStart",
2952
- "SubagentStop",
2953
- "UserPromptSubmit",
2954
- ]);
2955
- const GEMINI_HOOK_EVENTS = new Set([
2956
- "AfterAgent",
2957
- "AfterModel",
2958
- "AfterTool",
2959
- "BeforeAgent",
2960
- "BeforeModel",
2961
- "BeforeTool",
2962
- "BeforeToolSelection",
2963
- "Notification",
2964
- "PreCompress",
2965
- "SessionEnd",
2966
- "SessionStart",
2967
- ]);
2968
- function hookEventSupported(harness, event) {
2969
- switch (harness) {
2970
- case "claude-code":
2971
- return CLAUDE_HOOK_EVENTS.has(event);
2972
- case "codex":
2973
- return CODEX_HOOK_EVENTS.has(event);
2974
- case "gemini":
2975
- return GEMINI_HOOK_EVENTS.has(event);
2976
- default:
2977
- return false;
2978
- }
2979
- }
2980
- function timeoutSeconds(timeoutMs) {
2981
- return Math.max(1, Math.ceil(timeoutMs / 1000));
2982
- }
2983
- function materializedHookCommand(event, command, add, plan) {
2984
- if (!command.env || Object.keys(command.env).length === 0) {
2985
- return command.command;
2986
- }
2987
- const wrapperId = canonicalCandidateDigest({
2988
- event,
2989
- command: command.command,
2990
- env: Object.fromEntries(Object.entries(command.env).sort(([left], [right]) => compareStrings(left, right))),
2991
- }).slice("sha256:".length);
2992
- const path = `.tangle/profile-hooks/${wrapperId}.mjs`;
2993
- const envSources = Object.fromEntries(Object.entries(command.env)
2994
- .sort(([left], [right]) => compareStrings(left, right))
2995
- .map(([name, value]) => {
2996
- const sourceName = `TANGLE_PROFILE_HOOK_${sha256(`${wrapperId}\0${name}`)
2997
- .slice(0, 24)
2998
- .toUpperCase()}`;
2999
- setPlanEnvironment(plan, sourceName, value);
3000
- return [name, sourceName];
3001
- }));
3002
- add(path, hookWrapperSourceFromEnvironment("/bin/sh", ["-c", command.command], envSources));
3003
- return `node ${path}`;
3004
- }
3005
- function hookWrapperSourceFromEnvironment(executable, args, envSources) {
3006
- return [
3007
- 'import { spawnSync } from "node:child_process"',
3008
- `const executable = ${JSON.stringify(executable)}`,
3009
- `const args = ${JSON.stringify(args)}`,
3010
- `const envSources = ${JSON.stringify(envSources)}`,
3011
- "const extraEnv = {}",
3012
- "for (const [name, source] of Object.entries(envSources)) {",
3013
- " const value = process.env[source]",
3014
- " if (value === undefined) {",
3015
- ' console.error("missing materialized hook environment " + source)',
3016
- " process.exit(127)",
3017
- " }",
3018
- " extraEnv[name] = value",
3019
- "}",
3020
- "const result = spawnSync(executable, args, {",
3021
- ' stdio: "inherit",',
3022
- " env: { ...process.env, ...extraEnv },",
3023
- " shell: false,",
3024
- "})",
3025
- "if (result.error) {",
3026
- " console.error(result.error.message)",
3027
- " process.exit(127)",
3028
- "}",
3029
- "if (result.signal) {",
3030
- ' console.error("hook terminated by " + result.signal)',
3031
- " process.exit(1)",
3032
- "}",
3033
- "process.exit(result.status ?? 1)",
3034
- "",
3035
- ].join("\n");
3036
- }
3037
- function hookWrapperSource(executable, args, env) {
3038
- return [
3039
- 'import { spawnSync } from "node:child_process"',
3040
- `const executable = ${JSON.stringify(executable)}`,
3041
- `const args = ${JSON.stringify(args)}`,
3042
- `const extraEnv = ${JSON.stringify(env)}`,
3043
- "const result = spawnSync(executable, args, {",
3044
- ' stdio: "inherit",',
3045
- " env: { ...process.env, ...extraEnv },",
3046
- " shell: false,",
3047
- "})",
3048
- "if (result.error) {",
3049
- " console.error(result.error.message)",
3050
- " process.exit(127)",
3051
- "}",
3052
- "if (result.signal) {",
3053
- ' console.error("hook terminated by " + result.signal)',
3054
- " process.exit(1)",
3055
- "}",
3056
- "process.exit(result.status ?? 1)",
3057
- "",
3058
- ].join("\n");
3059
- }
3060
- function materializeSubagent(h, name, sa, plan, add, unsupported) {
3061
- if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) {
3062
- unsupported("subagents", `${h}: subagent name "${name}" must use lowercase letters, digits, underscores, or hyphens`);
3063
- return;
3064
- }
3065
- switch (h) {
3066
- case "claude-code":
3067
- case "nanoclaw": {
3068
- const fm = ["---", `name: ${name}`];
3069
- fm.push(`description: ${JSON.stringify(sa.description ?? name)}`);
3070
- if (sa.model)
3071
- fm.push(`model: ${JSON.stringify(sa.model)}`);
3072
- if (sa.tools) {
3073
- const tools = Object.entries(sa.tools);
3074
- const allowed = tools
3075
- .filter(([, enabled]) => enabled)
3076
- .map(([tool]) => tool);
3077
- const denied = tools
3078
- .filter(([, enabled]) => !enabled)
3079
- .map(([tool]) => tool);
3080
- if (allowed.length > 0)
3081
- fm.push(`tools: ${JSON.stringify(allowed)}`);
3082
- if (denied.length > 0)
3083
- fm.push(`disallowedTools: ${JSON.stringify(denied)}`);
3084
- }
3085
- if (sa.maxSteps !== undefined)
3086
- fm.push(`maxTurns: ${sa.maxSteps}`);
3087
- reportUnsupportedSubagentFields(h, name, sa, new Set(["description", "prompt", "model", "tools", "maxSteps"]), unsupported);
3088
- fm.push("---", "", sa.prompt ?? sa.description ?? name);
3089
- add(`.claude/agents/${name}.md`, `${fm.join("\n")}\n`);
3090
- break;
3091
- }
3092
- case "opencode": {
3093
- const fm = [
3094
- "---",
3095
- `description: ${JSON.stringify(sa.description ?? name)}`,
3096
- "mode: subagent",
3097
- ];
3098
- if (sa.model)
3099
- fm.push(`model: ${JSON.stringify(sa.model)}`);
3100
- if (sa.tools)
3101
- fm.push(`tools: ${JSON.stringify(sa.tools)}`);
3102
- if (sa.permissions)
3103
- fm.push(`permission: ${JSON.stringify(sa.permissions)}`);
3104
- if (sa.maxSteps !== undefined)
3105
- fm.push(`steps: ${sa.maxSteps}`);
3106
- reportUnsupportedSubagentFields(h, name, sa, new Set([
3107
- "description",
3108
- "prompt",
3109
- "model",
3110
- "tools",
3111
- "permissions",
3112
- "maxSteps",
3113
- ]), unsupported);
3114
- fm.push("---", "", sa.prompt ?? "");
3115
- add(`.opencode/agents/${name}.md`, `${fm.join("\n")}\n`);
3116
- break;
3117
- }
3118
- case "gemini": {
3119
- const tools = sa.tools
3120
- ? Object.entries(sa.tools)
3121
- .filter(([, enabled]) => enabled)
3122
- .map(([tool]) => tool)
3123
- : undefined;
3124
- reportUnsupportedSubagentFields(h, name, sa, new Set(["description", "prompt", "model", "tools", "maxSteps"]), unsupported);
3125
- add(`.gemini/agents/${name}.md`, [
3126
- "---",
3127
- `name: ${name}`,
3128
- `description: ${JSON.stringify(sa.description ?? name)}`,
3129
- "kind: local",
3130
- ...(sa.model ? [`model: ${JSON.stringify(sa.model)}`] : []),
3131
- ...(tools ? [`tools: ${JSON.stringify(tools)}`] : []),
3132
- ...(sa.maxSteps !== undefined ? [`max_turns: ${sa.maxSteps}`] : []),
3133
- "---",
3134
- "",
3135
- sa.prompt ?? "",
3136
- "",
3137
- ].join("\n"));
3138
- break;
3139
- }
3140
- case "codex": {
3141
- const relPath = `.codex/agents/${name}.toml`;
3142
- const lines = [
3143
- `name = ${tomlStr(name)}`,
3144
- `description = ${tomlStr(sa.description ?? name)}`,
3145
- `developer_instructions = ${tomlStr(sa.prompt ?? sa.description ?? name)}`,
3146
- ];
3147
- if (sa.model)
3148
- lines.push(`model = ${tomlStr(sa.model)}`);
3149
- reportUnsupportedSubagentFields(h, name, sa, new Set(["description", "prompt", "model"]), unsupported);
3150
- add(relPath, `${lines.join("\n")}\n`);
3151
- registerCodexAgent(plan, name, relPath, sa.description ?? name);
3152
- break;
3153
- }
3154
- case "prime":
3155
- // Prime subagents ARE expressible — as entries in the continual-harness
3156
- // store at <agentDir>/harness/harness_state.json (fork refinement.ts:269-279,
3157
- // re-read on every system-prompt build: agent-session.ts:7548-7554) — but
3158
- // that store lives under the executor-owned agent dir, which no
3159
- // workspace-relative plan file can reach. Executors that own the agent
3160
- // dir seed it with seedPrimeProfileSubagents; the plan stays fail-closed
3161
- // so a plan-forwarding caller can never believe the roster is in force.
3162
- unsupported("subagents", `prime: subagents live in the agent-dir continual-harness store (<agentDir>/harness/harness_state.json), outside any workspace plan — seed "${name}" with seedPrimeProfileSubagents from the executor that owns the agent dir`);
3163
- break;
3164
- default:
3165
- unsupported("subagents", `${h}: subagents have no cwd file (config/flag/global only); "${name}" not mounted`);
3166
- }
3167
- }
3168
- function codexAgentRoleName(resourceName) {
3169
- const name = resourceName.endsWith(".toml")
3170
- ? resourceName.slice(0, -".toml".length)
3171
- : resourceName;
3172
- return /^[a-z0-9][a-z0-9_-]*$/.test(name) ? name : undefined;
3173
- }
3174
- function registerCodexAgent(plan, name, configFile, description) {
3175
- plan.flags.push("-c", `agents.${name}.description=${tomlStr(description)}`, "-c", `agents.${name}.config_file=${tomlStr(configFile)}`);
3176
- }
3177
- function reportUnsupportedSubagentFields(harness, name, subagent, supported, unsupported) {
3178
- for (const field of [
3179
- "description",
3180
- "prompt",
3181
- "model",
3182
- "tools",
3183
- "permissions",
3184
- "maxSteps",
3185
- "metadata",
3186
- ]) {
3187
- if (subagent[field] !== undefined && !supported.has(field)) {
3188
- unsupported("subagents", `${harness}: subagent "${name}" does not support ${field}`);
3189
- }
3190
- }
3191
- }
3192
- function materializeCommand(h, name, body, add, unsupported) {
3193
- switch (h) {
3194
- case "claude-code":
3195
- case "nanoclaw":
3196
- add(`.claude/commands/${name}.md`, body.endsWith("\n") ? body : `${body}\n`);
3197
- break;
3198
- case "opencode":
3199
- add(`.opencode/commands/${name}.md`, body.endsWith("\n") ? body : `${body}\n`);
3200
- break;
3201
- case "gemini":
3202
- add(`.gemini/commands/${name}.toml`, `prompt = ${tomlStr(body)}\n`);
3203
- break;
3204
- case "pi":
3205
- add(`.pi/prompts/${name}.md`, body.endsWith("\n") ? body : `${body}\n`);
3206
- break;
3207
- case "prime":
3208
- // Auto-discovered project prompt templates: <cwd>/.prime/agent/prompts
3209
- // (fork prompt-templates.ts:218 resolves CONFIG_DIR_NAME "prompts";
3210
- // package-manager.ts:2176-2180 auto-adds the same projectDirs.prompts).
3211
- add(`.prime/agent/prompts/${name}.md`, body.endsWith("\n") ? body : `${body}\n`);
3212
- break;
3213
- case "kimi-code":
3214
- unsupported("commands", "kimi commands ARE skills (/skill:<name>) — provide as a skill instead");
3215
- break;
3216
- case "codex":
3217
- unsupported("commands", "codex project commands unsupported (~/.codex/prompts is user-global only)");
3218
- break;
3219
- default:
3220
- unsupported("commands", `${h}: no cwd command path`);
3221
- }
3222
- }
3223
- // ─── Prime agent-dir lowering ────────────────────────────────────────────────
3224
- //
3225
- // Prime (PrimeIntellect-ai/prime-agent v0.7.0, be9e2fa0) reads several profile
3226
- // dimensions from its AGENT DIR — a per-process config root the EXECUTOR owns
3227
- // (env PRIME_AGENT_CODING_AGENT_DIR, fork config.ts:498,521-527), which no
3228
- // workspace-relative plan file can reach. The helpers below are that lowering,
3229
- // shared so cli-bridge's prime backend and sdk-provider-prime consume one
3230
- // implementation instead of hand-rolling byte-divergent copies. Every control
3231
- // accepted here is backed by a verified read path in the pinned fork; every
3232
- // other control is rejected loudly so a caller can never believe a
3233
- // behavior-relevant setting is in force when it is not. The verified paths:
3234
- //
3235
- // - `prompt.systemPrompt` / `prompt.appendSystemPrompt` → `--system-prompt` /
3236
- // `--append-system-prompt` (cli/args.ts:139-143). Both ride
3237
- // `AgentSessionRuntimeConfig` (main.ts:643-644) through BOTH acp spawn
3238
- // paths — the daemon-client create request (main.ts:1512-1520 →
3239
- // daemon-mode.ts:1310/1478) and the in-process fallback — into
3240
- // `resourceLoaderOptions` (main.ts:745-746), where the replacement DELETES
3241
- // the built-in prompt (resource-loader.ts:482-485, system-prompt.ts:72-73)
3242
- // and the addition appends after it (system-prompt.ts:61,109-110). Values
3243
- // are materialized as FILES under the agent dir because the flag value is
3244
- // read as a file when the path exists (resource-loader.ts:41-56), which
3245
- // keeps literal-vs-path resolution deterministic AND removes the argv size
3246
- // ceiling an inline flag value would run into (Linux MAX_ARG_STRLEN).
3247
- //
3248
- // - `prompt.instructions` / `resources.instructions` → `<agentDir>/AGENTS.md`.
3249
- // The agent dir IS an instruction surface: `loadProjectContextFiles`
3250
- // (resource-loader.ts:76) reads the agent dir's own AGENTS.md/CLAUDE.md as
3251
- // the GLOBAL context file (:86 `loadContextFileFromDir(resolvedAgentDir)`,
3252
- // candidates at :59) BEFORE walking the cwd ancestors (:94-112), and the
3253
- // result is loaded unless `--no-context-files` (:476). Writing it here
3254
- // touches no caller workspace content, which is what the agent-dir lowering
3255
- // exists to guarantee. (`materializeProfile(profile, "prime")` lowers the
3256
- // same intent to the cwd AGENTS.md for callers that own the workspace
3257
- // instead of the agent dir.)
3258
- //
3259
- // - `resources.skills` (inline refs) → `--skill <agentDir>/profile/skills`
3260
- // (cli/args.ts:207-209 → main.ts:652 → agent-session-config.ts:67 →
3261
- // additionalSkillPaths, resource-loader.ts:437-442). An explicit flag rather
3262
- // than the agent dir's auto-discovered `skills/` tree (package-manager.ts:884
3263
- // global base, :2171 skills dir, :2240-2246 discovery), which also loads but
3264
- // goes through the fork's global-vs-project dedupe (package-manager.ts:880,
3265
- // "project scope wins over global") — so an unrelated project-scope skill of
3266
- // the same name silently shadows the profile's. The loader recurses into
3267
- // `<name>/SKILL.md` subdirs (skills.ts:328-372) and loaded skills are
3268
- // rendered into the system prompt (agent-session.ts:4272-4291,
3269
- // system-prompt.ts:87-89). The fork silently DROPS a SKILL.md without a
3270
- // frontmatter description (skills.ts:416-419), so content is normalized
3271
- // through {@link normalizeSkillMd}, which guarantees one.
3272
- //
3273
- // - `subagents` → `subagent` entries in the fork's continual-harness store at
3274
- // `<agentDir>/harness/harness_state.json` (refinement.ts:269-279). The
3275
- // store is re-read on every system-prompt build (agent-session.ts:4288,
3276
- // 7548-7554) and subagent entries render as a task-shaped roster the model
3277
- // spawns via `await rlm('<task>')` (refinement.ts:483-496); the kernel
3278
- // reaches the same store through the host-set RLM_GLOBAL_HARNESS_STATE_DIR
3279
- // (agent-session.ts:8805).
3280
- //
3281
- // Kept REJECTED with the fork evidence — see {@link PRIME_MCP_UNSUPPORTED_REASON}
3282
- // for `mcp`/`connections`, and the per-control reasons in
3283
- // {@link readPrimeProfileControls} for permissions, tools, hooks, modes,
3284
- // confidential, and the resource kinds with no agent-dir read path.
3285
- //
3286
- // The store is AGENT-OWNED: it is written by the agent mid-session and rides
3287
- // every later system prompt. A store that exists but no longer parses
3288
- // therefore fails the turn ({@link PrimeHarnessStateError}) rather than being
3289
- // reseeded from empty — the fork's own loader degrades to empty in-process
3290
- // (refinement.ts:290-300), but an executor that OVERWRITES the file makes that
3291
- // degradation permanent and destroys the evidence of what happened.
3292
- /** Skill name rules enforced by the fork's Agent Skills loader (skills.ts:122-147). */
3293
- const PRIME_SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
3294
- const PRIME_MAX_SKILL_NAME_LENGTH = 64;
3295
- const PRIME_PROFILE_DIR_NAME = "profile";
3296
- const PRIME_SKILLS_DIR_NAME = "skills";
3297
- const PRIME_SYSTEM_PROMPT_FILE = "system-prompt.md";
3298
- const PRIME_APPEND_SYSTEM_PROMPT_FILE = "append-system-prompt.md";
3299
- /** Agent-dir global context file the fork reads (resource-loader.ts:59,86). */
3300
- const PRIME_CONTEXT_FILE = "AGENTS.md";
3301
- /** Fork continual-harness store location under the agent dir (refinement.ts:269-279). */
3302
- const PRIME_HARNESS_DIR_NAME = "harness";
3303
- const PRIME_HARNESS_STATE_FILE = "harness_state.json";
3304
- const PRIME_HARNESS_KINDS = ["prompt", "memory", "skill", "subagent"];
3305
- /**
3306
- * Provenance marker for entries pinned from the profile. Stale removal keys on
3307
- * it, so agent-written (`source: "agent"`) and user-written entries are never
3308
- * touched.
3309
- */
3310
- const PRIME_PROFILE_ENTRY_SOURCE = "profile";
3311
- /**
3312
- * Provenance markers written by executors that predate this shared lowering.
3313
- *
3314
- * Pruning keys on the source tag, so an executor that switches to the shared
3315
- * implementation would otherwise leave its own earlier entries behind forever:
3316
- * they match no current profile and no current source, and the model's
3317
- * spawnable roster silently accumulates duplicates of every subagent. They are
3318
- * adopted (pruned or replaced) on the first shared run instead.
3319
- */
3320
- const PRIME_LEGACY_PROFILE_ENTRY_SOURCES = [
3321
- "cli-bridge-profile",
3322
- ];
3323
- /** True when the controls materialize anything at all. */
3324
- export function hasPrimeProfileMaterial(controls) {
3325
- return (controls.systemPrompt !== undefined ||
3326
- controls.appendSystemPrompt !== undefined ||
3327
- primeProfileNeedsAgentDir(controls) ||
3328
- controls.skills.length > 0);
3329
- }
3330
- /**
3331
- * True when the controls must WRITE INTO the agent dir itself.
3332
- *
3333
- * Prompt files and skill dirs are named by a flag, so they can live anywhere
3334
- * the agent can read (`--system-prompt` / `--append-system-prompt` resolve a
3335
- * path when it exists, fork resource-loader.ts:41-56; `--skill` takes an
3336
- * arbitrary dir, :437-442). The context file and the continual-harness store
3337
- * are located BY the agent dir and have no flag, so they cannot move.
3338
- *
3339
- * An executor pointed at an agent dir it does not own (an operator's shared
3340
- * dir) uses this to refuse exactly the controls it would have to write there,
3341
- * while still binding the ones it can materialize elsewhere.
3342
- */
3343
- export function primeProfileNeedsAgentDir(controls) {
3344
- return controls.instructions !== undefined || controls.subagents.length > 0;
3345
- }
3346
- /**
3347
- * Read one instruction block, which the profile may carry inline or as a
3348
- * resource ref. Only inline refs are accepted: resolving a GitHub-backed ref
3349
- * fetches remote content, which is `resolveAgentProfileResources`' job and
3350
- * must happen before a profile reaches an executor.
3351
- */
3352
- function primeInstructionBlock(value, control) {
3353
- if (typeof value === "string")
3354
- return value;
3355
- if (value.kind === "inline")
3356
- return value.content;
3357
- rejectPrimeControl(control, "only inline resource refs are accepted — resolve GitHub-backed refs " +
3358
- "(resolveAgentProfileResources) before handing the profile to this lowering.");
3359
- }
3360
- /**
3361
- * Validate and extract the profile controls the prime agent-dir lowering
3362
- * materializes. Throws {@link PrimeProfileError} on any control (or control
3363
- * facet) the fork has no verified read path for — partial acceptance would
3364
- * silently drop the rest.
3365
- */
3366
- export function readPrimeProfileControls(profile) {
3367
- const controls = { skills: [], subagents: [] };
3368
- if (!profile)
3369
- return controls;
3370
- if (profile.harness !== undefined && profile.harness !== "prime") {
3371
- rejectPrimeDeclaration("harness", `prime cannot run a profile pinned to harness "${profile.harness}" — ` +
3372
- 'only "prime" (or an unset harness preference) is accepted.');
3373
- }
3374
- // An entry with `enabled: false` is OFF, not requested. A fully disabled map
3375
- // asks for nothing, so accepting it drops no control — which is the only
3376
- // thing this refusal exists to prevent.
3377
- const requestedMcp = Object.keys(activeMcpServers(profile.mcp));
3378
- if (requestedMcp.length > 0) {
3379
- rejectPrimeControl(`mcp: ${requestedMcp.join(", ")}`, PRIME_MCP_UNSUPPORTED_REASON);
3380
- }
3381
- if (profile.connections !== undefined) {
3382
- rejectPrimeControl("connections", `a hub connection grant resolves to MCP tools, and ${PRIME_MCP_UNSUPPORTED_REASON}`);
3383
- }
3384
- if (profile.permissions !== undefined) {
3385
- rejectPrimeControl("permissions", "prime-agent exposes no permission control at all (full flag scan of fork " +
3386
- "cli/args.ts:93-295; native tools run unconditionally), so a permission map " +
3387
- "would be accepted and never enforced.");
3388
- }
3389
- if (profile.tools !== undefined) {
3390
- rejectPrimeControl("tools", "the only per-run tool control is `--tools` over the single builtin `ipython` " +
3391
- "(fork cli/args.ts:63-64,156-167); bash/edit and the rest are extension-provided " +
3392
- "and not addressable per run, so a harness-neutral tool map cannot bind to it.");
3393
- }
3394
- if (profile.hooks !== undefined) {
3395
- rejectPrimeControl("hooks", "prime-agent has no hook mechanism (fork cli/args.ts flag scan; extensions are " +
3396
- "host-trusted JS modules loaded into the agent process, not caller command hooks).");
3397
- }
3398
- if (profile.modes !== undefined) {
3399
- rejectPrimeControl("modes", "prime-agent has no mode concept (fork cli/args.ts flag scan).");
3400
- }
3401
- if (profile.confidential !== undefined) {
3402
- rejectPrimeControl("confidential", "confidential execution is a property of the executor's isolation boundary, not " +
3403
- "of the agent dir; this lowering cannot establish it.");
3404
- }
3405
- const instructionBlocks = [];
3406
- if (profile.prompt) {
3407
- const { systemPrompt, appendSystemPrompt, instructions } = profile.prompt;
3408
- const knownPromptFields = new Set([
3409
- "systemPrompt",
3410
- "appendSystemPrompt",
3411
- "instructions",
3412
- ]);
3413
- const unknown = Object.entries(profile.prompt)
3414
- .filter(([key, value]) => !knownPromptFields.has(key) && value !== undefined)
3415
- .map(([key]) => key);
3416
- if (unknown.length > 0) {
3417
- rejectPrimeControl(`prompt fields: ${unknown.join(", ")}`, "no verified prime-agent read path exists for them.");
3418
- }
3419
- if (systemPrompt !== undefined) {
3420
- if (typeof systemPrompt !== "string" || systemPrompt.trim() === "") {
3421
- rejectPrimeDeclaration("prompt.systemPrompt", "an empty replacement would delete the built-in system prompt and " +
3422
- "install nothing.");
3423
- }
3424
- controls.systemPrompt = systemPrompt;
3425
- }
3426
- if (appendSystemPrompt !== undefined) {
3427
- if (typeof appendSystemPrompt !== "string" ||
3428
- appendSystemPrompt.trim() === "") {
3429
- rejectPrimeDeclaration("prompt.appendSystemPrompt", "must be non-empty text.");
3430
- }
3431
- controls.appendSystemPrompt = appendSystemPrompt;
3432
- }
3433
- for (const block of instructions ?? []) {
3434
- if (typeof block === "string" && block.trim() !== "")
3435
- instructionBlocks.push(block);
3436
- }
3437
- }
3438
- if (profile.resources) {
3439
- const { skills, instructions, failOnError, ...unsupported } = profile.resources;
3440
- const rejectedKeys = Object.entries(unsupported)
3441
- .filter(([, value]) => value !== undefined)
3442
- .map(([key]) => key);
3443
- if (rejectedKeys.length > 0) {
3444
- rejectPrimeControl(`resources: ${rejectedKeys.join(", ")}`, "only resources.skills (--skill dirs, fork resource-loader.ts:437-442) and " +
3445
- "resources.instructions (agent-dir AGENTS.md, resource-loader.ts:86) have a " +
3446
- "verified prime-agent read path. Generic workspace files, tool files, " +
3447
- "subagent-definition files and prompt templates either have no headless read " +
3448
- "path or would run caller-authored code inside the agent process.");
3449
- }
3450
- if (failOnError === false) {
3451
- rejectPrimeControl("resources.failOnError: false", "this lowering always fails loud on a resource it cannot " +
3452
- "materialize; lenient materialization is not implemented.");
3453
- }
3454
- if (instructions !== undefined) {
3455
- const block = primeInstructionBlock(instructions, "resources.instructions");
3456
- if (block.trim() !== "")
3457
- instructionBlocks.push(block);
3458
- }
3459
- const seen = new Set();
3460
- for (const [index, ref] of (skills ?? []).entries()) {
3461
- if (ref.kind !== "inline") {
3462
- rejectPrimeControl(`resources.skills[${index}]`, "only inline skill refs are accepted — resolve GitHub-backed refs " +
3463
- "(resolveAgentProfileResources) before handing the profile to " +
3464
- "this lowering.");
3465
- }
3466
- const name = ref.name;
3467
- if (!PRIME_SKILL_NAME_RE.test(name) ||
3468
- name.length > PRIME_MAX_SKILL_NAME_LENGTH) {
3469
- rejectPrimeDeclaration(`resources.skills[${index}]`, `name ${JSON.stringify(name)} is not a valid prime skill name — it becomes the ` +
3470
- "skill directory name, so it must be lowercase a-z0-9 with single hyphens, at " +
3471
- `most ${PRIME_MAX_SKILL_NAME_LENGTH} chars (fork skills.ts:122-147).`);
3472
- }
3473
- if (seen.has(name)) {
3474
- rejectPrimeDeclaration("resources.skills", `declares ${JSON.stringify(name)} twice; duplicate skill names collide in the ` +
3475
- "fork's skill registry, which keeps only the first and warns.");
3476
- }
3477
- seen.add(name);
3478
- if (ref.content.trim() === "") {
3479
- rejectPrimeDeclaration(`resources.skills[${index}]`, `skill ${JSON.stringify(name)} declares no content.`);
3480
- }
3481
- controls.skills.push({ name, content: ref.content });
3482
- }
3483
- }
3484
- if (instructionBlocks.length > 0) {
3485
- controls.instructions = instructionBlocks.join("\n\n");
3486
- }
3487
- if (profile.subagents) {
3488
- const usedIds = new Map();
3489
- for (const [name, spec] of Object.entries(profile.subagents)) {
3490
- if (name.trim() === "") {
3491
- rejectPrimeDeclaration("subagents", "a subagent name must be non-empty.");
3492
- }
3493
- const inexpressible = [
3494
- ["model", spec.model],
3495
- ["tools", spec.tools],
3496
- ["permissions", spec.permissions],
3497
- ["maxSteps", spec.maxSteps],
3498
- ]
3499
- .filter(([, value]) => value !== undefined)
3500
- .map(([key]) => key);
3501
- if (inexpressible.length > 0) {
3502
- rejectPrimeControl(`subagents[${JSON.stringify(name)}] fields: ${inexpressible.join(", ")}`, "a prime continual-harness subagent spec is prompt-roster " +
3503
- "guidance for `await rlm('<task>')` (fork refinement.ts:483-496) " +
3504
- "and pins no model, tool set, permission, or step budget on the " +
3505
- "spawned child.");
3506
- }
3507
- if (!spec.description?.trim() && !spec.prompt?.trim()) {
3508
- rejectPrimeDeclaration(`subagents[${JSON.stringify(name)}]`, "declares no behavior — a spec needs a description and/or prompt, since an " +
3509
- "empty entry gives the model an empty roster line.");
3510
- }
3511
- const id = primeSubagentEntryId(name);
3512
- const clash = usedIds.get(id);
3513
- if (clash !== undefined) {
3514
- rejectPrimeDeclaration(`subagents[${JSON.stringify(name)}]`, `collides with subagent ${JSON.stringify(clash)} on harness-state id ` +
3515
- `${JSON.stringify(id)}.`);
3516
- }
3517
- usedIds.set(id, name);
3518
- controls.subagents.push({
3519
- name,
3520
- description: spec.description,
3521
- prompt: spec.prompt,
3522
- metadata: spec.metadata,
3523
- });
3524
- }
3525
- }
3526
- return controls;
3527
- }
3528
- /**
3529
- * Refuse a run whose selected model disagrees with the profile's own.
3530
- *
3531
- * One authority per run: a profile names the model it was written and scored
3532
- * against, so running it on a different one silently scores a different agent.
3533
- * Both sides go through {@link modelIdsMatch}, so the qualification level a
3534
- * caller happened to use is not a conflict.
3535
- *
3536
- * `provider` is the executor's PROVIDER SELECTION, and is compared only when
3537
- * the executor's provider is part of model identity. An executor whose
3538
- * provider key is merely a namespace inside the agent dir passes it as
3539
- * undefined and gets the model-name comparison alone.
3540
- */
3541
- export function assertPrimeModelAgreement(profile, selected) {
3542
- const declared = profile?.model?.default;
3543
- const declaredProvider = profile?.model?.provider;
3544
- if (declared !== undefined) {
3545
- const selectedId = qualifyModelId(selected.provider, selected.model);
3546
- const declaredId = qualifyModelId(declaredProvider, declared);
3547
- if (!modelIdsMatch(selectedId, declaredId, selected.provider)) {
3548
- rejectPrimeDeclaration("model.default", `the run selects ${JSON.stringify(selectedId)} but the profile names ` +
3549
- `${JSON.stringify(declaredId)}; a profile must not be scored against a model it ` +
3550
- "does not name.");
3551
- }
3552
- return;
3553
- }
3554
- if (selected.provider !== undefined &&
3555
- declaredProvider !== undefined &&
3556
- declaredProvider !== selected.provider) {
3557
- rejectPrimeDeclaration("model.provider", `the run selects provider ${JSON.stringify(selected.provider)} but the profile names ` +
3558
- `${JSON.stringify(declaredProvider)}.`);
3559
- }
3560
- }
3561
- /** Deterministic harness-state entry id for a profile subagent name. */
3562
- export function primeSubagentEntryId(name) {
3563
- const slug = name
3564
- .toLowerCase()
3565
- .replace(/[^a-z0-9]+/g, "-")
3566
- .replace(/^-+|-+$/g, "");
3567
- return `profile-${slug || "subagent"}`;
3568
- }
3569
- /**
3570
- * Write one agent-dir file atomically (temp + rename).
3571
- *
3572
- * Every file this lowering writes lands in a directory a LIVE agent may be
3573
- * reading: prime reloads models.json at model-resolution time and re-reads the
3574
- * harness store on every system-prompt build, so a partial write is observable.
3575
- * `mode` is applied to the temp file before the rename, so the final file is
3576
- * never briefly world-readable.
3577
- */
3578
- export function writePrimeAgentFile(path, data, mode = 0o600) {
3579
- mkdirSync(dirname(path), { recursive: true });
3580
- const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
3581
- writeFileSync(temp, data, { mode });
3582
- renameSync(temp, path);
3583
- }
3584
- /**
3585
- * Materialize the validated controls and return the extra spawn args that bind
3586
- * them. Runs before every spawn (same place models.json is materialized), so a
3587
- * continual agent dir is re-pinned to the current profile on each fresh prime
3588
- * process.
3589
- */
3590
- export function materializePrimeProfileControls(agentDir, controls, options = {}) {
3591
- const { flagFileRoot = agentDir, warn } = options;
3592
- const args = [];
3593
- const profileDir = join(flagFileRoot, PRIME_PROFILE_DIR_NAME);
3594
- if (controls.systemPrompt !== undefined) {
3595
- const promptPath = join(profileDir, PRIME_SYSTEM_PROMPT_FILE);
3596
- writePrimeAgentFile(promptPath, controls.systemPrompt);
3597
- args.push("--system-prompt", promptPath);
3598
- }
3599
- if (controls.appendSystemPrompt !== undefined) {
3600
- const appendPath = join(profileDir, PRIME_APPEND_SYSTEM_PROMPT_FILE);
3601
- writePrimeAgentFile(appendPath, controls.appendSystemPrompt);
3602
- args.push("--append-system-prompt", appendPath);
3603
- }
3604
- if (controls.instructions !== undefined) {
3605
- writePrimeAgentFile(join(agentDir, PRIME_CONTEXT_FILE), `${controls.instructions}\n`);
3606
- }
3607
- if (controls.skills.length > 0) {
3608
- const skillsRoot = join(profileDir, PRIME_SKILLS_DIR_NAME);
3609
- for (const skill of controls.skills) {
3610
- writePrimeAgentFile(join(skillsRoot, skill.name, "SKILL.md"), normalizeSkillMd(skill.name, skill.content));
3611
- }
3612
- args.push("--skill", skillsRoot);
3613
- }
3614
- seedPrimeProfileSubagents(agentDir, controls.subagents, warn);
3615
- return args;
3616
- }
3617
- /**
3618
- * Remove the files a PREVIOUS materialization of this lowering wrote, so a
3619
- * profile that shrank cannot leave a stale instruction file or skill behind for
3620
- * the next run against the same dirs.
3621
- *
3622
- * Only the paths this lowering NAMES are removed. There is deliberately no
3623
- * manifest: a manifest would live where the agent can write it, and reading a
3624
- * delete list out of agent-writable state turns pruning into an arbitrary-path
3625
- * delete.
3626
- */
3627
- export function prunePrimeProfileMaterial(agentDir, options = {}) {
3628
- const { flagFileRoot = agentDir } = options;
3629
- rmSync(join(flagFileRoot, PRIME_PROFILE_DIR_NAME), {
3630
- recursive: true,
3631
- force: true,
3632
- });
3633
- rmSync(join(agentDir, PRIME_CONTEXT_FILE), { force: true });
3634
- }
3635
- /**
3636
- * Upsert profile subagent specs into the fork's global continual-harness
3637
- * store and remove profile-sourced entries the current profile no longer
3638
- * declares. Agent-written entries (`source: "agent"`) — and every top-level
3639
- * key this lowering does not manage — are preserved untouched.
3640
- *
3641
- * A store that exists but no longer parses raises
3642
- * {@link PrimeHarnessStateError}: the file is the agent's own self-modification
3643
- * state, and overwriting it destroys what the run was supposed to observe.
3644
- */
3645
- export function seedPrimeProfileSubagents(agentDir, subagents, warn) {
3646
- const statePath = join(agentDir, PRIME_HARNESS_DIR_NAME, PRIME_HARNESS_STATE_FILE);
3647
- let state = {};
3648
- if (existsSync(statePath)) {
3649
- let parsed;
3650
- try {
3651
- parsed = JSON.parse(readFileSync(statePath, "utf8"));
3652
- }
3653
- catch (err) {
3654
- throw new PrimeHarnessStateError(statePath, err instanceof Error ? err.message : String(err));
3655
- }
3656
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3657
- throw new PrimeHarnessStateError(statePath, "the store is not a JSON object");
3658
- }
3659
- state = parsed;
3660
- }
3661
- else if (subagents.length === 0) {
3662
- // Nothing to pin and nothing stale to remove.
3663
- return;
3664
- }
3665
- const rawEntries = state.entries;
3666
- const entries = rawEntries && typeof rawEntries === "object" && !Array.isArray(rawEntries)
3667
- ? rawEntries
3668
- : {};
3669
- for (const kind of PRIME_HARNESS_KINDS) {
3670
- const records = entries[kind];
3671
- if (!records || typeof records !== "object" || Array.isArray(records)) {
3672
- entries[kind] = {};
3673
- }
3674
- }
3675
- const existing = entries.subagent;
3676
- const desiredIds = new Set(subagents.map((s) => primeSubagentEntryId(s.name)));
3677
- let changed = false;
3678
- for (const [id, entry] of Object.entries(existing)) {
3679
- const source = entry?.source;
3680
- const isLegacy = typeof source === "string" &&
3681
- PRIME_LEGACY_PROFILE_ENTRY_SOURCES.includes(source);
3682
- if (isLegacy) {
3683
- warn?.("prime: adopting a subagent entry left by a pre-shared executor", {
3684
- statePath,
3685
- id,
3686
- source,
3687
- });
3688
- }
3689
- if ((source === PRIME_PROFILE_ENTRY_SOURCE || isLegacy) &&
3690
- !desiredIds.has(id)) {
3691
- delete existing[id];
3692
- changed = true;
3693
- }
3694
- }
3695
- const now = new Date().toISOString();
3696
- for (const spec of subagents) {
3697
- const id = primeSubagentEntryId(spec.name);
3698
- const content = [spec.description?.trim(), spec.prompt?.trim()]
3699
- .filter((part) => !!part)
3700
- .join("\n\n");
3701
- const previous = existing[id];
3702
- if (previous &&
3703
- previous.source === PRIME_PROFILE_ENTRY_SOURCE &&
3704
- previous.title === spec.name &&
3705
- previous.content === content) {
3706
- continue;
3707
- }
3708
- existing[id] = {
3709
- id,
3710
- kind: "subagent",
3711
- title: spec.name,
3712
- content,
3713
- path: PRIME_PROFILE_ENTRY_SOURCE,
3714
- scope: "global",
3715
- reference: {},
3716
- arguments: {},
3717
- metadata: spec.metadata ?? {},
3718
- source: PRIME_PROFILE_ENTRY_SOURCE,
3719
- created_at: previous?.created_at ?? now,
3720
- updated_at: now,
3721
- version: previous && typeof previous.version === "number"
3722
- ? previous.version + 1
3723
- : 1,
3724
- };
3725
- changed = true;
3726
- }
3727
- if (!changed)
3728
- return;
3729
- writePrimeAgentFile(statePath, `${JSON.stringify({
3730
- ...state,
3731
- schema: typeof state.schema === "number" ? state.schema : 1,
3732
- entries,
3733
- refinements: Array.isArray(state.refinements) ? state.refinements : [],
3734
- }, null, 2)}\n`);
3735
- }
15
+ export { assertPrimeModelAgreement, hasPrimeProfileMaterial, primeProfileNeedsAgentDir, primeSubagentEntryId, readPrimeProfileControls, } from "./prime-profile.js";
16
+ export { materializeProfile, UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER, } from "./profile-materializer.js";
17
+ export { normalizeSkillMd, resolveAgentProfileResources, } from "./resource-resolution.js";