@tangle-network/agent-profile-materialize 0.20.9 → 0.22.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.
@@ -14,7 +14,15 @@ export declare const PI_EXECUTION_CONTROL_EXTENSION_PATH = ".tangle/pi-execution
14
14
  * extension and MCP tools profile-configurable without a static catalog.
15
15
  */
16
16
  export declare function piToolId(name: string): string | undefined;
17
- export declare function mapSupportedBooleanEntries(entries: [string, boolean][], mapName: (name: string) => string | undefined, onUnsupported: (name: string) => void, label: string): [string, boolean][];
17
+ /**
18
+ * Map tool switches to native ids; spellings of one tool keep its explicit off.
19
+ *
20
+ * Products compose profiles in layers: a shared allowlist, then a product
21
+ * constraint that disables a native tool under its own spelling. Physim adds
22
+ * `webfetch: false` over an allowlist holding `web_fetch: true`. Off is the
23
+ * only value that honours every layer, so it wins regardless of entry order.
24
+ */
25
+ export declare function mapSupportedBooleanEntries(entries: [string, boolean][], mapName: (name: string) => string | undefined, onUnsupported: (name: string) => void): [string, boolean][];
18
26
  export declare function permissionAllowsEverything(policy: AgentProfilePermission): boolean;
19
27
  export declare function mapSupportedPermissionEntries(entries: [string, AgentProfilePermission][], mapName: (name: string) => string | undefined, onUnsupported: (name: string) => void, label: string): [string, AgentProfilePermission][];
