@tangle-network/agent-profile-materialize 0.3.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +43 -10
- package/dist/index.js +1028 -192
- package/package.json +3 -4
package/dist/index.js
CHANGED
|
@@ -22,13 +22,11 @@
|
|
|
22
22
|
* cli-bridge and agent-dev-container — this is the single source those re-export.
|
|
23
23
|
*/
|
|
24
24
|
import { createHash, randomUUID } from "node:crypto";
|
|
25
|
-
import { closeSync, fchmodSync, constants as fsConstants, lstatSync, mkdirSync, openSync,
|
|
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) {
|
|
@@ -618,11 +838,13 @@ export function hashWorkspacePlan(plan) {
|
|
|
618
838
|
const env = Object.fromEntries(Object.entries(plan.env).sort(([a], [b]) => compareStrings(a, b)));
|
|
619
839
|
const unsupported = [...plan.unsupported].sort((a, b) => compareStrings(`${a.dimension}\0${a.reason}`, `${b.dimension}\0${b.reason}`));
|
|
620
840
|
const canonical = JSON.stringify({
|
|
621
|
-
version: 1,
|
|
622
841
|
harness: canonicalHarness(plan.harness),
|
|
623
842
|
files,
|
|
624
843
|
env,
|
|
625
844
|
flags: [...plan.flags],
|
|
845
|
+
...(plan.systemPrompt === undefined
|
|
846
|
+
? {}
|
|
847
|
+
: { systemPrompt: plan.systemPrompt }),
|
|
626
848
|
unsupported,
|
|
627
849
|
});
|
|
628
850
|
return `sha256:${sha256(canonical)}`;
|
|
@@ -630,7 +852,7 @@ export function hashWorkspacePlan(plan) {
|
|
|
630
852
|
/** Build the exact digest-free identity document required by agent-interface. */
|
|
631
853
|
export function agentCandidateProfilePlanMaterial(plan) {
|
|
632
854
|
const material = {
|
|
633
|
-
|
|
855
|
+
sourceProfileDigest: plan.sourceProfileDigest,
|
|
634
856
|
harness: canonicalHarness(plan.harness),
|
|
635
857
|
files: normalizePlanFiles(plan.files).map((file) => ({
|
|
636
858
|
relPath: file.relPath,
|
|
@@ -639,6 +861,9 @@ export function agentCandidateProfilePlanMaterial(plan) {
|
|
|
639
861
|
})),
|
|
640
862
|
env: Object.fromEntries(Object.entries(plan.env).sort(([left], [right]) => compareStrings(left, right))),
|
|
641
863
|
flags: [...plan.flags],
|
|
864
|
+
...(plan.systemPrompt === undefined
|
|
865
|
+
? {}
|
|
866
|
+
: { systemPrompt: plan.systemPrompt }),
|
|
642
867
|
unsupported: [...plan.unsupported].sort((left, right) => compareStrings(`${left.dimension}\0${left.reason}`, `${right.dimension}\0${right.reason}`)),
|
|
643
868
|
};
|
|
644
869
|
return agentCandidateProfilePlanMaterialSchema.parse(material);
|
|
@@ -649,10 +874,8 @@ export function agentCandidateProfilePlanMaterial(plan) {
|
|
|
649
874
|
*/
|
|
650
875
|
export function canonicalizeAgentCandidateProfilePlan(plan) {
|
|
651
876
|
const material = agentCandidateProfilePlanMaterial(plan);
|
|
652
|
-
const bytes =
|
|
653
|
-
const digest =
|
|
654
|
-
.update(bytes)
|
|
655
|
-
.digest("hex")}`;
|
|
877
|
+
const bytes = canonicalCandidateBytes(material);
|
|
878
|
+
const digest = canonicalCandidateDigest(material);
|
|
656
879
|
return { material, bytes, digest };
|
|
657
880
|
}
|
|
658
881
|
/** Capture portable evidence of the exact canonical profile-plan bytes. */
|
|
@@ -665,7 +888,6 @@ export function captureAgentCandidateProfilePlan(plan) {
|
|
|
665
888
|
byteLength: canonical.bytes.byteLength,
|
|
666
889
|
};
|
|
667
890
|
return agentCandidateProfilePlanEvidenceSchema.parse({
|
|
668
|
-
schemaVersion: 1,
|
|
669
891
|
kind: "agent-profile-workspace-plan",
|
|
670
892
|
digest: canonical.digest,
|
|
671
893
|
material: canonical.material,
|
|
@@ -677,10 +899,22 @@ export function captureAgentCandidateProfilePlan(plan) {
|
|
|
677
899
|
* canonical evidence. No caller needs to re-serialize or reinterpret the plan.
|
|
678
900
|
*/
|
|
679
901
|
export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorkspace) {
|
|
680
|
-
|
|
681
|
-
throw new Error(formatUnsupported(plan.unsupported));
|
|
682
|
-
}
|
|
902
|
+
assertWorkspacePlanSupported(plan);
|
|
683
903
|
const profilePlan = captureAgentCandidateProfilePlan(plan);
|
|
904
|
+
const activationFiles = normalizePlanFiles(plan.files).map((file) => ({
|
|
905
|
+
path: file.relPath,
|
|
906
|
+
mode: file.mode,
|
|
907
|
+
content: file.content,
|
|
908
|
+
}));
|
|
909
|
+
const activationMaterial = {
|
|
910
|
+
kind: "agent-candidate-profile-activation",
|
|
911
|
+
profilePlan,
|
|
912
|
+
files: activationFiles,
|
|
913
|
+
};
|
|
914
|
+
const profileActivation = agentCandidateProfileActivationSchema.parse({
|
|
915
|
+
...activationMaterial,
|
|
916
|
+
digest: canonicalCandidateDigest(activationMaterial),
|
|
917
|
+
});
|
|
684
918
|
const applied = applyWorkspacePlan({
|
|
685
919
|
harness: plan.harness,
|
|
686
920
|
files: plan.files,
|
|
@@ -689,10 +923,13 @@ export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorks
|
|
|
689
923
|
candidatePublicString(value),
|
|
690
924
|
])),
|
|
691
925
|
flags: plan.flags.map(candidatePublicString),
|
|
926
|
+
...(plan.systemPrompt === undefined
|
|
927
|
+
? {}
|
|
928
|
+
: { systemPrompt: candidatePublicString(plan.systemPrompt) }),
|
|
692
929
|
unsupported: plan.unsupported,
|
|
693
930
|
}, workspaceDir);
|
|
694
931
|
return {
|
|
695
|
-
|
|
932
|
+
profileActivation,
|
|
696
933
|
application: {
|
|
697
934
|
planDigest: profilePlan.digest,
|
|
698
935
|
targetWorkspace,
|
|
@@ -700,41 +937,24 @@ export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorks
|
|
|
700
937
|
},
|
|
701
938
|
env: { ...plan.env },
|
|
702
939
|
flags: [...plan.flags],
|
|
940
|
+
...(plan.systemPrompt === undefined
|
|
941
|
+
? {}
|
|
942
|
+
: { systemPrompt: plan.systemPrompt }),
|
|
703
943
|
unsupported: [...plan.unsupported],
|
|
704
944
|
};
|
|
705
945
|
}
|
|
706
|
-
function rfc8785Serialize(value) {
|
|
707
|
-
if (value === null || typeof value === "boolean")
|
|
708
|
-
return JSON.stringify(value);
|
|
709
|
-
if (typeof value === "string")
|
|
710
|
-
return JSON.stringify(value);
|
|
711
|
-
if (typeof value === "number") {
|
|
712
|
-
if (!Number.isFinite(value)) {
|
|
713
|
-
throw new Error("RFC 8785 cannot serialize a non-finite number");
|
|
714
|
-
}
|
|
715
|
-
return JSON.stringify(value);
|
|
716
|
-
}
|
|
717
|
-
if (Array.isArray(value)) {
|
|
718
|
-
return `[${value.map(rfc8785Serialize).join(",")}]`;
|
|
719
|
-
}
|
|
720
|
-
if (typeof value === "object") {
|
|
721
|
-
const record = value;
|
|
722
|
-
return `{${Object.keys(record)
|
|
723
|
-
.sort(compareStrings)
|
|
724
|
-
.map((key) => `${JSON.stringify(key)}:${rfc8785Serialize(record[key])}`)
|
|
725
|
-
.join(",")}}`;
|
|
726
|
-
}
|
|
727
|
-
throw new Error(`RFC 8785 cannot serialize ${typeof value}`);
|
|
728
|
-
}
|
|
729
946
|
/**
|
|
730
947
|
* Apply a WorkspacePlan to a real workspace dir: write every file (creating parent
|
|
731
948
|
* dirs), and return the env + flags the caller must hand the spawn. This is the IO
|
|
732
949
|
* consumer of the pure `materializeProfile` — used by the cli-bridge host spawn, the
|
|
733
950
|
* agent-dev-container box, and VB. Returns `unsupported` so the caller can log/fail.
|
|
734
951
|
*/
|
|
735
|
-
export function applyWorkspacePlan(plan, workspaceDir) {
|
|
952
|
+
export function applyWorkspacePlan(plan, workspaceDir, options = {}) {
|
|
736
953
|
const workspaceRoot = resolve(workspaceDir);
|
|
737
|
-
const
|
|
954
|
+
const runtimeOnlyFiles = new Set((options.runtimeOnlyFiles ?? []).map(normalizeWorkspaceRelativePath));
|
|
955
|
+
const files = normalizePlanFiles(plan.files)
|
|
956
|
+
.filter((file) => !runtimeOnlyFiles.has(file.relPath))
|
|
957
|
+
.map((file) => ({
|
|
738
958
|
...file,
|
|
739
959
|
absPath: resolveWorkspaceFilePath(workspaceRoot, file.relPath),
|
|
740
960
|
}));
|
|
@@ -742,6 +962,9 @@ export function applyWorkspacePlan(plan, workspaceDir) {
|
|
|
742
962
|
// existing symlink cannot leave a partially materialized workspace.
|
|
743
963
|
for (const file of files) {
|
|
744
964
|
assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
|
|
965
|
+
if (workspaceFileExists(file.absPath) && !workspaceFileMatches(file)) {
|
|
966
|
+
throw new Error(`Refusing to replace existing workspace file: ${file.relPath}`);
|
|
967
|
+
}
|
|
745
968
|
}
|
|
746
969
|
for (const file of files) {
|
|
747
970
|
mkdirSync(dirname(file.absPath), { recursive: true });
|
|
@@ -751,17 +974,39 @@ export function applyWorkspacePlan(plan, workspaceDir) {
|
|
|
751
974
|
}
|
|
752
975
|
const written = [];
|
|
753
976
|
for (const file of files) {
|
|
754
|
-
|
|
977
|
+
if (!workspaceFileExists(file.absPath)) {
|
|
978
|
+
writeWorkspaceFile(workspaceRoot, file);
|
|
979
|
+
}
|
|
755
980
|
written.push(file.relPath);
|
|
756
981
|
}
|
|
757
982
|
return {
|
|
758
983
|
workspacePlanDigest: hashWorkspacePlan(plan),
|
|
759
984
|
env: plan.env,
|
|
760
985
|
flags: plan.flags,
|
|
986
|
+
...(plan.systemPrompt === undefined
|
|
987
|
+
? {}
|
|
988
|
+
: { systemPrompt: plan.systemPrompt }),
|
|
761
989
|
unsupported: plan.unsupported,
|
|
762
990
|
written,
|
|
763
991
|
};
|
|
764
992
|
}
|
|
993
|
+
function workspaceFileExists(pathValue) {
|
|
994
|
+
try {
|
|
995
|
+
lstatSync(pathValue);
|
|
996
|
+
return true;
|
|
997
|
+
}
|
|
998
|
+
catch (error) {
|
|
999
|
+
if (isFsError(error, "ENOENT"))
|
|
1000
|
+
return false;
|
|
1001
|
+
throw error;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
function workspaceFileMatches(file) {
|
|
1005
|
+
const stats = lstatSync(file.absPath);
|
|
1006
|
+
return (stats.isFile() &&
|
|
1007
|
+
(stats.mode & 0o777) === file.mode &&
|
|
1008
|
+
readFileSync(file.absPath, "utf8") === file.content);
|
|
1009
|
+
}
|
|
765
1010
|
function resolveWorkspaceFilePath(workspaceRoot, relativePath) {
|
|
766
1011
|
const targetPath = resolve(workspaceRoot, ...relativePath.split("/"));
|
|
767
1012
|
const targetRelativePath = relative(workspaceRoot, targetPath);
|
|
@@ -812,7 +1057,15 @@ function writeWorkspaceFile(workspaceRoot, file) {
|
|
|
812
1057
|
closeSync(descriptor);
|
|
813
1058
|
descriptor = undefined;
|
|
814
1059
|
assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
|
|
815
|
-
|
|
1060
|
+
try {
|
|
1061
|
+
linkSync(temporaryPath, file.absPath);
|
|
1062
|
+
}
|
|
1063
|
+
catch (error) {
|
|
1064
|
+
if (!isFsError(error, "EEXIST") || !workspaceFileMatches(file)) {
|
|
1065
|
+
throw error;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
unlinkSync(temporaryPath);
|
|
816
1069
|
}
|
|
817
1070
|
catch (error) {
|
|
818
1071
|
if (descriptor !== undefined)
|
|
@@ -834,55 +1087,337 @@ function isFsError(error, code) {
|
|
|
834
1087
|
error.code === code);
|
|
835
1088
|
}
|
|
836
1089
|
// ─── Per-dimension routing (verified matrix) ────────────────────────────────
|
|
1090
|
+
function materializeExecutionControls(h, tools, permissions, modes, plan, add, unsupported) {
|
|
1091
|
+
const toolEntries = Object.entries(tools ?? {}).sort(([left], [right]) => compareStrings(left, right));
|
|
1092
|
+
const permissionEntries = Object.entries(permissions ?? {}).sort(([left], [right]) => compareStrings(left, right));
|
|
1093
|
+
switch (h) {
|
|
1094
|
+
case "claude-code": {
|
|
1095
|
+
if (toolEntries.length > 0 || permissionEntries.length > 0) {
|
|
1096
|
+
const nativeTools = mapSupportedBooleanEntries(toolEntries, claudeToolName, (name) => unsupported("tools", `claude-code: no verified native tool mapping for "${name}"`), "claude-code tool");
|
|
1097
|
+
const nativePermissions = mapSupportedPermissionEntries(permissionEntries, claudeToolName, (name) => unsupported("permissions", `claude-code: no verified native permission mapping for "${name}"`), "claude-code permission");
|
|
1098
|
+
const rules = claudePermissionRules(nativePermissions);
|
|
1099
|
+
for (const [tool, enabled] of nativeTools) {
|
|
1100
|
+
rules[enabled ? "allow" : "deny"].push(tool);
|
|
1101
|
+
}
|
|
1102
|
+
add(CLAUDE_SETTINGS_PATH, JSON.stringify({ permissions: sealClaudePermissionRules(rules) }, null, 2));
|
|
1103
|
+
registerClaudeSettings(plan);
|
|
1104
|
+
}
|
|
1105
|
+
break;
|
|
1106
|
+
}
|
|
1107
|
+
case "opencode": {
|
|
1108
|
+
const enforceableTools = toolEntries.filter(([name, enabled]) => {
|
|
1109
|
+
if (normalizedControlName(name) !== "lsp" || !enabled)
|
|
1110
|
+
return true;
|
|
1111
|
+
unsupported("tools", "opencode: the LSP tool is disabled by the runtime and cannot be enabled exactly");
|
|
1112
|
+
return false;
|
|
1113
|
+
});
|
|
1114
|
+
const enforceablePermissions = permissionEntries.filter(([name, policy]) => {
|
|
1115
|
+
if (normalizedControlName(name) !== "read")
|
|
1116
|
+
return true;
|
|
1117
|
+
if (!permissionAllowsEverything(policy)) {
|
|
1118
|
+
unsupported("permissions", "opencode: read access cannot be denied or approval-gated");
|
|
1119
|
+
}
|
|
1120
|
+
return false;
|
|
1121
|
+
});
|
|
1122
|
+
const nativeTools = mapSupportedBooleanEntries(enforceableTools, openCodeToolName, (name) => unsupported("tools", `opencode: no verified native tool mapping for "${name}"`), "opencode tool");
|
|
1123
|
+
const nativePermissions = mapSupportedPermissionEntries(enforceablePermissions, openCodePermissionName, (name) => unsupported("permissions", `opencode: no verified native permission mapping for "${name}"`), "opencode permission");
|
|
1124
|
+
if (enforceableTools.length > 0 || permissionEntries.length > 0) {
|
|
1125
|
+
add("opencode.json", JSON.stringify({
|
|
1126
|
+
$schema: "https://opencode.ai/config.json",
|
|
1127
|
+
...(nativeTools.length > 0
|
|
1128
|
+
? { tools: Object.fromEntries(nativeTools) }
|
|
1129
|
+
: {}),
|
|
1130
|
+
...(nativePermissions.length > 0
|
|
1131
|
+
? { permission: Object.fromEntries(nativePermissions) }
|
|
1132
|
+
: {}),
|
|
1133
|
+
}, null, 2));
|
|
1134
|
+
}
|
|
1135
|
+
break;
|
|
1136
|
+
}
|
|
1137
|
+
case "codex": {
|
|
1138
|
+
const searchEntries = [];
|
|
1139
|
+
for (const [name, enabled] of toolEntries) {
|
|
1140
|
+
const posture = resolveNativeWebTools({ [name]: enabled });
|
|
1141
|
+
if (posture.search === enabled && posture.fetch === undefined) {
|
|
1142
|
+
searchEntries.push([name, enabled]);
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
if (posture.fetch === enabled && posture.search === undefined) {
|
|
1146
|
+
if (enabled) {
|
|
1147
|
+
unsupported("tools", `codex: native web fetch is unavailable for "${name}"`);
|
|
1148
|
+
}
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
unsupported("tools", `codex: no verified primary-process tool mapping for "${name}"`);
|
|
1152
|
+
}
|
|
1153
|
+
const nativeSearch = mapSupportedBooleanEntries(searchEntries, () => "web_search", () => undefined, "codex tool");
|
|
1154
|
+
if (nativeSearch[0]) {
|
|
1155
|
+
plan.flags.push("-c", `tools.web_search=${nativeSearch[0][1]}`);
|
|
1156
|
+
}
|
|
1157
|
+
if (permissionEntries.length > 0) {
|
|
1158
|
+
unsupported("permissions", "codex: no verified primary-process permission policy mapping");
|
|
1159
|
+
}
|
|
1160
|
+
break;
|
|
1161
|
+
}
|
|
1162
|
+
default:
|
|
1163
|
+
if (toolEntries.length > 0) {
|
|
1164
|
+
unsupported("tools", `${h}: no verified primary-process tool availability mapping`);
|
|
1165
|
+
}
|
|
1166
|
+
if (permissionEntries.length > 0) {
|
|
1167
|
+
unsupported("permissions", `${h}: no verified primary-process permission policy mapping`);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
if (modes && Object.keys(modes).length > 0) {
|
|
1171
|
+
unsupported("modes", `${h}: resolve one named AgentProfile mode before materialization`);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
function normalizedControlName(name) {
|
|
1175
|
+
return name.replace(/[^a-zA-Z0-9_]/g, "").toLowerCase();
|
|
1176
|
+
}
|
|
1177
|
+
function nativeWebToolKind(name) {
|
|
1178
|
+
const posture = resolveNativeWebTools({ [name]: true });
|
|
1179
|
+
if (posture.search === true && posture.fetch === undefined)
|
|
1180
|
+
return "search";
|
|
1181
|
+
if (posture.fetch === true && posture.search === undefined)
|
|
1182
|
+
return "fetch";
|
|
1183
|
+
return undefined;
|
|
1184
|
+
}
|
|
1185
|
+
const CLAUDE_TOOL_NAMES = {
|
|
1186
|
+
askuserquestion: "AskUserQuestion",
|
|
1187
|
+
bash: "Bash",
|
|
1188
|
+
edit: "Edit",
|
|
1189
|
+
glob: "Glob",
|
|
1190
|
+
grep: "Grep",
|
|
1191
|
+
notebookedit: "NotebookEdit",
|
|
1192
|
+
read: "Read",
|
|
1193
|
+
skill: "Skill",
|
|
1194
|
+
task: "Task",
|
|
1195
|
+
taskoutput: "TaskOutput",
|
|
1196
|
+
todowrite: "TodoWrite",
|
|
1197
|
+
webfetch: "WebFetch",
|
|
1198
|
+
websearch: "WebSearch",
|
|
1199
|
+
write: "Write",
|
|
1200
|
+
};
|
|
1201
|
+
function claudeToolName(name) {
|
|
1202
|
+
const web = nativeWebToolKind(name);
|
|
1203
|
+
if (web === "search")
|
|
1204
|
+
return "WebSearch";
|
|
1205
|
+
if (web === "fetch")
|
|
1206
|
+
return "WebFetch";
|
|
1207
|
+
return CLAUDE_TOOL_NAMES[normalizedControlName(name)];
|
|
1208
|
+
}
|
|
1209
|
+
const OPENCODE_TOOL_NAMES = new Set([
|
|
1210
|
+
"bash",
|
|
1211
|
+
"batch",
|
|
1212
|
+
"codesearch",
|
|
1213
|
+
"edit",
|
|
1214
|
+
"glob",
|
|
1215
|
+
"grep",
|
|
1216
|
+
"list",
|
|
1217
|
+
"lsp",
|
|
1218
|
+
"multiedit",
|
|
1219
|
+
"patch",
|
|
1220
|
+
"read",
|
|
1221
|
+
"task",
|
|
1222
|
+
"todoread",
|
|
1223
|
+
"todowrite",
|
|
1224
|
+
"webfetch",
|
|
1225
|
+
"websearch",
|
|
1226
|
+
"write",
|
|
1227
|
+
]);
|
|
1228
|
+
function openCodeToolName(name) {
|
|
1229
|
+
const web = nativeWebToolKind(name);
|
|
1230
|
+
if (web === "search")
|
|
1231
|
+
return "websearch";
|
|
1232
|
+
if (web === "fetch")
|
|
1233
|
+
return "webfetch";
|
|
1234
|
+
const normalized = normalizedControlName(name);
|
|
1235
|
+
return OPENCODE_TOOL_NAMES.has(normalized) ? normalized : undefined;
|
|
1236
|
+
}
|
|
1237
|
+
function openCodePermissionName(name) {
|
|
1238
|
+
const normalized = normalizedControlName(name);
|
|
1239
|
+
if (["write", "edit"].includes(normalized))
|
|
1240
|
+
return "edit";
|
|
1241
|
+
const web = nativeWebToolKind(name);
|
|
1242
|
+
if (web === "search")
|
|
1243
|
+
return "websearch";
|
|
1244
|
+
if (web === "fetch")
|
|
1245
|
+
return "webfetch";
|
|
1246
|
+
if (["bash", "mcp", "doom_loop", "external_directory"].includes(normalized)) {
|
|
1247
|
+
return normalized;
|
|
1248
|
+
}
|
|
1249
|
+
return normalized !== "read" && OPENCODE_TOOL_NAMES.has(normalized)
|
|
1250
|
+
? normalized
|
|
1251
|
+
: undefined;
|
|
1252
|
+
}
|
|
1253
|
+
function mapSupportedBooleanEntries(entries, mapName, onUnsupported, label) {
|
|
1254
|
+
return mapControlEntries(entries, mapName, onUnsupported, (left, right, nativeName) => {
|
|
1255
|
+
if (left !== right)
|
|
1256
|
+
throw new Error(`${label} aliases disagree for "${nativeName}"`);
|
|
1257
|
+
return left;
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
function permissionAllowsEverything(policy) {
|
|
1261
|
+
return typeof policy === "string"
|
|
1262
|
+
? policy === "allow"
|
|
1263
|
+
: Object.values(policy).every((value) => value === "allow");
|
|
1264
|
+
}
|
|
1265
|
+
function mapSupportedPermissionEntries(entries, mapName, onUnsupported, label) {
|
|
1266
|
+
return mapControlEntries(entries, mapName, onUnsupported, (left, right, nativeName) => mergePermissionPolicies(left, right, `${label} "${nativeName}"`));
|
|
1267
|
+
}
|
|
1268
|
+
function mapControlEntries(entries, mapName, onUnsupported, merge) {
|
|
1269
|
+
const mapped = new Map();
|
|
1270
|
+
for (const [name, value] of entries) {
|
|
1271
|
+
const nativeName = mapName(name);
|
|
1272
|
+
if (!nativeName) {
|
|
1273
|
+
onUnsupported(name);
|
|
1274
|
+
continue;
|
|
1275
|
+
}
|
|
1276
|
+
const previous = mapped.get(nativeName);
|
|
1277
|
+
mapped.set(nativeName, previous === undefined ? value : merge(previous, value, nativeName));
|
|
1278
|
+
}
|
|
1279
|
+
return [...mapped.entries()].sort(([left], [right]) => compareStrings(left, right));
|
|
1280
|
+
}
|
|
1281
|
+
function mergePermissionPolicies(left, right, label) {
|
|
1282
|
+
if (typeof left === "string" || typeof right === "string") {
|
|
1283
|
+
if (left !== right)
|
|
1284
|
+
throw new Error(`${label} aliases disagree`);
|
|
1285
|
+
return left;
|
|
1286
|
+
}
|
|
1287
|
+
const merged = { ...left };
|
|
1288
|
+
for (const [specifier, decision] of Object.entries(right)) {
|
|
1289
|
+
const previous = merged[specifier];
|
|
1290
|
+
if (previous !== undefined && previous !== decision) {
|
|
1291
|
+
throw new Error(`${label} aliases disagree for "${specifier}"`);
|
|
1292
|
+
}
|
|
1293
|
+
merged[specifier] = decision;
|
|
1294
|
+
}
|
|
1295
|
+
return Object.fromEntries(Object.entries(merged).sort(([leftKey], [rightKey]) => compareStrings(leftKey, rightKey)));
|
|
1296
|
+
}
|
|
1297
|
+
function claudePermissionRules(entries) {
|
|
1298
|
+
const rules = {
|
|
1299
|
+
allow: [],
|
|
1300
|
+
ask: [],
|
|
1301
|
+
deny: [],
|
|
1302
|
+
};
|
|
1303
|
+
for (const [tool, policy] of entries) {
|
|
1304
|
+
if (typeof policy === "string") {
|
|
1305
|
+
rules[policy].push(tool);
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
for (const [specifier, decision] of Object.entries(policy).sort(([left], [right]) => compareStrings(left, right))) {
|
|
1309
|
+
rules[decision].push(specifier === "*" ? tool : `${tool}(${specifier})`);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
rules.allow.sort(compareStrings);
|
|
1313
|
+
rules.ask.sort(compareStrings);
|
|
1314
|
+
rules.deny.sort(compareStrings);
|
|
1315
|
+
return rules;
|
|
1316
|
+
}
|
|
1317
|
+
function sealClaudePermissionRules(rules) {
|
|
1318
|
+
const owner = new Map();
|
|
1319
|
+
for (const decision of ["allow", "ask", "deny"]) {
|
|
1320
|
+
for (const rule of rules[decision]) {
|
|
1321
|
+
const previous = owner.get(rule);
|
|
1322
|
+
if (previous && previous !== decision) {
|
|
1323
|
+
throw new Error(`claude-code execution control "${rule}" conflicts between ${previous} and ${decision}`);
|
|
1324
|
+
}
|
|
1325
|
+
owner.set(rule, decision);
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
return {
|
|
1329
|
+
allow: [...new Set(rules.allow)].sort(compareStrings),
|
|
1330
|
+
ask: [...new Set(rules.ask)].sort(compareStrings),
|
|
1331
|
+
deny: [...new Set(rules.deny)].sort(compareStrings),
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
837
1334
|
function materializeMcp(h, mcp, plan, add, unsupported) {
|
|
838
1335
|
switch (h) {
|
|
839
|
-
case "claude-code":
|
|
1336
|
+
case "claude-code": {
|
|
1337
|
+
const servers = {};
|
|
1338
|
+
for (const [name, server] of Object.entries(mcp)) {
|
|
1339
|
+
if (server.url) {
|
|
1340
|
+
servers[name] = {
|
|
1341
|
+
type: server.transport ?? "http",
|
|
1342
|
+
url: server.url,
|
|
1343
|
+
...(server.headers ? { headers: server.headers } : {}),
|
|
1344
|
+
};
|
|
1345
|
+
continue;
|
|
1346
|
+
}
|
|
1347
|
+
if (server.cwd !== undefined) {
|
|
1348
|
+
unsupported("mcp", `${h}: local MCP server "${name}" does not support a cwd field`);
|
|
1349
|
+
}
|
|
1350
|
+
servers[name] = {
|
|
1351
|
+
type: "stdio",
|
|
1352
|
+
command: server.command,
|
|
1353
|
+
...(server.args ? { args: server.args } : {}),
|
|
1354
|
+
...(server.env ? { env: server.env } : {}),
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
const relPath = ".tangle/claude-mcp.json";
|
|
1358
|
+
add(relPath, JSON.stringify({ mcpServers: servers }, null, 2));
|
|
1359
|
+
plan.flags.push("--mcp-config", relPath, "--strict-mcp-config");
|
|
1360
|
+
break;
|
|
1361
|
+
}
|
|
840
1362
|
case "nanoclaw":
|
|
841
|
-
|
|
842
|
-
// enable them via .claude/settings.json enabledMcpjsonServers to skip the prompt.
|
|
843
|
-
add(".mcp.json", JSON.stringify({ mcpServers: mcp }, null, 2));
|
|
844
|
-
add(".claude/settings.json", JSON.stringify({ enabledMcpjsonServers: Object.keys(mcp) }, null, 2));
|
|
845
|
-
if (h === "nanoclaw")
|
|
846
|
-
unsupported("mcp", "nanoclaw MCP is SDK-injected (options.mcpServers); .mcp.json may be ignored — inject via the SDK adapter");
|
|
1363
|
+
unsupported("mcp", "nanoclaw MCP must be injected through options.mcpServers by its SDK adapter");
|
|
847
1364
|
break;
|
|
848
1365
|
case "codex": {
|
|
849
|
-
|
|
850
|
-
const
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
if (s.env)
|
|
862
|
-
lines.push(`env = { ${Object.entries(s.env)
|
|
863
|
-
.map(([k, v]) => `${k} = ${tomlStr(v)}`)
|
|
864
|
-
.join(", ")} }`);
|
|
865
|
-
lines.push("");
|
|
1366
|
+
const servers = [];
|
|
1367
|
+
for (const [name, server] of Object.entries(mcp).sort(([left], [right]) => compareStrings(left, right))) {
|
|
1368
|
+
if (server.url && server.transport === "sse") {
|
|
1369
|
+
unsupported("mcp", `codex: remote MCP server "${name}" uses unsupported SSE transport`);
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
servers.push(`${tomlStr(name)} = ${codexMcpServerToml(name, server, plan)}`);
|
|
1373
|
+
}
|
|
1374
|
+
if (servers.length > 0) {
|
|
1375
|
+
// Runtime overrides do not require persisted project trust. Header values
|
|
1376
|
+
// ride environment variables so credentials never appear in argv.
|
|
1377
|
+
plan.flags.push("-c", `mcp_servers={ ${servers.join(", ")} }`);
|
|
866
1378
|
}
|
|
867
|
-
add(".codex/config.toml", lines.join("\n"));
|
|
868
1379
|
break;
|
|
869
1380
|
}
|
|
870
1381
|
case "opencode":
|
|
871
1382
|
// opencode.json "mcp" block (local|remote).
|
|
872
|
-
add("opencode.json", JSON.stringify({
|
|
1383
|
+
add("opencode.json", JSON.stringify({
|
|
1384
|
+
$schema: "https://opencode.ai/config.json",
|
|
1385
|
+
mcp: openCodeMcpConfig(mcp),
|
|
1386
|
+
}, null, 2));
|
|
873
1387
|
break;
|
|
874
|
-
case "gemini":
|
|
875
|
-
|
|
1388
|
+
case "gemini": {
|
|
1389
|
+
const servers = {};
|
|
1390
|
+
for (const [name, server] of Object.entries(mcp)) {
|
|
1391
|
+
if (server.url) {
|
|
1392
|
+
servers[name] = {
|
|
1393
|
+
[server.transport === "sse" ? "url" : "httpUrl"]: server.url,
|
|
1394
|
+
...(server.headers ? { headers: server.headers } : {}),
|
|
1395
|
+
};
|
|
1396
|
+
continue;
|
|
1397
|
+
}
|
|
1398
|
+
servers[name] = {
|
|
1399
|
+
command: server.command,
|
|
1400
|
+
...(server.args ? { args: server.args } : {}),
|
|
1401
|
+
...(server.env ? { env: server.env } : {}),
|
|
1402
|
+
...(server.cwd ? { cwd: server.cwd } : {}),
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
add(".gemini/settings.json", JSON.stringify({ mcpServers: servers }, null, 2));
|
|
876
1406
|
break;
|
|
1407
|
+
}
|
|
877
1408
|
case "kimi-code": {
|
|
878
1409
|
// NO cwd auto-discovery — kimi needs an explicit --mcp-config-file FLAG.
|
|
879
1410
|
const rel = ".kimi/mcp.json";
|
|
880
|
-
|
|
1411
|
+
const servers = Object.fromEntries(Object.entries(mcp).map(([name, server]) => [
|
|
1412
|
+
name,
|
|
1413
|
+
kimiMcpServer(server),
|
|
1414
|
+
]));
|
|
1415
|
+
add(rel, JSON.stringify({ mcpServers: servers }, null, 2));
|
|
881
1416
|
plan.flags.push("--mcp-config-file", rel);
|
|
882
1417
|
break;
|
|
883
1418
|
}
|
|
884
1419
|
case "pi":
|
|
885
|
-
unsupported("mcp", "pi MCP
|
|
1420
|
+
unsupported("mcp", "pi MCP requires a loaded MCP extension; the base Pi runtime has no MCP control");
|
|
886
1421
|
break;
|
|
887
1422
|
case "hermes":
|
|
888
1423
|
unsupported("mcp", "hermes MCP is global config.yaml (mcp_servers), no cwd discovery — write to the hermes global config or pass a config flag");
|
|
@@ -892,70 +1427,182 @@ function materializeMcp(h, mcp, plan, add, unsupported) {
|
|
|
892
1427
|
break;
|
|
893
1428
|
}
|
|
894
1429
|
}
|
|
895
|
-
function
|
|
1430
|
+
function tomlInlineStringTable(values) {
|
|
1431
|
+
return `{ ${Object.entries(values)
|
|
1432
|
+
.sort(([left], [right]) => compareStrings(left, right))
|
|
1433
|
+
.map(([key, value]) => `${tomlStr(key)} = ${tomlStr(value)}`)
|
|
1434
|
+
.join(", ")} }`;
|
|
1435
|
+
}
|
|
1436
|
+
function codexMcpServerToml(name, server, plan) {
|
|
1437
|
+
const fields = [];
|
|
1438
|
+
if (server.url) {
|
|
1439
|
+
fields.push(`url = ${tomlStr(server.url)}`);
|
|
1440
|
+
if (server.headers && Object.keys(server.headers).length > 0) {
|
|
1441
|
+
const envHeaders = {};
|
|
1442
|
+
for (const [header, value] of Object.entries(server.headers).sort(([left], [right]) => compareStrings(left, right))) {
|
|
1443
|
+
const envName = `TANGLE_MCP_HEADER_${sha256(`${name}\0${header}`)
|
|
1444
|
+
.slice(0, 24)
|
|
1445
|
+
.toUpperCase()}`;
|
|
1446
|
+
setPlanEnvironment(plan, envName, value);
|
|
1447
|
+
envHeaders[header] = envName;
|
|
1448
|
+
}
|
|
1449
|
+
fields.push(`env_http_headers = ${tomlInlineStringTable(envHeaders)}`);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
else {
|
|
1453
|
+
if (!server.command) {
|
|
1454
|
+
throw new Error(`Codex MCP server "${name}" must define url or command`);
|
|
1455
|
+
}
|
|
1456
|
+
fields.push(`command = ${tomlStr(server.command)}`);
|
|
1457
|
+
if (server.args?.length) {
|
|
1458
|
+
fields.push(`args = [${server.args.map(tomlStr).join(", ")}]`);
|
|
1459
|
+
}
|
|
1460
|
+
if (server.cwd)
|
|
1461
|
+
fields.push(`cwd = ${tomlStr(server.cwd)}`);
|
|
1462
|
+
if (server.env && Object.keys(server.env).length > 0) {
|
|
1463
|
+
const envNames = Object.keys(server.env).sort(compareStrings);
|
|
1464
|
+
for (const envName of envNames) {
|
|
1465
|
+
setPlanEnvironment(plan, envName, server.env[envName]);
|
|
1466
|
+
}
|
|
1467
|
+
fields.push(`env_vars = [${envNames.map(tomlStr).join(", ")}]`);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
return `{ ${fields.join(", ")} }`;
|
|
1471
|
+
}
|
|
1472
|
+
function setPlanEnvironment(plan, name, value) {
|
|
1473
|
+
const previous = plan.env[name];
|
|
1474
|
+
if (previous !== undefined && previous !== value) {
|
|
1475
|
+
throw new Error(`profile environment values disagree for ${name}`);
|
|
1476
|
+
}
|
|
1477
|
+
plan.env[name] = value;
|
|
1478
|
+
}
|
|
1479
|
+
function kimiMcpServer(server) {
|
|
1480
|
+
if (server.url) {
|
|
1481
|
+
return {
|
|
1482
|
+
url: server.url,
|
|
1483
|
+
transport: server.transport ?? "http",
|
|
1484
|
+
...(server.headers ? { headers: server.headers } : {}),
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
return {
|
|
1488
|
+
command: server.command,
|
|
1489
|
+
...(server.args ? { args: server.args } : {}),
|
|
1490
|
+
...(server.env ? { env: server.env } : {}),
|
|
1491
|
+
...(server.cwd ? { cwd: server.cwd } : {}),
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
/** Lower canonical MCP definitions to OpenCode's native startup configuration. */
|
|
1495
|
+
export function openCodeMcpConfig(mcp) {
|
|
896
1496
|
const out = {};
|
|
897
1497
|
for (const [name, s] of Object.entries(mcp)) {
|
|
898
|
-
|
|
899
|
-
|
|
1498
|
+
if (s.enabled === false)
|
|
1499
|
+
continue;
|
|
1500
|
+
if (s.url) {
|
|
1501
|
+
if (s.transport === "sse") {
|
|
1502
|
+
throw new Error(`OpenCode MCP server "${name}" uses unsupported SSE transport`);
|
|
1503
|
+
}
|
|
1504
|
+
out[name] = {
|
|
900
1505
|
type: "remote",
|
|
901
1506
|
url: s.url,
|
|
902
|
-
enabled:
|
|
1507
|
+
enabled: true,
|
|
903
1508
|
...(s.headers ? { headers: s.headers } : {}),
|
|
904
|
-
}
|
|
905
|
-
: {
|
|
906
|
-
type: "local",
|
|
907
|
-
command: [s.command, ...(s.args ?? [])].filter(Boolean),
|
|
908
|
-
enabled: s.enabled !== false,
|
|
909
|
-
...(s.env ? { environment: s.env } : {}),
|
|
910
1509
|
};
|
|
1510
|
+
continue;
|
|
1511
|
+
}
|
|
1512
|
+
if (!s.command) {
|
|
1513
|
+
throw new Error(`OpenCode MCP server "${name}" must define either url or command`);
|
|
1514
|
+
}
|
|
1515
|
+
if (s.cwd !== undefined) {
|
|
1516
|
+
throw new Error(`OpenCode MCP server "${name}" does not support a cwd field`);
|
|
1517
|
+
}
|
|
1518
|
+
out[name] = {
|
|
1519
|
+
type: "local",
|
|
1520
|
+
command: [s.command, ...(s.args ?? [])],
|
|
1521
|
+
enabled: true,
|
|
1522
|
+
...(s.env ? { environment: s.env } : {}),
|
|
1523
|
+
};
|
|
911
1524
|
}
|
|
912
1525
|
return out;
|
|
913
1526
|
}
|
|
914
1527
|
function materializeHooks(h, hooks, add, unsupported, plan) {
|
|
915
1528
|
switch (h) {
|
|
916
|
-
case "claude-code":
|
|
917
|
-
|
|
918
|
-
// .claude/settings.json hooks{} — each event → [{matcher, hooks:[{type:command,command}]}].
|
|
1529
|
+
case "claude-code": {
|
|
1530
|
+
// Claude settings hooks{} — each event → [{matcher, hooks:[{type:command,command}]}].
|
|
919
1531
|
const settingsHooks = {};
|
|
920
1532
|
for (const [event, cmds] of Object.entries(hooks)) {
|
|
1533
|
+
if (!hookEventSupported("claude-code", event)) {
|
|
1534
|
+
unsupported("hooks", `claude-code: unsupported hook event "${event}"`);
|
|
1535
|
+
continue;
|
|
1536
|
+
}
|
|
921
1537
|
settingsHooks[event] = cmds.map((c) => ({
|
|
922
1538
|
matcher: c.matcher ?? "*",
|
|
923
1539
|
hooks: [
|
|
924
1540
|
{
|
|
925
1541
|
type: "command",
|
|
926
|
-
command: c
|
|
927
|
-
...(c.timeoutMs
|
|
928
|
-
|
|
929
|
-
: {}),
|
|
1542
|
+
command: materializedHookCommand(event, c, add),
|
|
1543
|
+
...(c.timeoutMs ? { timeout: timeoutSeconds(c.timeoutMs) } : {}),
|
|
1544
|
+
...(c.blocking === false ? { async: true } : {}),
|
|
930
1545
|
},
|
|
931
1546
|
],
|
|
932
1547
|
}));
|
|
933
1548
|
}
|
|
934
|
-
add(
|
|
1549
|
+
add(CLAUDE_SETTINGS_PATH, JSON.stringify({ hooks: settingsHooks }, null, 2));
|
|
1550
|
+
registerClaudeSettings(plan);
|
|
935
1551
|
break;
|
|
936
1552
|
}
|
|
1553
|
+
case "nanoclaw":
|
|
1554
|
+
unsupported("hooks", "nanoclaw's chat bridge does not expose Claude Code hook settings to the spawned agent");
|
|
1555
|
+
break;
|
|
937
1556
|
case "gemini": {
|
|
938
1557
|
const settingsHooks = {};
|
|
939
|
-
for (const [event, cmds] of Object.entries(hooks))
|
|
1558
|
+
for (const [event, cmds] of Object.entries(hooks)) {
|
|
1559
|
+
if (!hookEventSupported("gemini", event)) {
|
|
1560
|
+
unsupported("hooks", `gemini: unsupported hook event "${event}"`);
|
|
1561
|
+
continue;
|
|
1562
|
+
}
|
|
1563
|
+
if (cmds.some((command) => command.blocking === false)) {
|
|
1564
|
+
unsupported("hooks", `gemini: hook event "${event}" does not support non-blocking commands`);
|
|
1565
|
+
}
|
|
940
1566
|
settingsHooks[event] = cmds.map((c) => ({
|
|
941
1567
|
matcher: c.matcher ?? "*",
|
|
942
|
-
hooks: [
|
|
1568
|
+
hooks: [
|
|
1569
|
+
{
|
|
1570
|
+
type: "command",
|
|
1571
|
+
command: materializedHookCommand(event, c, add),
|
|
1572
|
+
...(c.timeoutMs ? { timeout: c.timeoutMs } : {}),
|
|
1573
|
+
},
|
|
1574
|
+
],
|
|
943
1575
|
}));
|
|
1576
|
+
}
|
|
944
1577
|
add(".gemini/settings.json", JSON.stringify({ hooks: settingsHooks }, null, 2));
|
|
945
1578
|
break;
|
|
946
1579
|
}
|
|
947
1580
|
case "codex": {
|
|
948
|
-
|
|
949
|
-
|
|
1581
|
+
for (const [event, cmds] of Object.entries(hooks).sort(([left], [right]) => compareStrings(left, right))) {
|
|
1582
|
+
if (!hookEventSupported("codex", event)) {
|
|
1583
|
+
unsupported("hooks", `codex: unsupported hook event "${event}"`);
|
|
1584
|
+
continue;
|
|
1585
|
+
}
|
|
1586
|
+
if (cmds.some((command) => command.blocking === false)) {
|
|
1587
|
+
unsupported("hooks", `codex: hook event "${event}" cannot execute asynchronous commands`);
|
|
1588
|
+
continue;
|
|
1589
|
+
}
|
|
1590
|
+
const entries = cmds.map((command) => {
|
|
1591
|
+
const handler = [
|
|
1592
|
+
`type = ${tomlStr("command")}`,
|
|
1593
|
+
`command = ${tomlStr(materializedHookCommand(event, command, add))}`,
|
|
1594
|
+
...(command.timeoutMs
|
|
1595
|
+
? [`timeout = ${timeoutSeconds(command.timeoutMs)}`]
|
|
1596
|
+
: []),
|
|
1597
|
+
];
|
|
1598
|
+
return `{ matcher = ${tomlStr(command.matcher ?? "*")}, hooks = [{ ${handler.join(", ")} }] }`;
|
|
1599
|
+
});
|
|
1600
|
+
plan.flags.push("-c", `hooks.${event}=[${entries.join(", ")}]`);
|
|
1601
|
+
}
|
|
950
1602
|
break;
|
|
951
1603
|
}
|
|
952
1604
|
case "opencode":
|
|
953
|
-
|
|
954
|
-
for (const [event, cmds] of Object.entries(hooks)) {
|
|
955
|
-
const body = cmds.map((c) => ` // ${event}: ${c.command}`).join("\n");
|
|
956
|
-
add(`.opencode/plugins/vb-hooks-${event}.js`, `// auto-generated hook plugin for ${event}\nexport default async function () {\n${body}\n}\n`);
|
|
957
|
-
}
|
|
958
|
-
unsupported("hooks", "opencode hooks are JS/TS plugins — generated a stub; a real handler must run the command (format differs from settings.json)");
|
|
1605
|
+
unsupported("hooks", "opencode hooks require executable JS/TS plugins; command hooks cannot be translated exactly");
|
|
959
1606
|
break;
|
|
960
1607
|
case "kimi-code":
|
|
961
1608
|
unsupported("hooks", "kimi hooks are [[hooks]] in the config TOML loaded via --config-file, not cwd — pass --config-file");
|
|
@@ -963,31 +1610,159 @@ function materializeHooks(h, hooks, add, unsupported, plan) {
|
|
|
963
1610
|
case "pi":
|
|
964
1611
|
unsupported("hooks", "pi hooks are TS extensions (.pi/extensions/*.ts) — build an extension");
|
|
965
1612
|
break;
|
|
966
|
-
case "hermes":
|
|
967
|
-
|
|
968
|
-
for (const [event, cmds] of Object.entries(hooks))
|
|
969
|
-
add(`.hermes/plugins/vb-${event}/plugin.md`, cmds.map((c) => c.command).join("\n"));
|
|
970
|
-
plan.env.HERMES_ENABLE_PROJECT_PLUGINS = "1";
|
|
1613
|
+
case "hermes":
|
|
1614
|
+
unsupported("hooks", "hermes project plugins do not expose an exact command-hook mapping");
|
|
971
1615
|
break;
|
|
972
|
-
}
|
|
973
1616
|
case "openclaw":
|
|
974
1617
|
unsupported("hooks", "openclaw hooks are DISABLED by default — enable in ~/.openclaw/openclaw.json; cwd hooks/ alone is a no-op");
|
|
975
1618
|
break;
|
|
976
1619
|
}
|
|
977
1620
|
}
|
|
978
|
-
function
|
|
1621
|
+
function registerClaudeSettings(plan) {
|
|
1622
|
+
for (let index = 0; index < plan.flags.length - 1; index += 1) {
|
|
1623
|
+
if (plan.flags[index] === "--settings" &&
|
|
1624
|
+
plan.flags[index + 1] === CLAUDE_SETTINGS_PATH) {
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
plan.flags.push("--settings", CLAUDE_SETTINGS_PATH);
|
|
1629
|
+
}
|
|
1630
|
+
const CLAUDE_HOOK_EVENTS = new Set([
|
|
1631
|
+
"ConfigChange",
|
|
1632
|
+
"CwdChanged",
|
|
1633
|
+
"Elicitation",
|
|
1634
|
+
"ElicitationResult",
|
|
1635
|
+
"FileChanged",
|
|
1636
|
+
"InstructionsLoaded",
|
|
1637
|
+
"MessageDisplay",
|
|
1638
|
+
"Notification",
|
|
1639
|
+
"PermissionDenied",
|
|
1640
|
+
"PermissionRequest",
|
|
1641
|
+
"PostCompact",
|
|
1642
|
+
"PostToolBatch",
|
|
1643
|
+
"PostToolUse",
|
|
1644
|
+
"PostToolUseFailure",
|
|
1645
|
+
"PreCompact",
|
|
1646
|
+
"PreToolUse",
|
|
1647
|
+
"SessionEnd",
|
|
1648
|
+
"SessionStart",
|
|
1649
|
+
"Setup",
|
|
1650
|
+
"Stop",
|
|
1651
|
+
"StopFailure",
|
|
1652
|
+
"SubagentStart",
|
|
1653
|
+
"SubagentStop",
|
|
1654
|
+
"TaskCompleted",
|
|
1655
|
+
"TaskCreated",
|
|
1656
|
+
"TeammateIdle",
|
|
1657
|
+
"UserPromptExpansion",
|
|
1658
|
+
"UserPromptSubmit",
|
|
1659
|
+
"WorktreeCreate",
|
|
1660
|
+
"WorktreeRemove",
|
|
1661
|
+
]);
|
|
1662
|
+
const CODEX_HOOK_EVENTS = new Set([
|
|
1663
|
+
"PermissionRequest",
|
|
1664
|
+
"PostCompact",
|
|
1665
|
+
"PostToolUse",
|
|
1666
|
+
"PreCompact",
|
|
1667
|
+
"PreToolUse",
|
|
1668
|
+
"SessionStart",
|
|
1669
|
+
"Stop",
|
|
1670
|
+
"SubagentStart",
|
|
1671
|
+
"SubagentStop",
|
|
1672
|
+
"UserPromptSubmit",
|
|
1673
|
+
]);
|
|
1674
|
+
const GEMINI_HOOK_EVENTS = new Set([
|
|
1675
|
+
"AfterAgent",
|
|
1676
|
+
"AfterModel",
|
|
1677
|
+
"AfterTool",
|
|
1678
|
+
"BeforeAgent",
|
|
1679
|
+
"BeforeModel",
|
|
1680
|
+
"BeforeTool",
|
|
1681
|
+
"BeforeToolSelection",
|
|
1682
|
+
"Notification",
|
|
1683
|
+
"PreCompress",
|
|
1684
|
+
"SessionEnd",
|
|
1685
|
+
"SessionStart",
|
|
1686
|
+
]);
|
|
1687
|
+
function hookEventSupported(harness, event) {
|
|
1688
|
+
switch (canonicalHarness(harness)) {
|
|
1689
|
+
case "claude-code":
|
|
1690
|
+
return CLAUDE_HOOK_EVENTS.has(event);
|
|
1691
|
+
case "codex":
|
|
1692
|
+
return CODEX_HOOK_EVENTS.has(event);
|
|
1693
|
+
case "gemini":
|
|
1694
|
+
return GEMINI_HOOK_EVENTS.has(event);
|
|
1695
|
+
default:
|
|
1696
|
+
return false;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
function timeoutSeconds(timeoutMs) {
|
|
1700
|
+
return Math.max(1, Math.ceil(timeoutMs / 1000));
|
|
1701
|
+
}
|
|
1702
|
+
function materializedHookCommand(event, command, add) {
|
|
1703
|
+
if (!command.env || Object.keys(command.env).length === 0) {
|
|
1704
|
+
return command.command;
|
|
1705
|
+
}
|
|
1706
|
+
const wrapperId = canonicalCandidateDigest({
|
|
1707
|
+
event,
|
|
1708
|
+
command: command.command,
|
|
1709
|
+
env: Object.fromEntries(Object.entries(command.env).sort(([left], [right]) => compareStrings(left, right))),
|
|
1710
|
+
}).slice("sha256:".length);
|
|
1711
|
+
const path = `.tangle/profile-hooks/${wrapperId}.mjs`;
|
|
1712
|
+
add(path, hookWrapperSource("/bin/sh", ["-c", command.command], command.env));
|
|
1713
|
+
return `node ${path}`;
|
|
1714
|
+
}
|
|
1715
|
+
function hookWrapperSource(executable, args, env) {
|
|
1716
|
+
return [
|
|
1717
|
+
'import { spawnSync } from "node:child_process"',
|
|
1718
|
+
`const executable = ${JSON.stringify(executable)}`,
|
|
1719
|
+
`const args = ${JSON.stringify(args)}`,
|
|
1720
|
+
`const extraEnv = ${JSON.stringify(env)}`,
|
|
1721
|
+
"const result = spawnSync(executable, args, {",
|
|
1722
|
+
' stdio: "inherit",',
|
|
1723
|
+
" env: { ...process.env, ...extraEnv },",
|
|
1724
|
+
" shell: false,",
|
|
1725
|
+
"})",
|
|
1726
|
+
"if (result.error) {",
|
|
1727
|
+
" console.error(result.error.message)",
|
|
1728
|
+
" process.exit(127)",
|
|
1729
|
+
"}",
|
|
1730
|
+
"if (result.signal) {",
|
|
1731
|
+
' console.error("hook terminated by " + result.signal)',
|
|
1732
|
+
" process.exit(1)",
|
|
1733
|
+
"}",
|
|
1734
|
+
"process.exit(result.status ?? 1)",
|
|
1735
|
+
"",
|
|
1736
|
+
].join("\n");
|
|
1737
|
+
}
|
|
1738
|
+
function materializeSubagent(h, name, sa, plan, add, unsupported) {
|
|
1739
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) {
|
|
1740
|
+
unsupported("subagents", `${h}: subagent name "${name}" must use lowercase letters, digits, underscores, or hyphens`);
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
979
1743
|
switch (h) {
|
|
980
1744
|
case "claude-code":
|
|
981
1745
|
case "nanoclaw": {
|
|
982
1746
|
const fm = ["---", `name: ${name}`];
|
|
983
|
-
|
|
984
|
-
fm.push(`description: ${JSON.stringify(sa.description)}`);
|
|
1747
|
+
fm.push(`description: ${JSON.stringify(sa.description ?? name)}`);
|
|
985
1748
|
if (sa.model)
|
|
986
|
-
fm.push(`model: ${sa.model}`);
|
|
987
|
-
if (sa.tools)
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
.
|
|
1749
|
+
fm.push(`model: ${JSON.stringify(sa.model)}`);
|
|
1750
|
+
if (sa.tools) {
|
|
1751
|
+
const tools = Object.entries(sa.tools);
|
|
1752
|
+
const allowed = tools
|
|
1753
|
+
.filter(([, enabled]) => enabled)
|
|
1754
|
+
.map(([tool]) => tool);
|
|
1755
|
+
const denied = tools
|
|
1756
|
+
.filter(([, enabled]) => !enabled)
|
|
1757
|
+
.map(([tool]) => tool);
|
|
1758
|
+
if (allowed.length > 0)
|
|
1759
|
+
fm.push(`tools: ${JSON.stringify(allowed)}`);
|
|
1760
|
+
if (denied.length > 0)
|
|
1761
|
+
fm.push(`disallowedTools: ${JSON.stringify(denied)}`);
|
|
1762
|
+
}
|
|
1763
|
+
if (sa.maxSteps !== undefined)
|
|
1764
|
+
fm.push(`maxTurns: ${sa.maxSteps}`);
|
|
1765
|
+
reportUnsupportedSubagentFields(h, name, sa, new Set(["description", "prompt", "model", "tools", "maxSteps"]), unsupported);
|
|
991
1766
|
fm.push("---", "", sa.prompt ?? sa.description ?? name);
|
|
992
1767
|
add(`.claude/agents/${name}.md`, `${fm.join("\n")}\n`);
|
|
993
1768
|
break;
|
|
@@ -996,31 +1771,92 @@ function materializeSubagent(h, name, sa, add, unsupported) {
|
|
|
996
1771
|
const fm = [
|
|
997
1772
|
"---",
|
|
998
1773
|
`description: ${JSON.stringify(sa.description ?? name)}`,
|
|
1774
|
+
"mode: subagent",
|
|
999
1775
|
];
|
|
1000
1776
|
if (sa.model)
|
|
1001
|
-
fm.push(`model: ${sa.model}`);
|
|
1777
|
+
fm.push(`model: ${JSON.stringify(sa.model)}`);
|
|
1778
|
+
if (sa.tools)
|
|
1779
|
+
fm.push(`tools: ${JSON.stringify(sa.tools)}`);
|
|
1780
|
+
if (sa.permissions)
|
|
1781
|
+
fm.push(`permission: ${JSON.stringify(sa.permissions)}`);
|
|
1782
|
+
if (sa.maxSteps !== undefined)
|
|
1783
|
+
fm.push(`steps: ${sa.maxSteps}`);
|
|
1784
|
+
reportUnsupportedSubagentFields(h, name, sa, new Set([
|
|
1785
|
+
"description",
|
|
1786
|
+
"prompt",
|
|
1787
|
+
"model",
|
|
1788
|
+
"tools",
|
|
1789
|
+
"permissions",
|
|
1790
|
+
"maxSteps",
|
|
1791
|
+
]), unsupported);
|
|
1002
1792
|
fm.push("---", "", sa.prompt ?? "");
|
|
1003
1793
|
add(`.opencode/agents/${name}.md`, `${fm.join("\n")}\n`);
|
|
1004
1794
|
break;
|
|
1005
1795
|
}
|
|
1006
|
-
case "gemini":
|
|
1007
|
-
|
|
1796
|
+
case "gemini": {
|
|
1797
|
+
const tools = sa.tools
|
|
1798
|
+
? Object.entries(sa.tools)
|
|
1799
|
+
.filter(([, enabled]) => enabled)
|
|
1800
|
+
.map(([tool]) => tool)
|
|
1801
|
+
: undefined;
|
|
1802
|
+
reportUnsupportedSubagentFields(h, name, sa, new Set(["description", "prompt", "model", "tools", "maxSteps"]), unsupported);
|
|
1803
|
+
add(`.gemini/agents/${name}.md`, [
|
|
1804
|
+
"---",
|
|
1805
|
+
`name: ${name}`,
|
|
1806
|
+
`description: ${JSON.stringify(sa.description ?? name)}`,
|
|
1807
|
+
"kind: local",
|
|
1808
|
+
...(sa.model ? [`model: ${JSON.stringify(sa.model)}`] : []),
|
|
1809
|
+
...(tools ? [`tools: ${JSON.stringify(tools)}`] : []),
|
|
1810
|
+
...(sa.maxSteps !== undefined ? [`max_turns: ${sa.maxSteps}`] : []),
|
|
1811
|
+
"---",
|
|
1812
|
+
"",
|
|
1813
|
+
sa.prompt ?? "",
|
|
1814
|
+
"",
|
|
1815
|
+
].join("\n"));
|
|
1008
1816
|
break;
|
|
1817
|
+
}
|
|
1009
1818
|
case "codex": {
|
|
1010
|
-
const
|
|
1011
|
-
|
|
1012
|
-
|
|
1819
|
+
const relPath = `.codex/agents/${name}.toml`;
|
|
1820
|
+
const lines = [
|
|
1821
|
+
`name = ${tomlStr(name)}`,
|
|
1822
|
+
`description = ${tomlStr(sa.description ?? name)}`,
|
|
1823
|
+
`developer_instructions = ${tomlStr(sa.prompt ?? sa.description ?? name)}`,
|
|
1824
|
+
];
|
|
1013
1825
|
if (sa.model)
|
|
1014
1826
|
lines.push(`model = ${tomlStr(sa.model)}`);
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1827
|
+
reportUnsupportedSubagentFields(h, name, sa, new Set(["description", "prompt", "model"]), unsupported);
|
|
1828
|
+
add(relPath, `${lines.join("\n")}\n`);
|
|
1829
|
+
registerCodexAgent(plan, name, relPath, sa.description ?? name);
|
|
1018
1830
|
break;
|
|
1019
1831
|
}
|
|
1020
1832
|
default:
|
|
1021
1833
|
unsupported("subagents", `${h}: subagents have no cwd file (config/flag/global only); "${name}" not mounted`);
|
|
1022
1834
|
}
|
|
1023
1835
|
}
|
|
1836
|
+
function codexAgentRoleName(resourceName) {
|
|
1837
|
+
const name = resourceName.endsWith(".toml")
|
|
1838
|
+
? resourceName.slice(0, -".toml".length)
|
|
1839
|
+
: resourceName;
|
|
1840
|
+
return /^[a-z0-9][a-z0-9_-]*$/.test(name) ? name : undefined;
|
|
1841
|
+
}
|
|
1842
|
+
function registerCodexAgent(plan, name, configFile, description) {
|
|
1843
|
+
plan.flags.push("-c", `agents.${name}.description=${tomlStr(description)}`, "-c", `agents.${name}.config_file=${tomlStr(configFile)}`);
|
|
1844
|
+
}
|
|
1845
|
+
function reportUnsupportedSubagentFields(harness, name, subagent, supported, unsupported) {
|
|
1846
|
+
for (const field of [
|
|
1847
|
+
"description",
|
|
1848
|
+
"prompt",
|
|
1849
|
+
"model",
|
|
1850
|
+
"tools",
|
|
1851
|
+
"permissions",
|
|
1852
|
+
"maxSteps",
|
|
1853
|
+
"metadata",
|
|
1854
|
+
]) {
|
|
1855
|
+
if (subagent[field] !== undefined && !supported.has(field)) {
|
|
1856
|
+
unsupported("subagents", `${harness}: subagent "${name}" does not support ${field}`);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1024
1860
|
function materializeCommand(h, name, body, add, unsupported) {
|
|
1025
1861
|
switch (h) {
|
|
1026
1862
|
case "claude-code":
|