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