20
28
  export declare function claudePermissionRules(entries: [string, AgentProfilePermission][]): {
@@ -22,6 +30,11 @@ export declare function claudePermissionRules(entries: [string, AgentProfilePerm
22
30
  ask: string[];
23
31
  deny: string[];
24
32
  };
33
+ /**
34
+ * Give each Claude rule one decision. A tool switch and a permission can name
35
+ * the same rule; the stricter decision wins, as deny already beats allow
36
+ * inside Claude Code.
37
+ */
25
38
  export declare function sealClaudePermissionRules(rules: {
26
39
  allow: string[];
27
40
  ask: string[];
@@ -101,12 +101,16 @@ export function piToolId(name) {
101
101
  return "find";
102
102
  return trimmed;
103
103
  }
104
- export function mapSupportedBooleanEntries(entries, mapName, onUnsupported, label) {
105
- return mapControlEntries(entries, mapName, onUnsupported, (left, right, nativeName) => {
106
- if (left !== right)
107
- throw new Error(`${label} aliases disagree for "${nativeName}"`);
108
- return left;
109
- });
104
+ /**
105
+ * Map tool switches to native ids; spellings of one tool keep its explicit off.
106
+ *
107
+ * Products compose profiles in layers: a shared allowlist, then a product
108
+ * constraint that disables a native tool under its own spelling. Physim adds
109
+ * `webfetch: false` over an allowlist holding `web_fetch: true`. Off is the
110
+ * only value that honours every layer, so it wins regardless of entry order.
111
+ */
112
+ export function mapSupportedBooleanEntries(entries, mapName, onUnsupported) {
113
+ return mapControlEntries(entries, mapName, onUnsupported, (left, right) => left && right);
110
114
  }
111
115
  export function permissionAllowsEverything(policy) {
112
116
  return typeof policy === "string"
@@ -129,21 +133,43 @@ function mapControlEntries(entries, mapName, onUnsupported, merge) {
129
133
  }
130
134
  return [...mapped.entries()].sort(([left], [right]) => compareStrings(left, right));
131
135
  }
136
+ const PERMISSION_RESTRICTION = {
137
+ allow: 0,
138
+ ask: 1,
139
+ deny: 2,
140
+ };
141
+ function stricterDecision(left, right) {
142
+ return PERMISSION_RESTRICTION[right] > PERMISSION_RESTRICTION[left]
143
+ ? right
144
+ : left;
145
+ }
146
+ /**
147
+ * Merge policies whose names resolve to one native tool; the stricter
148
+ * decision wins, as an explicit off does for tool switches.
149
+ *
150
+ * Pattern rules merge only when both spellings list the same patterns in the
151
+ * same order. OpenCode applies the last matching pattern, so any other pair
152
+ * has no merge at least as strict as each spelling: `{"*": "deny"}` beside
153
+ * `{"https://x/*": "allow"}` would allow x. Those pairs stay contradictions
154
+ * that `executionControlConflict` reports at admission.
155
+ */
132
156
  function mergePermissionPolicies(left, right, label) {
157
+ if (typeof left === "string" && typeof right === "string") {
158
+ return stricterDecision(left, right);
159
+ }
133
160
  if (typeof left === "string" || typeof right === "string") {
134
- if (left !== right)
135
- throw new Error(`${label} aliases disagree`);
136
- return left;
161
+ throw new Error(`${label} aliases mix a whole-tool decision with pattern rules`);
137
162
  }
138
- const merged = { ...left };
139
- for (const [specifier, decision] of Object.entries(right)) {
140
- const previous = merged[specifier];
141
- if (previous !== undefined && previous !== decision) {
142
- throw new Error(`${label} aliases disagree for "${specifier}"`);
143
- }
144
- merged[specifier] = decision;
163
+ const patterns = Object.keys(left);
164
+ const rightPatterns = Object.keys(right);
165
+ if (patterns.length !== rightPatterns.length ||
166
+ rightPatterns.some((pattern, index) => pattern !== patterns[index])) {
167
+ throw new Error(`${label} aliases set different pattern rules`);
145
168
  }
146
- return Object.fromEntries(Object.entries(merged).sort(([leftKey], [rightKey]) => compareStrings(leftKey, rightKey)));
169
+ return Object.fromEntries(Object.entries(left).map(([pattern, decision]) => [
170
+ pattern,
171
+ stricterDecision(decision, right[pattern] ?? decision),
172
+ ]));
147
173
  }
148
174
  export function claudePermissionRules(entries) {
149
175
  const rules = {
@@ -165,22 +191,32 @@ export function claudePermissionRules(entries) {
165
191
  rules.deny.sort(compareStrings);
166
192
  return rules;
167
193
  }
194
+ /**
195
+ * Give each Claude rule one decision. A tool switch and a permission can name
196
+ * the same rule; the stricter decision wins, as deny already beats allow
197
+ * inside Claude Code.
198
+ */
168
199
  export function sealClaudePermissionRules(rules) {
169
200
  const owner = new Map();
170
201
  for (const decision of ["allow", "ask", "deny"]) {
171
202
  for (const rule of rules[decision]) {
172
203
  const previous = owner.get(rule);
173
- if (previous && previous !== decision) {
174
- throw new Error(`claude-code execution control "${rule}" conflicts between ${previous} and ${decision}`);
175
- }
176
- owner.set(rule, decision);
204
+ owner.set(rule, previous === undefined
205
+ ? decision
206
+ : stricterDecision(previous, decision));
177
207
  }
178
208
  }
179
- return {
180
- allow: [...new Set(rules.allow)].sort(compareStrings),
181
- ask: [...new Set(rules.ask)].sort(compareStrings),
182
- deny: [...new Set(rules.deny)].sort(compareStrings),
209
+ const sealed = {
210
+ allow: [],
211
+ ask: [],
212
+ deny: [],
183
213
  };
214
+ for (const [rule, decision] of owner)
215
+ sealed[decision].push(rule);
216
+ sealed.allow.sort(compareStrings);
217
+ sealed.ask.sort(compareStrings);
218
+ sealed.deny.sort(compareStrings);
219
+ return sealed;
184
220
  }
185
221
  import { resolveNativeWebTools } from "@tangle-network/agent-interface";
186
222
  import { compareStrings } from "./workspace-files.js";
@@ -4,7 +4,7 @@ export function materializeExecutionControls(h, tools, permissions, modes, plan,
4
4
  switch (h) {
5
5
  case "claude-code": {
6
6
  if (toolEntries.length > 0 || permissionEntries.length > 0) {
7
- const nativeTools = mapSupportedBooleanEntries(toolEntries, claudeToolName, (name) => unsupported("tools", `claude-code: no verified native tool mapping for "${name}"`), "claude-code tool");
7
+ const nativeTools = mapSupportedBooleanEntries(toolEntries, claudeToolName, (name) => unsupported("tools", `claude-code: no verified native tool mapping for "${name}"`));
8
8
  const nativePermissions = mapSupportedPermissionEntries(permissionEntries, claudeToolName, (name) => unsupported("permissions", `claude-code: no verified native permission mapping for "${name}"`), "claude-code permission");
9
9
  const rules = claudePermissionRules(nativePermissions);
10
10
  for (const [tool, enabled] of nativeTools) {
@@ -32,7 +32,7 @@ export function materializeExecutionControls(h, tools, permissions, modes, plan,
32
32
  }
33
33
  return false;
34
34
  });
35
- const nativeTools = mapSupportedBooleanEntries(enforceableTools, openCodeToolName, (name) => unsupported("tools", `opencode: no verified native tool mapping for "${name}"`), "opencode tool");
35
+ const nativeTools = mapSupportedBooleanEntries(enforceableTools, openCodeToolName, (name) => unsupported("tools", `opencode: no verified native tool mapping for "${name}"`));
36
36
  const nativePermissions = mapSupportedPermissionEntries(enforceablePermissions, openCodePermissionName, (name) => unsupported("permissions", `opencode: no verified native permission mapping for "${name}"`), "opencode permission");
37
37
  if (enforceableTools.length > 0 || permissionEntries.length > 0) {
38
38
  add(OPENCODE_CONFIG_FILE, serializeWorkspacePlanJson({
@@ -49,6 +49,7 @@ export function materializeExecutionControls(h, tools, permissions, modes, plan,
49
49
  }
50
50
  case "codex": {
51
51
  const searchEntries = [];
52
+ const fetchEntries = [];
52
53
  for (const [name, enabled] of toolEntries) {
53
54
  const posture = resolveNativeWebTools({ [name]: enabled });
54
55
  if (posture.search === enabled && posture.fetch === undefined) {
@@ -56,14 +57,20 @@ export function materializeExecutionControls(h, tools, permissions, modes, plan,
56
57
  continue;
57
58
  }
58
59
  if (posture.fetch === enabled && posture.search === undefined) {
59
- if (enabled) {
60
- unsupported("tools", `codex: native web fetch is unavailable for "${name}"`);
61
- }
60
+ fetchEntries.push([name, enabled]);
62
61
  continue;
63
62
  }
64
63
  unsupported("tools", `codex: no verified primary-process tool mapping for "${name}"`);
65
64
  }
66
- const nativeSearch = mapSupportedBooleanEntries(searchEntries, () => "web_search", () => undefined, "codex tool");
65
+ // Codex has no native fetch: only a fetch that stays on after its
66
+ // spellings merge asks for something this harness cannot run.
67
+ const nativeFetch = mapSupportedBooleanEntries(fetchEntries, () => "web_fetch", () => undefined);
68
+ if (nativeFetch[0]?.[1]) {
69
+ for (const [name] of fetchEntries) {
70
+ unsupported("tools", `codex: native web fetch is unavailable for "${name}"`);
71
+ }
72
+ }
73
+ const nativeSearch = mapSupportedBooleanEntries(searchEntries, () => "web_search", () => undefined);
67
74
  if (nativeSearch[0]) {
68
75
  plan.flags.push("-c", `tools.web_search=${nativeSearch[0][1]}`);
69
76
  }
@@ -73,7 +80,7 @@ export function materializeExecutionControls(h, tools, permissions, modes, plan,
73
80
  break;
74
81
  }
75
82
  case "pi": {
76
- const nativeTools = mapSupportedBooleanEntries(toolEntries, piToolId, (name) => unsupported("tools", `pi: tool name "${name}" is empty after trimming`), "pi tool");
83
+ const nativeTools = mapSupportedBooleanEntries(toolEntries, piToolId, (name) => unsupported("tools", `pi: tool name "${name}" is empty after trimming`));
77
84
  const nativePermissions = mapSupportedPermissionEntries(permissionEntries, piToolId, (name) => unsupported("permissions", `pi: permission name "${name}" is empty after trimming`), "pi permission");
78
85
  const enabled = new Set(nativeTools
79
86
  .filter(([, value]) => value)
@@ -92,15 +99,12 @@ export function materializeExecutionControls(h, tools, permissions, modes, plan,
92
99
  unsupported("permissions", `pi: permission policy "ask" for "${name}" requires an interactive decision surface`);
93
100
  continue;
94
101
  }
95
- if (policy === "allow") {
96
- if (excluded.has(name)) {
97
- throw new Error(`pi execution control "${name}" conflicts between disabled and allow`);
98
- }
102
+ // The stricter control holds, as across spellings of one tool: an
103
+ // allow keeps a disabled tool disabled, and a deny excludes an
104
+ // enabled one.
105
+ if (policy === "allow")
99
106
  continue;
100
- }
101
- if (enabled.has(name)) {
102
- throw new Error(`pi execution control "${name}" conflicts between enabled and deny`);
103
- }
107
+ enabled.delete(name);
104
108
  excluded.add(name);
105
109
  }
106
110
  if (enabled.size > 0 || excluded.size > 0) {
package/dist/harness.d.ts CHANGED
@@ -79,7 +79,7 @@ export declare const MATERIALIZER_CONTEXT_PATHS: ReadonlySet<string>;
79
79
  * `opencode.json` and every context file is a documented name a user repository
80
80
  * can carry, so the name alone proves nothing and a profile resource may still
81
81
  * mount one. An existing file there is replaced only when
82
- * {@link isMaterializerOutput} recognizes its bytes as this package's own
82
+ * {@link materializerReplacement} recognizes its bytes as this package's own
83
83
  * earlier output.
84
84
  *
85
85
  * Deliberately absent: `.gemini/system.md`, the file `prompt.systemPrompt`
@@ -110,11 +110,14 @@ export declare function markMaterializerSubagent(relPath: string, content: strin
110
110
  * (`.gemini/system.md`; `.codex/system-prompt.md` is a reserved name this
111
111
  * package invented and needs no marker to be owned).
112
112
  *
113
- * It carries the digest of everything BELOW it, which the subagent marker does
114
- * not need. `CLAUDE.md` is claude-code's own memory file and the agent appends
115
- * to it mid-session, so a bare marker would still sit on line 1 of a file the
116
- * agent has since rewritten. Ownership has to mean "these are still our bytes",
117
- * not "this package wrote here once".
113
+ * It carries the digest of the generated body below it, which the subagent
114
+ * marker does not need. `CLAUDE.md` is claude-code's own memory file and the
115
+ * agent appends to it mid-session, so a bare marker would still sit on line 1
116
+ * of a file the agent has since rewritten. The digest tells which leading bytes
117
+ * are still this package's own: the next materialization replaces exactly
118
+ * those and keeps what the agent appended below them
119
+ * ({@link generatedContextAppendix}). The marker's wording is part of every
120
+ * marker already written into live workspaces, so it stays as it is.
118
121
  *
119
122
  * A reserved context path — opencode's invented instruction file — is returned
120
123
  * unchanged: its NAME already proves the provenance, and nothing else may mount
@@ -134,21 +137,15 @@ export declare function markMaterializerContextFile(relPath: string, content: st
134
137
  */
135
138
  export declare function isMaterializerOwnedPath(relPath: string): boolean;
136
139
  /**
137
- * Whether the file already at owned path `relPath` is this package's own
138
- * earlier output. A reserved name proves that by itself. Native agent files
139
- * require the generated marker; context files require the marker AND the body
140
- * digest it commits to, so an agent that appended to its memory file is no
141
- * longer holding this package's bytes. An exact unmarked copy of the planned
142
- * file is adopted once for upgrades from materializer versions without that
143
- * marker. `undefined` content means no regular file is there.
144
- *
145
- * An `opencode.json` this package wrote holds the opencode schema id, only the
146
- * keys this package writes, and only instruction paths this package invented.
147
- * A user config that holds nothing else is indistinguishable from that output
148
- * and is replaced too; a config with any other key, or with an instruction path
149
- * of the user's own, is refused.
140
+ * The bytes to publish at owned path `relPath` over the file already there, or
141
+ * `undefined` when that file is not this package's earlier output and must be
142
+ * refused (`undefined` content: no regular file there). For a context file the
143
+ * replacement is the planned file followed by whatever the agent appended
144
+ * below the generated body (claude-code appends its memory to `CLAUDE.md`). A
145
+ * body written before the marker existed is adopted once, only when it is
146
+ * byte-identical to the body this plan writes.
150
147
  */
151
- export declare function isMaterializerOutput(relPath: string, content: () => string | undefined, plannedContent?: string): boolean;
148
+ export declare function materializerReplacement(relPath: string, content: () => string | undefined, plannedContent: string): string | undefined;
152
149
  /**
153
150
  * cwd-relative native skill dir per harness, or null if the harness has no
154
151
  * cwd skill primitive (skills live in a user/global dir the workspace plan
package/dist/harness.js CHANGED
@@ -153,7 +153,7 @@ export const MATERIALIZER_CONTEXT_PATHS = new Set([...CONTEXT_FILE_PATHS].filter
153
153
  * `opencode.json` and every context file is a documented name a user repository
154
154
  * can carry, so the name alone proves nothing and a profile resource may still
155
155
  * mount one. An existing file there is replaced only when
156
- * {@link isMaterializerOutput} recognizes its bytes as this package's own
156
+ * {@link materializerReplacement} recognizes its bytes as this package's own
157
157
  * earlier output.
158
158
  *
159
159
  * Deliberately absent: `.gemini/system.md`, the file `prompt.systemPrompt`
@@ -214,11 +214,14 @@ function materializerContextMarker(body) {
214
214
  * (`.gemini/system.md`; `.codex/system-prompt.md` is a reserved name this
215
215
  * package invented and needs no marker to be owned).
216
216
  *
217
- * It carries the digest of everything BELOW it, which the subagent marker does
218
- * not need. `CLAUDE.md` is claude-code's own memory file and the agent appends
219
- * to it mid-session, so a bare marker would still sit on line 1 of a file the
220
- * agent has since rewritten. Ownership has to mean "these are still our bytes",
221
- * not "this package wrote here once".
217
+ * It carries the digest of the generated body below it, which the subagent
218
+ * marker does not need. `CLAUDE.md` is claude-code's own memory file and the
219
+ * agent appends to it mid-session, so a bare marker would still sit on line 1
220
+ * of a file the agent has since rewritten. The digest tells which leading bytes
221
+ * are still this package's own: the next materialization replaces exactly
222
+ * those and keeps what the agent appended below them
223
+ * ({@link generatedContextAppendix}). The marker's wording is part of every
224
+ * marker already written into live workspaces, so it stays as it is.
222
225
  *
223
226
  * A reserved context path — opencode's invented instruction file — is returned
224
227
  * unchanged: its NAME already proves the provenance, and nothing else may mount
@@ -232,16 +235,55 @@ export function markMaterializerContextFile(relPath, content) {
232
235
  return content;
233
236
  return `${materializerContextMarker(content)}\n${content}`;
234
237
  }
238
+ const MATERIALIZER_CONTEXT_MARKER_HEAD = `<!-- ${MATERIALIZER_MARKER} sha256:`;
239
+ const MATERIALIZER_CONTEXT_MARKER_TAIL = `; ${MATERIALIZER_CONTEXT_MARKER_NOTE} -->`;
240
+ const SHA256_HEX = /^[0-9a-f]{64}$/u;
235
241
  /**
236
- * Whether `existing` is a context file this package wrote and nothing edited
237
- * afterwards: line 1 is exactly the marker computed over every byte below it.
242
+ * Text an agent appended below this package's generated context body: `""`
243
+ * for exact output, `undefined` when no marker is on line 1 or no leading part
244
+ * of the body matches its digest (an edit inside the generated part). Each
245
+ * line boundary is a candidate end; one incremental hash walks the body once.
238
246
  */
239
- function isMarkedContextOutput(existing) {
247
+ function generatedContextAppendix(existing) {
240
248
  const lineEnd = existing.indexOf("\n");
241
249
  if (lineEnd === -1)
242
- return false;
243
- return (existing.slice(0, lineEnd) ===
244
- materializerContextMarker(existing.slice(lineEnd + 1)));
250
+ return undefined;
251
+ const marker = existing.slice(0, lineEnd);
252
+ if (!marker.startsWith(MATERIALIZER_CONTEXT_MARKER_HEAD) ||
253
+ !marker.endsWith(MATERIALIZER_CONTEXT_MARKER_TAIL)) {
254
+ return undefined;
255
+ }
256
+ const digest = marker.slice(MATERIALIZER_CONTEXT_MARKER_HEAD.length, marker.length - MATERIALIZER_CONTEXT_MARKER_TAIL.length);
257
+ if (!SHA256_HEX.test(digest))
258
+ return undefined;
259
+ const body = existing.slice(lineEnd + 1);
260
+ // No cut at 0: a generated body is never empty, so it never adopts a whole file.
261
+ const boundaries = [];
262
+ for (let newline = body.indexOf("\n"); newline !== -1; newline = body.indexOf("\n", newline + 1)) {
263
+ boundaries.push(newline, newline + 1);
264
+ }
265
+ if (boundaries.length === 0 ||
266
+ boundaries[boundaries.length - 1] !== body.length) {
267
+ boundaries.push(body.length);
268
+ }
269
+ const hash = createHash("sha256");
270
+ let hashed = 0;
271
+ for (const end of boundaries) {
272
+ hash.update(body.slice(hashed, end), "utf8");
273
+ hashed = end;
274
+ if (hash.copy().digest("hex") === digest)
275
+ return body.slice(end);
276
+ }
277
+ return undefined;
278
+ }
279
+ /** The planned context file, then the agent's appended text on its own line. */
280
+ function withContextAppendix(plannedContent, appendix) {
281
+ if (appendix === "" ||
282
+ plannedContent.endsWith("\n") ||
283
+ appendix.startsWith("\n")) {
284
+ return `${plannedContent}${appendix}`;
285
+ }
286
+ return `${plannedContent}\n${appendix}`;
245
287
  }
246
288
  /**
247
289
  * Whether a generated file occupies a path that the profile materializer owns.
@@ -267,13 +309,43 @@ const OPENCODE_CONFIG_KEYS = new Set([
267
309
  "permission",
268
310
  ]);
269
311
  /**
270
- * Whether the file already at owned path `relPath` is this package's own
271
- * earlier output. A reserved name proves that by itself. Native agent files
272
- * require the generated marker; context files require the marker AND the body
273
- * digest it commits to, so an agent that appended to its memory file is no
274
- * longer holding this package's bytes. An exact unmarked copy of the planned
275
- * file is adopted once for upgrades from materializer versions without that
276
- * marker. `undefined` content means no regular file is there.
312
+ * The bytes to publish at owned path `relPath` over the file already there, or
313
+ * `undefined` when that file is not this package's earlier output and must be
314
+ * refused (`undefined` content: no regular file there). For a context file the
315
+ * replacement is the planned file followed by whatever the agent appended
316
+ * below the generated body (claude-code appends its memory to `CLAUDE.md`). A
317
+ * body written before the marker existed is adopted once, only when it is
318
+ * byte-identical to the body this plan writes.
319
+ */
320
+ export function materializerReplacement(relPath, content, plannedContent) {
321
+ if (!MATERIALIZER_CONTEXT_PATHS.has(relPath)) {
322
+ return isMaterializerOutput(relPath, content, plannedContent)
323
+ ? plannedContent
324
+ : undefined;
325
+ }
326
+ const existing = content();
327
+ if (existing === undefined)
328
+ return undefined;
329
+ const appendix = generatedContextAppendix(existing);
330
+ if (appendix === "")
331
+ return plannedContent;
332
+ // Appended text rides only on a marked plan, which the next version recognizes.
333
+ if (generatedContextAppendix(plannedContent) !== "")
334
+ return undefined;
335
+ if (appendix !== undefined) {
336
+ return withContextAppendix(plannedContent, appendix);
337
+ }
338
+ return existing === plannedContent.slice(plannedContent.indexOf("\n") + 1)
339
+ ? plannedContent
340
+ : undefined;
341
+ }
342
+ /**
343
+ * Whether the file already at owned path `relPath`, other than a context file,
344
+ * is this package's own earlier output. A reserved name proves that by itself.
345
+ * Native agent files require the generated marker. An exact unmarked copy of
346
+ * the planned native agent file is adopted once for upgrades from materializer
347
+ * versions without that marker. `undefined` content means no regular file is
348
+ * there.
277
349
  *
278
350
  * An `opencode.json` this package wrote holds the opencode schema id, only the
279
351
  * keys this package writes, and only instruction paths this package invented.
@@ -281,7 +353,7 @@ const OPENCODE_CONFIG_KEYS = new Set([
281
353
  * and is replaced too; a config with any other key, or with an instruction path
282
354
  * of the user's own, is refused.
283
355
  */
284
- export function isMaterializerOutput(relPath, content, plannedContent) {
356
+ function isMaterializerOutput(relPath, content, plannedContent) {
285
357
  if (MATERIALIZER_RESERVED_PATHS.has(relPath))
286
358
  return true;
287
359
  if (MATERIALIZER_SUBAGENT_PATH.test(relPath)) {
@@ -291,8 +363,6 @@ export function isMaterializerOutput(relPath, content, plannedContent) {
291
363
  const markerPrefix = materializerSubagentMarkerPrefix(relPath);
292
364
  if (existing.startsWith(markerPrefix))
293
365
  return true;
294
- if (plannedContent === undefined)
295
- return false;
296
366
  if (!plannedContent.startsWith(markerPrefix))
297
367
  return false;
298
368
  const legacyContent = relPath.endsWith(".toml")
@@ -300,23 +370,6 @@ export function isMaterializerOutput(relPath, content, plannedContent) {
300
370
  : `---\n${plannedContent.slice(markerPrefix.length)}`;
301
371
  return existing === legacyContent;
302
372
  }
303
- if (MATERIALIZER_CONTEXT_PATHS.has(relPath)) {
304
- const existing = content();
305
- if (existing === undefined)
306
- return false;
307
- if (isMarkedContextOutput(existing))
308
- return true;
309
- // A workspace materialized before this marker existed carries the body
310
- // alone. Adopt it once, and only when it is byte-identical to the body this
311
- // plan writes, so upgrading the package does not turn every resumed session
312
- // into a refusal. A body that differs is either a user's file or an earlier
313
- // profile's, and neither can be told apart without the marker.
314
- if (plannedContent === undefined)
315
- return false;
316
- if (!isMarkedContextOutput(plannedContent))
317
- return false;
318
- return existing === plannedContent.slice(plannedContent.indexOf("\n") + 1);
319
- }
320
373
  if (relPath !== OPENCODE_CONFIG_FILE)
321
374
  return false;
322
375
  const bytes = content();
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
- /** Stable public facade for profile materialization and workspace application. */
2
1
  export { applyAgentCandidateWorkspacePlan, materializeCandidateProfile, } from "./candidate.js";
3
2
  export type { AgentCandidateWorkspacePlan, AgentCandidateWorkspacePlanReceipt, ApplyAgentCandidateWorkspacePlanOptions, MaterializeCandidateProfileOptions, } from "./candidate-types.js";
4
3
  export { OPENCODE_NATIVE_TOOL_IDS, openCodeNativeToolId, } from "./control-maps.js";
5
4
  export type { HarnessId } from "./harness.js";
6
5
  export { HARNESS_SKILL_DIRS, harnessAutoLoadsAgentContextFile, harnessAutoLoadsContextFile, harnessContextFile, isMaterializerHarness, MATERIALIZER_CONTEXT_PATHS, MATERIALIZER_HARNESSES, markMaterializerContextFile, skillDirForHarness, } from "./harness.js";
7
- export { joinSystemPromptAdditions, keepsHarnessSystemPrompt, } from "./harness-prompt.js";
6
+ export * from "./harness-prompt.js";
7
+ export { type ConcurrentProfileRevision, ensurePublicConcurrentProfileRoots, type ManagedWorkspaceFileDigest, type ManagedWorkspaceManifest, preparePublicConcurrentProfileRevision, } from "./managed-workspace.js";
8
8
  export type { OpenCodeMcpConfig } from "./mcp.js";
9
9
  export { openCodeMcpConfig } from "./mcp.js";
10
10
  export { modelIdsMatch, qualifyModelId, unqualifyModelId, } from "./model-id.js";
@@ -21,6 +21,7 @@ export type { PrimeProfileErrorKind, PrimeThinkingLevel, } from "./prime-fork.js
21
21
  export { matchPrimeKernelDeadMarker, PRIME_AGENT_DIR_ENV_VAR, PRIME_AGENT_DIR_ENV_VARS, PRIME_FORK_INSTALL, PRIME_FORK_PIN, PRIME_HARNESS_STATE_ENV_DENYLIST, PRIME_KERNEL_DEAD_MARKERS, PRIME_KERNEL_ENV_PASSTHROUGH, PRIME_MCP_UNSUPPORTED_REASON, PRIME_THINKING_LEVELS, PrimeHarnessStateError, PrimeProfileError, parsePrimeModelsJson, primeAgentDirEnv, primeApiKeyEnvNames, primeThinkingLevel, stripPrimeJsonComments, } from "./prime-fork.js";
22
22
  export type { PrimeProfileControls, PrimeProfileSkill, PrimeProfileSubagent, } from "./prime-profile.js";
23
23
  export { assertPrimeModelAgreement, hasPrimeProfileMaterial, primeProfileNeedsAgentDir, primeSubagentEntryId, readPrimeProfileControls, } from "./prime-profile.js";
24
- export { materializeProfile, UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER, } from "./profile-materializer.js";
24
+ export { executionControlConflict, materializeProfile, UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER, } from "./profile-materializer.js";
25
25
  export type { ResolveAgentProfileResourcesOptions } from "./resource-resolution.js";
26
26
  export { normalizeSkillMd, resolveAgentProfileResources, } from "./resource-resolution.js";
27
+ export { prepareSecretConcurrentProfileRevision } from "./secret-concurrent-revision.js";
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
- /** Stable public facade for profile materialization and workspace application. */
2
1
  export { applyAgentCandidateWorkspacePlan, materializeCandidateProfile, } from "./candidate.js";
3
2
  export { OPENCODE_NATIVE_TOOL_IDS, openCodeNativeToolId, } from "./control-maps.js";
4
3
  export { HARNESS_SKILL_DIRS, harnessAutoLoadsAgentContextFile, harnessAutoLoadsContextFile, harnessContextFile, isMaterializerHarness, MATERIALIZER_CONTEXT_PATHS, MATERIALIZER_HARNESSES, markMaterializerContextFile, skillDirForHarness, } from "./harness.js";
5
- export { joinSystemPromptAdditions, keepsHarnessSystemPrompt, } from "./harness-prompt.js";
4
+ export * from "./harness-prompt.js";
5
+ export { ensurePublicConcurrentProfileRoots, preparePublicConcurrentProfileRevision, } from "./managed-workspace.js";
6
6
  export { openCodeMcpConfig } from "./mcp.js";
7
7
  export { modelIdsMatch, qualifyModelId, unqualifyModelId, } from "./model-id.js";
8
8
  export { buildPiModelsConfig, buildPiModelsConfigFile, PI_MODELS_CONFIG_PATH, resolvePiRuntimePaths, } from "./pi-models-config.js";
@@ -14,5 +14,6 @@ export { BINDABLE_LAUNCH_CONTROLS, EXECUTOR_SYSTEM_PROMPT_BINDINGS, isExecutorSy
14
14
  export { materializePrimeProfileControls, prunePrimeProfileMaterial, seedPrimeProfileSubagents, writePrimeAgentFile, } from "./prime-agent-dir.js";
15
15
  export { matchPrimeKernelDeadMarker, PRIME_AGENT_DIR_ENV_VAR, PRIME_AGENT_DIR_ENV_VARS, PRIME_FORK_INSTALL, PRIME_FORK_PIN, PRIME_HARNESS_STATE_ENV_DENYLIST, PRIME_KERNEL_DEAD_MARKERS, PRIME_KERNEL_ENV_PASSTHROUGH, PRIME_MCP_UNSUPPORTED_REASON, PRIME_THINKING_LEVELS, PrimeHarnessStateError, PrimeProfileError, parsePrimeModelsJson, primeAgentDirEnv, primeApiKeyEnvNames, primeThinkingLevel, stripPrimeJsonComments, } from "./prime-fork.js";
16
16
  export { assertPrimeModelAgreement, hasPrimeProfileMaterial, primeProfileNeedsAgentDir, primeSubagentEntryId, readPrimeProfileControls, } from "./prime-profile.js";
17
- export { materializeProfile, UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER, } from "./profile-materializer.js";
17
+ export { executionControlConflict, materializeProfile, UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER, } from "./profile-materializer.js";
18
18
  export { normalizeSkillMd, resolveAgentProfileResources, } from "./resource-resolution.js";
19
+ export { prepareSecretConcurrentProfileRevision } from "./secret-concurrent-revision.js";
@@ -0,0 +1,4 @@
1
+ import type { WorkspacePlan, WorkspacePlanApplication } from "./plan-types.js";
2
+ export declare function bindConcurrentLaunch(plan: WorkspacePlan, application: WorkspacePlanApplication, revisionWorkspaceDir: string, options?: {
3
+ readonly secretOpenCodeRevisionCwd?: boolean;
4
+ }): WorkspacePlanApplication["spawn"];
@@ -0,0 +1,94 @@
1
+ import { join } from "node:path";
2
+ export function bindConcurrentLaunch(plan, application, revisionWorkspaceDir, options = {}) {
3
+ if (plan.requires?.length) {
4
+ throw new Error(`Concurrent ${plan.harness} profile needs a native extension that this launcher cannot attest`);
5
+ }
6
+ if (application.spawn.systemPromptBinding) {
7
+ throw new Error(`Concurrent ${plan.harness} profile has an unbound system prompt control`);
8
+ }
9
+ const flags = [...application.spawn.flags];
10
+ const env = { ...application.spawn.env };
11
+ const supportedFiles = new Set();
12
+ const bindFlagFile = (flag, relPath) => {
13
+ const index = flags.findIndex((value, at) => value === flag && flags[at + 1] === relPath);
14
+ if (index < 0)
15
+ throw new Error(`Concurrent ${plan.harness} profile lacks ${flag} for ${relPath}`);
16
+ flags[index + 1] = join(revisionWorkspaceDir, relPath);
17
+ supportedFiles.add(relPath);
18
+ };
19
+ switch (plan.harness) {
20
+ case "claude-code":
21
+ for (const relPath of [
22
+ ".tangle/claude-mcp.json",
23
+ ".tangle/claude-settings.json",
24
+ ]) {
25
+ if (plan.files.some((file) => file.relPath === relPath)) {
26
+ bindFlagFile(relPath.endsWith("mcp.json") ? "--mcp-config" : "--settings", relPath);
27
+ }
28
+ }
29
+ break;
30
+ case "codex":
31
+ if (plan.files.some((file) => file.relPath === ".codex/system-prompt.md")) {
32
+ const original = "model_instructions_file=.codex/system-prompt.md";
33
+ const index = flags.findIndex((value, at) => value === original && flags[at - 1] === "-c");
34
+ if (index < 0)
35
+ throw new Error("Concurrent codex profile lacks model_instructions_file binding");
36
+ flags[index] =
37
+ `model_instructions_file=${join(revisionWorkspaceDir, ".codex/system-prompt.md")}`;
38
+ supportedFiles.add(".codex/system-prompt.md");
39
+ }
40
+ break;
41
+ case "kimi-code":
42
+ if (plan.files.some((file) => file.relPath === ".kimi/mcp.json")) {
43
+ bindFlagFile("--mcp-config-file", ".kimi/mcp.json");
44
+ }
45
+ break;
46
+ case "opencode": {
47
+ const runtimeConfig = application.runtimeOnlyFiles["opencode.json"];
48
+ if (runtimeConfig !== undefined)
49
+ env.OPENCODE_CONFIG_CONTENT = runtimeConfig;
50
+ const configFile = plan.files.find((file) => file.relPath === "opencode.json");
51
+ if (configFile) {
52
+ env.OPENCODE_CONFIG = join(revisionWorkspaceDir, "opencode.json");
53
+ env.TANGLE_PROFILE_REVISION_WORKSPACE = revisionWorkspaceDir;
54
+ supportedFiles.add("opencode.json");
55
+ const config = JSON.parse(configFile.content);
56
+ if (Array.isArray(config.instructions)) {
57
+ for (const entry of config.instructions) {
58
+ const prefix = "{env:TANGLE_PROFILE_REVISION_WORKSPACE}/";
59
+ if (typeof entry !== "string" || !entry.startsWith(prefix)) {
60
+ throw new Error("Concurrent OpenCode instruction lacks an explicit revision binding");
61
+ }
62
+ supportedFiles.add(entry.slice(prefix.length));
63
+ }
64
+ }
65
+ }
66
+ break;
67
+ }
68
+ }
69
+ for (const file of plan.files) {
70
+ if (file.source !== "generated") {
71
+ if (options.secretOpenCodeRevisionCwd &&
72
+ plan.harness === "opencode" &&
73
+ (file.root ?? "workspace") === "workspace" &&
74
+ (file.relPath.startsWith("reference/") ||
75
+ file.relPath.startsWith(".opencode/skills/"))) {
76
+ // The secret launcher uses this immutable revision tree as native cwd.
77
+ // These content-only resources are therefore visible without writing
78
+ // into the shared workspace or changing native authority config.
79
+ continue;
80
+ }
81
+ throw new Error(`Concurrent ${plan.harness} profile resource ${file.relPath} needs a shared-workspace collision-safe copy`);
82
+ }
83
+ if ((file.root ?? "workspace") !== "workspace" ||
84
+ !supportedFiles.has(file.relPath)) {
85
+ throw new Error(`Concurrent ${plan.harness} profile cannot redirect native file ${file.relPath}; use a verified launch control`);
86
+ }
87
+ }
88
+ for (const name of Object.keys(application.runtimeOnlyFiles)) {
89
+ if (plan.harness !== "opencode" || name !== "opencode.json") {
90
+ throw new Error(`Concurrent ${plan.harness} profile cannot bind runtime-only file ${name}`);
91
+ }
92
+ }
93
+ return { ...application.spawn, flags, env };
94
+ }