@visual-z/xeno 0.5.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 (79) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +7 -3
  3. package/docs/app-server.md +32 -3
  4. package/docs/foundations.md +88 -0
  5. package/docs/install.md +23 -2
  6. package/docs/research/foundation-clients-2026-10-09.md +27 -0
  7. package/docs/research/foundation-editing-2026-10-09.md +56 -0
  8. package/docs/research/foundation-network-2026-10-09.md +37 -0
  9. package/docs/research/foundation-workflow-2026-10-09.md +37 -0
  10. package/package.json +10 -2
  11. package/src/apps/acp-server.ts +41 -0
  12. package/src/apps/agent-profiles.ts +19 -0
  13. package/src/apps/app-server.ts +4 -2
  14. package/src/apps/cli.ts +18 -2
  15. package/src/apps/commands.ts +20 -0
  16. package/src/apps/control-transport.ts +3 -2
  17. package/src/apps/model-catalog.ts +57 -31
  18. package/src/apps/question-ui.ts +27 -0
  19. package/src/apps/resources.ts +63 -25
  20. package/src/apps/review.ts +109 -0
  21. package/src/apps/session-hub.ts +84 -0
  22. package/src/apps/shell.ts +54 -0
  23. package/src/apps/startup.ts +157 -22
  24. package/src/apps/ui/app.ts +21 -22
  25. package/src/apps/ui/surface.ts +1 -0
  26. package/src/apps/update-check.ts +71 -0
  27. package/src/apps/workflow.ts +58 -0
  28. package/src/core/agent-profile.ts +130 -0
  29. package/src/core/control-protocol.ts +8 -1
  30. package/src/core/file-precondition.ts +6 -0
  31. package/src/core/lsp-framing.ts +34 -0
  32. package/src/core/mcp-config.ts +68 -0
  33. package/src/core/patch.ts +130 -0
  34. package/src/core/permissions.ts +143 -4
  35. package/src/core/question.ts +94 -0
  36. package/src/core/review.ts +68 -0
  37. package/src/core/update-check.ts +24 -5
  38. package/src/core/web-content.ts +80 -0
  39. package/src/core/workflow-mode.ts +34 -0
  40. package/src/runtime/acp-agent.ts +289 -0
  41. package/src/runtime/agent-profiles.ts +21 -0
  42. package/src/runtime/catalog.ts +2 -2
  43. package/src/runtime/control-client.ts +149 -0
  44. package/src/runtime/control-connection.ts +39 -4
  45. package/src/runtime/extensions.ts +20 -2
  46. package/src/runtime/formatter-tool.ts +38 -0
  47. package/src/runtime/formatter.ts +50 -0
  48. package/src/runtime/foundation.ts +78 -0
  49. package/src/runtime/integrations.ts +8 -2
  50. package/src/runtime/loop.ts +68 -10
  51. package/src/runtime/lsp-client.ts +201 -0
  52. package/src/runtime/mcp-client.ts +264 -0
  53. package/src/runtime/mcp-oauth.ts +168 -0
  54. package/src/runtime/mcp-schema.ts +51 -0
  55. package/src/runtime/oauth.ts +1 -1
  56. package/src/runtime/output-schema.ts +26 -0
  57. package/src/runtime/patch-tool.ts +38 -0
  58. package/src/runtime/permission-resources.ts +11 -0
  59. package/src/runtime/provider.ts +2 -0
  60. package/src/runtime/question-tool.ts +62 -0
  61. package/src/runtime/review-tool.ts +48 -0
  62. package/src/runtime/session-control.ts +60 -2
  63. package/src/runtime/session-hub.ts +85 -0
  64. package/src/runtime/tool-discovery.ts +4 -3
  65. package/src/runtime/tools.ts +37 -7
  66. package/src/runtime/update-check.ts +3 -2
  67. package/src/runtime/web-http.ts +56 -0
  68. package/src/runtime/web-tools.ts +88 -0
  69. package/src/runtime/workspace-lifecycle.ts +14 -1
  70. package/src/store/agent-profile-files.ts +13 -0
  71. package/src/store/context-files.ts +77 -1
  72. package/src/store/file-checkpoints.ts +5 -4
  73. package/src/store/foundation-config.ts +24 -0
  74. package/src/store/lsp-document.ts +30 -0
  75. package/src/store/mcp-credentials.ts +85 -0
  76. package/src/store/patch.ts +92 -0
  77. package/src/store/permission-paths.ts +34 -0
  78. package/src/store/review.ts +109 -0
  79. package/src/store/search.ts +48 -9
@@ -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
 
@@ -702,7 +716,7 @@ export function adviceTools(ctx: ToolContext): string[] {
702
716
  return TOOL_DEFINITIONS.map(tool => tool.name).filter(name => !ctx.extensions?.has(name) &&
703
717
  (ctx.advice === undefined || ctx.advice.includes(name)) &&
704
718
  (ctx.allow === undefined || ctx.allow.includes(name)) &&
705
- decide([...(ctx.sessionRules ?? []), ...(ctx.rules ?? [])], { tool: name }) !== "deny");
719
+ decideWithSessionRules(ctx.rules ?? [], ctx.sessionRules ?? [], { tool: name }) !== "deny");
706
720
  }
