@visual-z/xeno 0.5.0 → 0.7.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 (102) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +19 -4
  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 +95 -0
  22. package/src/apps/shell.ts +55 -1
  23. package/src/apps/startup.ts +157 -22
  24. package/src/apps/ui/app.ts +89 -38
  25. package/src/apps/ui/image-cache.ts +71 -28
  26. package/src/apps/ui/surface.ts +1 -0
  27. package/src/apps/update-check.ts +71 -0
  28. package/src/apps/workflow.ts +58 -0
  29. package/src/core/activity-status.ts +28 -0
  30. package/src/core/agent-profile.ts +130 -0
  31. package/src/core/control-protocol.ts +8 -1
  32. package/src/core/file-precondition.ts +6 -0
  33. package/src/core/git-status.ts +29 -0
  34. package/src/core/image-limits.ts +72 -0
  35. package/src/core/lsp-framing.ts +34 -0
  36. package/src/core/mcp-config.ts +68 -0
  37. package/src/core/patch.ts +130 -0
  38. package/src/core/permissions.ts +143 -4
  39. package/src/core/progress.ts +9 -0
  40. package/src/core/question.ts +94 -0
  41. package/src/core/request-budget.ts +13 -0
  42. package/src/core/review.ts +68 -0
  43. package/src/core/session-exit.ts +26 -0
  44. package/src/core/settings.ts +1 -1
  45. package/src/core/stall.ts +18 -6
  46. package/src/core/stop-report.ts +25 -0
  47. package/src/core/update-check.ts +24 -5
  48. package/src/core/web-content.ts +80 -0
  49. package/src/core/workflow-mode.ts +34 -0
  50. package/src/runtime/acp-agent.ts +289 -0
  51. package/src/runtime/agent-profiles.ts +21 -0
  52. package/src/runtime/catalog.ts +2 -2
  53. package/src/runtime/control-client.ts +149 -0
  54. package/src/runtime/control-connection.ts +39 -4
  55. package/src/runtime/exec-sessions.ts +40 -4
  56. package/src/runtime/extensions.ts +20 -2
  57. package/src/runtime/formatter-tool.ts +38 -0
  58. package/src/runtime/formatter.ts +50 -0
  59. package/src/runtime/foundation.ts +78 -0
  60. package/src/runtime/images.ts +6 -2
  61. package/src/runtime/integrations.ts +8 -2
  62. package/src/runtime/loop.ts +124 -21
  63. package/src/runtime/lsp-client.ts +201 -0
  64. package/src/runtime/mcp-client.ts +264 -0
  65. package/src/runtime/mcp-oauth.ts +168 -0
  66. package/src/runtime/mcp-schema.ts +51 -0
  67. package/src/runtime/oauth.ts +1 -1
  68. package/src/runtime/openai-responses.ts +6 -4
  69. package/src/runtime/openai.ts +6 -1
  70. package/src/runtime/output-schema.ts +26 -0
  71. package/src/runtime/patch-tool.ts +38 -0
  72. package/src/runtime/permission-resources.ts +11 -0
  73. package/src/runtime/pi-provider.ts +5 -4
  74. package/src/runtime/provider-tool-policy.ts +33 -0
  75. package/src/runtime/provider.ts +8 -1
  76. package/src/runtime/question-tool.ts +62 -0
  77. package/src/runtime/review-tool.ts +48 -0
  78. package/src/runtime/session-control.ts +61 -2
  79. package/src/runtime/session-hub.ts +85 -0
  80. package/src/runtime/tool-discovery.ts +4 -3
  81. package/src/runtime/tools.ts +44 -10
  82. package/src/runtime/update-check.ts +3 -2
  83. package/src/runtime/web-http.ts +56 -0
  84. package/src/runtime/web-tools.ts +88 -0
  85. package/src/runtime/workspace-lifecycle.ts +14 -1
  86. package/src/store/agent-profile-files.ts +13 -0
  87. package/src/store/blobs.ts +2 -1
  88. package/src/store/context-files.ts +77 -1
  89. package/src/store/file-checkpoints.ts +5 -4
  90. package/src/store/foundation-config.ts +24 -0
  91. package/src/store/git-status.ts +15 -0
  92. package/src/store/lsp-document.ts +30 -0
  93. package/src/store/mcp-credentials.ts +85 -0
  94. package/src/store/patch.ts +92 -0
  95. package/src/store/permission-paths.ts +34 -0
  96. package/src/store/review.ts +109 -0
  97. package/src/store/search.ts +48 -9
  98. package/src/tui/ui/layout.ts +7 -7
  99. package/src/tui/ui/messages.ts +1 -1
  100. package/src/tui/ui/pictures.ts +26 -9
  101. package/src/tui/ui/screen.ts +12 -8
  102. package/src/tui/ui/status.ts +32 -6
