@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.
- package/dist/index.d.ts +51 -10
- package/dist/index.js +1162 -219
- 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,
|
|
26
|
-
import { dirname, isAbsolute, join, relative, resolve, sep, win32, } from "node:path";
|
|
27
|
-
import { agentCandidateProfilePlanEvidenceSchema, agentCandidateProfilePlanMaterialSchema, agentCandidateProfileSchema, } from "@tangle-network/agent-interface";
|
|
28
|
-
/** Resolve
|
|
29
|
-
*
|
|
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: "
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
//
|
|
270
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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
|
-
*
|
|
308
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
605
|
+
const nativeHarness = canonicalHarness(harness);
|
|
606
|
+
if (nativeHarness !== "codex" && nativeHarness !== "claude-code") {
|
|
358
607
|
return {
|
|
359
608
|
wrapperFiles: [],
|
|
360
|
-
unsupportedReason: `${
|
|
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
|
|
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((
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
?
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
:
|
|
463
|
-
|
|
464
|
-
|
|
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 =
|
|
651
|
-
const digest =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
847
|
-
const
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
924
|
-
...(c.timeoutMs
|
|
925
|
-
|
|
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(
|
|
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: [
|
|
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
|
-
|
|
946
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
986
|
-
|
|
987
|
-
.
|
|
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
|
-
|
|
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
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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":
|