@tangle-network/agent-profile-materialize 0.4.0 → 0.5.1

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