@visual-z/xeno 0.4.0 → 0.6.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.
Files changed (90) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +41 -10
  3. package/docs/app-server.md +35 -4
  4. package/docs/foundations.md +88 -0
  5. package/docs/install.md +23 -2
  6. package/docs/providers.md +4 -0
  7. package/docs/research/foundation-clients-2026-10-09.md +27 -0
  8. package/docs/research/foundation-editing-2026-10-09.md +56 -0
  9. package/docs/research/foundation-network-2026-10-09.md +37 -0
  10. package/docs/research/foundation-workflow-2026-10-09.md +37 -0
  11. package/package.json +10 -2
  12. package/src/apps/acp-server.ts +41 -0
  13. package/src/apps/agent-profiles.ts +19 -0
  14. package/src/apps/app-server.ts +4 -2
  15. package/src/apps/cli.ts +18 -2
  16. package/src/apps/commands.ts +20 -0
  17. package/src/apps/control-transport.ts +3 -2
  18. package/src/apps/model-catalog.ts +57 -31
  19. package/src/apps/preferences.ts +20 -0
  20. package/src/apps/question-ui.ts +27 -0
  21. package/src/apps/resources.ts +63 -25
  22. package/src/apps/review.ts +109 -0
  23. package/src/apps/session-hub.ts +84 -0
  24. package/src/apps/shell.ts +88 -30
  25. package/src/apps/startup-input.ts +7 -2
  26. package/src/apps/startup.ts +180 -25
  27. package/src/apps/ui/app.ts +74 -91
  28. package/src/apps/ui/surface.ts +1 -0
  29. package/src/apps/update-check.ts +71 -0
  30. package/src/apps/workflow.ts +58 -0
  31. package/src/core/agent-profile.ts +130 -0
  32. package/src/core/completion-review.ts +13 -4
  33. package/src/core/control-protocol.ts +8 -1
  34. package/src/core/file-precondition.ts +6 -0
  35. package/src/core/lsp-framing.ts +34 -0
  36. package/src/core/mcp-config.ts +68 -0
  37. package/src/core/model-preferences.ts +28 -0
  38. package/src/core/patch.ts +130 -0
  39. package/src/core/permissions.ts +156 -11
  40. package/src/core/question.ts +94 -0
  41. package/src/core/review.ts +68 -0
  42. package/src/core/settings.ts +1 -1
  43. package/src/core/update-check.ts +24 -5
  44. package/src/core/web-content.ts +80 -0
  45. package/src/core/workflow-mode.ts +34 -0
  46. package/src/runtime/acp-agent.ts +289 -0
  47. package/src/runtime/agent-profiles.ts +21 -0
  48. package/src/runtime/catalog.ts +10 -3
  49. package/src/runtime/control-client.ts +149 -0
  50. package/src/runtime/control-connection.ts +39 -4
  51. package/src/runtime/extensions.ts +20 -2
  52. package/src/runtime/formatter-tool.ts +38 -0
  53. package/src/runtime/formatter.ts +50 -0
  54. package/src/runtime/foundation.ts +78 -0
  55. package/src/runtime/integrations.ts +8 -2
  56. package/src/runtime/loop.ts +68 -10
  57. package/src/runtime/lsp-client.ts +201 -0
  58. package/src/runtime/mcp-client.ts +264 -0
  59. package/src/runtime/mcp-oauth.ts +168 -0
  60. package/src/runtime/mcp-schema.ts +51 -0
  61. package/src/runtime/oauth.ts +1 -1
  62. package/src/runtime/output-schema.ts +26 -0
  63. package/src/runtime/patch-tool.ts +38 -0
  64. package/src/runtime/permission-resources.ts +11 -0
  65. package/src/runtime/provider.ts +2 -0
  66. package/src/runtime/question-tool.ts +62 -0
  67. package/src/runtime/review-tool.ts +48 -0
  68. package/src/runtime/session-control.ts +60 -2
  69. package/src/runtime/session-hub.ts +85 -0
  70. package/src/runtime/tool-discovery.ts +4 -3
  71. package/src/runtime/tools.ts +48 -16
  72. package/src/runtime/update-check.ts +3 -2
  73. package/src/runtime/web-http.ts +56 -0
  74. package/src/runtime/web-tools.ts +88 -0
  75. package/src/runtime/workspace-lifecycle.ts +14 -1
  76. package/src/store/agent-profile-files.ts +13 -0
  77. package/src/store/context-files.ts +77 -1
  78. package/src/store/file-checkpoints.ts +5 -4
  79. package/src/store/foundation-config.ts +24 -0
  80. package/src/store/lsp-document.ts +30 -0
  81. package/src/store/mcp-credentials.ts +85 -0
  82. package/src/store/patch.ts +92 -0
  83. package/src/store/permission-paths.ts +34 -0
  84. package/src/store/resources.ts +13 -22
  85. package/src/store/review.ts +109 -0
  86. package/src/store/search.ts +48 -9
  87. package/src/store/user-settings.ts +30 -0
  88. package/src/tui/ui/control-panel.ts +7 -5
  89. package/src/tui/ui/control-sidebar.ts +49 -0
  90. package/src/tui/ui/screen.ts +107 -11
