@tangle-network/agent-profile-materialize 0.15.2 → 0.16.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.
- package/dist/candidate-types.d.ts +32 -0
- package/dist/candidate-types.js +1 -0
- package/dist/candidate.d.ts +9 -0
- package/dist/candidate.js +279 -0
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +32 -0
- package/dist/control-maps.d.ts +34 -0
- package/dist/control-maps.js +185 -0
- package/dist/execution-controls.d.ts +5 -0
- package/dist/execution-controls.js +186 -0
- package/dist/harness.d.ts +81 -0
- package/dist/harness.js +167 -0
- package/dist/hooks.d.ts +6 -0
- package/dist/hooks.js +261 -0
- package/dist/index.d.ts +23 -623
- package/dist/index.js +14 -3733
- package/dist/mcp.d.ts +22 -0
- package/dist/mcp.js +244 -0
- package/dist/plan-application.d.ts +10 -0
- package/dist/plan-application.js +152 -0
- package/dist/plan-digest.d.ts +18 -0
- package/dist/plan-digest.js +90 -0
- package/dist/plan-json.d.ts +18 -0
- package/dist/plan-json.js +91 -0
- package/dist/plan-normalize.d.ts +13 -0
- package/dist/plan-normalize.js +156 -0
- package/dist/plan-prompt.d.ts +38 -0
- package/dist/plan-prompt.js +49 -0
- package/dist/plan-support.d.ts +9 -0
- package/dist/plan-support.js +22 -0
- package/dist/plan-types.d.ts +294 -0
- package/dist/plan-types.js +106 -0
- package/dist/prime-agent-dir.d.ts +47 -0
- package/dist/prime-agent-dir.js +164 -0
- package/dist/prime-profile.d.ts +88 -0
- package/dist/prime-profile.js +284 -0
- package/dist/profile-materializer.d.ts +12 -0
- package/dist/profile-materializer.js +59 -0
- package/dist/profile-plan.d.ts +14 -0
- package/dist/profile-plan.js +86 -0
- package/dist/profile-prompt.d.ts +4 -0
- package/dist/profile-prompt.js +105 -0
- package/dist/profile-support.d.ts +8 -0
- package/dist/profile-support.js +6 -0
- package/dist/resource-lowering.d.ts +7 -0
- package/dist/resource-lowering.js +103 -0
- package/dist/resource-resolution.d.ts +27 -0
- package/dist/resource-resolution.js +241 -0
- package/dist/subagents.d.ts +6 -0
- package/dist/subagents.js +133 -0
- package/dist/workspace-apply.d.ts +12 -0
- package/dist/workspace-apply.js +334 -0
- package/dist/workspace-files.d.ts +8 -0
- package/dist/workspace-files.js +46 -0
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,3735 +1,16 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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 { applyWorkspacePlan, applyWorkspacePlanWithSecretProvider, } from "./plan-application.js";
|
|
8
|
+
export { agentCandidateProfilePlanMaterial, canonicalizeAgentCandidateProfilePlan, captureAgentCandidateProfilePlan, hashWorkspacePlan, } from "./plan-digest.js";
|
|
9
|
+
export { takeSystemPromptBinding } from "./plan-prompt.js";
|
|
10
|
+
export { assertWorkspacePlanSupported, enforceWorkspacePlanSupport, } from "./plan-support.js";
|
|
11
|
+
export { BINDABLE_LAUNCH_CONTROLS, EXECUTOR_SYSTEM_PROMPT_BINDINGS, isExecutorSystemPromptBinding, isSkippableDimension, SKIPPABLE_DIMENSIONS, WorkspacePlanCleanupError, } from "./plan-types.js";
|
|
12
|
+
export { materializePrimeProfileControls, prunePrimeProfileMaterial, seedPrimeProfileSubagents, writePrimeAgentFile, } from "./prime-agent-dir.js";
|
|
25
13
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
}
|
|
14
|
+
export { assertPrimeModelAgreement, hasPrimeProfileMaterial, primeProfileNeedsAgentDir, primeSubagentEntryId, readPrimeProfileControls, } from "./prime-profile.js";
|
|
15
|
+
export { materializeProfile, UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER, } from "./profile-materializer.js";
|
|
16
|
+
export { normalizeSkillMd, resolveAgentProfileResources, } from "./resource-resolution.js";
|