@tangle-network/agent-profile-materialize 0.4.0 → 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 -189
  3. package/package.json +2 -2
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) {
@@ -622,6 +842,9 @@ export function hashWorkspacePlan(plan) {
622
842
  files,
623
843
  env,
624
844
  flags: [...plan.flags],
845
+ ...(plan.systemPrompt === undefined
846
+ ? {}
847
+ : { systemPrompt: plan.systemPrompt }),
625
848
  unsupported,
626
849
  });
627
850
  return `sha256:${sha256(canonical)}`;
@@ -629,6 +852,7 @@ export function hashWorkspacePlan(plan) {
629
852
  /** Build the exact digest-free identity document required by agent-interface. */
630
853
  export function agentCandidateProfilePlanMaterial(plan) {
631
854
  const material = {
855
+ sourceProfileDigest: plan.sourceProfileDigest,
632
856
  harness: canonicalHarness(plan.harness),
633
857
  files: normalizePlanFiles(plan.files).map((file) => ({
634
858
  relPath: file.relPath,
@@ -637,6 +861,9 @@ export function agentCandidateProfilePlanMaterial(plan) {
637
861
  })),
638
862
  env: Object.fromEntries(Object.entries(plan.env).sort(([left], [right]) => compareStrings(left, right))),
639
863
  flags: [...plan.flags],
864
+ ...(plan.systemPrompt === undefined
865
+ ? {}
866
+ : { systemPrompt: plan.systemPrompt }),
640
867
  unsupported: [...plan.unsupported].sort((left, right) => compareStrings(`${left.dimension}\0${left.reason}`, `${right.dimension}\0${right.reason}`)),
641
868
  };
642
869
  return agentCandidateProfilePlanMaterialSchema.parse(material);
@@ -647,10 +874,8 @@ export function agentCandidateProfilePlanMaterial(plan) {
647
874
  */
648
875
  export function canonicalizeAgentCandidateProfilePlan(plan) {
649
876
  const material = agentCandidateProfilePlanMaterial(plan);
650
- const bytes = Uint8Array.from(Buffer.from(rfc8785Serialize(material), "utf8"));
651
- const digest = `sha256:${createHash("sha256")
652
- .update(bytes)
653
- .digest("hex")}`;
877
+ const bytes = canonicalCandidateBytes(material);
878
+ const digest = canonicalCandidateDigest(material);
654
879
  return { material, bytes, digest };
655
880
  }
656
881
  /** Capture portable evidence of the exact canonical profile-plan bytes. */
@@ -674,10 +899,22 @@ export function captureAgentCandidateProfilePlan(plan) {
674
899
  * canonical evidence. No caller needs to re-serialize or reinterpret the plan.
675
900
  */
676
901
  export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorkspace) {
677
- if (plan.unsupported.length > 0) {
678
- throw new Error(formatUnsupported(plan.unsupported));
679
- }
902
+ assertWorkspacePlanSupported(plan);
680
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
+ });
681
918
  const applied = applyWorkspacePlan({
682
919
  harness: plan.harness,
683
920
  files: plan.files,
@@ -686,10 +923,13 @@ export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorks
686
923
  candidatePublicString(value),
687
924
  ])),
688
925
  flags: plan.flags.map(candidatePublicString),
926
+ ...(plan.systemPrompt === undefined
927
+ ? {}
928
+ : { systemPrompt: candidatePublicString(plan.systemPrompt) }),
689
929
  unsupported: plan.unsupported,
690
930
  }, workspaceDir);
691
931
  return {
692
- profilePlan,
932
+ profileActivation,
693
933
  application: {
694
934
  planDigest: profilePlan.digest,
695
935
  targetWorkspace,
@@ -697,41 +937,24 @@ export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorks
697
937
  },
698
938
  env: { ...plan.env },
699
939
  flags: [...plan.flags],
940
+ ...(plan.systemPrompt === undefined
941
+ ? {}
942
+ : { systemPrompt: plan.systemPrompt }),
700
943
  unsupported: [...plan.unsupported],
701
944
  };
702
945
  }