707
721
 
708
722
  /** A proposed recovery is data, not execution. Validate it against today's exact admission inputs. */
@@ -716,7 +730,7 @@ function scopeToolAdvice(ctx: ToolContext, result: ToolResult): ToolResult {
716
730
  const input = normalizeToolInput(ctx, recover.tool, recover.input);
717
731
  const definition = toolDefinition(recover.tool, ctx)!;
718
732
  allowed = inputError(definition.input_schema, input, "input", definition.hiddenInputs) === null &&
719
- decide([...(ctx.sessionRules ?? []), ...(ctx.rules ?? [])], permissionRequest(recover.tool, input)) !== "deny";
733
+ decideWithSessionRules(ctx.rules ?? [], ctx.sessionRules ?? [], permissionRequest(recover.tool, input)) !== "deny";
720
734
  } catch { /* Unavailable or invalid advice is omitted; the original failure remains. */ }
721
735
  }
722
736
  return scopeFailureGuidance(diagnostic, names, allowed);
@@ -1190,6 +1204,7 @@ async function toolGrep(ctx: ToolContext, input: Record<string, unknown>): Promi
1190
1204
  const limit = optionalLimit(input);
1191
1205
  const result = await grep({
1192
1206
  root: searchRoot(ctx, abs),
1207
+ ...(ctx.rules ? { canRead: async (path: string) => await permissionResourceAction(searchRoot(ctx, abs), path, ctx.rules ?? [], ctx.sessionRules ?? [], "grep") === "allow" } : {}),
1193
1208
  path: abs,
1194
1209
  pattern: String(input["pattern"] ?? ""),
1195
1210
  engine: grepEngine(input),
@@ -1249,6 +1264,7 @@ async function toolFind(ctx: ToolContext, input: Record<string, unknown>): Promi
1249
1264
  const limit = optionalLimit(input);
1250
1265
  const result = await findFiles({
1251
1266
  root: searchRoot(ctx, abs),
1267
+ ...(ctx.rules ? { canRead: async (path: string) => await permissionResourceAction(searchRoot(ctx, abs), path, ctx.rules ?? [], ctx.sessionRules ?? [], "find") === "allow" } : {}),
1252
1268
  path: abs,
1253
1269
  pattern: String(input["pattern"] ?? ""),
1254
1270
  timeoutMs: ctx.timeoutMs,
@@ -1264,6 +1280,7 @@ async function toolLs(ctx: ToolContext, input: Record<string, unknown>): Promise
1264
1280
  const limit = optionalLimit(input);
1265
1281
  const result = await ls({
1266
1282
  root: searchRoot(ctx, abs),
1283
+ ...(ctx.rules ? { canRead: async (path: string) => await permissionResourceAction(searchRoot(ctx, abs), path, ctx.rules ?? [], ctx.sessionRules ?? [], "ls") === "allow" } : {}),
1267
1284
  path: abs,
1268
1285
  ...(ctx.signal ? { signal: ctx.signal } : {}),
1269
1286
  ...(limit !== undefined ? { limit } : {}),
@@ -1315,8 +1332,14 @@ export async function gateTool(
1315
1332
  return failure("permission_denied", `refused: ${name} is not available here (allowed: ${ctx.allow.join(", ")})`);
1316
1333
  }
1317
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.`);
1318
1338
  input = repairToolInput(name, input);
1319
- 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);
1320
1343
  if (invalid) {
1321
1344
  const example = minimalInput(definition!.input_schema);
1322
1345
  return failure("invalid_input", invalid, NOT_STARTED, example !== null && typeof example === "object" && !Array.isArray(example) &&
@@ -1324,9 +1347,14 @@ export async function gateTool(
1324
1347
  }
1325
1348
  if (ctx.rules === undefined) return null;
1326
1349
 
1327
- const request = permissionRequest(name, input);
1328
- const rules = [...(ctx.sessionRules ?? []), ...ctx.rules];
1329
- 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
+ }
1330
1358
  switch (decision) {
1331
1359
  case "allow":
1332
1360
  return null;
@@ -1405,6 +1433,8 @@ export async function prepareNormalizedTool(ctx: ToolContext, name: string, inpu
1405
1433
  try {
1406
1434
  const gated = await gateTool(ctx, name, input);
1407
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));
1408
1438
  const veto = await scoped(() => ctx.extensionHooks?.before(name, observerValue(input), ctx.callId, ctx.parentCallId));
1409
1439
  await ctx.drainNested?.();
1410
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
+ }
@@ -67,8 +67,9 @@ export async function archiveEdit(blobs: string, before: Uint8Array | null, afte
67
67
  await putBlob(blobs, after);
68
68
  }
69
69
 
70
- export async function preflightChanges(root: string, blobs: string, changes: FileChanges): Promise<void> {
70
+ export async function preflightChanges(root: string, blobs: string, changes: FileChanges, signal?: AbortSignal): Promise<void> {
71
71
  for (const path of changedPaths(changes)) {
72
+ if (signal?.aborted) throw new ToolFault("cancelled", "file operation cancelled", NOT_STARTED);
72
73
  const change = changes[path]!;
73
74
  if (!sameVersion((await readEditableFile(root, path)).version, change.before)) throw new Error(`file conflict: ${path}; external changes were preserved`);
74
75
  if (change.after && !await verifyBlob(blobs, change.after.sha256)) throw new Error(`missing checkpoint object: ${path}`);
@@ -77,15 +78,15 @@ export async function preflightChanges(root: string, blobs: string, changes: Fil
77
78
  }
78
79
 
79
80
  /** Preflight the complete plan, then compensate partial writes if an individual replacement fails. */
80
- export async function applyFileChanges(root: string, blobs: string, changes: FileChanges): Promise<void> {
81
- await preflightChanges(root, blobs, changes);
81
+ export async function applyFileChanges(root: string, blobs: string, changes: FileChanges, signal?: AbortSignal): Promise<void> {
82
+ await preflightChanges(root, blobs, changes, signal);
82
83
  const applied: string[] = [];
83
84
  try {
84
85
  for (const path of changedPaths(changes)) {
85
86
  const { before, after } = changes[path]!;
86
87
  const bytes = after === null ? null : await getBlobBytes(blobs, after.sha256);
87
88
  if (after && bytes === null) throw new Error(`missing checkpoint object: ${path}`);
88
- await replaceFile(root, path, before, bytes, after?.mode ?? 0o644);
89
+ await replaceFile(root, path, before, bytes, after?.mode ?? 0o644, signal);
89
90
  applied.push(path);
90
91
  }
91
92
  } catch (error) {
@@ -0,0 +1,24 @@
1
+ /** Trusted executable configuration follows the same user/project boundary as extensions. */
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ export async function readFoundationConfig(configDirectory: string, workspace: string, trusted: boolean): Promise<Record<string, unknown>> {
6
+ let value: Record<string, unknown> = {};
7
+ for (const path of [join(configDirectory, "foundation.json"), ...(trusted ? [join(workspace, ".xeno", "foundation.json")] : [])]) {
8
+ let text: string;
9
+ try { text = await readFile(path, "utf8"); }
10
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
11
+ const parsed: unknown = JSON.parse(text);
12
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw Error(`${path}: expected an object`);
13
+ const allowed = ["mcp", "webSearch", "formatters", "lsp", "permissions"];
14
+ if (Object.keys(parsed).some(key => !allowed.includes(key))) throw Error(`${path}: unknown foundation setting`);
15
+ value = { ...value, ...parsed };
16
+ }
17
+ return value;
18
+ }
19
+
20
+ export async function readOutputSchema(path: string): Promise<unknown> {
21
+ const text = await readFile(path, "utf8");
22
+ if (Buffer.byteLength(text) > 1_048_576) throw Error("output schema exceeds 1 MiB");
23
+ return JSON.parse(text) as unknown;
24
+ }
@@ -0,0 +1,30 @@
1
+ import { editablePath } from "./file-checkpoints.ts";
2
+ import { open } from "node:fs/promises";
3
+ import { constants } from "node:fs";
4
+ import { blobAddress } from "./blobs.ts";
5
+ import { pathToFileURL } from "node:url";
6
+ import { resolve } from "node:path";
7
+
8
+ // Operational memory ceiling: a language server is optional and must not read arbitrarily large files.
9
+ const MAX_LSP_DOCUMENT = 2 * 1024 * 1024;
10
+ export async function readLspDocument(root: string, path: string, signal?: AbortSignal): Promise<{ uri: string; text: string; sha256: string }> {
11
+ signal?.throwIfAborted();
12
+ const absolute = await editablePath(root, path);
13
+ const file = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
14
+ try {
15
+ const info = await file.stat();
16
+ if (!info.isFile() || info.nlink > 1) throw Error("LSP requires an existing ordinary workspace file");
17
+ if (info.size > MAX_LSP_DOCUMENT) throw Error("LSP document exceeds 2 MiB");
18
+ const bytes = new Uint8Array(MAX_LSP_DOCUMENT + 1); let length = 0;
19
+ for (;;) {
20
+ signal?.throwIfAborted();
21
+ const result = await file.read(bytes, length, bytes.length - length, null);
22
+ if (!result.bytesRead) break;
23
+ length += result.bytesRead;
24
+ if (length > MAX_LSP_DOCUMENT) throw Error("LSP document exceeds 2 MiB");
25
+ }
26
+ signal?.throwIfAborted();
27
+ const content = bytes.subarray(0, length);
28
+ return { uri: pathToFileURL(resolve(root, path)).href, text: new TextDecoder("utf8", { fatal: true }).decode(content), sha256: blobAddress(content) };
29
+ } finally { await file.close(); }
30
+ }