@@ -0,0 +1,85 @@
1
+ /** A hub owns live sessions, not transcript mirrors. Durable listing stays a store projection. */
2
+ import type { SessionControl } from "./session-control.ts";
3
+ import { basename } from "node:path";
4
+
5
+ // Assumed live-owner bound includes admitted opens, so concurrent requests cannot exceed it.
6
+ export const MAX_LIVE_SESSIONS = 32;
7
+
8
+ export interface SessionHubHost {
9
+ list(): Promise<unknown>;
10
+ open(kind: "new" | "resume" | "fork", params: Record<string, unknown>): Promise<SessionControl>;
11
+ dispose(control: SessionControl): Promise<void>;
12
+ }
13
+ export class SessionHub {
14
+ private readonly controls = new Map<string, SessionControl>();
15
+ private readonly opening = new Set<Promise<SessionControl>>();
16
+ private readonly resuming = new Map<string, Promise<SessionControl>>();
17
+ private readonly closing = new Map<string, Promise<void>>();
18
+ private reserved = 0;
19
+ private closed = false;
20
+ private disposal: Promise<void> | undefined;
21
+ private readonly host: SessionHubHost;
22
+ readonly initial: SessionControl;
23
+ constructor(initial: SessionControl, host: SessionHubHost) {
24
+ this.initial = initial; this.host = host; this.controls.set(initial.threadId, initial);
25
+ }
26
+ get(threadId: string): SessionControl {
27
+ if (this.closed || this.closing.has(threadId)) throw Error("session hub or thread is closing");
28
+ const control = this.controls.get(threadId);
29
+ if (!control) throw Error("unknown or closed threadId; resume the session first");
30
+ return control;
31
+ }
32
+ async list(): Promise<unknown> { if (this.closed) throw Error("session hub is closing"); return this.host.list(); }
33
+ open(kind: "new" | "resume" | "fork", params: Record<string, unknown>): Promise<SessionControl> {
34
+ if (this.closed) return Promise.reject(Error("session hub is closing; no new session was admitted"));
35
+ const captured = structuredClone(params);
36
+ // Coalesce resume only. Two explicit new/fork requests create two distinct sessions.
37
+ const key = kind === "resume" ? JSON.stringify(captured) : "";
38
+ const pending = key ? this.resuming.get(key) : undefined;
39
+ if (pending) return pending;
40
+ const requested = captured["session"];
41
+ const known = kind === "resume" && [...this.controls.values()].some(control => !this.closing.has(control.threadId)
42
+ && [control.threadId, control.session.config.logDir, basename(control.session.config.logDir)].includes(String(requested)));
43
+ if (!known && this.controls.size + this.reserved >= MAX_LIVE_SESSIONS) return Promise.reject(Error("live session limit reached; close a session first"));
44
+ if (!known) this.reserved++;
45
+ const operation = (async () => {
46
+ try {
47
+ const control = await this.host.open(kind, captured), existing = this.controls.get(control.threadId);
48
+ if (this.closing.has(control.threadId) || !existing && this.controls.size >= MAX_LIVE_SESSIONS) {
49
+ if (existing !== control) await this.host.dispose(control);
50
+ throw Error("thread is closing or the live session limit was reached");
51
+ }
52
+ if (existing && existing !== control) { await this.host.dispose(control); if (this.closed) throw Error("session hub closed during opening"); return existing; }
53
+ this.controls.set(control.threadId, control);
54
+ if (this.closed) throw Error("session hub closed during opening; the admitted owner will be drained");
55
+ return control;
56
+ } finally { if (!known) this.reserved--; }
57
+ })();
58
+ this.opening.add(operation); if (key) this.resuming.set(key, operation);
59
+ // Observe rejected opens without changing the caller's promise or leaking unhandled cleanup rejections.
60
+ void operation.finally(() => { this.opening.delete(operation); if (key) this.resuming.delete(key); }).catch(() => {});
61
+ return operation;
62
+ }
63
+ close(threadId: string): Promise<void> {
64
+ const pending = this.closing.get(threadId); if (pending) return pending;
65
+ const control = this.controls.get(threadId); if (!control) return Promise.reject(Error("unknown or closed threadId"));
66
+ const operation = (async () => {
67
+ if (control.activeTurnId) control.interrupt(control.activeTurnId);
68
+ await control.whenIdle().catch(() => {});
69
+ await this.host.dispose(control); this.controls.delete(threadId);
70
+ })();
71
+ this.closing.set(threadId, operation);
72
+ void operation.then(() => { if (this.closing.get(threadId) === operation) this.closing.delete(threadId); }, () => {});
73
+ return operation;
74
+ }
75
+ dispose(): Promise<void> {
76
+ if (this.disposal) return this.disposal;
77
+ this.closed = true;
78
+ return this.disposal = (async () => {
79
+ // Every kind is tracked; late publication belongs to this same draining generation.
80
+ await Promise.allSettled([...this.opening]);
81
+ const results = await Promise.allSettled([...this.controls.keys()].map(id => this.close(id)));
82
+ if (results.some(result => result.status === "rejected")) throw Error("session cleanup could not be confirmed");
83
+ })();
84
+ }
85
+ }
@@ -1,7 +1,7 @@
1
1
  /** R11: a harness-owned discovery entry shares normal Session admission and result persistence. */
2
2
  import { createHash } from "node:crypto";
3
3
  import { discoverTools, MAX_DISCOVERY_QUERY, MAX_DISCOVERY_RESULTS, type DeclarationRequest } from "../core/tool-discovery.ts";
4
- import { decide } from "../core/permissions.ts";
4
+ import { decideWithSessionRules } from "../core/permissions.ts";
5
5
  import { failureDiagnostic, NOT_STARTED } from "../core/tool-failure.ts";
6
6
  import type { ToolSchema } from "./provider.ts";
7
7
  import type { ToolContext, ToolDefinition, ToolResult } from "./tools.ts";
@@ -14,7 +14,7 @@ export const DISCOVERY_TOOL: ToolDefinition = {
14
14
  query: { type: "string", minLength: 1, maxLength: MAX_DISCOVERY_QUERY },
15
15
  limit: { type: "integer", minimum: 1, maximum: MAX_DISCOVERY_RESULTS, description: "Maximum matches to request; defaults to 5." },
16
16
  }, required: ["query"] },
17
- access: "state", execution: "exclusive", branch: "none",
17
+ access: "read", execution: "exclusive", branch: "none",
18
18
  run: async () => ({ ok: false, content: "Tool discovery requires a live Session checkpoint." }),
19
19
  };
20
20
 
@@ -23,7 +23,8 @@ export function discoveryFingerprint(tool: ToolSchema): string {
23
23
  return createHash("sha256").update(JSON.stringify({ name: tool.name, description: tool.description, input_schema: tool.input_schema })).digest("hex");
24
24
  }
25
25
  export function discoveryPermitted(ctx: ToolContext, name: string): boolean {
26
- return decide([...(ctx.sessionRules ?? []), ...(ctx.rules ?? [])], { tool: name }) !== "deny";
26
+ if (ctx.workflowMode === "plan" && ctx.extensions?.get(name)?.access !== "read") return false;
27
+ return decideWithSessionRules(ctx.rules ?? [], ctx.sessionRules ?? [], { tool: name }) !== "deny";
27
28
  }
28
29
  /** A directory entry without its matching live implementation is unavailable, regardless of history. */