703
- function rfc8785Serialize(value) {
704
- if (value === null || typeof value === "boolean")
705
- return JSON.stringify(value);
706
- if (typeof value === "string")
707
- return JSON.stringify(value);
708
- if (typeof value === "number") {
709
- if (!Number.isFinite(value)) {
710
- throw new Error("RFC 8785 cannot serialize a non-finite number");
711
- }
712
- return JSON.stringify(value);
713
- }
714
- if (Array.isArray(value)) {
715
- return `[${value.map(rfc8785Serialize).join(",")}]`;
716
- }
717
- if (typeof value === "object") {
718
- const record = value;
719
- return `{${Object.keys(record)
720
- .sort(compareStrings)
721
- .map((key) => `${JSON.stringify(key)}:${rfc8785Serialize(record[key])}`)
722
- .join(",")}}`;
723
- }
724
- throw new Error(`RFC 8785 cannot serialize ${typeof value}`);
725
- }
726
946
  /**
727
947
  * Apply a WorkspacePlan to a real workspace dir: write every file (creating parent
728
948
  * dirs), and return the env + flags the caller must hand the spawn. This is the IO
729
949
  * consumer of the pure `materializeProfile` — used by the cli-bridge host spawn, the
730
950
  * agent-dev-container box, and VB. Returns `unsupported` so the caller can log/fail.
731
951
  */
732
- export function applyWorkspacePlan(plan, workspaceDir) {
952
+ export function applyWorkspacePlan(plan, workspaceDir, options = {}) {
733
953
  const workspaceRoot = resolve(workspaceDir);
734
- 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) => ({
735
958
  ...file,
736
959
  absPath: resolveWorkspaceFilePath(workspaceRoot, file.relPath),
737
960
  }));
@@ -739,6 +962,9 @@ export function applyWorkspacePlan(plan, workspaceDir) {
739
962
  // existing symlink cannot leave a partially materialized workspace.
740
963
  for (const file of files) {
741
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
+ }
742
968
  }
743
969
  for (const file of files) {
744
970
  mkdirSync(dirname(file.absPath), { recursive: true });
@@ -748,17 +974,39 @@ export function applyWorkspacePlan(plan, workspaceDir) {
748
974
  }
749
975
  const written = [];
750
976
  for (const file of files) {
751
- writeWorkspaceFile(workspaceRoot, file);
977
+ if (!workspaceFileExists(file.absPath)) {
978
+ writeWorkspaceFile(workspaceRoot, file);
979
+ }
752
980
  written.push(file.relPath);
753
981
  }
754
982
  return {
755
983
  workspacePlanDigest: hashWorkspacePlan(plan),
756
984
  env: plan.env,
757
985
  flags: plan.flags,
986
+ ...(plan.systemPrompt === undefined
987
+ ? {}
988
+ : { systemPrompt: plan.systemPrompt }),
758
989
  unsupported: plan.unsupported,
759
990
  written,
760
991
  };
761
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
+ }
762
1010
  function resolveWorkspaceFilePath(workspaceRoot, relativePath) {
763
1011
  const targetPath = resolve(workspaceRoot, ...relativePath.split("/"));
764
1012
  const targetRelativePath = relative(workspaceRoot, targetPath);
@@ -809,7 +1057,15 @@ function writeWorkspaceFile(workspaceRoot, file) {
809
1057
  closeSync(descriptor);
810
1058
  descriptor = undefined;
811
1059
  assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
812
- 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);
813
1069
  }
814
1070
  catch (error) {
815
1071
  if (descriptor !== undefined)
@@ -831,55 +1087,337 @@ function isFsError(error, code) {
831
1087
  error.code === code);
832
1088
  }
