@tangle-network/agent-profile-materialize 0.3.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +43 -10
  2. package/dist/index.js +1028 -192
  3. package/package.json +3 -4
package/dist/index.js CHANGED
@@ -22,13 +22,11 @@
22
22
  * cli-bridge and agent-dev-container — this is the single source those re-export.
23
23
  */
24
24
  import { createHash, randomUUID } from "node:crypto";
25
- import { closeSync, fchmodSync, constants as fsConstants, lstatSync, mkdirSync, openSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
26
- import { dirname, isAbsolute, join, relative, resolve, sep, win32, } from "node:path";
27
- import { agentCandidateProfilePlanEvidenceSchema, agentCandidateProfilePlanMaterialSchema, agentCandidateProfileSchema, } from "@tangle-network/agent-interface";
28
- /** Resolve an inline resource ref to {name, content}. github refs need an async fetch
29
- * (the providers' resolveProfileResource does this); the sync materializer can't, so a
30
- * github ref is reported unsupported and the caller pre-resolves it. A ref with
31
- * `content` (or kind 'inline') is inline. */
25
+ import { closeSync, fchmodSync, constants as fsConstants, linkSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
26
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32, } from "node:path";
27
+ import { agentCandidateProfileActivationSchema, agentCandidateProfilePlanEvidenceSchema, agentCandidateProfilePlanMaterialSchema, agentCandidateProfileSchema, agentProfileSchema, canonicalCandidateBytes, canonicalCandidateDigest, resolveNativeWebTools, } from "@tangle-network/agent-interface";
28
+ /** Resolve inline bytes already present on the profile. Remote resources must
29
+ * be resolved by {@link resolveAgentProfileResources} before materialization. */
32
30
  function resolveInlineRef(ref) {
33
31
  const r = ref;
34
32
  if (r.kind === "inline" || typeof r.content === "string") {
@@ -39,6 +37,12 @@ function resolveInlineRef(ref) {
39
37
  }
40
38
  return null; // github / unresolved
41
39
  }
40
+ /** Fail when a profile asks for controls the chosen harness cannot execute. */
41
+ export function assertWorkspacePlanSupported(plan) {
42
+ if (plan.unsupported.length > 0) {
43
+ throw new Error(formatUnsupported(plan.unsupported));
44
+ }
45
+ }
42
46
  // ─── Verified per-harness conventions (capability matrix) ───────────────────
43
47
  /** Project context file each harness actually reads from cwd. NEVER a constant. */
44
48
  const CONTEXT_FILE = {
@@ -47,7 +51,7 @@ const CONTEXT_FILE = {
47
51
  claudish: "CLAUDE.md",
48
52
  nanoclaw: "CLAUDE.md",
49
53
  codex: "AGENTS.md",
50
- opencode: "AGENTS.md",
54
+ opencode: ".opencode/profile-instructions.md",
51
55
  "kimi-code": "AGENTS.md",
52
56
  kimi: "AGENTS.md",
53
57
  pi: "AGENTS.md",
@@ -83,10 +87,74 @@ const collapse = (s) => s.replace(/\s+/g, " ").trim();
83
87
  const compareStrings = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
84
88
  const DEFAULT_FILE_MODE = 0o644;
85
89
  const EXECUTABLE_FILE_MODE = 0o755;
90
+ const DEFAULT_RESOURCE_FETCH_TIMEOUT_MS = 30_000;
91
+ const MAX_RESOURCE_BYTES = 10 * 1024 * 1024;
92
+ const MAX_PROFILE_RESOURCE_BYTES = 32 * 1024 * 1024;
93
+ const MAX_PROFILE_RESOURCE_REFS = 128;
94
+ const MAX_CONCURRENT_RESOURCE_FETCHES = 8;
95
+ const CLAUDE_SETTINGS_PATH = ".tangle/claude-settings.json";
86
96
  const SAFE_WRITE_FLAGS = fsConstants.O_WRONLY |
87
97
  fsConstants.O_CREAT |
88
98
  fsConstants.O_EXCL |
89
99
  (typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0);
100
+ async function readResourceBody(response, description, consumeBytes) {
101
+ if (!response.body)
102
+ return "";
103
+ const reader = response.body.getReader();
104
+ const chunks = [];
105
+ let totalBytes = 0;
106
+ try {
107
+ while (true) {
108
+ const { done, value } = await reader.read();
109
+ if (done)
110
+ break;
111
+ if (!value)
112
+ continue;
113
+ totalBytes += value.byteLength;
114
+ if (totalBytes > MAX_RESOURCE_BYTES) {
115
+ await reader.cancel().catch(() => undefined);
116
+ throw new Error(`GitHub profile resource exceeds ${MAX_RESOURCE_BYTES} bytes: ${description}`);
117
+ }
118
+ try {
119
+ consumeBytes(value.byteLength);
120
+ }
121
+ catch (error) {
122
+ await reader.cancel().catch(() => undefined);
123
+ throw error;
124
+ }
125
+ chunks.push(value);
126
+ }
127
+ }
128
+ finally {
129
+ reader.releaseLock();
130
+ }
131
+ return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)), totalBytes).toString("utf8");
132
+ }
133
+ async function mapWithConcurrency(values, concurrency, controller, transform) {
134
+ const results = new Array(values.length);
135
+ let nextIndex = 0;
136
+ let firstFailure;
137
+ const worker = async () => {
138
+ while (!firstFailure) {
139
+ const index = nextIndex++;
140
+ if (index >= values.length)
141
+ return;
142
+ try {
143
+ results[index] = await transform(values[index]);
144
+ }
145
+ catch (error) {
146
+ if (!firstFailure) {
147
+ firstFailure = { error };
148
+ controller.abort(error);
149
+ }
150
+ }
151
+ }
152
+ };
153
+ await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, async () => worker()));
154
+ if (firstFailure)
155
+ throw firstFailure.error;
156
+ return results;
157
+ }
90
158
  function normalizeWorkspaceRelativePath(pathValue) {
91
159
  if (typeof pathValue !== "string" ||
92
160
  !pathValue ||
@@ -102,6 +170,7 @@ function normalizeWorkspaceRelativePath(pathValue) {
102
170
  if (segments.some((segment) => !segment ||
103
171
  segment === "." ||
104
172
  segment === ".." ||
173
+ segment === ".git" ||
105
174
  segment === ".sidecar")) {
106
175
  throw new Error(`Workspace file path must be canonical: ${pathValue}`);
107
176
  }
@@ -122,6 +191,115 @@ function validateFileMode(mode) {
122
191
  }
123
192
  return normalized;
124
193
  }
194
+ /** Resolve every GitHub resource to exact inline bytes before materialization. */
195
+ export async function resolveAgentProfileResources(inputProfile, options = {}) {
196
+ const profile = agentProfileSchema.parse(inputProfile);
197
+ const resources = profile.resources;
198
+ if (!resources)
199
+ return profile;
200
+ const refs = [
201
+ ...(resources.files?.map((mount) => mount.resource) ?? []),
202
+ ...(resources.tools ?? []),
203
+ ...(resources.skills ?? []),
204
+ ...(resources.agents ?? []),
205
+ ...(resources.commands ?? []),
206
+ ...(typeof resources.instructions === "object"
207
+ ? [resources.instructions]
208
+ : []),
209
+ ];
210
+ if (refs.length > MAX_PROFILE_RESOURCE_REFS) {
211
+ throw new Error(`AgentProfile may contain at most ${MAX_PROFILE_RESOURCE_REFS} resource references`);
212
+ }
213
+ const batchController = new AbortController();
214
+ let fetchedBytes = 0;
215
+ const consumeFetchedBytes = (bytes) => {
216
+ fetchedBytes += bytes;
217
+ if (fetchedBytes > MAX_PROFILE_RESOURCE_BYTES) {
218
+ throw new Error(`GitHub profile resources exceed ${MAX_PROFILE_RESOURCE_BYTES} total bytes`);
219
+ }
220
+ };
221
+ const resolveRef = async (ref) => {
222
+ if (ref.kind === "inline")
223
+ return ref;
224
+ const repository = ref.repository ?? options.repository;
225
+ if (!repository) {
226
+ throw new Error(`GitHub profile resource "${ref.path}" requires repository`);
227
+ }
228
+ const [owner, repo, ...extraRepositoryParts] = repository.split("/");
229
+ if (!owner ||
230
+ !repo ||
231
+ extraRepositoryParts.length > 0 ||
232
+ !/^[A-Za-z0-9_.-]+$/.test(owner) ||
233
+ !/^[A-Za-z0-9_.-]+$/.test(repo)) {
234
+ throw new Error(`GitHub profile resource repository must be owner/repo: ${repository}`);
235
+ }
236
+ const resourcePath = normalizeWorkspaceRelativePath(ref.path);
237
+ const gitRef = ref.ref ?? "main";
238
+ if (!gitRef ||
239
+ hasControlCharacter(gitRef) ||
240
+ gitRef.split("/").some((segment) => !segment || segment === "..")) {
241
+ throw new Error(`Invalid GitHub profile resource ref: ${gitRef}`);
242
+ }
243
+ const encoded = (value) => value.split("/").map(encodeURIComponent).join("/");
244
+ const url = `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encoded(gitRef)}/${encoded(resourcePath)}`;
245
+ const controller = new AbortController();
246
+ const abortRequest = () => controller.abort(batchController.signal.reason);
247
+ batchController.signal.addEventListener("abort", abortRequest, {
248
+ once: true,
249
+ });
250
+ const timeoutError = new Error(`GitHub profile resource timed out: ${repository}/${resourcePath}@${gitRef}`);
251
+ const timeout = setTimeout(() => controller.abort(timeoutError), options.timeoutMs ?? DEFAULT_RESOURCE_FETCH_TIMEOUT_MS);
252
+ try {
253
+ const fetchResource = options.fetch ?? globalThis.fetch;
254
+ const response = await fetchResource(url, { signal: controller.signal });
255
+ if (!response.ok) {
256
+ throw new Error(`Failed to fetch GitHub profile resource ${repository}/${resourcePath}@${gitRef}: ${response.status} ${response.statusText}`);
257
+ }
258
+ const contentLength = Number(response.headers.get("content-length"));
259
+ if (Number.isFinite(contentLength) &&
260
+ contentLength > MAX_RESOURCE_BYTES) {
261
+ throw new Error(`GitHub profile resource exceeds ${MAX_RESOURCE_BYTES} bytes: ${repository}/${resourcePath}`);
262
+ }
263
+ const content = await readResourceBody(response, `${repository}/${resourcePath}`, consumeFetchedBytes);
264
+ return {
265
+ kind: "inline",
266
+ name: ref.name ?? basename(resourcePath),
267
+ content,
268
+ };
269
+ }
270
+ catch (error) {
271
+ if (controller.signal.aborted && controller.signal.reason !== undefined) {
272
+ throw controller.signal.reason;
273
+ }
274
+ throw error;
275
+ }
276
+ finally {
277
+ clearTimeout(timeout);
278
+ batchController.signal.removeEventListener("abort", abortRequest);
279
+ }
280
+ };
281
+ const resolvedRefs = await mapWithConcurrency(refs, MAX_CONCURRENT_RESOURCE_FETCHES, batchController, resolveRef);
282
+ let resolvedIndex = 0;
283
+ const takeRef = () => resolvedRefs[resolvedIndex++];
284
+ const takeRefs = (values) => values?.map(takeRef);
285
+ const resolvedResources = {
286
+ ...resources,
287
+ files: resources.files === undefined
288
+ ? undefined
289
+ : resources.files.map((mount) => ({
290
+ ...mount,
291
+ resource: takeRef(),
292
+ })),
293
+ tools: takeRefs(resources.tools),
294
+ skills: takeRefs(resources.skills),
295
+ agents: takeRefs(resources.agents),
296
+ commands: takeRefs(resources.commands),
297
+ instructions: typeof resources.instructions === "object"
298
+ ? takeRef()
299
+ : resources.instructions,
300
+ };
301
+ return agentProfileSchema.parse({ ...profile, resources: resolvedResources });
302
+ }
125
303
  /** Normalize any skill markdown (VB frontmatter `skill:`/`description:`, or raw) into a
126
304
  * harness SKILL.md with `name:`/`description:` frontmatter the harnesses require. */
127
305
  export function normalizeSkillMd(name, raw) {
@@ -160,7 +338,8 @@ const tomlStr = (s) => JSON.stringify(s); // valid TOML basic string
160
338
  * Build the WorkspacePlan for `profile` on `harness`. Pure — no IO. The caller
161
339
  * writes plan.files into the run cwd and applies plan.env/plan.flags to the spawn.
162
340
  */
163
- export function materializeProfile(profile, harness, opts = {}) {
341
+ export function materializeProfile(inputProfile, harness, opts = {}) {
342
+ const profile = agentProfileSchema.parse(inputProfile);
164
343
  const h = canonicalHarness(harness);
165
344
  const skip = new Set(opts.skip ?? []);
166
345
  const plan = {
@@ -234,12 +413,36 @@ export function materializeProfile(profile, harness, opts = {}) {
234
413
  unsupported("subagents", `${h}: agent definitions have no verified cwd-native resource path`);
235
414
  continue;
236
415
  }
237
- addExplicitResource("subagents", `${directory}/${resource.name}`, resource.content);
416
+ const relPath = `${directory}/${resource.name}`;
417
+ addExplicitResource("subagents", relPath, resource.content);
418
+ if (h === "codex") {
419
+ const roleName = codexAgentRoleName(resource.name);
420
+ if (!roleName) {
421
+ unsupported("subagents", `codex: agent resource "${resource.name}" must use a lowercase role name`);
422
+ }
423
+ else {
424
+ registerCodexAgent(plan, roleName, relPath, roleName);
425
+ }
426
+ }
427
+ }
428
+ // 4) PROMPT. A system prompt is a replacement, while instructions are
429
+ // additive workspace context. Never lower one into the other.
430
+ if (profile.prompt?.systemPrompt !== undefined) {
431
+ if (h === "claude-code" ||
432
+ h === "codex" ||
433
+ h === "opencode" ||
434
+ h === "pi") {
435
+ plan.systemPrompt = profile.prompt.systemPrompt;
436
+ }
437
+ else if (h === "gemini") {
438
+ add(".gemini/system.md", profile.prompt.systemPrompt);
439
+ plan.env.GEMINI_SYSTEM_MD = "1";
440
+ }
441
+ else {
442
+ unsupported("systemPrompt", `${h}: no verified exact system-prompt replacement control`);
443
+ }
238
444
  }
239
- // 4) CONTEXT (instructions + systemPrompt → the harness's native context file).
240
445
  const instr = [];
241
- if (profile.prompt?.systemPrompt)
242
- instr.push(profile.prompt.systemPrompt);
243
446
  if (profile.prompt?.instructions?.length)
244
447
  instr.push(...profile.prompt.instructions);
245
448
  const resInstr = profile.resources?.instructions;
@@ -247,11 +450,31 @@ export function materializeProfile(profile, harness, opts = {}) {
247
450
  instr.push(resInstr);
248
451
  else if (resInstr && resInstr.kind === "inline")
249
452
  instr.push(resInstr.content);
453
+ else if (resInstr) {
454
+ unsupported("instructions", "instruction ref is not inline (github refs need async pre-resolution)");
455
+ }
250
456
  if (instr.length) {
251
- add(CONTEXT_FILE[h], `${instr.join("\n\n")}\n`);
457
+ const instructionPath = CONTEXT_FILE[h];
458
+ add(instructionPath, `${instr.join("\n\n")}\n`);
459
+ if (h === "opencode") {
460
+ add("opencode.json", JSON.stringify({
461
+ $schema: "https://opencode.ai/config.json",
462
+ instructions: [instructionPath],
463
+ }, null, 2));
464
+ }
252
465
  // gemini reads AGENTS.md only via settings; we write GEMINI.md so no extra step.
253
466
  }
254
- // 5) SKILLS → native skill dir, from resources.skills refs (the box's shape), or
467
+ // 5) ACTIVE EXECUTION CONTROLS. These alter the tools visible to the primary
468
+ // process and its permission policy, so they must be lowered into native
469
+ // launch/config inputs rather than merely retained in profile metadata.
470
+ materializeExecutionControls(h, profile.tools, profile.permissions, profile.modes, plan, add, unsupported);
471
+ if ((profile.connections?.length ?? 0) > 0) {
472
+ unsupported("connections", `${h}: Hub connections must be resolved to authorized MCP servers by the execution runtime`);
473
+ }
474
+ if (profile.confidential !== undefined) {
475
+ unsupported("confidential", `${h}: confidential execution requires runtime placement and attestation, not workspace files`);
476
+ }
477
+ // 6) SKILLS → native skill dir, from resources.skills refs (the box's shape), or
255
478
  // fail-closed (hermes has no cwd skill dir; github refs need async pre-resolution).
256
479
  for (const ref of profile.resources?.skills ?? []) {
257
480
  const r = resolveInlineRef(ref);
@@ -266,20 +489,27 @@ export function materializeProfile(profile, harness, opts = {}) {
266
489
  }
267
490
  addExplicitResource("skills", `${dir}/${r.name}/SKILL.md`, normalizeSkillMd(r.name, r.content));
268
491
  }
269
- // 6) MCP → per-harness format (the divergence the matrix names). Skippable so the
270
- // additive host-wiring can leave MCP on cli-bridge's existing per-harness path.
271
- const mcp = profile.mcp ?? {};
492
+ // 7) MCP -> each harness's native configuration and launch controls.
493
+ const mcp = activeMcpServers(profile.mcp);
272
494
  if (!skip.has("mcp") && Object.keys(mcp).length)
273
495
  materializeMcp(h, mcp, plan, add, unsupported);
274
- // 7) HOOKS → per-harness format; cwd-native only for claude/codex/gemini/opencode(plugin).
496
+ // 8) HOOKS → per-harness format; cwd-native only for claude/codex/gemini/opencode(plugin).
275
497
  const hooks = profile.hooks ?? {};
276
498
  if (Object.keys(hooks).length)
277
499
  materializeHooks(h, hooks, add, unsupported, plan);
278
- // 8) SUBAGENTS → cwd-native for claude/codex/opencode/gemini; flag/global elsewhere.
500
+ // 9) SUBAGENTS → cwd-native for claude/codex/opencode/gemini; flag/global elsewhere.
279
501
  const subagents = profile.subagents ?? {};
280
502
  for (const [name, sa] of Object.entries(subagents))
281
- materializeSubagent(h, name, sa, add, unsupported);
282
- // 9) COMMANDS → cwd-native (resources.commands refs); "commands are skills" on kimi.
503
+ materializeSubagent(h, name, sa, plan, add, unsupported);
504
+ if (!skip.has("extensions")) {
505
+ for (const namespace of extensionNamespaces(h)) {
506
+ const extension = profile.extensions?.[namespace];
507
+ if (extension && Object.keys(extension).length > 0) {
508
+ unsupported("extensions", `${h}: extension namespace "${namespace}" requires provider-native handling`);
509
+ }
510
+ }
511
+ }
512
+ // 10) COMMANDS → cwd-native (resources.commands refs); "commands are skills" on kimi.
283
513
  for (const ref of profile.resources?.commands ?? []) {
284
514
  const r = resolveInlineRef(ref);
285
515
  if (!r) {
@@ -294,7 +524,7 @@ export function materializeProfile(profile, harness, opts = {}) {
294
524
  throw new Error(`Profile ${dimension} resource conflicts with generated harness file: ${relPath}`);
295
525
  }
296
526
  }
297
- // Merge native multi-writer JSON files (e.g. .claude/settings.json from both
527
+ // Merge native multi-writer JSON files (for example Claude settings from both
298
528
  // mcp and hooks) so the second writer cannot clobber the first.
299
529
  plan.files = mergeJsonFiles(plan.files).sort((a, b) => compareStrings(a.relPath, b.relPath));
300
530
  if (profile.resources?.failOnError && plan.unsupported.length) {
@@ -302,11 +532,23 @@ export function materializeProfile(profile, harness, opts = {}) {
302
532
  }
303
533
  return plan;
304
534
  }
535
+ function activeMcpServers(servers) {
536
+ return Object.fromEntries(Object.entries(servers ?? {}).filter((entry) => entry[1].enabled !== false));
537
+ }
538
+ function extensionNamespaces(harness) {
539
+ switch (harness) {
540
+ case "claude-code":
541
+ return ["claude-code", "claude"];
542
+ case "kimi-code":
543
+ return ["kimi-code", "kimi"];
544
+ default:
545
+ return [harness];
546
+ }
547
+ }
305
548
  /**
306
549
  * Materialize a sealed candidate profile without weakening its process values.
307
- * Candidate hooks deliberately fail closed because the currently supported
308
- * native hook formats accept a shell command string rather than executable +
309
- * argv + env as separate fields.
550
+ * Codex and Claude hooks use generated no-shell wrappers to preserve executable,
551
+ * argv, and environment values separately. Other hook formats fail closed.
310
552
  */
311
553
  export function materializeCandidateProfile(profile, harness, opts = {}) {
312
554
  const candidate = agentCandidateProfileSchema.parse(profile);
@@ -326,13 +568,6 @@ export function materializeCandidateProfile(profile, harness, opts = {}) {
326
568
  hooks: hookLowering.hooks,
327
569
  };
328
570
  const classicPlan = materializeProfile(classicProfile, harness);
329
- if (canonicalHarness(harness) === "opencode" &&
330
- Object.values(candidate.mcp ?? {}).some((server) => server.enabled !== false && server.cwd !== undefined)) {
331
- classicPlan.unsupported.push({
332
- dimension: "mcp",
333
- reason: "opencode local MCP configuration does not support a cwd field",
334
- });
335
- }
336
571
  if (hookLowering.unsupportedReason) {
337
572
  classicPlan.unsupported.push({
338
573
  dimension: "hooks",
@@ -340,24 +575,27 @@ export function materializeCandidateProfile(profile, harness, opts = {}) {
340
575
  });
341
576
  }
342
577
  classicPlan.unsupported.sort((left, right) => compareStrings(`${left.dimension}\0${left.reason}`, `${right.dimension}\0${right.reason}`));
343
- if (classicPlan.unsupported.length > 0) {
344
- throw new Error(formatUnsupported(classicPlan.unsupported));
345
- }
578
+ assertWorkspacePlanSupported(classicPlan);
346
579
  return {
580
+ sourceProfileDigest: canonicalCandidateDigest(candidate),
347
581
  harness: classicPlan.harness,
348
582
  files: classicPlan.files,
349
583
  env: candidatePublicRecord(classicPlan.env),
350
584
  flags: classicPlan.flags.map(candidatePublicValue),
585
+ ...(classicPlan.systemPrompt === undefined
586
+ ? {}
587
+ : { systemPrompt: candidatePublicValue(classicPlan.systemPrompt) }),
351
588
  unsupported: classicPlan.unsupported,
352
589
  };
353
590
  }
354
591
  function lowerCandidateHooks(hooks, harness) {
355
592
  if (!hooks || Object.keys(hooks).length === 0)
356
593
  return { wrapperFiles: [] };
357
- if (canonicalHarness(harness) !== "codex") {
594
+ const nativeHarness = canonicalHarness(harness);
595
+ if (nativeHarness !== "codex" && nativeHarness !== "claude-code") {
358
596
  return {
359
597
  wrapperFiles: [],
360
- unsupportedReason: `${canonicalHarness(harness)} hook configuration requires an unverified command-string mapping; ` +
598
+ unsupportedReason: `${nativeHarness} hook configuration requires an unverified command-string mapping; ` +
361
599
  "sealed candidate hooks preserve executable, argv, and env separately",
362
600
  };
363
601
  }
@@ -389,7 +627,7 @@ function candidateHookWrapper(event, index, command) {
389
627
  const env = Object.fromEntries(Object.entries(command.env ?? {})
390
628
  .sort(([left], [right]) => compareStrings(left, right))
391
629
  .map(([name, value]) => [name, candidatePublicString(value)]));
392
- const identity = rfc8785Serialize({
630
+ const wrapperId = canonicalCandidateDigest({
393
631
  event,
394
632
  index,
395
633
  executable: command.executable,
@@ -398,30 +636,9 @@ function candidateHookWrapper(event, index, command) {
398
636
  timeoutMs: command.timeoutMs ?? null,
399
637
  blocking: command.blocking ?? null,
400
638
  matcher: command.matcher ?? null,
401
- });
402
- const wrapperId = sha256(identity);
639
+ }).slice("sha256:".length);
403
640
  const path = `.tangle/profile-hooks/${wrapperId}.mjs`;
404
- const content = [
405
- 'import { spawnSync } from "node:child_process"',
406
- `const executable = ${JSON.stringify(command.executable)}`,
407
- `const args = ${JSON.stringify(args)}`,
408
- `const extraEnv = ${JSON.stringify(env)}`,
409
- "const result = spawnSync(executable, args, {",
410
- ' stdio: "inherit",',
411
- " env: { ...process.env, ...extraEnv },",
412
- " shell: false,",
413
- "})",
414
- "if (result.error) {",
415
- " console.error(result.error.message)",
416
- " process.exit(127)",
417
- "}",
418
- "if (result.signal) {",
419
- ' console.error("hook terminated by " + result.signal)',
420
- " process.exit(1)",
421
- "}",
422
- "process.exit(result.status ?? 1)",
423
- "",
424
- ].join("\n");
641
+ const content = hookWrapperSource(command.executable, args, env);
425
642
  return { path, content };
426
643
  }
427
644
  function agentResourceDirectory(harness) {
@@ -443,7 +660,7 @@ function candidateMcpRecordForClassicMaterializer(servers) {
443
660
  if (!servers)
444
661
  return undefined;
445
662
  return Object.fromEntries(Object.entries(servers)
446
- .filter(([, server]) => server.enabled !== false)
663
+ .filter((entry) => entry[1].enabled !== false)
447
664
  .map(([name, server]) => [
448
665
  name,
449
666
  candidateMcpForClassicMaterializer(server),
@@ -451,17 +668,20 @@ function candidateMcpRecordForClassicMaterializer(servers) {
451
668
  }
452
669
  function candidateMcpForClassicMaterializer(server) {
453
670
  return {
454
- transport: server.transport,
455
671
  command: server.command,
456
- args: server.args?.map(candidatePublicString),
457
- env: server.env
458
- ? Object.fromEntries(Object.entries(server.env).map(([name, value]) => [
459
- name,
460
- candidatePublicString(value),
461
- ]))
462
- : undefined,
463
- cwd: server.cwd,
464
- enabled: server.enabled,
672
+ ...(server.transport === undefined ? {} : { transport: server.transport }),
673
+ ...(server.args === undefined
674
+ ? {}
675
+ : { args: server.args.map(candidatePublicString) }),
676
+ ...(server.env === undefined
677
+ ? {}
678
+ : {
679
+ env: Object.fromEntries(Object.entries(server.env).map(([name, value]) => [
680
+ name,
681
+ candidatePublicString(value),
682
+ ])),
683
+ }),
684
+ ...(server.cwd === undefined ? {} : { cwd: server.cwd }),
465
685
  };
466
686
  }
467
687
  function candidateResourcesForClassicMaterializer(resources, resolvedResources) {
@@ -618,11 +838,13 @@ export function hashWorkspacePlan(plan) {
618
838
  const env = Object.fromEntries(Object.entries(plan.env).sort(([a], [b]) => compareStrings(a, b)));
619
839
  const unsupported = [...plan.unsupported].sort((a, b) => compareStrings(`${a.dimension}\0${a.reason}`, `${b.dimension}\0${b.reason}`));
620
840
  const canonical = JSON.stringify({
621
- version: 1,
622
841
  harness: canonicalHarness(plan.harness),
623
842
  files,
624
843
  env,
625
844
  flags: [...plan.flags],
845
+ ...(plan.systemPrompt === undefined
846
+ ? {}
847
+ : { systemPrompt: plan.systemPrompt }),
626
848
  unsupported,
627
849
  });
628
850
  return `sha256:${sha256(canonical)}`;
@@ -630,7 +852,7 @@ export function hashWorkspacePlan(plan) {
630
852
  /** Build the exact digest-free identity document required by agent-interface. */
631
853
  export function agentCandidateProfilePlanMaterial(plan) {
632
854
  const material = {
633
- version: 1,
855
+ sourceProfileDigest: plan.sourceProfileDigest,
634
856
  harness: canonicalHarness(plan.harness),
635
857
  files: normalizePlanFiles(plan.files).map((file) => ({
636
858
  relPath: file.relPath,
@@ -639,6 +861,9 @@ export function agentCandidateProfilePlanMaterial(plan) {
639
861
  })),
640
862
  env: Object.fromEntries(Object.entries(plan.env).sort(([left], [right]) => compareStrings(left, right))),
641
863
  flags: [...plan.flags],
864
+ ...(plan.systemPrompt === undefined
865
+ ? {}
866
+ : { systemPrompt: plan.systemPrompt }),
642
867
  unsupported: [...plan.unsupported].sort((left, right) => compareStrings(`${left.dimension}\0${left.reason}`, `${right.dimension}\0${right.reason}`)),
643
868
  };
644
869
  return agentCandidateProfilePlanMaterialSchema.parse(material);
@@ -649,10 +874,8 @@ export function agentCandidateProfilePlanMaterial(plan) {
649
874
  */
650
875
  export function canonicalizeAgentCandidateProfilePlan(plan) {
651
876
  const material = agentCandidateProfilePlanMaterial(plan);
652
- const bytes = Uint8Array.from(Buffer.from(rfc8785Serialize(material), "utf8"));
653
- const digest = `sha256:${createHash("sha256")
654
- .update(bytes)
655
- .digest("hex")}`;
877
+ const bytes = canonicalCandidateBytes(material);
878
+ const digest = canonicalCandidateDigest(material);
656
879
  return { material, bytes, digest };
657
880
  }
658
881
  /** Capture portable evidence of the exact canonical profile-plan bytes. */
@@ -665,7 +888,6 @@ export function captureAgentCandidateProfilePlan(plan) {
665
888
  byteLength: canonical.bytes.byteLength,
666
889
  };
667
890
  return agentCandidateProfilePlanEvidenceSchema.parse({
668
- schemaVersion: 1,
669
891
  kind: "agent-profile-workspace-plan",
670
892
  digest: canonical.digest,
671
893
  material: canonical.material,
@@ -677,10 +899,22 @@ export function captureAgentCandidateProfilePlan(plan) {
677
899
  * canonical evidence. No caller needs to re-serialize or reinterpret the plan.
678
900
  */
679
901
  export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorkspace) {
680
- if (plan.unsupported.length > 0) {
681
- throw new Error(formatUnsupported(plan.unsupported));
682
- }
902
+ assertWorkspacePlanSupported(plan);
683
903
  const profilePlan = captureAgentCandidateProfilePlan(plan);
904
+ const activationFiles = normalizePlanFiles(plan.files).map((file) => ({
905
+ path: file.relPath,
906
+ mode: file.mode,
907
+ content: file.content,
908
+ }));
909
+ const activationMaterial = {
910
+ kind: "agent-candidate-profile-activation",
911
+ profilePlan,
912
+ files: activationFiles,
913
+ };
914
+ const profileActivation = agentCandidateProfileActivationSchema.parse({
915
+ ...activationMaterial,
916
+ digest: canonicalCandidateDigest(activationMaterial),
917
+ });
684
918
  const applied = applyWorkspacePlan({
685
919
  harness: plan.harness,
686
920
  files: plan.files,
@@ -689,10 +923,13 @@ export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorks
689
923
  candidatePublicString(value),
690
924
  ])),
691
925
  flags: plan.flags.map(candidatePublicString),
926
+ ...(plan.systemPrompt === undefined
927
+ ? {}
928
+ : { systemPrompt: candidatePublicString(plan.systemPrompt) }),
692
929
  unsupported: plan.unsupported,
693
930
  }, workspaceDir);
694
931
  return {
695
- profilePlan,
932
+ profileActivation,
696
933
  application: {
697
934
  planDigest: profilePlan.digest,
698
935
  targetWorkspace,
@@ -700,41 +937,24 @@ export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorks
700
937
  },
701
938
  env: { ...plan.env },
702
939
  flags: [...plan.flags],
940
+ ...(plan.systemPrompt === undefined
941
+ ? {}
942
+ : { systemPrompt: plan.systemPrompt }),
703
943
  unsupported: [...plan.unsupported],
704
944
  };
705
945
  }
706
- function rfc8785Serialize(value) {
707
- if (value === null || typeof value === "boolean")
708
- return JSON.stringify(value);
709
- if (typeof value === "string")
710
- return JSON.stringify(value);
711
- if (typeof value === "number") {
712
- if (!Number.isFinite(value)) {
713
- throw new Error("RFC 8785 cannot serialize a non-finite number");
714
- }
715
- return JSON.stringify(value);
716
- }
717
- if (Array.isArray(value)) {
718
- return `[${value.map(rfc8785Serialize).join(",")}]`;
719
- }
720
- if (typeof value === "object") {
721
- const record = value;
722
- return `{${Object.keys(record)
723
- .sort(compareStrings)
724
- .map((key) => `${JSON.stringify(key)}:${rfc8785Serialize(record[key])}`)
725
- .join(",")}}`;
726
- }
727
- throw new Error(`RFC 8785 cannot serialize ${typeof value}`);
728
- }
729
946
  /**
730
947
  * Apply a WorkspacePlan to a real workspace dir: write every file (creating parent
731
948
  * dirs), and return the env + flags the caller must hand the spawn. This is the IO
732
949
  * consumer of the pure `materializeProfile` — used by the cli-bridge host spawn, the
733
950
  * agent-dev-container box, and VB. Returns `unsupported` so the caller can log/fail.
734
951
  */
735
- export function applyWorkspacePlan(plan, workspaceDir) {
952
+ export function applyWorkspacePlan(plan, workspaceDir, options = {}) {
736
953
  const workspaceRoot = resolve(workspaceDir);
737
- const files = normalizePlanFiles(plan.files).map((file) => ({
954
+ const runtimeOnlyFiles = new Set((options.runtimeOnlyFiles ?? []).map(normalizeWorkspaceRelativePath));
955
+ const files = normalizePlanFiles(plan.files)
956
+ .filter((file) => !runtimeOnlyFiles.has(file.relPath))
957
+ .map((file) => ({
738
958
  ...file,
739
959
  absPath: resolveWorkspaceFilePath(workspaceRoot, file.relPath),
740
960
  }));
@@ -742,6 +962,9 @@ export function applyWorkspacePlan(plan, workspaceDir) {
742
962
  // existing symlink cannot leave a partially materialized workspace.
743
963
  for (const file of files) {
744
964
  assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
965
+ if (workspaceFileExists(file.absPath) && !workspaceFileMatches(file)) {
966
+ throw new Error(`Refusing to replace existing workspace file: ${file.relPath}`);
967
+ }
745
968
  }
746
969
  for (const file of files) {
747
970
  mkdirSync(dirname(file.absPath), { recursive: true });
@@ -751,17 +974,39 @@ export function applyWorkspacePlan(plan, workspaceDir) {
751
974
  }
752
975
  const written = [];
753
976
  for (const file of files) {
754
- writeWorkspaceFile(workspaceRoot, file);
977
+ if (!workspaceFileExists(file.absPath)) {
978
+ writeWorkspaceFile(workspaceRoot, file);
979
+ }
755
980
  written.push(file.relPath);
756
981
  }
757
982
  return {
758
983
  workspacePlanDigest: hashWorkspacePlan(plan),
759
984
  env: plan.env,
760
985
  flags: plan.flags,
986
+ ...(plan.systemPrompt === undefined
987
+ ? {}
988
+ : { systemPrompt: plan.systemPrompt }),
761
989
  unsupported: plan.unsupported,
762
990
  written,
763
991
  };
764
992
  }
993
+ function workspaceFileExists(pathValue) {
994
+ try {
995
+ lstatSync(pathValue);
996
+ return true;
997
+ }
998
+ catch (error) {
999
+ if (isFsError(error, "ENOENT"))
1000
+ return false;
1001
+ throw error;
1002
+ }
1003
+ }
1004
+ function workspaceFileMatches(file) {
1005
+ const stats = lstatSync(file.absPath);
1006
+ return (stats.isFile() &&
1007
+ (stats.mode & 0o777) === file.mode &&
1008
+ readFileSync(file.absPath, "utf8") === file.content);
1009
+ }
765
1010
  function resolveWorkspaceFilePath(workspaceRoot, relativePath) {
766
1011
  const targetPath = resolve(workspaceRoot, ...relativePath.split("/"));
767
1012
  const targetRelativePath = relative(workspaceRoot, targetPath);
@@ -812,7 +1057,15 @@ function writeWorkspaceFile(workspaceRoot, file) {
812
1057
  closeSync(descriptor);
813
1058
  descriptor = undefined;
814
1059
  assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
815
- renameSync(temporaryPath, file.absPath);
1060
+ try {
1061
+ linkSync(temporaryPath, file.absPath);
1062
+ }
1063
+ catch (error) {
1064
+ if (!isFsError(error, "EEXIST") || !workspaceFileMatches(file)) {
1065
+ throw error;
1066
+ }
1067
+ }
1068
+ unlinkSync(temporaryPath);
816
1069
  }
817
1070
  catch (error) {
818
1071
  if (descriptor !== undefined)
@@ -834,55 +1087,337 @@ function isFsError(error, code) {
834
1087
  error.code === code);
835
1088
  }
836
1089
  // ─── Per-dimension routing (verified matrix) ────────────────────────────────
1090
+ function materializeExecutionControls(h, tools, permissions, modes, plan, add, unsupported) {
1091
+ const toolEntries = Object.entries(tools ?? {}).sort(([left], [right]) => compareStrings(left, right));
1092
+ const permissionEntries = Object.entries(permissions ?? {}).sort(([left], [right]) => compareStrings(left, right));
1093
+ switch (h) {
1094
+ case "claude-code": {
1095
+ if (toolEntries.length > 0 || permissionEntries.length > 0) {
1096
+ const nativeTools = mapSupportedBooleanEntries(toolEntries, claudeToolName, (name) => unsupported("tools", `claude-code: no verified native tool mapping for "${name}"`), "claude-code tool");
1097
+ const nativePermissions = mapSupportedPermissionEntries(permissionEntries, claudeToolName, (name) => unsupported("permissions", `claude-code: no verified native permission mapping for "${name}"`), "claude-code permission");
1098
+ const rules = claudePermissionRules(nativePermissions);
1099
+ for (const [tool, enabled] of nativeTools) {
1100
+ rules[enabled ? "allow" : "deny"].push(tool);
1101
+ }
1102
+ add(CLAUDE_SETTINGS_PATH, JSON.stringify({ permissions: sealClaudePermissionRules(rules) }, null, 2));
1103
+ registerClaudeSettings(plan);
1104
+ }
1105
+ break;
1106
+ }
1107
+ case "opencode": {
1108
+ const enforceableTools = toolEntries.filter(([name, enabled]) => {
1109
+ if (normalizedControlName(name) !== "lsp" || !enabled)
1110
+ return true;
1111
+ unsupported("tools", "opencode: the LSP tool is disabled by the runtime and cannot be enabled exactly");
1112
+ return false;
1113
+ });
1114
+ const enforceablePermissions = permissionEntries.filter(([name, policy]) => {
1115
+ if (normalizedControlName(name) !== "read")
1116
+ return true;
1117
+ if (!permissionAllowsEverything(policy)) {
1118
+ unsupported("permissions", "opencode: read access cannot be denied or approval-gated");
1119
+ }
1120
+ return false;
1121
+ });
1122
+ const nativeTools = mapSupportedBooleanEntries(enforceableTools, openCodeToolName, (name) => unsupported("tools", `opencode: no verified native tool mapping for "${name}"`), "opencode tool");
1123
+ const nativePermissions = mapSupportedPermissionEntries(enforceablePermissions, openCodePermissionName, (name) => unsupported("permissions", `opencode: no verified native permission mapping for "${name}"`), "opencode permission");
1124
+ if (enforceableTools.length > 0 || permissionEntries.length > 0) {
1125
+ add("opencode.json", JSON.stringify({
1126
+ $schema: "https://opencode.ai/config.json",
1127
+ ...(nativeTools.length > 0
1128
+ ? { tools: Object.fromEntries(nativeTools) }
1129
+ : {}),
1130
+ ...(nativePermissions.length > 0
1131
+ ? { permission: Object.fromEntries(nativePermissions) }
1132
+ : {}),
1133
+ }, null, 2));
1134
+ }
1135
+ break;
1136
+ }
1137
+ case "codex": {
1138
+ const searchEntries = [];
1139
+ for (const [name, enabled] of toolEntries) {
1140
+ const posture = resolveNativeWebTools({ [name]: enabled });
1141
+ if (posture.search === enabled && posture.fetch === undefined) {
1142
+ searchEntries.push([name, enabled]);
1143
+ continue;
1144
+ }
1145
+ if (posture.fetch === enabled && posture.search === undefined) {
1146
+ if (enabled) {
1147
+ unsupported("tools", `codex: native web fetch is unavailable for "${name}"`);
1148
+ }
1149
+ continue;
1150
+ }
1151
+ unsupported("tools", `codex: no verified primary-process tool mapping for "${name}"`);
1152
+ }
1153
+ const nativeSearch = mapSupportedBooleanEntries(searchEntries, () => "web_search", () => undefined, "codex tool");
1154
+ if (nativeSearch[0]) {
1155
+ plan.flags.push("-c", `tools.web_search=${nativeSearch[0][1]}`);
1156
+ }
1157
+ if (permissionEntries.length > 0) {
1158
+ unsupported("permissions", "codex: no verified primary-process permission policy mapping");
1159
+ }
1160
+ break;
1161
+ }
1162
+ default:
1163
+ if (toolEntries.length > 0) {
1164
+ unsupported("tools", `${h}: no verified primary-process tool availability mapping`);
1165
+ }
1166
+ if (permissionEntries.length > 0) {
1167
+ unsupported("permissions", `${h}: no verified primary-process permission policy mapping`);
1168
+ }
1169
+ }
1170
+ if (modes && Object.keys(modes).length > 0) {
1171
+ unsupported("modes", `${h}: resolve one named AgentProfile mode before materialization`);
1172
+ }
1173
+ }
1174
+ function normalizedControlName(name) {
1175
+ return name.replace(/[^a-zA-Z0-9_]/g, "").toLowerCase();
1176
+ }
1177
+ function nativeWebToolKind(name) {
1178
+ const posture = resolveNativeWebTools({ [name]: true });
1179
+ if (posture.search === true && posture.fetch === undefined)
1180
+ return "search";
1181
+ if (posture.fetch === true && posture.search === undefined)
1182
+ return "fetch";
1183
+ return undefined;
1184
+ }
1185
+ const CLAUDE_TOOL_NAMES = {
1186
+ askuserquestion: "AskUserQuestion",
1187
+ bash: "Bash",
1188
+ edit: "Edit",
1189
+ glob: "Glob",
1190
+ grep: "Grep",
1191
+ notebookedit: "NotebookEdit",
1192
+ read: "Read",
1193
+ skill: "Skill",
1194
+ task: "Task",
1195
+ taskoutput: "TaskOutput",
1196
+ todowrite: "TodoWrite",
1197
+ webfetch: "WebFetch",
1198
+ websearch: "WebSearch",
1199
+ write: "Write",
1200
+ };
1201
+ function claudeToolName(name) {
1202
+ const web = nativeWebToolKind(name);
1203
+ if (web === "search")
1204
+ return "WebSearch";
1205
+ if (web === "fetch")
1206
+ return "WebFetch";
1207
+ return CLAUDE_TOOL_NAMES[normalizedControlName(name)];
1208
+ }
1209
+ const OPENCODE_TOOL_NAMES = new Set([
1210
+ "bash",
1211
+ "batch",
1212
+ "codesearch",
1213
+ "edit",
1214
+ "glob",
1215
+ "grep",
1216
+ "list",
1217
+ "lsp",
1218
+ "multiedit",
1219
+ "patch",
1220
+ "read",
1221
+ "task",
1222
+ "todoread",
1223
+ "todowrite",
1224
+ "webfetch",
1225
+ "websearch",
1226
+ "write",
1227
+ ]);
1228
+ function openCodeToolName(name) {
1229
+ const web = nativeWebToolKind(name);
1230
+ if (web === "search")
1231
+ return "websearch";
1232
+ if (web === "fetch")
1233
+ return "webfetch";
1234
+ const normalized = normalizedControlName(name);
1235
+ return OPENCODE_TOOL_NAMES.has(normalized) ? normalized : undefined;
1236
+ }
1237
+ function openCodePermissionName(name) {
1238
+ const normalized = normalizedControlName(name);
1239
+ if (["write", "edit"].includes(normalized))
1240
+ return "edit";
1241
+ const web = nativeWebToolKind(name);
1242
+ if (web === "search")
1243
+ return "websearch";
1244
+ if (web === "fetch")
1245
+ return "webfetch";
1246
+ if (["bash", "mcp", "doom_loop", "external_directory"].includes(normalized)) {
1247
+ return normalized;
1248
+ }
1249
+ return normalized !== "read" && OPENCODE_TOOL_NAMES.has(normalized)
1250
+ ? normalized
1251
+ : undefined;
1252
+ }
1253
+ function mapSupportedBooleanEntries(entries, mapName, onUnsupported, label) {
1254
+ return mapControlEntries(entries, mapName, onUnsupported, (left, right, nativeName) => {
1255
+ if (left !== right)
1256
+ throw new Error(`${label} aliases disagree for "${nativeName}"`);
1257
+ return left;
1258
+ });
1259
+ }
1260
+ function permissionAllowsEverything(policy) {
1261
+ return typeof policy === "string"
1262
+ ? policy === "allow"
1263
+ : Object.values(policy).every((value) => value === "allow");
1264
+ }
1265
+ function mapSupportedPermissionEntries(entries, mapName, onUnsupported, label) {
1266
+ return mapControlEntries(entries, mapName, onUnsupported, (left, right, nativeName) => mergePermissionPolicies(left, right, `${label} "${nativeName}"`));
1267
+ }
1268
+ function mapControlEntries(entries, mapName, onUnsupported, merge) {
1269
+ const mapped = new Map();
1270
+ for (const [name, value] of entries) {
1271
+ const nativeName = mapName(name);
1272
+ if (!nativeName) {
1273
+ onUnsupported(name);
1274
+ continue;
1275
+ }
1276
+ const previous = mapped.get(nativeName);
1277
+ mapped.set(nativeName, previous === undefined ? value : merge(previous, value, nativeName));
1278
+ }
1279
+ return [...mapped.entries()].sort(([left], [right]) => compareStrings(left, right));
1280
+ }
1281
+ function mergePermissionPolicies(left, right, label) {
1282
+ if (typeof left === "string" || typeof right === "string") {
1283
+ if (left !== right)
1284
+ throw new Error(`${label} aliases disagree`);
1285
+ return left;
1286
+ }
1287
+ const merged = { ...left };
1288
+ for (const [specifier, decision] of Object.entries(right)) {
1289
+ const previous = merged[specifier];
1290
+ if (previous !== undefined && previous !== decision) {
1291
+ throw new Error(`${label} aliases disagree for "${specifier}"`);
1292
+ }
1293
+ merged[specifier] = decision;
1294
+ }
1295
+ return Object.fromEntries(Object.entries(merged).sort(([leftKey], [rightKey]) => compareStrings(leftKey, rightKey)));
1296
+ }
1297
+ function claudePermissionRules(entries) {
1298
+ const rules = {
1299
+ allow: [],
1300
+ ask: [],
1301
+ deny: [],
1302
+ };
1303
+ for (const [tool, policy] of entries) {
1304
+ if (typeof policy === "string") {
1305
+ rules[policy].push(tool);
1306
+ continue;
1307
+ }
1308
+ for (const [specifier, decision] of Object.entries(policy).sort(([left], [right]) => compareStrings(left, right))) {
1309
+ rules[decision].push(specifier === "*" ? tool : `${tool}(${specifier})`);
1310
+ }
1311
+ }
1312
+ rules.allow.sort(compareStrings);
1313
+ rules.ask.sort(compareStrings);
1314
+ rules.deny.sort(compareStrings);
1315
+ return rules;
1316
+ }
1317
+ function sealClaudePermissionRules(rules) {
1318
+ const owner = new Map();
1319
+ for (const decision of ["allow", "ask", "deny"]) {
1320
+ for (const rule of rules[decision]) {
1321
+ const previous = owner.get(rule);
1322
+ if (previous && previous !== decision) {
1323
+ throw new Error(`claude-code execution control "${rule}" conflicts between ${previous} and ${decision}`);
1324
+ }
1325
+ owner.set(rule, decision);
1326
+ }
1327
+ }
1328
+ return {
1329
+ allow: [...new Set(rules.allow)].sort(compareStrings),
1330
+ ask: [...new Set(rules.ask)].sort(compareStrings),
1331
+ deny: [...new Set(rules.deny)].sort(compareStrings),
1332
+ };
1333
+ }
837
1334
  function materializeMcp(h, mcp, plan, add, unsupported) {
838
1335
  switch (h) {
839
- case "claude-code":
1336
+ case "claude-code": {
1337
+ const servers = {};
1338
+ for (const [name, server] of Object.entries(mcp)) {
1339
+ if (server.url) {
1340
+ servers[name] = {
1341
+ type: server.transport ?? "http",
1342
+ url: server.url,
1343
+ ...(server.headers ? { headers: server.headers } : {}),
1344
+ };
1345
+ continue;
1346
+ }
1347
+ if (server.cwd !== undefined) {
1348
+ unsupported("mcp", `${h}: local MCP server "${name}" does not support a cwd field`);
1349
+ }
1350
+ servers[name] = {
1351
+ type: "stdio",
1352
+ command: server.command,
1353
+ ...(server.args ? { args: server.args } : {}),
1354
+ ...(server.env ? { env: server.env } : {}),
1355
+ };
1356
+ }
1357
+ const relPath = ".tangle/claude-mcp.json";
1358
+ add(relPath, JSON.stringify({ mcpServers: servers }, null, 2));
1359
+ plan.flags.push("--mcp-config", relPath, "--strict-mcp-config");
1360
+ break;
1361
+ }
840
1362
  case "nanoclaw":
841
- // claude reads ./.mcp.json but each server needs per-project APPROVAL; we also
842
- // enable them via .claude/settings.json enabledMcpjsonServers to skip the prompt.
843
- add(".mcp.json", JSON.stringify({ mcpServers: mcp }, null, 2));
844
- add(".claude/settings.json", JSON.stringify({ enabledMcpjsonServers: Object.keys(mcp) }, null, 2));
845
- if (h === "nanoclaw")
846
- unsupported("mcp", "nanoclaw MCP is SDK-injected (options.mcpServers); .mcp.json may be ignored — inject via the SDK adapter");
1363
+ unsupported("mcp", "nanoclaw MCP must be injected through options.mcpServers by its SDK adapter");
847
1364
  break;
848
1365
  case "codex": {
849
- // codex reads [mcp_servers.<id>] in .codex/config.toml; trusted projects only.
850
- const lines = ['[projects."."]', 'trust_level = "trusted"', ""];
851
- for (const [name, s] of Object.entries(mcp)) {
852
- lines.push(`[mcp_servers.${name}]`);
853
- if (s.command)
854
- lines.push(`command = ${tomlStr(s.command)}`);
855
- if (s.args?.length)
856
- lines.push(`args = [${s.args.map(tomlStr).join(", ")}]`);
857
- if (s.cwd)
858
- lines.push(`cwd = ${tomlStr(s.cwd)}`);
859
- if (s.url)
860
- lines.push(`url = ${tomlStr(s.url)}`);
861
- if (s.env)
862
- lines.push(`env = { ${Object.entries(s.env)
863
- .map(([k, v]) => `${k} = ${tomlStr(v)}`)
864
- .join(", ")} }`);
865
- lines.push("");
1366
+ const servers = [];
1367
+ for (const [name, server] of Object.entries(mcp).sort(([left], [right]) => compareStrings(left, right))) {
1368
+ if (server.url && server.transport === "sse") {
1369
+ unsupported("mcp", `codex: remote MCP server "${name}" uses unsupported SSE transport`);
1370
+ continue;
1371
+ }
1372
+ servers.push(`${tomlStr(name)} = ${codexMcpServerToml(name, server, plan)}`);
1373
+ }
1374
+ if (servers.length > 0) {
1375
+ // Runtime overrides do not require persisted project trust. Header values
1376
+ // ride environment variables so credentials never appear in argv.
1377
+ plan.flags.push("-c", `mcp_servers={ ${servers.join(", ")} }`);
866
1378
  }
867
- add(".codex/config.toml", lines.join("\n"));
868
1379
  break;
869
1380
  }
870
1381
  case "opencode":
871
1382
  // opencode.json "mcp" block (local|remote).
872
- add("opencode.json", JSON.stringify({ $schema: "https://opencode.ai/config.json", mcp: opencodeMcp(mcp) }, null, 2));
1383
+ add("opencode.json", JSON.stringify({
1384
+ $schema: "https://opencode.ai/config.json",
1385
+ mcp: openCodeMcpConfig(mcp),
1386
+ }, null, 2));
873
1387
  break;
874
- case "gemini":
875
- add(".gemini/settings.json", JSON.stringify({ mcpServers: mcp }, null, 2));
1388
+ case "gemini": {
1389
+ const servers = {};
1390
+ for (const [name, server] of Object.entries(mcp)) {
1391
+ if (server.url) {
1392
+ servers[name] = {
1393
+ [server.transport === "sse" ? "url" : "httpUrl"]: server.url,
1394
+ ...(server.headers ? { headers: server.headers } : {}),
1395
+ };
1396
+ continue;
1397
+ }
1398
+ servers[name] = {
1399
+ command: server.command,
1400
+ ...(server.args ? { args: server.args } : {}),
1401
+ ...(server.env ? { env: server.env } : {}),
1402
+ ...(server.cwd ? { cwd: server.cwd } : {}),
1403
+ };
1404
+ }
1405
+ add(".gemini/settings.json", JSON.stringify({ mcpServers: servers }, null, 2));
876
1406
  break;
1407
+ }
877
1408
  case "kimi-code": {
878
1409
  // NO cwd auto-discovery — kimi needs an explicit --mcp-config-file FLAG.
879
1410
  const rel = ".kimi/mcp.json";
880
- add(rel, JSON.stringify({ mcpServers: mcp }, null, 2));
1411
+ const servers = Object.fromEntries(Object.entries(mcp).map(([name, server]) => [
1412
+ name,
1413
+ kimiMcpServer(server),
1414
+ ]));
1415
+ add(rel, JSON.stringify({ mcpServers: servers }, null, 2));
881
1416
  plan.flags.push("--mcp-config-file", rel);
882
1417
  break;
883
1418
  }
884
1419
  case "pi":
885
- unsupported("mcp", "pi MCP is wired via a TS extension (.pi/extensions/*.ts), not a declarative cwd file — build an extension");
1420
+ unsupported("mcp", "pi MCP requires a loaded MCP extension; the base Pi runtime has no MCP control");
886
1421
  break;
887
1422
  case "hermes":
888
1423
  unsupported("mcp", "hermes MCP is global config.yaml (mcp_servers), no cwd discovery — write to the hermes global config or pass a config flag");
@@ -892,70 +1427,182 @@ function materializeMcp(h, mcp, plan, add, unsupported) {
892
1427
  break;
893
1428
  }
894
1429
  }
895
- function opencodeMcp(mcp) {
1430
+ function tomlInlineStringTable(values) {
1431
+ return `{ ${Object.entries(values)
1432
+ .sort(([left], [right]) => compareStrings(left, right))
1433
+ .map(([key, value]) => `${tomlStr(key)} = ${tomlStr(value)}`)
1434
+ .join(", ")} }`;
1435
+ }
1436
+ function codexMcpServerToml(name, server, plan) {
1437
+ const fields = [];
1438
+ if (server.url) {
1439
+ fields.push(`url = ${tomlStr(server.url)}`);
1440
+ if (server.headers && Object.keys(server.headers).length > 0) {
1441
+ const envHeaders = {};
1442
+ for (const [header, value] of Object.entries(server.headers).sort(([left], [right]) => compareStrings(left, right))) {
1443
+ const envName = `TANGLE_MCP_HEADER_${sha256(`${name}\0${header}`)
1444
+ .slice(0, 24)
1445
+ .toUpperCase()}`;
1446
+ setPlanEnvironment(plan, envName, value);
1447
+ envHeaders[header] = envName;
1448
+ }
1449
+ fields.push(`env_http_headers = ${tomlInlineStringTable(envHeaders)}`);
1450
+ }
1451
+ }
1452
+ else {
1453
+ if (!server.command) {
1454
+ throw new Error(`Codex MCP server "${name}" must define url or command`);
1455
+ }
1456
+ fields.push(`command = ${tomlStr(server.command)}`);
1457
+ if (server.args?.length) {
1458
+ fields.push(`args = [${server.args.map(tomlStr).join(", ")}]`);
1459
+ }
1460
+ if (server.cwd)
1461
+ fields.push(`cwd = ${tomlStr(server.cwd)}`);
1462
+ if (server.env && Object.keys(server.env).length > 0) {
1463
+ const envNames = Object.keys(server.env).sort(compareStrings);
1464
+ for (const envName of envNames) {
1465
+ setPlanEnvironment(plan, envName, server.env[envName]);
1466
+ }
1467
+ fields.push(`env_vars = [${envNames.map(tomlStr).join(", ")}]`);
1468
+ }
1469
+ }
1470
+ return `{ ${fields.join(", ")} }`;
1471
+ }
1472
+ function setPlanEnvironment(plan, name, value) {
1473
+ const previous = plan.env[name];
1474
+ if (previous !== undefined && previous !== value) {
1475
+ throw new Error(`profile environment values disagree for ${name}`);
1476
+ }
1477
+ plan.env[name] = value;
1478
+ }
1479
+ function kimiMcpServer(server) {
1480
+ if (server.url) {
1481
+ return {
1482
+ url: server.url,
1483
+ transport: server.transport ?? "http",
1484
+ ...(server.headers ? { headers: server.headers } : {}),
1485
+ };
1486
+ }
1487
+ return {
1488
+ command: server.command,
1489
+ ...(server.args ? { args: server.args } : {}),
1490
+ ...(server.env ? { env: server.env } : {}),
1491
+ ...(server.cwd ? { cwd: server.cwd } : {}),
1492
+ };
1493
+ }
1494
+ /** Lower canonical MCP definitions to OpenCode's native startup configuration. */
1495
+ export function openCodeMcpConfig(mcp) {
896
1496
  const out = {};
897
1497
  for (const [name, s] of Object.entries(mcp)) {
898
- out[name] = s.url
899
- ? {
1498
+ if (s.enabled === false)
1499
+ continue;
1500
+ if (s.url) {
1501
+ if (s.transport === "sse") {
1502
+ throw new Error(`OpenCode MCP server "${name}" uses unsupported SSE transport`);
1503
+ }
1504
+ out[name] = {
900
1505
  type: "remote",
901
1506
  url: s.url,
902
- enabled: s.enabled !== false,
1507
+ enabled: true,
903
1508
  ...(s.headers ? { headers: s.headers } : {}),
904
- }
905
- : {
906
- type: "local",
907
- command: [s.command, ...(s.args ?? [])].filter(Boolean),
908
- enabled: s.enabled !== false,
909
- ...(s.env ? { environment: s.env } : {}),
910
1509
  };
1510
+ continue;
1511
+ }
1512
+ if (!s.command) {
1513
+ throw new Error(`OpenCode MCP server "${name}" must define either url or command`);
1514
+ }
1515
+ if (s.cwd !== undefined) {
1516
+ throw new Error(`OpenCode MCP server "${name}" does not support a cwd field`);
1517
+ }
1518
+ out[name] = {
1519
+ type: "local",
1520
+ command: [s.command, ...(s.args ?? [])],
1521
+ enabled: true,
1522
+ ...(s.env ? { environment: s.env } : {}),
1523
+ };
911
1524
  }
912
1525
  return out;
913
1526
  }
914
1527
  function materializeHooks(h, hooks, add, unsupported, plan) {
915
1528
  switch (h) {
916
- case "claude-code":
917
- case "nanoclaw": {
918
- // .claude/settings.json hooks{} — each event → [{matcher, hooks:[{type:command,command}]}].
1529
+ case "claude-code": {
1530
+ // Claude settings hooks{} — each event → [{matcher, hooks:[{type:command,command}]}].
919
1531
  const settingsHooks = {};
920
1532
  for (const [event, cmds] of Object.entries(hooks)) {
1533
+ if (!hookEventSupported("claude-code", event)) {
1534
+ unsupported("hooks", `claude-code: unsupported hook event "${event}"`);
1535
+ continue;
1536
+ }
921
1537
  settingsHooks[event] = cmds.map((c) => ({
922
1538
  matcher: c.matcher ?? "*",
923
1539
  hooks: [
924
1540
  {
925
1541
  type: "command",
926
- command: c.command,
927
- ...(c.timeoutMs
928
- ? { timeout: Math.round(c.timeoutMs / 1000) }
929
- : {}),
1542
+ command: materializedHookCommand(event, c, add),
1543
+ ...(c.timeoutMs ? { timeout: timeoutSeconds(c.timeoutMs) } : {}),
1544
+ ...(c.blocking === false ? { async: true } : {}),
930
1545
  },
931
1546
  ],
932
1547
  }));
933
1548
  }
934
- add(".claude/settings.json", JSON.stringify({ hooks: settingsHooks }, null, 2));
1549
+ add(CLAUDE_SETTINGS_PATH, JSON.stringify({ hooks: settingsHooks }, null, 2));
1550
+ registerClaudeSettings(plan);
935
1551
  break;
936
1552
  }
1553
+ case "nanoclaw":
1554
+ unsupported("hooks", "nanoclaw's chat bridge does not expose Claude Code hook settings to the spawned agent");
1555
+ break;
937
1556
  case "gemini": {
938
1557
  const settingsHooks = {};
939
- for (const [event, cmds] of Object.entries(hooks))
1558
+ for (const [event, cmds] of Object.entries(hooks)) {
1559
+ if (!hookEventSupported("gemini", event)) {
1560
+ unsupported("hooks", `gemini: unsupported hook event "${event}"`);
1561
+ continue;
1562
+ }
1563
+ if (cmds.some((command) => command.blocking === false)) {
1564
+ unsupported("hooks", `gemini: hook event "${event}" does not support non-blocking commands`);
1565
+ }
940
1566
  settingsHooks[event] = cmds.map((c) => ({
941
1567
  matcher: c.matcher ?? "*",
942
- hooks: [{ type: "command", command: c.command }],
1568
+ hooks: [
1569
+ {
1570
+ type: "command",
1571
+ command: materializedHookCommand(event, c, add),
1572
+ ...(c.timeoutMs ? { timeout: c.timeoutMs } : {}),
1573
+ },
1574
+ ],
943
1575
  }));
1576
+ }
944
1577
  add(".gemini/settings.json", JSON.stringify({ hooks: settingsHooks }, null, 2));
945
1578
  break;
946
1579
  }
947
1580
  case "codex": {
948
- // codex hooks.json (event → commands).
949
- add(".codex/hooks.json", JSON.stringify({ hooks }, null, 2));
1581
+ for (const [event, cmds] of Object.entries(hooks).sort(([left], [right]) => compareStrings(left, right))) {
1582
+ if (!hookEventSupported("codex", event)) {
1583
+ unsupported("hooks", `codex: unsupported hook event "${event}"`);
1584
+ continue;
1585
+ }
1586
+ if (cmds.some((command) => command.blocking === false)) {
1587
+ unsupported("hooks", `codex: hook event "${event}" cannot execute asynchronous commands`);
1588
+ continue;
1589
+ }
1590
+ const entries = cmds.map((command) => {
1591
+ const handler = [
1592
+ `type = ${tomlStr("command")}`,
1593
+ `command = ${tomlStr(materializedHookCommand(event, command, add))}`,
1594
+ ...(command.timeoutMs
1595
+ ? [`timeout = ${timeoutSeconds(command.timeoutMs)}`]
1596
+ : []),
1597
+ ];
1598
+ return `{ matcher = ${tomlStr(command.matcher ?? "*")}, hooks = [{ ${handler.join(", ")} }] }`;
1599
+ });
1600
+ plan.flags.push("-c", `hooks.${event}=[${entries.join(", ")}]`);
1601
+ }
950
1602
  break;
951
1603
  }
952
1604
  case "opencode":
953
- // opencode hooks are PLUGINS (JS/TS), not a settings block — emit a plugin stub.
954
- for (const [event, cmds] of Object.entries(hooks)) {
955
- const body = cmds.map((c) => ` // ${event}: ${c.command}`).join("\n");
956
- add(`.opencode/plugins/vb-hooks-${event}.js`, `// auto-generated hook plugin for ${event}\nexport default async function () {\n${body}\n}\n`);
957
- }
958
- unsupported("hooks", "opencode hooks are JS/TS plugins — generated a stub; a real handler must run the command (format differs from settings.json)");
1605
+ unsupported("hooks", "opencode hooks require executable JS/TS plugins; command hooks cannot be translated exactly");
959
1606
  break;
960
1607
  case "kimi-code":
961
1608
  unsupported("hooks", "kimi hooks are [[hooks]] in the config TOML loaded via --config-file, not cwd — pass --config-file");
@@ -963,31 +1610,159 @@ function materializeHooks(h, hooks, add, unsupported, plan) {
963
1610
  case "pi":
964
1611
  unsupported("hooks", "pi hooks are TS extensions (.pi/extensions/*.ts) — build an extension");
965
1612
  break;
966
- case "hermes": {
967
- // opt-in project plugins; requires HERMES_ENABLE_PROJECT_PLUGINS=1.
968
- for (const [event, cmds] of Object.entries(hooks))
969
- add(`.hermes/plugins/vb-${event}/plugin.md`, cmds.map((c) => c.command).join("\n"));
970
- plan.env.HERMES_ENABLE_PROJECT_PLUGINS = "1";
1613
+ case "hermes":
1614
+ unsupported("hooks", "hermes project plugins do not expose an exact command-hook mapping");
971
1615
  break;
972
- }
973
1616
  case "openclaw":
974
1617
  unsupported("hooks", "openclaw hooks are DISABLED by default — enable in ~/.openclaw/openclaw.json; cwd hooks/ alone is a no-op");
975
1618
  break;
976
1619
  }
977
1620
  }
978
- function materializeSubagent(h, name, sa, add, unsupported) {
1621
+ function registerClaudeSettings(plan) {
1622
+ for (let index = 0; index < plan.flags.length - 1; index += 1) {
1623
+ if (plan.flags[index] === "--settings" &&
1624
+ plan.flags[index + 1] === CLAUDE_SETTINGS_PATH) {
1625
+ return;
1626
+ }
1627
+ }
1628
+ plan.flags.push("--settings", CLAUDE_SETTINGS_PATH);
1629
+ }
1630
+ const CLAUDE_HOOK_EVENTS = new Set([
1631
+ "ConfigChange",
1632
+ "CwdChanged",
1633
+ "Elicitation",
1634
+ "ElicitationResult",
1635
+ "FileChanged",
1636
+ "InstructionsLoaded",
1637
+ "MessageDisplay",
1638
+ "Notification",
1639
+ "PermissionDenied",
1640
+ "PermissionRequest",
1641
+ "PostCompact",
1642
+ "PostToolBatch",
1643
+ "PostToolUse",
1644
+ "PostToolUseFailure",
1645
+ "PreCompact",
1646
+ "PreToolUse",
1647
+ "SessionEnd",
1648
+ "SessionStart",
1649
+ "Setup",
1650
+ "Stop",
1651
+ "StopFailure",
1652
+ "SubagentStart",
1653
+ "SubagentStop",
1654
+ "TaskCompleted",
1655
+ "TaskCreated",
1656
+ "TeammateIdle",
1657
+ "UserPromptExpansion",
1658
+ "UserPromptSubmit",
1659
+ "WorktreeCreate",
1660
+ "WorktreeRemove",
1661
+ ]);
1662
+ const CODEX_HOOK_EVENTS = new Set([
1663
+ "PermissionRequest",
1664
+ "PostCompact",
1665
+ "PostToolUse",
1666
+ "PreCompact",
1667
+ "PreToolUse",
1668
+ "SessionStart",
1669
+ "Stop",
1670
+ "SubagentStart",
1671
+ "SubagentStop",
1672
+ "UserPromptSubmit",
1673
+ ]);
1674
+ const GEMINI_HOOK_EVENTS = new Set([
1675
+ "AfterAgent",
1676
+ "AfterModel",
1677
+ "AfterTool",
1678
+ "BeforeAgent",
1679
+ "BeforeModel",
1680
+ "BeforeTool",
1681
+ "BeforeToolSelection",
1682
+ "Notification",
1683
+ "PreCompress",
1684
+ "SessionEnd",
1685
+ "SessionStart",
1686
+ ]);
1687
+ function hookEventSupported(harness, event) {
1688
+ switch (canonicalHarness(harness)) {
1689
+ case "claude-code":
1690
+ return CLAUDE_HOOK_EVENTS.has(event);
1691
+ case "codex":
1692
+ return CODEX_HOOK_EVENTS.has(event);
1693
+ case "gemini":
1694
+ return GEMINI_HOOK_EVENTS.has(event);
1695
+ default:
1696
+ return false;
1697
+ }
1698
+ }
1699
+ function timeoutSeconds(timeoutMs) {
1700
+ return Math.max(1, Math.ceil(timeoutMs / 1000));
1701
+ }
1702
+ function materializedHookCommand(event, command, add) {
1703
+ if (!command.env || Object.keys(command.env).length === 0) {
1704
+ return command.command;
1705
+ }
1706
+ const wrapperId = canonicalCandidateDigest({
1707
+ event,
1708
+ command: command.command,
1709
+ env: Object.fromEntries(Object.entries(command.env).sort(([left], [right]) => compareStrings(left, right))),
1710
+ }).slice("sha256:".length);
1711
+ const path = `.tangle/profile-hooks/${wrapperId}.mjs`;
1712
+ add(path, hookWrapperSource("/bin/sh", ["-c", command.command], command.env));
1713
+ return `node ${path}`;
1714
+ }
1715
+ function hookWrapperSource(executable, args, env) {
1716
+ return [
1717
+ 'import { spawnSync } from "node:child_process"',
1718
+ `const executable = ${JSON.stringify(executable)}`,
1719
+ `const args = ${JSON.stringify(args)}`,
1720
+ `const extraEnv = ${JSON.stringify(env)}`,
1721
+ "const result = spawnSync(executable, args, {",
1722
+ ' stdio: "inherit",',
1723
+ " env: { ...process.env, ...extraEnv },",
1724
+ " shell: false,",
1725
+ "})",
1726
+ "if (result.error) {",
1727
+ " console.error(result.error.message)",
1728
+ " process.exit(127)",
1729
+ "}",
1730
+ "if (result.signal) {",
1731
+ ' console.error("hook terminated by " + result.signal)',
1732
+ " process.exit(1)",
1733
+ "}",
1734
+ "process.exit(result.status ?? 1)",
1735
+ "",
1736
+ ].join("\n");
1737
+ }
1738
+ function materializeSubagent(h, name, sa, plan, add, unsupported) {
1739
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) {
1740
+ unsupported("subagents", `${h}: subagent name "${name}" must use lowercase letters, digits, underscores, or hyphens`);
1741
+ return;
1742
+ }
979
1743
  switch (h) {
980
1744
  case "claude-code":
981
1745
  case "nanoclaw": {
982
1746
  const fm = ["---", `name: ${name}`];
983
- if (sa.description)
984
- fm.push(`description: ${JSON.stringify(sa.description)}`);
1747
+ fm.push(`description: ${JSON.stringify(sa.description ?? name)}`);
985
1748
  if (sa.model)
986
- fm.push(`model: ${sa.model}`);
987
- if (sa.tools)
988
- fm.push(`tools: ${Object.keys(sa.tools)
989
- .filter((t) => sa.tools?.[t])
990
- .join(", ")}`);
1749
+ fm.push(`model: ${JSON.stringify(sa.model)}`);
1750
+ if (sa.tools) {
1751
+ const tools = Object.entries(sa.tools);
1752
+ const allowed = tools
1753
+ .filter(([, enabled]) => enabled)
1754
+ .map(([tool]) => tool);
1755
+ const denied = tools
1756
+ .filter(([, enabled]) => !enabled)
1757
+ .map(([tool]) => tool);
1758
+ if (allowed.length > 0)
1759
+ fm.push(`tools: ${JSON.stringify(allowed)}`);
1760
+ if (denied.length > 0)
1761
+ fm.push(`disallowedTools: ${JSON.stringify(denied)}`);
1762
+ }
1763
+ if (sa.maxSteps !== undefined)
1764
+ fm.push(`maxTurns: ${sa.maxSteps}`);
1765
+ reportUnsupportedSubagentFields(h, name, sa, new Set(["description", "prompt", "model", "tools", "maxSteps"]), unsupported);
991
1766
  fm.push("---", "", sa.prompt ?? sa.description ?? name);
992
1767
  add(`.claude/agents/${name}.md`, `${fm.join("\n")}\n`);
993
1768
  break;
@@ -996,31 +1771,92 @@ function materializeSubagent(h, name, sa, add, unsupported) {
996
1771
  const fm = [
997
1772
  "---",
998
1773
  `description: ${JSON.stringify(sa.description ?? name)}`,
1774
+ "mode: subagent",
999
1775
  ];
1000
1776
  if (sa.model)
1001
- fm.push(`model: ${sa.model}`);
1777
+ fm.push(`model: ${JSON.stringify(sa.model)}`);
1778
+ if (sa.tools)
1779
+ fm.push(`tools: ${JSON.stringify(sa.tools)}`);
1780
+ if (sa.permissions)
1781
+ fm.push(`permission: ${JSON.stringify(sa.permissions)}`);
1782
+ if (sa.maxSteps !== undefined)
1783
+ fm.push(`steps: ${sa.maxSteps}`);
1784
+ reportUnsupportedSubagentFields(h, name, sa, new Set([
1785
+ "description",
1786
+ "prompt",
1787
+ "model",
1788
+ "tools",
1789
+ "permissions",
1790
+ "maxSteps",
1791
+ ]), unsupported);
1002
1792
  fm.push("---", "", sa.prompt ?? "");
1003
1793
  add(`.opencode/agents/${name}.md`, `${fm.join("\n")}\n`);
1004
1794
  break;
1005
1795
  }
1006
- case "gemini":
1007
- add(`.gemini/agents/${name}.md`, `---\nname: ${name}\ndescription: ${JSON.stringify(sa.description ?? name)}\n---\n\n${sa.prompt ?? ""}\n`);
1796
+ case "gemini": {
1797
+ const tools = sa.tools
1798
+ ? Object.entries(sa.tools)
1799
+ .filter(([, enabled]) => enabled)
1800
+ .map(([tool]) => tool)
1801
+ : undefined;
1802
+ reportUnsupportedSubagentFields(h, name, sa, new Set(["description", "prompt", "model", "tools", "maxSteps"]), unsupported);
1803
+ add(`.gemini/agents/${name}.md`, [
1804
+ "---",
1805
+ `name: ${name}`,
1806
+ `description: ${JSON.stringify(sa.description ?? name)}`,
1807
+ "kind: local",
1808
+ ...(sa.model ? [`model: ${JSON.stringify(sa.model)}`] : []),
1809
+ ...(tools ? [`tools: ${JSON.stringify(tools)}`] : []),
1810
+ ...(sa.maxSteps !== undefined ? [`max_turns: ${sa.maxSteps}`] : []),
1811
+ "---",
1812
+ "",
1813
+ sa.prompt ?? "",
1814
+ "",
1815
+ ].join("\n"));
1008
1816
  break;
1817
+ }
1009
1818
  case "codex": {
1010
- const lines = [`name = ${tomlStr(name)}`];
1011
- if (sa.description)
1012
- lines.push(`description = ${tomlStr(sa.description)}`);
1819
+ const relPath = `.codex/agents/${name}.toml`;
1820
+ const lines = [
1821
+ `name = ${tomlStr(name)}`,
1822
+ `description = ${tomlStr(sa.description ?? name)}`,
1823
+ `developer_instructions = ${tomlStr(sa.prompt ?? sa.description ?? name)}`,
1824
+ ];
1013
1825
  if (sa.model)
1014
1826
  lines.push(`model = ${tomlStr(sa.model)}`);
1015
- if (sa.prompt)
1016
- lines.push(`prompt = ${tomlStr(sa.prompt)}`);
1017
- add(`.codex/agents/${name}.toml`, `${lines.join("\n")}\n`);
1827
+ reportUnsupportedSubagentFields(h, name, sa, new Set(["description", "prompt", "model"]), unsupported);
1828
+ add(relPath, `${lines.join("\n")}\n`);
1829
+ registerCodexAgent(plan, name, relPath, sa.description ?? name);
1018
1830
  break;
1019
1831
  }
1020
1832
  default:
1021
1833
  unsupported("subagents", `${h}: subagents have no cwd file (config/flag/global only); "${name}" not mounted`);
1022
1834
  }
1023
1835
  }
1836
+ function codexAgentRoleName(resourceName) {
1837
+ const name = resourceName.endsWith(".toml")
1838
+ ? resourceName.slice(0, -".toml".length)
1839
+ : resourceName;
1840
+ return /^[a-z0-9][a-z0-9_-]*$/.test(name) ? name : undefined;
1841
+ }
1842
+ function registerCodexAgent(plan, name, configFile, description) {
1843
+ plan.flags.push("-c", `agents.${name}.description=${tomlStr(description)}`, "-c", `agents.${name}.config_file=${tomlStr(configFile)}`);
1844
+ }
1845
+ function reportUnsupportedSubagentFields(harness, name, subagent, supported, unsupported) {
1846
+ for (const field of [
1847
+ "description",
1848
+ "prompt",
1849
+ "model",
1850
+ "tools",
1851
+ "permissions",
1852
+ "maxSteps",
1853
+ "metadata",
1854
+ ]) {
1855
+ if (subagent[field] !== undefined && !supported.has(field)) {
1856
+ unsupported("subagents", `${harness}: subagent "${name}" does not support ${field}`);
1857
+ }
1858
+ }
1859
+ }
1024
1860
  function materializeCommand(h, name, body, add, unsupported) {
1025
1861
  switch (h) {
1026
1862
  case "claude-code":