@@ -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. */
@@ -115,6 +127,9 @@ export interface ToolContext {
115
127
  historyCache?: HistoryCache;
116
128
  /** O.14: opaque process ownership is supplied by Session, never by model input. */
117
129
  execOwner?: string;
130
+ /** Session-owned empty stdin waits; low-level/native callers retain bounded polling. */
131
+ processWait?: { wake(): AbortSignal };
132
+ onProcessWait?: (session: number, active: boolean) => void;
118
133
  /** Session-owned registrations never mutate the built-in table (D13). */
119
134
  extensions?: ReadonlyMap<string, ToolDefinition>;
120
135
  /** R11: current locally selected optional schemas, never restored as authority from historical text. */
@@ -285,6 +300,8 @@ export interface ToolDefinition extends ToolSchema {
285
300
  /** Accepted by admission but not advertised to the model: harness and test switches. */
286
301
  hiddenInputs?: readonly string[];
287
302
  prepareArguments?: (input: unknown) => unknown;
303
+ /** Protocol adapters with complete schemas own validation, rather than the builtin vocabulary. */
304
+ validateInput?: (input: unknown) => void;
288
305
  run?: (ctx: ToolContext, input: Record<string, unknown>) => Promise<ToolResult>;
289
306
  }
290
307
 
@@ -615,7 +632,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
615
632
  access: "shell", execution: "exclusive", branch: "write", run: toolWriteStdin,
616
633
  description:
617
634
  "Send characters to an owned running process handle and return the output since the last call. " +
618
- "Empty chars polls. Include \\n to submit a line; \\u0003 is Ctrl-C, \\u0004 is Ctrl-D (end of input). " +
635
+ "In an agent session, empty chars waits for new output, exit, hard timeout, cancellation or user steering without periodic model polling. Include \\n to submit a line; \\u0003 is Ctrl-C, \\u0004 is Ctrl-D (end of input). " +
619
636
  "After exit, empty chars retrieves the last receipt without rerunning or producing fresh verification. Source and byte cursor are in data; full_output recovers all captured output. " +
620
637
  "Lost handles cannot accept input; a last running observation is not an exit. Ctrl-C may interrupt a PTY foreground job while its REPL stays alive.",
621
638
  input_schema: {
@@ -623,7 +640,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
623
640
  properties: {
624
641
  session_id: { type: "integer", minimum: 1 },
625
642
  chars: { type: "string", description: "Characters to write; empty to poll" },
626
- yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "Milliseconds to wait for output; default 10000" },
643
+ yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "Bounded wait when sending input or using low-level polling; agent empty waits are owned until an observation changes" },
627
644
  },
628
645
  required: ["session_id"],
629
646
  },
@@ -702,7 +719,7 @@ export function adviceTools(ctx: ToolContext): string[] {
702
719
  return TOOL_DEFINITIONS.map(tool => tool.name).filter(name => !ctx.extensions?.has(name) &&
703
720
  (ctx.advice === undefined || ctx.advice.includes(name)) &&
704
721
  (ctx.allow === undefined || ctx.allow.includes(name)) &&
705
- decide([...(ctx.sessionRules ?? []), ...(ctx.rules ?? [])], { tool: name }) !== "deny");
722
+ decideWithSessionRules(ctx.rules ?? [], ctx.sessionRules ?? [], { tool: name }) !== "deny");
706
723
  }
707
724
 
708
725
  /** A proposed recovery is data, not execution. Validate it against today's exact admission inputs. */