833
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
+ }
834
1334
  function materializeMcp(h, mcp, plan, add, unsupported) {
835
1335
  switch (h) {
836
- 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
+ }
837
1362
  case "nanoclaw":
838
- // claude reads ./.mcp.json but each server needs per-project APPROVAL; we also
839
- // enable them via .claude/settings.json enabledMcpjsonServers to skip the prompt.
840
- add(".mcp.json", JSON.stringify({ mcpServers: mcp }, null, 2));
841
- add(".claude/settings.json", JSON.stringify({ enabledMcpjsonServers: Object.keys(mcp) }, null, 2));
842
- if (h === "nanoclaw")
843
- 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");
844
1364
  break;
845
1365
  case "codex": {
846
- // codex reads [mcp_servers.<id>] in .codex/config.toml; trusted projects only.
847
- const lines = ['[projects."."]', 'trust_level = "trusted"', ""];
848
- for (const [name, s] of Object.entries(mcp)) {
849
- lines.push(`[mcp_servers.${name}]`);
850
- if (s.command)
851
- lines.push(`command = ${tomlStr(s.command)}`);
852
- if (s.args?.length)
853
- lines.push(`args = [${s.args.map(tomlStr).join(", ")}]`);
854
- if (s.cwd)
855
- lines.push(`cwd = ${tomlStr(s.cwd)}`);
856
- if (s.url)
857
- lines.push(`url = ${tomlStr(s.url)}`);
858
- if (s.env)
859
- lines.push(`env = { ${Object.entries(s.env)
860
- .map(([k, v]) => `${k} = ${tomlStr(v)}`)
861
- .join(", ")} }`);
862
- 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(", ")} }`);
863
1378
  }
864
- add(".codex/config.toml", lines.join("\n"));
865
1379
  break;
866
1380
  }
867
1381
  case "opencode":
868
1382
  // opencode.json "mcp" block (local|remote).
869
- 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));
870
1387
  break;
871
- case "gemini":
872
- 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));
873
1406
  break;
1407
+ }
874
1408
  case "kimi-code": {
875
1409
  // NO cwd auto-discovery — kimi needs an explicit --mcp-config-file FLAG.
876
1410
  const rel = ".kimi/mcp.json";
877
- 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));
878
1416
  plan.flags.push("--mcp-config-file", rel);
879
1417
  break;
880
1418
  }
881
1419
  case "pi":
882
- 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");
883
1421
  break;
884
1422
  case "hermes":
885
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");
@@ -889,70 +1427,182 @@ function materializeMcp(h, mcp, plan, add, unsupported) {
889
1427
  break;
890
1428
  }
891
1429
  }
892
- 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) {
893
1496
  const out = {};
894
1497
  for (const [name, s] of Object.entries(mcp)) {
895
- out[name] = s.url
896
- ? {
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] = {
897
1505
  type: "remote",
898
1506
  url: s.url,
899
- enabled: s.enabled !== false,
1507
+ enabled: true,
900
1508
  ...(s.headers ? { headers: s.headers } : {}),
901
- }
902
- : {
903
- type: "local",
904
- command: [s.command, ...(s.args ?? [])].filter(Boolean),
905
- enabled: s.enabled !== false,
906
- ...(s.env ? { environment: s.env } : {}),
907
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
+ };
908
1524
  }
909
1525
  return out;
910
1526
  }
911
1527
  function materializeHooks(h, hooks, add, unsupported, plan) {
912
1528
  switch (h) {
913
- case "claude-code":
914
- case "nanoclaw": {
915
- // .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}]}].
916
1531
  const settingsHooks = {};
917
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
+ }
918
1537
  settingsHooks[event] = cmds.map((c) => ({
919
1538
  matcher: c.matcher ?? "*",
920
1539
  hooks: [
921
1540
  {
922
1541
  type: "command",
923
- command: c.command,
924
- ...(c.timeoutMs
925
- ? { timeout: Math.round(c.timeoutMs / 1000) }
926
- : {}),
1542
+ command: materializedHookCommand(event, c, add),
1543
+ ...(c.timeoutMs ? { timeout: timeoutSeconds(c.timeoutMs) } : {}),
1544
+ ...(c.blocking === false ? { async: true } : {}),
927
1545
  },
928
1546
  ],
929
1547
  }));
930
1548
  }
931
- add(".claude/settings.json", JSON.stringify({ hooks: settingsHooks }, null, 2));
1549
+ add(CLAUDE_SETTINGS_PATH, JSON.stringify({ hooks: settingsHooks }, null, 2));
1550
+ registerClaudeSettings(plan);
932
1551
  break;
933
1552
  }
1553
+ case "nanoclaw":
1554
+ unsupported("hooks", "nanoclaw's chat bridge does not expose Claude Code hook settings to the spawned agent");
1555
+ break;
934
1556
  case "gemini": {
935
1557
  const settingsHooks = {};
936
- 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
+ }
937
1566
  settingsHooks[event] = cmds.map((c) => ({
938
1567
  matcher: c.matcher ?? "*",
939
- 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
+ ],
940
1575
  }));
1576
+ }
941
1577
  add(".gemini/settings.json", JSON.stringify({ hooks: settingsHooks }, null, 2));
942
1578
  break;
943
1579
  }
944
1580
  case "codex": {
945
- // codex hooks.json (event → commands).
946
- 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
+ }
947
1602
  break;
948
1603
  }
949
1604
  case "opencode":
950
- // opencode hooks are PLUGINS (JS/TS), not a settings block — emit a plugin stub.
951
- for (const [event, cmds] of Object.entries(hooks)) {
952
- const body = cmds.map((c) => ` // ${event}: ${c.command}`).join("\n");
953
- add(`.opencode/plugins/vb-hooks-${event}.js`, `// auto-generated hook plugin for ${event}\nexport default async function () {\n${body}\n}\n`);
954
- }
955
- 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");
956
1606
  break;