29
30
  export function currentDeferredTools(ctx: ToolContext): readonly ToolSchema[] {
@@ -54,7 +54,6 @@ import { parseQueryInput, MAX_SQL_CHARS, SQL_QUERY_HELP } from "../core/sql-quer
54
54
  import { readQuerySources } from "../store/query-source.ts";
55
55
  import {
56
56
  askWithoutTtyMessage,
57
- decide,
58
57
  deniedMessage,
59
58
  permissionRequest,
60
59
  type PermissionRequest,
@@ -76,6 +75,10 @@ import { observeEnvironment } from "./environment-tools.ts";
76
75
  import { beginCommandArtifacts, prepareArtifactArguments, commandArtifactSummary } from "./command-artifacts.ts";
77
76
  import { MAX_COMMAND_ARTIFACTS } from "../core/command-artifacts.ts";
78
77
  import { checkSourceCancellation } from "../store/source-cancellation.ts";
78
+ import { canonicalPermissionRequest } from "../store/permission-paths.ts";
79
+ import { permissionResourceAction } from "./permission-resources.ts";
80
+ import { parsePatch, patchPaths } from "../core/patch.ts";
81
+ import { decideWithSessionRules } from "../core/permissions.ts";
79
82
 
80
83
  export interface ToolResult {
81
84
  /** Broker-owned identity, returned to a composer only after the original receipt commits. */
@@ -98,6 +101,15 @@ export interface ToolResult {
98
101
  }
99
102
 
100
103
  export interface ToolContext {
104
+ historyEvents?: () => Promise<import("../core/events.ts").LogEvent[]>;
105
+ checkpointBlobs?: string;
106
+ frozenSystem?: () => string;
107
+ instructionCheck?: (ctx: ToolContext, name: string, input: unknown) => Promise<ToolResult | null>;
108
+ workflowMode?: "build" | "plan";
109
+ /** CLI owns its approval defaults; SDK callers retain their supplied host policy across reload. */
110
+ appOwnsPermissions?: true;
111
+ hostPermissionRules?: PermissionRule[];
112
+ questionSlot?: import("./question-tool.ts").QuestionSlot;
101
113
  /** R10: model-visible names constrain new advice without granting tool admission. */
102
114
  advice?: readonly string[];
103
115
  /** Runtime adapter identity, never supplied by a model tool argument. */
@@ -285,6 +297,8 @@ export interface ToolDefinition extends ToolSchema {
285
297
  /** Accepted by admission but not advertised to the model: harness and test switches. */
286
298
  hiddenInputs?: readonly string[];
287
299
  prepareArguments?: (input: unknown) => unknown;
300
+ /** Protocol adapters with complete schemas own validation, rather than the builtin vocabulary. */
301
+ validateInput?: (input: unknown) => void;
288
302
  run?: (ctx: ToolContext, input: Record<string, unknown>) => Promise<ToolResult>;
289
303
  }
290
304
 
@@ -370,7 +384,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
370
384
  description:
371
385
  "Read a file. Returns the contents with 1-indexed line numbers. " +
372
386
  "Optionally limit to an inclusive line range. Reports total lines and the next range; end beyond EOF is clamped. " +
373
- "Returns read_handle and whole-file sha256 for version checks. Long lines and large previews retain an immutable artifact. Line ranges describe the selection; the observation receipt describes exactly what was visible. An unavailable artifact-recovery capability leaves a gap, not proof of full coverage.",
387
+ "Session remembers the whole-file version for later changes; no digest copying is needed. Returns read_handle and whole-file sha256 for selecting an explicit observation. Long lines and large previews retain an immutable artifact. Line ranges describe the selection; the observation receipt describes exactly what was visible. An unavailable artifact-recovery capability leaves a gap, not proof of full coverage.",
374
388
  input_schema: {
375
389
  type: "object",
376
390
  properties: {
@@ -384,14 +398,14 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
384
398
  {
385
399
  name: "write",
386
400
  access: "write", execution: "exclusive", branch: "write", run: toolWrite,
387
- description: "Write a file, creating parent directories. Replaces existing content. Pass read_handle or expected_sha256 for an explicit version check; otherwise Session uses its last observed version. create_only requires absence. A conflict requires a fresh read. Identical content makes no change.",
401
+ description: "Write a file, creating parent directories. Replaces existing content. Normally omit read_handle and expected_sha256: Session checks its latest observed version. Without a prior observation, omission has no stale-version protection. To select a specific observation instead, pass its exact version receipt. create_only requires absence. A conflict requires a fresh read. Identical content makes no change.",
388
402
  input_schema: {
389
403
  type: "object",
390
404
  properties: {
391
405
  path: { type: "string" },
392
406
  content: { type: "string" },
393
- read_handle: { type: "string", description: "Descriptor SHA from an observation of this file, workspace and host" },
394
- expected_sha256: { type: "string", description: "Expected whole-file raw-byte SHA-256" },
407
+ read_handle: { type: "string", description: "Optional explicit descriptor SHA for this file, workspace and host; normally omit" },
408
+ expected_sha256: { type: "string", description: "Optional explicit whole-file raw-byte SHA-256; normally omit" },
395
409
  create_only: { type: "boolean", description: "Require that the target does not exist; excludes read_handle and expected_sha256" },
396
410
  },
397
411
  required: ["path", "content"],
@@ -400,14 +414,16 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
400
414
  {
401
415
  name: "edit",
402
416
  example: { path: "src/main.ts", old_string: "const size = 1;", new_string: "const size = 2;" },
403
- guidance: [{ requires: ["read"], text: "Use read to obtain current text and a version receipt before an edit." }],
417
+ guidance: [{ requires: ["read"], text: "Use read to obtain current text before an edit; Session remembers its version automatically." }],
404
418
  access: "write", execution: "exclusive", branch: "write", run: toolEdit,
405
419
  description:
406
- "Replace exact text in a file. old_string must match once unless replace_all is set; copy it from observed file content, " +
420
+ "Replace exact text in a file. Normally omit read_handle and expected_sha256: Session checks its latest observed version. " +
421
+ "Without a prior observation, omission has no stale-version protection. To select a specific observation instead, pass its exact version receipt. " +
422
+ "old_string must match once unless replace_all is set; copy it from observed file content, " +
407
423
  "including indentation (line numbers are not part of the file). For several changes to one file pass edits[], " +
408
424
  "each matched against the original; all apply or none do. If there is no exact match, a unique match that differs only in " +
409
425
  "trailing whitespace, quote or dash style is applied and flagged fuzzy. A failure shows the closest text in the file. " +
410
- "Pass read_handle or expected_sha256 from a file observation to reject stale edits; when omitted, Session uses its last observed version. A conflict requires a fresh observation. A partial read handle checks the whole file version, not permission to edit unseen lines.",
426
+ "A conflict requires a fresh observation. A partial read handle checks the whole file version, not permission to edit unseen lines.",
411
427
  input_schema: {
412
428
  type: "object",
413
429
  properties: {
@@ -419,8 +435,8 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
419
435
  type: "object", required: ["old_string", "new_string"], properties: {
420
436
  old_string: { type: "string", minLength: 1 }, new_string: { type: "string" }, replace_all: { type: "boolean" },
421
437
  } } },
422
- expected_sha256: { type: "string", description: "Observed whole-file version; mismatch leaves the file unchanged" },
423
- read_handle: { type: "string", description: "Descriptor SHA from an observation of this file, workspace and host" },
438
+ expected_sha256: { type: "string", description: "Optional explicit whole-file raw-byte SHA-256; normally omit" },
439
+ read_handle: { type: "string", description: "Optional explicit descriptor SHA for this file, workspace and host; normally omit" },
424
440
  },
425
441
  required: ["path"],
426
442
  },
@@ -700,7 +716,7 @@ export function adviceTools(ctx: ToolContext): string[] {
700
716
  return TOOL_DEFINITIONS.map(tool => tool.name).filter(name => !ctx.extensions?.has(name) &&
701
717
  (ctx.advice === undefined || ctx.advice.includes(name)) &&
702
718
  (ctx.allow === undefined || ctx.allow.includes(name)) &&
703
- decide([...(ctx.sessionRules ?? []), ...(ctx.rules ?? [])], { tool: name }) !== "deny");
719
+ decideWithSessionRules(ctx.rules ?? [], ctx.sessionRules ?? [], { tool: name }) !== "deny");
704
720
  }
705
721
 
706
722
  /** A proposed recovery is data, not execution. Validate it against today's exact admission inputs. */
@@ -714,7 +730,7 @@ function scopeToolAdvice(ctx: ToolContext, result: ToolResult): ToolResult {
714
730
  const input = normalizeToolInput(ctx, recover.tool, recover.input);
715
731
  const definition = toolDefinition(recover.tool, ctx)!;
716
732
  allowed = inputError(definition.input_schema, input, "input", definition.hiddenInputs) === null &&
717
- decide([...(ctx.sessionRules ?? []), ...(ctx.rules ?? [])], permissionRequest(recover.tool, input)) !== "deny";
733
+ decideWithSessionRules(ctx.rules ?? [], ctx.sessionRules ?? [], permissionRequest(recover.tool, input)) !== "deny";
718
734
  } catch { /* Unavailable or invalid advice is omitted; the original failure remains. */ }
719
735
  }
720
736
  return scopeFailureGuidance(diagnostic, names, allowed);
@@ -1188,6 +1204,7 @@ async function toolGrep(ctx: ToolContext, input: Record<string, unknown>): Promi
1188
1204
  const limit = optionalLimit(input);
1189
1205
  const result = await grep({
1190
1206
  root: searchRoot(ctx, abs),
1207
+ ...(ctx.rules ? { canRead: async (path: string) => await permissionResourceAction(searchRoot(ctx, abs), path, ctx.rules ?? [], ctx.sessionRules ?? [], "grep") === "allow" } : {}),
1191
1208
  path: abs,
1192
1209
  pattern: String(input["pattern"] ?? ""),
1193
1210
  engine: grepEngine(input),
@@ -1247,6 +1264,7 @@ async function toolFind(ctx: ToolContext, input: Record<string, unknown>): Promi
1247
1264
  const limit = optionalLimit(input);
1248
1265
  const result = await findFiles({
1249
1266
  root: searchRoot(ctx, abs),
1267
+ ...(ctx.rules ? { canRead: async (path: string) => await permissionResourceAction(searchRoot(ctx, abs), path, ctx.rules ?? [], ctx.sessionRules ?? [], "find") === "allow" } : {}),
1250
1268
  path: abs,
1251
1269
  pattern: String(input["pattern"] ?? ""),
1252
1270
  timeoutMs: ctx.timeoutMs,
@@ -1262,6 +1280,7 @@ async function toolLs(ctx: ToolContext, input: Record<string, unknown>): Promise
1262
1280
  const limit = optionalLimit(input);
1263
1281
  const result = await ls({
1264
1282
  root: searchRoot(ctx, abs),
1283
+ ...(ctx.rules ? { canRead: async (path: string) => await permissionResourceAction(searchRoot(ctx, abs), path, ctx.rules ?? [], ctx.sessionRules ?? [], "ls") === "allow" } : {}),
1265
1284
  path: abs,
1266
1285
  ...(ctx.signal ? { signal: ctx.signal } : {}),
1267
1286
  ...(limit !== undefined ? { limit } : {}),
@@ -1313,8 +1332,14 @@ export async function gateTool(
1313
1332
  return failure("permission_denied", `refused: ${name} is not available here (allowed: ${ctx.allow.join(", ")})`);
1314
1333
  }
1315
1334
  const definition = toolDefinition(name, ctx);
1335
+ if (ctx.workflowMode === "plan" && definition && definition.access !== "read" &&
1336
+ !(definition.access === "state" && (name === "note" || name === "question" && ctx.questionSlot)))
1337
+ return failure("permission_denied", `${name} is unavailable in Plan mode; select Build at an idle checkpoint.`);
1316
1338
  input = repairToolInput(name, input);
1317
- const invalid = definition ? inputError(definition.input_schema, input, "input", definition.hiddenInputs) : null;
1339
+ let invalid: string | null = null;
1340
+ if (definition?.validateInput) {
1341
+ try { definition.validateInput(input); } catch (error) { return caughtFailure(error, NOT_STARTED, "Invalid tool arguments: "); }
1342
+ } else if (definition) invalid = inputError(definition.input_schema, input, "input", definition.hiddenInputs);
1318
1343
  if (invalid) {
1319
1344
  const example = minimalInput(definition!.input_schema);
1320
1345
  return failure("invalid_input", invalid, NOT_STARTED, example !== null && typeof example === "object" && !Array.isArray(example) &&
@@ -1322,9 +1347,14 @@ export async function gateTool(
1322
1347
  }
1323
1348
  if (ctx.rules === undefined) return null;
1324
1349
 
1325
- const request = permissionRequest(name, input);
1326
- const rules = [...(ctx.sessionRules ?? []), ...ctx.rules];
1327
- const decision = decide(rules, request);
1350
+ const permissionInput = name === "apply_patch" && input && typeof input === "object" && "patchText" in input ? { paths: patchPaths(parsePatch(String(input.patchText))) } : input;
1351
+ const request = await canonicalPermissionRequest(ctx.root, name, permissionInput);
1352
+ let decision = decideWithSessionRules(ctx.rules, ctx.sessionRules ?? [], request);
1353
+ if (definition?.access === "read" && request.paths) for (const path of request.paths) {
1354
+ const resource = await permissionResourceAction(ctx.root, path, ctx.rules, ctx.sessionRules ?? [], name);
1355
+ if (resource === "deny") { decision = "deny"; break; }
1356
+ if (resource === "ask" && decision === "allow") decision = "ask";
1357
+ }
1328
1358
  switch (decision) {
1329
1359
  case "allow":
1330
1360
  return null;
@@ -1403,6 +1433,8 @@ export async function prepareNormalizedTool(ctx: ToolContext, name: string, inpu
1403
1433
  try {
1404
1434
  const gated = await gateTool(ctx, name, input);
1405
1435
  if (gated) return scopeToolAdvice(ctx, finishToolResult(gated));
1436
+ const instructions = await ctx.instructionCheck?.(ctx, name, input);
1437
+ if (instructions) return scopeToolAdvice(ctx, finishToolResult(instructions));
1406
1438
  const veto = await scoped(() => ctx.extensionHooks?.before(name, observerValue(input), ctx.callId, ctx.parentCallId));
1407
1439
  await ctx.drainNested?.();
1408
1440
  if (veto) return scopeToolAdvice(ctx, finishToolResult(failure("permission_denied", veto)));
@@ -5,9 +5,10 @@
5
5
  * timeout, falling back to the package's `latest` document. It carries
6
6
  * Bun's default User-Agent and nothing that identifies the person: the
7
7
  * registry sees an IP address, the time and the package name. Failure of
8
- * any kind is "no answer"; it never reaches the screen as an error.
8
+ * any kind is "no answer"; callers decide whether an explicit check needs feedback.
9
9
  */
10
10
  import { distTagsUrl } from "../core/update-check.ts";
11
+ import { parseVersion } from "../core/version.ts";
11
12
 
12
13
  /** How long to wait for the registry. Codex and pi wait longer; startup never waits on it anyway. Assumed. */
13
14
  export const UPDATE_TIMEOUT_MS = 3000;
@@ -40,7 +41,7 @@ export async function fetchLatestVersion(registry: string | undefined, options:
40
41
  const response = await fetcher(url, { signal, headers: { accept: "application/json" } });
41
42
  if (!response.ok) continue;
42
43
  const value = pick(await response.json() as Record<string, unknown>);
43
- if (typeof value === "string" && value.length <= 64) return value;
44
+ if (typeof value === "string" && value.length <= 64 && parseVersion(value)) return value.trim();
44
45
  } catch { if (signal.aborted) return null; }
45
46
  }
46
47
  return null;
@@ -0,0 +1,56 @@
1
+ /** Shared bounded network leaves; request errors never include arbitrary peer text or credentials. */
2
+ import { webUrl } from "../core/web-content.ts";
3
+ import { READ_ATTEMPTED, ToolFault } from "../core/tool-failure.ts";
4
+
5
+ // Assumed resource bounds follow OpenCode's 5 MiB response cap; streamed enforcement prevents oversized allocation.
6
+ export const MAX_WEB_RESPONSE_BYTES = 5 * 1024 * 1024;
7
+ export const MAX_WEB_REDIRECTS = 5;
8
+ export type WebFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
9
+ export async function boundedResponseBytes(response: Response, limit = MAX_WEB_RESPONSE_BYTES): Promise<Uint8Array> {
10
+ const length = Number(response.headers.get("content-length"));
11
+ if (Number.isFinite(length) && length > limit) {
12
+ await response.body?.cancel(); throw new ToolFault("execution_failed", `Response exceeds ${limit} bytes`, READ_ATTEMPTED);
13
+ }
14
+ if (!response.body) return new Uint8Array();
15
+ const reader = response.body.getReader(), chunks: Uint8Array[] = [];
16
+ let size = 0;
17
+ try {
18
+ for (;;) {
19
+ const next = await reader.read();
20
+ if (next.done) break;
21
+ size += next.value.byteLength;
22
+ if (size > limit) { await reader.cancel(); throw new ToolFault("execution_failed", `Response exceeds ${limit} bytes`, READ_ATTEMPTED); }
23
+ chunks.push(next.value);
24
+ }
25
+ } finally { reader.releaseLock(); }
26
+ const bytes = new Uint8Array(size); let offset = 0;
27
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
28
+ return bytes;
29
+ }
30
+ export async function boundedWebRequest(url: string, init: RequestInit, timeoutMs: number, signal: AbortSignal | undefined,
31
+ fetcher: WebFetch = fetch, redirects = MAX_WEB_REDIRECTS): Promise<{ response: Response; bytes: Uint8Array; url: string; redirects: string[] }> {
32
+ const deadline = new AbortController(), combined = AbortSignal.any([deadline.signal, ...(signal ? [signal] : [])]);
33
+ const timer = setTimeout(() => deadline.abort(), timeoutMs), history: string[] = [];
34
+ let target = webUrl(url);
35
+ try {
36
+ combined.throwIfAborted();
37
+ for (;;) {
38
+ const response = await fetcher(target, { ...init, redirect: "manual", signal: combined });
39
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
40
+ await response.body?.cancel();
41
+ const location = response.headers.get("location");
42
+ if (!location || history.length >= redirects) throw new ToolFault("execution_failed", "Redirect limit exceeded or Location missing", READ_ATTEMPTED);
43
+ const next = webUrl(new URL(location, target).href);
44
+ if (target.startsWith("https:") && next.startsWith("http:")) throw new ToolFault("execution_failed", "HTTPS redirects cannot downgrade to HTTP", READ_ATTEMPTED);
45
+ history.push(target); target = next;
46
+ continue;
47
+ }
48
+ return { response, bytes: await boundedResponseBytes(response), url: target, redirects: history };
49
+ }
50
+ } catch (error) {
51
+ if (signal?.aborted) throw new ToolFault("cancelled", "Network request cancelled", READ_ATTEMPTED);
52
+ if (deadline.signal.aborted) throw new ToolFault("timeout", "Network request timed out", READ_ATTEMPTED);
53
+ if (error instanceof ToolFault) throw error;
54
+ throw new ToolFault("execution_failed", "Network request failed; verify the URL and network connection", READ_ATTEMPTED);
55
+ } finally { clearTimeout(timer); }
56
+ }
@@ -0,0 +1,88 @@
1
+ /** Network adapters share the normal tool admission and append-only observation path (D11/D13). */
2
+ import { join } from "node:path";
3
+ import { webFetchArguments, webSearchArguments, webSearchHits, webSearchProjection, decodeWebEntities,
4
+ type WebFormat, type WebSearchConfig } from "../core/web-content.ts";
5
+ import { ToolFault, NOT_STARTED, READ_ATTEMPTED } from "../core/tool-failure.ts";
6
+ import { archiveObservation } from "../store/observations.ts";
7
+ import { presentObservation, type ToolContext, type ToolDefinition } from "./tools.ts";
8
+ import { boundedWebRequest, type WebFetch } from "./web-http.ts";
9
+
10
+ /** Bun's native HTML parser handles malformed/quoted markup; no homegrown HTML tokeniser or new dependency. */
11
+ export async function extractWebHtml(html: string, url: string, format: Exclude<WebFormat, "html">): Promise<{ title: string; content: string }> {
12
+ let title = "";
13
+ const cleaned = await new HTMLRewriter().on("title", { text(chunk) { title += chunk.text; } })
14
+ .on("head,script,style,noscript,iframe,object,embed,template", { element(element) { element.remove(); } })
15
+ .transform(new Response(html)).text();
16
+ const blocks = new Set(["p", "div", "section", "article", "main", "header", "footer", "blockquote", "ul", "ol", "table", "tr", "pre", "h1", "h2", "h3", "h4", "h5", "h6"]);
17
+ const content = await new HTMLRewriter().on("*", { element(element) {
18
+ const tag = element.tagName;
19
+ if (blocks.has(tag) || tag === "br" || tag === "li") element.before("\n");
20
+ if (format === "markdown" && /^h[1-6]$/.test(tag)) element.before("#".repeat(Number(tag[1])) + " ");
21
+ if (format === "markdown" && tag === "li") element.before("- ");
22
+ if (format === "markdown" && tag === "pre") element.before("```\n");
23
+ if (tag === "a") {
24
+ const href = element.getAttribute("href");
25
+ if (href) try {
26
+ const link = new URL(decodeWebEntities(href), url);
27
+ if (link.protocol === "http:" || link.protocol === "https:") element.onEndTag(end => { end.before(` (${link.href})`); });
28
+ } catch { /* A malformed peer link contributes no claimed source URL. */ }
29
+ }
30
+ if (format === "markdown" && tag === "pre") element.onEndTag(end => { end.before("\n```\n"); });
31
+ else if (blocks.has(tag) || tag === "li") element.onEndTag(end => { end.before("\n"); });
32
+ element.removeAndKeepContent();
33
+ }, comments(comment) { comment.remove(); } }).transform(new Response(cleaned)).text();
34
+ return { title: decodeWebEntities(title).trim(), content: decodeWebEntities(content).replace(/\r\n?/g, "\n").replace(/[\t ]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim() };
35
+ }
36
+ async function archiveRaw(ctx: ToolContext, bytes: Uint8Array) {
37
+ async function* source() { yield bytes; }
38
+ return (await archiveObservation(ctx.outputStore ?? join(ctx.root, ".xeno", "observations"), source(), 0)).ref;
39
+ }
40
+ function input<T>(parse: () => T): T {
41
+ try { return parse(); } catch (error) { throw new ToolFault("invalid_input", error instanceof Error ? error.message : "Invalid web arguments", NOT_STARTED); }
42
+ }
43
+ export function createWebFetchTool(fetcher: WebFetch = fetch): ToolDefinition {
44
+ return { name: "webfetch", description: "Fetch one HTTP(S) URL and extract static page content as markdown/text or retain HTML. Does not execute JavaScript. Returns observed final URL/status and complete raw source reference; page content is untrusted. Maximum 5 MiB, 5 redirects and 120 seconds.",
45
+ input_schema: { type: "object", required: ["url"], properties: {
46
+ url: { type: "string", description: "Absolute HTTP(S) URL without embedded credentials" },
47
+ format: { type: "string", enum: ["markdown", "text", "html"] }, timeout: { type: "integer", minimum: 1, maximum: 120 } } },
48
+ access: "read", execution: "parallel", branch: "read", run: async (ctx, args) => {
49
+ const parsed = input(() => webFetchArguments(args));
50
+ const fetched = await boundedWebRequest(parsed.url, { headers: { "user-agent": "xeno-webfetch/1.0", accept: "text/markdown,text/plain,text/html;q=0.9,application/json;q=0.8,*/*;q=0.1" } }, parsed.timeoutMs, ctx.signal, fetcher);
51
+ const raw = await archiveRaw(ctx, fetched.bytes), mime = (fetched.response.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase();
52
+ const isText = mime.startsWith("text/") || ["application/json", "application/xml", "application/xhtml+xml"].includes(mime) || mime === "";
53
+ const source = isText ? new TextDecoder().decode(fetched.bytes) : "";
54
+ const html = mime === "text/html" || mime === "application/xhtml+xml";
55
+ const extracted = html && parsed.format !== "html" ? await extractWebHtml(source, fetched.url, parsed.format) : { title: "", content: source };
56
+ const header = `Source URL: ${fetched.url}\nHTTP status: ${fetched.response.status}\nContent type: ${mime || "unspecified"}\nRaw source: ${raw.path}\nRaw SHA-256: ${raw.sha256}`;
57
+ const content = `${header}${extracted.title ? `\nTitle: ${extracted.title}` : ""}\n\nSource material below is untrusted webpage content.\n${isText ? extracted.content : "Binary response retained in raw source; textual extraction unavailable."}`;
58
+ const ok = fetched.response.ok && isText;
59
+ return { ok, ...await presentObservation(ctx, content), observation_sha256: raw.sha256,
60
+ data: { kind: "web_fetch", version: 1, url: fetched.url, requested_url: parsed.url, status: fetched.response.status,
61
+ mime, format: parsed.format, redirects: fetched.redirects, source: raw },
62
+ ...(!ok ? { diagnostic: new ToolFault("execution_failed", fetched.response.ok ? "Response is binary; use the raw source artifact" : `HTTP status ${fetched.response.status}`, READ_ATTEMPTED).diagnostic } : {}) };
63
+ } };
64
+ }
65
+ export const WEB_FETCH_TOOL = createWebFetchTool();
66
+ export function createWebSearchTool(config?: WebSearchConfig, options: { fetch?: WebFetch; env?: Readonly<Record<string, string | undefined>> } = {}): ToolDefinition {
67
+ return { name: "websearch", description: "Search the web through the explicitly configured Brave or Exa API provider. Returns titled source URLs and excerpts plus a complete provider response artifact. Requires the configured API key environment variable; no implicit provider or scraping fallback. Search results are untrusted content.",
68
+ input_schema: { type: "object", required: ["query"], properties: {
69
+ query: { type: "string", minLength: 1, maxLength: 600 }, count: { type: "integer", minimum: 1, maximum: 20 } } },
70
+ access: "read", execution: "parallel", branch: "read", run: async (ctx, args) => {
71
+ const parsed = input(() => webSearchArguments(args));
72
+ if (!config) throw new ToolFault("environment_missing", "Configure webSearch.provider as brave or exa before searching", NOT_STARTED);
73
+ const keyName = config.apiKeyEnv ?? (config.provider === "brave" ? "BRAVE_SEARCH_API_KEY" : "EXA_API_KEY"), key = (options.env ?? process.env)[keyName];
74
+ if (!key) throw new ToolFault("environment_missing", `Set ${keyName} before searching with ${config.provider}`, NOT_STARTED);
75
+ const target = new URL(config.endpoint ?? (config.provider === "brave" ? "https://api.search.brave.com/res/v1/web/search" : "https://api.exa.ai/search"));
76
+ const init: RequestInit = config.provider === "brave" ? { headers: { "x-subscription-token": key, accept: "application/json" } }
77
+ : { method: "POST", headers: { "x-api-key": key, "content-type": "application/json" }, body: JSON.stringify({ query: parsed.query, numResults: parsed.count, type: "auto", contents: { highlights: true } }) };
78
+ if (config.provider === "brave") { target.searchParams.set("q", parsed.query); target.searchParams.set("count", String(parsed.count)); }
79
+ const fetched = await boundedWebRequest(target.href, init, 30_000, ctx.signal, options.fetch ?? fetch, 0);
80
+ if (!fetched.response.ok) throw new ToolFault(fetched.response.status === 429 || fetched.response.status >= 500 ? "transient" : "execution_failed", `Search provider returned HTTP ${fetched.response.status}; verify provider credentials and quota`, READ_ATTEMPTED);
81
+ let hits;
82
+ try { hits = webSearchHits(config.provider, JSON.parse(new TextDecoder().decode(fetched.bytes)), parsed.count); }
83
+ catch { throw new ToolFault("execution_failed", "Search provider response failed schema validation", READ_ATTEMPTED); }
84
+ const raw = await archiveRaw(ctx, fetched.bytes);
85
+ return { ok: true, ...await presentObservation(ctx, webSearchProjection(config.provider, parsed.query, hits) + `\n\nProvider response: ${raw.path}\nSHA-256: ${raw.sha256}`),
86
+ observation_sha256: raw.sha256, data: { kind: "web_search", version: 1, provider: config.provider, query: parsed.query, results: hits.map(hit => ({ ...hit })), source: raw } };
87
+ } };
88
+ }
@@ -1,6 +1,6 @@
1
1
  /** P.3: execution owns checkpoints; session opening owns no filesystem baseline. */
2
2
  import { randomUUID } from "node:crypto";
3
- import type { CheckpointEvent, FileVersion, WorkspaceObservationEvent } from "../core/checkpoints.ts";
3
+ import type { CheckpointEvent, FileVersion, FileChanges, WorkspaceObservationEvent } from "../core/checkpoints.ts";
4
4
  import { sameVersion } from "../core/checkpoints.ts";
5
5
  import { blobAddress } from "../store/blobs.ts";
6
6
  import { archiveEdit, replaceFile, readEditableFile } from "../store/file-checkpoints.ts";
@@ -28,6 +28,19 @@ export class WorkspaceLifecycle {
28
28
  private interfere(): void {
29
29
  for (const checkpoint of this.pending.values()) checkpoint.unavailable = "overlapping mutations prevent attributing a command checkpoint";
30
30
  }
31
+ async patch(changes: FileChanges, execute: () => Promise<void>, signal?: AbortSignal): Promise<void> {
32
+ signal?.throwIfAborted(); this.interfere();
33
+ const operation = randomUUID();
34
+ await this.record({ t: "checkpoint", operation, source: "edit", phase: "prepared", changes });
35
+ try {
36
+ await execute();
37
+ for (const [path, change] of Object.entries(changes)) if (!sameVersion((await readEditableFile(this.root, path)).version, change.after)) throw Error(`file changed concurrently after patch: ${path}`);
38
+ await this.record({ t: "checkpoint", operation, source: "edit", phase: "applied", changes });
39
+ } catch (error) {
40
+ await this.record({ t: "checkpoint", operation, source: "edit", phase: "unavailable", changes, reason: "patch did not establish the expected versions; inspect before retrying" });
41
+ throw error;
42
+ }
43
+ }
31
44
  async edit(path: string, before: { bytes: Uint8Array | null; version: FileVersion }, after: Uint8Array, signal?: AbortSignal): Promise<void> {
32
45
  this.interfere();
33
46
  await archiveEdit(this.blobs, before.bytes, after);
@@ -0,0 +1,13 @@
1
+ /** R13: profile files are inert JSON; project trust is decided by the app. */
2
+ import { readFile } from "node:fs/promises";
3
+
4
+ export async function readAgentProfileFile(path: string): Promise<{ value: unknown; diagnostic?: string } | null> {
5
+ let body: string;
6
+ try { body = await readFile(path, "utf8"); }
7
+ catch (error) {
8
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return null;
9
+ return { value: null, diagnostic: `${path}: unable to read agents configuration` };
10
+ }
11
+ try { return { value: JSON.parse(body) as unknown }; }
12
+ catch { return { value: null, diagnostic: `${path}: invalid JSON in agents configuration` }; }
13
+ }
@@ -18,9 +18,11 @@
18
18
  * frozen at startup; an explicit reload checkpoints a changed prefix.
19
19
  */
20
20
 
21
- import { readFile, stat } from "node:fs/promises";
21
+ import { readFile, stat, lstat } from "node:fs/promises";
22
22
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
23
23
  import { assertNever } from "../core/exhaustive.ts";
24
+ import { blobAddress } from "./blobs.ts";
25
+ import { gitBytes } from "./workspace-observation.ts";
24
26
 
25
27
  /**
26
28
  * Per-file cap. Assumed, not measured: a rule file larger than this is a
@@ -354,3 +356,77 @@ export function formatNotice(notice: ContextNotice): string {
354
356
  return assertNever(notice, "formatNotice");
355
357
  }
356
358
  }
359
+
360
+ /** Startup uses the actual repository instruction chain; loadContext remains explicit/compatible. */
361
+ export async function resolveContextWorkspace(cwd: string): Promise<string> {
362
+ try {
363
+ const bytes = await gitBytes(cwd, ["--no-pager", "rev-parse", "--show-prefix"]);
364
+ const prefix = new TextDecoder("utf-8", { fatal: true }).decode(bytes).replace(/\n$/, "");
365
+ // Git canonicalizes macOS /var to /private/var. Ascending the known prefix
366
+ // keeps root and cwd in the same spelling, preserving the instruction chain.
367
+ let root = resolve(cwd);
368
+ const levels = prefix.split("/").filter(Boolean).length;
369
+ for (let level = 0; level < levels; level++) root = dirname(root);
370
+ return root;
371
+ } catch { return resolve(cwd); }
372
+ }
373
+
374
+ export interface TargetInstruction extends LoadedFile { scope: string; key: string }
375
+ export interface TargetInstructionOptions {
376
+ workspace: string;
377
+ paths: readonly string[];
378
+ /** Digests from frozen startup files and durable instruction events, never model claims. */
379
+ known?: ReadonlySet<string>;
380
+ noContextFiles?: boolean;
381
+ /** Admission runs before instruction bytes are captured. */
382
+ canRead?: (path: string) => Promise<boolean>;
383
+ }
384
+ export const instructionKey = (file: Pick<LoadedFile, "path" | "content">): string =>
385
+ `${resolve(file.path)}:${blobAddress(file.content)}`;
386
+
387
+ /** Target paths are explicit. No recursive scan, sibling instruction loading or prefix surgery. */
388
+ export async function discoverTargetInstructions(options: TargetInstructionOptions): Promise<{ files: TargetInstruction[]; notices: ContextNotice[] }> {
389
+ if (options.noContextFiles) return { files: [], notices: [] };
390
+ const root = resolve(options.workspace), directories = new Set<string>();
391
+ for (const path of options.paths) {
392
+ const absolute = resolve(root, path);
393
+ if (!isInside(absolute, root)) throw new Error(`instruction target escapes workspace: ${path}`);
394
+ for (const directory of projectDirectories(root, dirname(absolute))) directories.add(directory);
395
+ }
396
+ const ordered = [...directories].sort((a, b) => a.split(sep).length - b.split(sep).length || a.localeCompare(b));
397
+ const files: TargetInstruction[] = [], notices: ContextNotice[] = [], budget = new Budget();
398
+ for (const directory of ordered) {
399
+ // A target directory's symlink must not import another project's ambient authority.
400
+ let safe = true, current = root;
401
+ for (const part of relative(root, directory).split(sep).filter(Boolean)) {
402
+ current = join(current, part);
403
+ try { const info = await lstat(current); if (info.isSymbolicLink() || !info.isDirectory()) { safe = false; break; } }
404
+ catch (error) { if ((error as { code?: string }).code !== "ENOENT") throw error; }
405
+ }
406
+ if (!safe) { notices.push({ kind: "skipped", path: directory, reason: "instruction scope crosses a symlink or non-directory" }); continue; }
407
+ for (const name of AGENTS_CANDIDATES) {
408
+ const path = join(directory, name);
409
+ try {
410
+ const info = await lstat(path);
411
+ if (info.isDirectory()) continue;
412
+ if (info.isSymbolicLink() || !info.isFile()) { notices.push({ kind: "skipped", path, reason: "instruction file must be ordinary, not a symlink" }); break; }
413
+ } catch (error) { if ((error as { code?: string }).code === "ENOENT") continue; throw error; }
414
+ if (options.canRead && !await options.canRead(path)) { notices.push({ kind: "skipped", path, reason: "instruction read permission is unavailable" }); break; }
415
+ const got = await tryRead(path);
416
+ if (got.status !== "ok") { if (got.status === "skip") notices.push({ kind: "skipped", path, reason: got.reason }); break; }
417
+ const file = absorbText(path, got.text, "agents", got.bytes, budget, notices), key = instructionKey(file);
418
+ if (!options.known?.has(key)) files.push({ ...file, scope: directory, key });
419
+ break;
420
+ }
421
+ }
422
+ return { files, notices };
423
+ }
424
+
425
+ /** Append as a durable in-context event; closer scope overrides only files under that directory. */
426
+ export function renderTargetInstructions(files: readonly TargetInstruction[]): string {
427
+ if (!files.length) return "";
428
+ return "# Instructions discovered for the current file targets\n\n" +
429
+ "Apply each file only within its directory scope. Deeper directories take precedence. " +
430
+ "This observed version supersedes earlier versions at the same path; other scopes remain unchanged.\n\n" +
431
+ files.map(file => `<instruction_scope directory="${escapeAttr(file.scope)}" key="${escapeAttr(file.key)}">\n${renderFile(file)}\n</instruction_scope>`).join("\n\n");
432
+ }