@@ -716,7 +733,7 @@ function scopeToolAdvice(ctx: ToolContext, result: ToolResult): ToolResult {
716
733
  const input = normalizeToolInput(ctx, recover.tool, recover.input);
717
734
  const definition = toolDefinition(recover.tool, ctx)!;
718
735
  allowed = inputError(definition.input_schema, input, "input", definition.hiddenInputs) === null &&
719
- decide([...(ctx.sessionRules ?? []), ...(ctx.rules ?? [])], permissionRequest(recover.tool, input)) !== "deny";
736
+ decideWithSessionRules(ctx.rules ?? [], ctx.sessionRules ?? [], permissionRequest(recover.tool, input)) !== "deny";
720
737
  } catch { /* Unavailable or invalid advice is omitted; the original failure remains. */ }
721
738
  }
722
739
  return scopeFailureGuidance(diagnostic, names, allowed);
@@ -1094,7 +1111,8 @@ async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>):
1094
1111
  data: { kind: "process", version: 1, session_id: id, state: "unknown", capability: "unknown", source: null, running: null, exit: null, replayed: true } };
1095
1112
  }
1096
1113
  const outputStore = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
1097
- return sessionResult(ctx, session.command, session.timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, clampYield(input["yield_time_ms"]), chars, ctx.signal));
1114
+ return sessionResult(ctx, session.command, session.timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, clampYield(input["yield_time_ms"]), chars, ctx.signal,
1115
+ ctx.processWait && chars.length === 0 ? { wake: ctx.processWait.wake(), onWaiting: active => ctx.onProcessWait?.(id, active) } : undefined));
1098
1116
  }
1099
1117
 
1100
1118
  /** A successful historical query proves only that the receipt was found, never a new command exit. */
@@ -1190,6 +1208,7 @@ async function toolGrep(ctx: ToolContext, input: Record<string, unknown>): Promi
1190
1208
  const limit = optionalLimit(input);