957
1607
  case "kimi-code":
958
1608
  unsupported("hooks", "kimi hooks are [[hooks]] in the config TOML loaded via --config-file, not cwd — pass --config-file");
@@ -960,31 +1610,159 @@ function materializeHooks(h, hooks, add, unsupported, plan) {
960
1610
  case "pi":
961
1611
  unsupported("hooks", "pi hooks are TS extensions (.pi/extensions/*.ts) — build an extension");
962
1612
  break;
963
- case "hermes": {
964
- // opt-in project plugins; requires HERMES_ENABLE_PROJECT_PLUGINS=1.
965
- for (const [event, cmds] of Object.entries(hooks))
966
- add(`.hermes/plugins/vb-${event}/plugin.md`, cmds.map((c) => c.command).join("\n"));
967
- plan.env.HERMES_ENABLE_PROJECT_PLUGINS = "1";
1613
+ case "hermes":
1614
+ unsupported("hooks", "hermes project plugins do not expose an exact command-hook mapping");
968
1615
  break;
969
- }
970
1616
  case "openclaw":
971
1617
  unsupported("hooks", "openclaw hooks are DISABLED by default — enable in ~/.openclaw/openclaw.json; cwd hooks/ alone is a no-op");
972
1618
  break;
973
1619
  }
974
1620
  }
975
- 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
+ }
976
1743
  switch (h) {
977
1744
  case "claude-code":
978
1745
  case "nanoclaw": {
979
1746
  const fm = ["---", `name: ${name}`];
980
- if (sa.description)
981
- fm.push(`description: ${JSON.stringify(sa.description)}`);
1747
+ fm.push(`description: ${JSON.stringify(sa.description ?? name)}`);
982
1748
  if (sa.model)
983
- fm.push(`model: ${sa.model}`);
984
- if (sa.tools)
985
- fm.push(`tools: ${Object.keys(sa.tools)
986
- .filter((t) => sa.tools?.[t])
987
- .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);
988
1766
  fm.push("---", "", sa.prompt ?? sa.description ?? name);
989
1767
  add(`.claude/agents/${name}.md`, `${fm.join("\n")}\n`);
990
1768
  break;
@@ -993,31 +1771,92 @@ function materializeSubagent(h, name, sa, add, unsupported) {
993
1771
  const fm = [
994
1772
  "---",
995
1773
  `description: ${JSON.stringify(sa.description ?? name)}`,
1774
+ "mode: subagent",
996
1775
  ];
997
1776
  if (sa.model)
998
- 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);
999
1792
  fm.push("---", "", sa.prompt ?? "");
1000
1793
  add(`.opencode/agents/${name}.md`, `${fm.join("\n")}\n`);
1001
1794
  break;
1002
1795
  }
1003
- case "gemini":
1004
- 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"));
1005
1816
  break;
1817
+ }
1006
1818
  case "codex": {
1007
- const lines = [`name = ${tomlStr(name)}`];
1008
- if (sa.description)
1009
- 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
+ ];
1010
1825
  if (sa.model)
1011
1826
  lines.push(`model = ${tomlStr(sa.model)}`);
1012
- if (sa.prompt)
1013
- lines.push(`prompt = ${tomlStr(sa.prompt)}`);
1014
- 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);
1015
1830
  break;
1016
1831
  }
1017
1832
  default:
1018
1833
  unsupported("subagents", `${h}: subagents have no cwd file (config/flag/global only); "${name}" not mounted`);
1019
1834
  }
1020
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
+ }
1021
1860
  function materializeCommand(h, name, body, add, unsupported) {
1022
1861
  switch (h) {
1023
1862
  case "claude-code":