1191
1209
  const result = await grep({
1192
1210
  root: searchRoot(ctx, abs),
1211
+ ...(ctx.rules ? { canRead: async (path: string) => await permissionResourceAction(searchRoot(ctx, abs), path, ctx.rules ?? [], ctx.sessionRules ?? [], "grep") === "allow" } : {}),
1193
1212
  path: abs,
1194
1213
  pattern: String(input["pattern"] ?? ""),
1195
1214
  engine: grepEngine(input),
@@ -1249,6 +1268,7 @@ async function toolFind(ctx: ToolContext, input: Record<string, unknown>): Promi
1249
1268
  const limit = optionalLimit(input);
1250
1269
  const result = await findFiles({
1251
1270
  root: searchRoot(ctx, abs),
1271
+ ...(ctx.rules ? { canRead: async (path: string) => await permissionResourceAction(searchRoot(ctx, abs), path, ctx.rules ?? [], ctx.sessionRules ?? [], "find") === "allow" } : {}),
1252
1272
  path: abs,
1253
1273
  pattern: String(input["pattern"] ?? ""),
1254
1274
  timeoutMs: ctx.timeoutMs,
@@ -1264,6 +1284,7 @@ async function toolLs(ctx: ToolContext, input: Record<string, unknown>): Promise
1264
1284
  const limit = optionalLimit(input);
1265
1285
  const result = await ls({
1266
1286
  root: searchRoot(ctx, abs),
1287
+ ...(ctx.rules ? { canRead: async (path: string) => await permissionResourceAction(searchRoot(ctx, abs), path, ctx.rules ?? [], ctx.sessionRules ?? [], "ls") === "allow" } : {}),
1267
1288
  path: abs,
1268
1289
  ...(ctx.signal ? { signal: ctx.signal } : {}),
1269
1290
  ...(limit !== undefined ? { limit } : {}),
@@ -1315,8 +1336,14 @@ export async function gateTool(
1315
1336
  return failure("permission_denied", `refused: ${name} is not available here (allowed: ${ctx.allow.join(", ")})`);
1316
1337
  }
1317
1338
  const definition = toolDefinition(name, ctx);
1339
+ if (ctx.workflowMode === "plan" && definition && definition.access !== "read" &&
1340
+ !(definition.access === "state" && (name === "note" || name === "question" && ctx.questionSlot)))
1341
+ return failure("permission_denied", `${name} is unavailable in Plan mode; select Build at an idle checkpoint.`);
1318
1342
  input = repairToolInput(name, input);
1319
- const invalid = definition ? inputError(definition.input_schema, input, "input", definition.hiddenInputs) : null;
1343
+ let invalid: string | null = null;
1344
+ if (definition?.validateInput) {
1345
+ try { definition.validateInput(input); } catch (error) { return caughtFailure(error, NOT_STARTED, "Invalid tool arguments: "); }
1346
+ } else if (definition) invalid = inputError(definition.input_schema, input, "input", definition.hiddenInputs);
1320
1347
  if (invalid) {
1321
1348
  const example = minimalInput(definition!.input_schema);
1322
1349
  return failure("invalid_input", invalid, NOT_STARTED, example !== null && typeof example === "object" && !Array.isArray(example) &&
@@ -1324,9 +1351,14 @@ export async function gateTool(
1324
1351
  }
1325
1352
  if (ctx.rules === undefined) return null;
1326
1353
 
1327
- const request = permissionRequest(name, input);
1328
- const rules = [...(ctx.sessionRules ?? []), ...ctx.rules];
1329
- const decision = decide(rules, request);
1354
+ const permissionInput = name === "apply_patch" && input && typeof input === "object" && "patchText" in input ? { paths: patchPaths(parsePatch(String(input.patchText))) } : input;
1355
+ const request = await canonicalPermissionRequest(ctx.root, name, permissionInput);
1356
+ let decision = decideWithSessionRules(ctx.rules, ctx.sessionRules ?? [], request);
1357
+ if (definition?.access === "read" && request.paths) for (const path of request.paths) {
1358
+ const resource = await permissionResourceAction(ctx.root, path, ctx.rules, ctx.sessionRules ?? [], name);
1359
+ if (resource === "deny") { decision = "deny"; break; }
1360
+ if (resource === "ask" && decision === "allow") decision = "ask";
1361
+ }
1330
1362
  switch (decision) {
1331
1363
  case "allow":
1332
1364
  return null;
@@ -1405,6 +1437,8 @@ export async function prepareNormalizedTool(ctx: ToolContext, name: string, inpu
1405
1437
  try {
1406
1438
  const gated = await gateTool(ctx, name, input);
1407
1439
  if (gated) return scopeToolAdvice(ctx, finishToolResult(gated));
1440
+ const instructions = await ctx.instructionCheck?.(ctx, name, input);
1441
+ if (instructions) return scopeToolAdvice(ctx, finishToolResult(instructions));
1408
1442
  const veto = await scoped(() => ctx.extensionHooks?.before(name, observerValue(input), ctx.callId, ctx.parentCallId));
1409
1443
  await ctx.drainNested?.();
1410
1444
  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
+ }
@@ -121,8 +121,9 @@ export async function getBlob(dir: string, sha: string): Promise<string | null>
121
121
  }
122
122
 
123
123
  /** Restore uses bytes; decoding binary data is an irreversible transformation. */
124
- export async function getBlobBytes(dir: string, sha: string): Promise<Uint8Array | null> {
124
+ export async function getBlobBytes(dir: string, sha: string, maxBytes?: number): Promise<Uint8Array | null> {
125
125
  try {
126
+ if (maxBytes !== undefined && (await stat(blobFilePath(dir, sha))).size > maxBytes) throw new RangeError("Blob exceeds byte limit");
126
127
  const bytes = await readFile(blobFilePath(dir, sha));
127
128
  if (blobAddress(bytes) !== sha) throw new Error(`blob integrity failure: ${sha}`);
128
129
  return bytes;
@@ -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,15 @@
1
+ /** Local Git reads are asynchronous and bounded; a paint never launches a process. */
2
+ import { execFile } from "node:child_process";
3
+ import { parseGitStatus, type GitStatus } from "../core/git-status.ts";
4
+ /** Assumed cold-disk allowance; Git failure remains unavailable rather than "clean". */
5
+ const GIT_STATUS_TIMEOUT_MS = 1000;
6
+ /** A busy repository cannot flood memory or the status view with paths. */
7
+ const GIT_STATUS_MAX_BYTES = 1024 * 1024;
8
+
9
+ export function readGitStatus(cwd: string): Promise<GitStatus | null> {
10
+ return new Promise(resolve => {
11
+ execFile("git", ["--no-optional-locks", "status", "--porcelain=v2", "--branch", "-z", "--untracked-files=normal"],
12
+ { cwd, timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: GIT_STATUS_MAX_BYTES, encoding: "utf8" },
13
+ (error, output) => resolve(error ? null : parseGitStatus(output)));
14
+ });
15
+ }
@@ -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
+ }