@visual-z/xeno 0.1.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 (180) hide show
  1. package/README.md +498 -0
  2. package/docs/install.md +130 -0
  3. package/package.json +43 -0
  4. package/src/apps/approve.ts +49 -0
  5. package/src/apps/cli.ts +37 -0
  6. package/src/apps/commands.ts +226 -0
  7. package/src/apps/completions.ts +28 -0
  8. package/src/apps/input.ts +297 -0
  9. package/src/apps/render.ts +207 -0
  10. package/src/apps/shell.ts +2353 -0
  11. package/src/apps/startup.ts +1302 -0
  12. package/src/core/benchmark.ts +195 -0
  13. package/src/core/boundary.ts +174 -0
  14. package/src/core/churn.ts +59 -0
  15. package/src/core/completion-review.ts +66 -0
  16. package/src/core/context-policy.ts +60 -0
  17. package/src/core/context-window.ts +54 -0
  18. package/src/core/continuation.ts +114 -0
  19. package/src/core/cycle.ts +17 -0
  20. package/src/core/data-query.ts +201 -0
  21. package/src/core/deadline.ts +39 -0
  22. package/src/core/derive.ts +489 -0
  23. package/src/core/edit-plan.ts +200 -0
  24. package/src/core/environment.ts +29 -0
  25. package/src/core/event-validation.ts +99 -0
  26. package/src/core/events.ts +486 -0
  27. package/src/core/evidence-links.ts +71 -0
  28. package/src/core/evidence.ts +89 -0
  29. package/src/core/execution.ts +12 -0
  30. package/src/core/exhaustive.ts +18 -0
  31. package/src/core/experiment.ts +40 -0
  32. package/src/core/export.ts +169 -0
  33. package/src/core/file-references.ts +35 -0
  34. package/src/core/frontmatter.ts +213 -0
  35. package/src/core/handoff.ts +397 -0
  36. package/src/core/harness-comparison.ts +90 -0
  37. package/src/core/history-query.ts +103 -0
  38. package/src/core/history.ts +100 -0
  39. package/src/core/json-event.ts +135 -0
  40. package/src/core/json-pointer.ts +46 -0
  41. package/src/core/keymap.ts +241 -0
  42. package/src/core/observation-view.ts +87 -0
  43. package/src/core/outcome.ts +45 -0
  44. package/src/core/overflow.ts +59 -0
  45. package/src/core/permissions.ts +182 -0
  46. package/src/core/policy-evaluation.ts +55 -0
  47. package/src/core/progress.ts +101 -0
  48. package/src/core/project.ts +372 -0
  49. package/src/core/prompts.ts +83 -0
  50. package/src/core/queue.ts +60 -0
  51. package/src/core/read-bounds.ts +15 -0
  52. package/src/core/recall-analysis.ts +90 -0
  53. package/src/core/recall-trial.ts +153 -0
  54. package/src/core/response-recovery.ts +46 -0
  55. package/src/core/scheduling.ts +8 -0
  56. package/src/core/settings.ts +174 -0
  57. package/src/core/skills.ts +121 -0
  58. package/src/core/stall.ts +48 -0
  59. package/src/core/text-normalize.ts +81 -0
  60. package/src/core/theme.ts +179 -0
  61. package/src/core/tokens.ts +149 -0
  62. package/src/core/tool-contract.ts +102 -0
  63. package/src/core/tool-input.ts +38 -0
  64. package/src/core/transcript.ts +60 -0
  65. package/src/core/window-trial.ts +65 -0
  66. package/src/core/work-state.ts +103 -0
  67. package/src/core/yaml.ts +282 -0
  68. package/src/index.ts +10 -0
  69. package/src/runtime/auth.ts +255 -0
  70. package/src/runtime/benchmark.ts +187 -0
  71. package/src/runtime/catalog.ts +365 -0
  72. package/src/runtime/evidence-notes.ts +33 -0
  73. package/src/runtime/exec-sessions.ts +214 -0
  74. package/src/runtime/execution-control.ts +39 -0
  75. package/src/runtime/harness-comparison.ts +356 -0
  76. package/src/runtime/history-tools.ts +108 -0
  77. package/src/runtime/loop.ts +1983 -0
  78. package/src/runtime/model.ts +614 -0
  79. package/src/runtime/models-fetch.ts +155 -0
  80. package/src/runtime/oauth-device.ts +86 -0
  81. package/src/runtime/oauth.ts +759 -0
  82. package/src/runtime/openai-responses.ts +286 -0
  83. package/src/runtime/openai.ts +675 -0
  84. package/src/runtime/provider.ts +498 -0
  85. package/src/runtime/recall-trial.ts +226 -0
  86. package/src/runtime/resume.ts +66 -0
  87. package/src/runtime/retry.ts +297 -0
  88. package/src/runtime/tools.ts +1193 -0
  89. package/src/runtime/usage.ts +225 -0
  90. package/src/runtime/window-trial.ts +140 -0
  91. package/src/runtime/working-notes.ts +30 -0
  92. package/src/store/atomic.ts +31 -0
  93. package/src/store/auth-store.ts +50 -0
  94. package/src/store/blobs.ts +142 -0
  95. package/src/store/command.ts +75 -0
  96. package/src/store/config.ts +69 -0
  97. package/src/store/context-files.ts +356 -0
  98. package/src/store/file-index.ts +81 -0
  99. package/src/store/git-branch.ts +35 -0
  100. package/src/store/history-search.ts +19 -0
  101. package/src/store/log.ts +642 -0
  102. package/src/store/models.ts +41 -0
  103. package/src/store/observations.ts +181 -0
  104. package/src/store/query-source.ts +44 -0
  105. package/src/store/recovery.ts +39 -0
  106. package/src/store/replay.ts +63 -0
  107. package/src/store/resources.ts +361 -0
  108. package/src/store/restore.ts +302 -0
  109. package/src/store/search.ts +644 -0
  110. package/src/store/serial.ts +13 -0
  111. package/src/store/sessions.ts +463 -0
  112. package/src/store/trust.ts +69 -0
  113. package/src/store/workspace.ts +256 -0
  114. package/src/store/worktree.ts +233 -0
  115. package/src/tui/ansi.ts +67 -0
  116. package/src/tui/autocomplete.ts +29 -0
  117. package/src/tui/complete.ts +87 -0
  118. package/src/tui/diff.ts +160 -0
  119. package/src/tui/editor.ts +893 -0
  120. package/src/tui/exhaustive.ts +14 -0
  121. package/src/tui/fuzzy.ts +75 -0
  122. package/src/tui/host.ts +223 -0
  123. package/src/tui/input.ts +69 -0
  124. package/src/tui/keys.ts +378 -0
  125. package/src/tui/markdown.ts +17 -0
  126. package/src/tui/render.ts +153 -0
  127. package/src/tui/select.ts +240 -0
  128. package/src/tui/settings.ts +40 -0
  129. package/src/tui/statusline.ts +162 -0
  130. package/src/tui/style.ts +205 -0
  131. package/src/tui/terminal.ts +30 -0
  132. package/src/tui/text.ts +52 -0
  133. package/src/tui/toolview.ts +386 -0
  134. package/src/tui/transcript.ts +71 -0
  135. package/src/tui/tree.ts +405 -0
  136. package/src/tui/width.ts +567 -0
  137. package/src/vendor/pi-tui/LICENSE.upstream +21 -0
  138. package/src/vendor/pi-tui/VENDOR.md +23 -0
  139. package/src/vendor/pi-tui/alt-screen-search.ts +327 -0
  140. package/src/vendor/pi-tui/autocomplete.ts +835 -0
  141. package/src/vendor/pi-tui/components/alt-screen-flash.ts +51 -0
  142. package/src/vendor/pi-tui/components/box.ts +167 -0
  143. package/src/vendor/pi-tui/components/cancellable-loader.ts +40 -0
  144. package/src/vendor/pi-tui/components/editor.ts +2470 -0
  145. package/src/vendor/pi-tui/components/h-stack.ts +44 -0
  146. package/src/vendor/pi-tui/components/image.ts +127 -0
  147. package/src/vendor/pi-tui/components/input.ts +494 -0
  148. package/src/vendor/pi-tui/components/loader.ts +101 -0
  149. package/src/vendor/pi-tui/components/markdown.ts +1015 -0
  150. package/src/vendor/pi-tui/components/mouse-region.ts +33 -0
  151. package/src/vendor/pi-tui/components/scroll-view.ts +224 -0
  152. package/src/vendor/pi-tui/components/select-list.ts +273 -0
  153. package/src/vendor/pi-tui/components/settings-list.ts +328 -0
  154. package/src/vendor/pi-tui/components/spacer.ts +28 -0
  155. package/src/vendor/pi-tui/components/stack.ts +154 -0
  156. package/src/vendor/pi-tui/components/text.ts +107 -0
  157. package/src/vendor/pi-tui/components/truncated-text.ts +65 -0
  158. package/src/vendor/pi-tui/components/v-stack.ts +33 -0
  159. package/src/vendor/pi-tui/editor-component.ts +74 -0
  160. package/src/vendor/pi-tui/fuzzy.ts +138 -0
  161. package/src/vendor/pi-tui/index.ts +156 -0
  162. package/src/vendor/pi-tui/keybindings.ts +320 -0
  163. package/src/vendor/pi-tui/keys.ts +1401 -0
  164. package/src/vendor/pi-tui/kill-ring.ts +46 -0
  165. package/src/vendor/pi-tui/latex.ts +1506 -0
  166. package/src/vendor/pi-tui/layout-node.ts +51 -0
  167. package/src/vendor/pi-tui/layout.ts +449 -0
  168. package/src/vendor/pi-tui/native-modifiers.ts +13 -0
  169. package/src/vendor/pi-tui/native-module-path.ts +31 -0
  170. package/src/vendor/pi-tui/native-platform.ts +63 -0
  171. package/src/vendor/pi-tui/stdin-buffer.ts +444 -0
  172. package/src/vendor/pi-tui/terminal-colors.ts +73 -0
  173. package/src/vendor/pi-tui/terminal-image.ts +696 -0
  174. package/src/vendor/pi-tui/terminal.ts +547 -0
  175. package/src/vendor/pi-tui/tui-alt-screen.ts +1742 -0
  176. package/src/vendor/pi-tui/tui-main-screen.ts +655 -0
  177. package/src/vendor/pi-tui/tui.ts +1456 -0
  178. package/src/vendor/pi-tui/undo-stack.ts +28 -0
  179. package/src/vendor/pi-tui/utils.ts +1345 -0
  180. package/src/vendor/pi-tui/word-navigation.ts +117 -0
@@ -0,0 +1,1193 @@
1
+ /**
2
+ * Built-in tools: read, write, edit, bash, plus grep / find / ls.
3
+ *
4
+ * Search is its own trio because bash defaults to ask: without a confined
5
+ * read-only search the model reaches for the shell for every "where is this
6
+ * symbol", and each of those costs a keypress. The schemas stay small; the
7
+ * walk lives in store/search.ts so ignore rules and the no-symlink cap stay
8
+ * one implementation.
9
+ *
10
+ * The load-bearing property here is that a tool's EFFECT is derived by this
11
+ * module from what actually happened, never from what the model said it was
12
+ * doing. A read records the digest of the bytes it returned; an edit records
13
+ * the digests either side of the write; a command records its exit code. That
14
+ * is what lets deriveHandoff verify a completion claim against evidence rather
15
+ * than believing it.
16
+ *
17
+ * `bash` can reach outside any path the model declared, which the tree design
18
+ * has to account for -- see appendix I. This module does not try to prevent
19
+ * that by making the shell less capable; it records what it can and leaves the
20
+ * workspace-snapshot cross-check to the layer above.
21
+ */
22
+
23
+ import { readFile, writeFile, mkdir, stat } from "node:fs/promises";
24
+ import { createHash } from "node:crypto";
25
+ import { dirname, resolve, relative, isAbsolute, join, sep } from "node:path";
26
+ import type { Effect, LineRange, ArtifactRef } from "../core/events.ts";
27
+ import { archiveObservation, type ArchivedObservation } from "../store/observations.ts";
28
+ import { commandHeader, headTailView } from "../core/observation-view.ts";
29
+ import {
30
+ DEFAULT_YIELD_MS, MAX_YIELD_MS, MIN_YIELD_MS, findSession, runningSessions, startSession, takeOutput, waitSession,
31
+ } from "./exec-sessions.ts";
32
+ import { observeCommand } from "../store/command.ts";
33
+ import { executionOutcome, type ExecutionOutcome } from "../core/outcome.ts";
34
+ import { waitForAdmission } from "./execution-control.ts";
35
+ import { assertNever } from "../core/exhaustive.ts";
36
+ import { classifyGate } from "../core/evidence.ts";
37
+ import { readBounds } from "../core/read-bounds.ts";
38
+ import { closestName, inputError, isJsonValue, type JsonValue, type ToolDiagnostic } from "../core/tool-contract.ts";
39
+ import { planEdits, type EditSpec } from "../core/edit-plan.ts";
40
+ import { toView } from "../core/text-normalize.ts";
41
+ import { repairToolInput } from "../core/tool-input.ts";
42
+ import { HISTORY_SECTIONS } from "../core/history.ts";
43
+ import { parseDataQuery, queryData } from "../core/data-query.ts";
44
+ import { readQuerySources } from "../store/query-source.ts";
45
+ import {
46
+ askWithoutTtyMessage,
47
+ decide,
48
+ deniedMessage,
49
+ permissionRequest,
50
+ type PermissionRequest,
51
+ type PermissionRule,
52
+ } from "../core/permissions.ts";
53
+ import type { ToolSchema } from "./provider.ts";
54
+ import { find as findFiles, grep, ls } from "../store/search.ts";
55
+ import { scanWorkspace, workspaceVersion, DEFAULT_IGNORE } from "../store/workspace.ts";
56
+
57
+ export interface ToolResult {
58
+ /** What the model sees. */
59
+ content: string;
60
+ ok: boolean;
61
+ /** Derived by the harness from the actual outcome. */
62
+ effect?: Effect;
63
+ content_ref?: ArtifactRef;
64
+ content_sha256?: string;
65
+ /** Observed bytes without presentation/version metadata, for D9 novelty. */
66
+ observation_sha256?: string;
67
+ outcome?: ExecutionOutcome;
68
+ data?: JsonValue;
69
+ diagnostic?: ToolDiagnostic;
70
+ }
71
+
72
+ export interface ToolContext {
73
+ /** O.1: per-execution copy; never mutate the session's shared tool context with a live signal. */
74
+ signal?: AbortSignal;
75
+ /** Absolute path. Every tool path is confined to this subtree. */
76
+ root: string;
77
+ /**
78
+ * Absolute path containing `root`: the boundary for model-supplied paths.
79
+ * Absent means `root`. Setting it wider lets file tools reach, say, /etc
80
+ * during an evaluation without making `root` -- the base for relative
81
+ * paths and the scope of every fingerprint and snapshot -- the whole disk.
82
+ */
83
+ pathRoot?: string;
84
+ /**
85
+ * Which tools this caller may run at all.
86
+ *
87
+ * Narrowing the schema tells a cooperative model what not to call; it is
88
+ * not a boundary, because a call that arrives anyway still executes. A
89
+ * read-only branch has to be unable to write even if it asks, so the
90
+ * allow-list lives on the context that runTool actually consults.
91
+ */
92
+ allow?: readonly string[];
93
+ /** Command names that count as verification gates, by leading token. */
94
+ gates: Record<string, string>;
95
+ /** Per-command wall-clock default. An unbounded wait is not allowed. */
96
+ timeoutMs: number;
97
+ /** Ceiling on a model-requested `timeout`; the schema maximum is advice, this is the bound. */
98
+ maxTimeoutMs?: number;
99
+ /** Tool output above this is truncated; the marker says how much was cut. */
100
+ maxOutputBytes: number;
101
+ outputStore?: string;
102
+ /**
103
+ * First-match permission rules. Absent means every call is allowed, which
104
+ * is what the existing tests rely on: they exercise the tool, not the gate.
105
+ * The CLI always supplies a list whose default is bash=ask.
106
+ */
107
+ rules?: readonly PermissionRule[];
108
+ /**
109
+ * Session-scoped remembers. Mutated in place, never written to disk: a
110
+ * persisted "always allow" would fire months later when nobody is watching.
111
+ */
112
+ sessionRules?: PermissionRule[];
113
+ /**
114
+ * Called when decide() returns ask. Tests set this directly. The CLI
115
+ * uses `askSlot` so the TUI can attach and detach without replacing
116
+ * the ToolContext object (sessionRules live on that object).
117
+ */
118
+ ask?: (request: PermissionRequest) => Promise<ApprovalChoice>;
119
+ /**
120
+ * Stable slot the TUI writes. A missing `current` is "no tty": the
121
+ * refusal then names `--allow-tools` rather than pretending someone
122
+ * said no.
123
+ */
124
+ askSlot?: { current?: (request: PermissionRequest) => Promise<ApprovalChoice> };
125
+ }
126
+
127
+ /**
128
+ * What the person picked at the approval prompt.
129
+ *
130
+ * `command` is only meaningful for bash, where the prompt shows the exact
131
+ * string. Other tools have no command to remember.
132
+ */
133
+ export type ApprovalChoice = "once" | "command" | "tool" | "deny";
134
+
135
+ export const DEFAULT_TOOL_CONTEXT: Omit<ToolContext, "root"> = {
136
+ // Runner prefixes are resolved in command position so `python3 -m pytest`
137
+ // works without letting `echo pytest` forge a gate (D11 / M.2).
138
+ gates: { pytest: "pytest", tsc: "typecheck", cargo: "cargo", jest: "test", vitest: "test" },
139
+ // Five minutes covers typical compiles and installs; longer work asks for
140
+ // its own `timeout` up to the hour ceiling (omp uses the same 300 / 3600).
141
+ // Approved change from 120s (2026-09-28): 120s hard-killed real builds.
142
+ timeoutMs: 300_000,
143
+ maxTimeoutMs: 3_600_000,
144
+ maxOutputBytes: 30_000,
145
+ };
146
+
147
+ const sha256 = (s: string | Uint8Array) => createHash("sha256").update(s).digest("hex");
148
+
149
+ /**
150
+ * The configured gate a command counts as, if any.
151
+ *
152
+ * Matched in COMMAND POSITION only: the first token, or a token immediately
153
+ * after a runner prefix. The suite that proves something is frequently not
154
+ * the first word -- `python3 -m pytest`, `npx vitest run`, `uv run pytest` --
155
+ * so first-token matching misses real gates. But matching any token anywhere
156
+ * would let `echo pytest` count as a passing test suite, which is precisely
157
+ * the fake evidence the rest of this design exists to reject.
158
+ */
159
+ export function matchGate(ctx: ToolContext, command: string): string | undefined {
160
+ return classifyGate(ctx.gates, command);
161
+ }
162
+
163
+ /**
164
+ * Confines a model-supplied path to the workspace root.
165
+ *
166
+ * Schema validation is not path validation: the model's `path` is untrusted
167
+ * output, and `../` in it would otherwise reach anywhere on the filesystem.
168
+ */
169
+ function confine(ctx: ToolContext, path: string): string | { error: string } {
170
+ const abs = isAbsolute(path) ? resolve(path) : resolve(ctx.root, path);
171
+ const boundary = ctx.pathRoot ?? ctx.root;
172
+ if (!within(boundary, abs)) {
173
+ return { error: boundary === ctx.root ? `path escapes the workspace root: ${path}` : `path escapes the path root ${boundary}: ${path}` };
174
+ }
175
+ return abs;
176
+ }
177
+
178
+ function within(base: string, abs: string): boolean {
179
+ const rel = relative(base, abs);
180
+ return !(rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel));
181
+ }
182
+
183
+ /** Search reports paths relative to, and applies ignores from, the root that holds the target. */
184
+ const searchRoot = (ctx: ToolContext, abs: string) => within(ctx.root, abs) ? ctx.root : ctx.pathRoot ?? ctx.root;
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Schemas
188
+ // ---------------------------------------------------------------------------
189
+
190
+ export interface ToolDefinition extends ToolSchema {
191
+ access: "read" | "write" | "shell" | "state";
192
+ execution: "parallel" | "exclusive";
193
+ branch: "read" | "write" | "none";
194
+ /** Accepted by admission but not advertised to the model: harness and test switches. */
195
+ hiddenInputs?: readonly string[];
196
+ run?: (ctx: ToolContext, input: Record<string, unknown>) => Promise<ToolResult>;
197
+ }
198
+
199
+ export const TOOL_DEFINITIONS: ToolDefinition[] = [
200
+ {
201
+ name: "query",
202
+ access: "read", execution: "parallel", branch: "read", run: toolQuery,
203
+ description: "Compute over complete confined files (up to 32 MiB / 250000 records). Concatenates paths in order. CSV supports quoted records; JSON pointer selects nested data; JSONL parses each record; lines exposes line/text columns. Filter, group, aggregate and sort without shell or code execution. Returns full result with source hashes; large output has retrievable artifact pages. Numeric aggregates reject non-numeric values; empty/null values are excluded.",
204
+ input_schema: {
205
+ type: "object", required: ["paths", "format"], properties: {
206
+ paths: { type: "array", items: { type: "string" } },
207
+ format: { type: "string", enum: ["csv", "json", "jsonl", "lines"] },
208
+ pointer: { type: "string", description: "JSON pointer, e.g. /records" },
209
+ where: { type: "array", items: { type: "object", required: ["field", "op", "value"], properties: {
210
+ field: { type: "string" }, op: { type: "string", enum: ["eq", "ne", "contains", "gt", "gte", "lt", "lte"] }, value: {},
211
+ } } },
212
+ select: { type: "array", items: { type: "string" } },
213
+ group_by: { type: "array", items: { type: "string" } },
214
+ aggregate: { type: "array", items: { type: "object", required: ["op", "as"], properties: {
215
+ op: { type: "string", enum: ["count", "sum", "avg", "min", "max"] }, field: { type: "string" }, as: { type: "string" },
216
+ } } },
217
+ order_by: { type: "array", items: { type: "object", required: ["field"], properties: {
218
+ field: { type: "string" }, descending: { type: "boolean" },
219
+ } } },
220
+ },
221
+ },
222
+ },
223
+ {
224
+ name: "history",
225
+ access: "read", execution: "exclusive", branch: "read",
226
+ description: "Recover prior work: work_id and resource locate linked work and exact normalized paths; query adds literal AND keywords and shows up to 5 matching lines (hits) per result; recent lists activity. event reads original evidence; events reads 1..100 originals in request order and reports missing entries. Results keep version/replacement links and artifact pages. Also supports node/section handoff pages and artifact/offset. Active ancestry only.",
227
+ input_schema: {
228
+ type: "object",
229
+ properties: {
230
+ query: { type: "string" },
231
+ recent: { type: "boolean" },
232
+ event: { type: "string", description: "Exact source event id from query results" },
233
+ events: { type: "array", items: { type: "string" }, description: "1..100 exact event ids; preserves request order and identifies missing sources" },
234
+ resource: { type: "string", description: "Exact resource path, with ./ and repeated slashes normalized" },
235
+ work_id: { type: "string", description: "Canonical work ID returned by note" },
236
+ tool: { type: "string", description: "Optional tool name filter for query/recent" },
237
+ node: { type: "integer" },
238
+ artifact: { type: "string", description: "sha256 from a truncated tool observation; omit node and section" },
239
+ section: { type: "string", enum: [...HISTORY_SECTIONS] },
240
+ offset: { type: "integer", minimum: 0 },
241
+ limit: { type: "integer", minimum: 1, maximum: 100 },
242
+ },
243
+ required: [],
244
+ },
245
+ },
246
+ {
247
+ name: "read",
248
+ access: "read", execution: "parallel", branch: "read", run: toolRead,
249
+ description:
250
+ "Read a file. Returns the contents with 1-indexed line numbers. " +
251
+ "Optionally limit to an inclusive line range. Reports total lines and the next range; end beyond EOF is clamped. " +
252
+ "Reports sha256 for optional edit version checks. If the observation is truncated, recover its full artifact or smaller ranges before skipping ahead.",
253
+ input_schema: {
254
+ type: "object",
255
+ properties: {
256
+ path: { type: "string", description: "Path relative to the workspace root" },
257
+ start: { type: "integer", minimum: 1, description: "First existing line, 1-indexed" },
258
+ end: { type: "integer", minimum: 1, description: "Last line, inclusive; capped at EOF" },
259
+ },
260
+ required: ["path"],
261
+ },
262
+ },
263
+ {
264
+ name: "write",
265
+ access: "write", execution: "exclusive", branch: "write", run: toolWrite,
266
+ description: "Write a file, creating parent directories. Replaces existing content.",
267
+ input_schema: {
268
+ type: "object",
269
+ properties: {
270
+ path: { type: "string" },
271
+ content: { type: "string" },
272
+ },
273
+ required: ["path", "content"],
274
+ },
275
+ },
276
+ {
277
+ name: "edit",
278
+ access: "write", execution: "exclusive", branch: "write", run: toolEdit,
279
+ description:
280
+ "Replace exact text in a file. old_string must match once unless replace_all is set; copy it from read output, " +
281
+ "including indentation (line numbers are not part of the file). For several changes to one file pass edits[], " +
282
+ "each matched against the original; all apply or none do. If there is no exact match, a unique match that differs only in " +
283
+ "trailing whitespace, quote or dash style is applied and flagged fuzzy. A failure shows the closest text in the file. " +
284
+ "Pass expected_sha256 from read to reject stale edits.",
285
+ input_schema: {
286
+ type: "object",
287
+ properties: {
288
+ path: { type: "string" },
289
+ old_string: { type: "string", minLength: 1, description: "Exact text to replace, including indentation" },
290
+ new_string: { type: "string" },
291
+ replace_all: { type: "boolean" },
292
+ edits: { type: "array", minItems: 1, description: "Several replacements instead of old_string/new_string", items: {
293
+ type: "object", required: ["old_string", "new_string"], properties: {
294
+ old_string: { type: "string", minLength: 1 }, new_string: { type: "string" }, replace_all: { type: "boolean" },
295
+ } } },
296
+ expected_sha256: { type: "string", description: "Whole-file version returned by read; mismatch leaves the file unchanged" },
297
+ },
298
+ required: ["path"],
299
+ },
300
+ },
301
+ {
302
+ // The channel the handoff is built from. Without it every model-annotated
303
+ // field stays empty: no completion is ever recorded, so progress reads as
304
+ // zero and the stop-and-ask rule fires on a working session; and no next
305
+ // action survives a boundary, so the next node starts disoriented and
306
+ // spends a turn re-reading the workspace to find out where it was.
307
+ //
308
+ // One tool with a kind rather than six tools, because every tool schema is
309
+ // paid for in the prompt on every request.
310
+ name: "note",
311
+ access: "state", execution: "exclusive", branch: "read",
312
+ // Older spellings of proof that parseNote still reads; logs carry them.
313
+ hiddenInputs: ["gate", "command"],
314
+ description:
315
+ "Record something for the durable record that survives a context " +
316
+ "boundary. Use `done` the moment a command proves something works, and " +
317
+ "`next` before a long stretch of work so the next context knows where " +
318
+ "to resume. Also record decisions worth not relitigating and dead ends " +
319
+ "worth not retrying.",
320
+ input_schema: {
321
+ type: "object",
322
+ properties: {
323
+ kind: {
324
+ type: "string",
325
+ enum: ["done", "next", "decision", "pitfall", "blocked", "question", "unblock", "reopen", "cancel", "pin"],
326
+ description:
327
+ "done: something is finished and a command proves it. " +
328
+ "next: work in progress plus its concrete next step. " +
329
+ "decision: a choice made, so it is not reargued. " +
330
+ "pitfall: something tried that failed, so it is not retried. " +
331
+ "blocked: work that cannot proceed. question: needs a human. " +
332
+ "unblock/reopen: reactivate identified work with a current next_action. cancel: abandon identified work. pin: associate an original tool event with existing work; this does not verify completion.",
333
+ },
334
+ what: { type: "string", description: "The thing, in one line" },
335
+ work_id: { type: "string", description: "Stable ID for next/blocked/done. Required for unblock/reopen/cancel; keep it when the title changes." },
336
+ event: { type: "string", description: "pin: original observation event ID returned by history" },
337
+ resource: { type: "string", description: "pin: optional source resource path" },
338
+ supersedes: { type: "string", description: "pin: explicitly replace an earlier pinned source event for the same work item" },
339
+ proof: {
340
+ type: "string",
341
+ description:
342
+ "done: the command you ran that proves it, exactly as you ran it. " +
343
+ "It is matched against the log, so it must have already run and " +
344
+ "exited zero in this session -- otherwise the claim is recorded " +
345
+ "as unverified.",
346
+ },
347
+ next_action: { type: "string", description: "next/unblock/reopen: the current concrete next step" },
348
+ why: { type: "string", description: "decision: why. pitfall: why it failed. blocked: why." },
349
+ rejected: {
350
+ type: "array", items: { type: "string" },
351
+ description: "decision: alternatives considered and not taken",
352
+ },
353
+ },
354
+ required: ["kind", "what"],
355
+ },
356
+ },
357
+ {
358
+ /**
359
+ * Read-only exploration in a separate node.
360
+ *
361
+ * The value is isolation: the branch's own tool calls stay in its node
362
+ * and only a summary comes back, so a search that reads twenty files
363
+ * costs the parent one paragraph. That is also the limit of when it pays
364
+ * -- each branch carries about 20k tokens of fixed overhead, and a
365
+ * measured fan-out ran 2.6x the tokens of the sequential path at roughly
366
+ * five times the cost, while being slower. Sequential is usually right.
367
+ */
368
+ name: "branch",
369
+ access: "state", execution: "exclusive", branch: "none",
370
+ description:
371
+ "Run a bounded subtask in a separate context and return its outcome and handoff reference. " +
372
+ "Read-only by default; writes=true uses an isolated git worktree. Include all required context in the prompt. " +
373
+ "Useful for independent exploration with much larger inputs than results; branch overhead can exceed doing small tasks directly. " +
374
+ "Adopt continues the selected branch and switches to its workspace; it does not merge edits into the original directory.",
375
+ input_schema: {
376
+ type: "object",
377
+ properties: {
378
+ purpose: { type: "string", description: "What this branch is for, in one line" },
379
+ prompt: {
380
+ type: "string",
381
+ description:
382
+ "The branch's entire instruction. It starts with no knowledge of " +
383
+ "this conversation, so include the file paths, error text and " +
384
+ "decisions it needs -- this string is the only channel.",
385
+ },
386
+ writes: {
387
+ type: "boolean",
388
+ description:
389
+ "Set only if the branch must change files. It then works in an " +
390
+ "isolated checkout; adopt switches to that checkout without merging into the parent. " +
391
+ "It requires a git repository. Leave it off " +
392
+ "for exploration, which is both cheaper and the common case.",
393
+ },
394
+ },
395
+ required: ["purpose", "prompt"],
396
+ },
397
+ },
398
+ {
399
+ name: "resolve",
400
+ access: "state", execution: "exclusive", branch: "none",
401
+ description:
402
+ "Adopt or discard a branch that has come back. Every branch must be " +
403
+ "resolved; an unresolved one stays an open question.",
404
+ input_schema: {
405
+ type: "object",
406
+ properties: {
407
+ node: { type: "number", description: "The branch's node number" },
408
+ resolution: { type: "string", enum: ["adopt", "discard"] },
409
+ why: { type: "string", description: "discard: why it was rejected" },
410
+ },
411
+ required: ["node", "resolution"],
412
+ },
413
+ },
414
+ {
415
+ name: "bash",
416
+ access: "shell", execution: "exclusive", branch: "write", run: toolBash,
417
+ description:
418
+ "Run a command with bash in a fresh non-interactive shell (no TTY; cwd, variables and `cd` do not persist between calls). " +
419
+ "Returns a header `exit N · wall time` with any timeout, then stdout and stderr interleaved; long output keeps its head and tail. " +
420
+ "Default timeout 300s; pass timeout for longer work. Start servers or watchers in the background with output redirected, " +
421
+ "e.g. `nohup cmd > server.log 2>&1 &`, then poll the log or port.",
422
+ input_schema: {
423
+ type: "object",
424
+ properties: {
425
+ command: { type: "string" },
426
+ timeout: { type: "integer", minimum: 1, maximum: 3600, description: "Seconds before the process group is killed; default 300" },
427
+ workdir: { type: "string", description: "Directory to run in, relative to the workspace root; default the root" },
428
+ },
429
+ required: ["command"],
430
+ },
431
+ },
432
+ {
433
+ name: "exec_command",
434
+ access: "shell", execution: "exclusive", branch: "write", run: toolExec,
435
+ description:
436
+ "Start a command that may keep running: a server, a REPL, a long build, or a program that asks for input. " +
437
+ "Waits up to yield_time_ms; if it has exited, returns `exit N · wall time` and its output like bash, otherwise " +
438
+ "`running · session <id>` with the output so far while the process keeps running. Continue with write_stdin. " +
439
+ "tty=true runs it in a terminal, for programs that need one (REPLs, prompts, curses). Prefer bash for commands that simply finish.",
440
+ input_schema: {
441
+ type: "object",
442
+ properties: {
443
+ cmd: { type: "string", description: "Shell command, run with bash -lc" },
444
+ workdir: { type: "string", description: "Directory to run in; default the workspace root" },
445
+ tty: { type: "boolean", description: "Run in a pseudo-terminal; default false" },
446
+ yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "How long to wait before returning; default 10000" },
447
+ timeout: { type: "integer", minimum: 1, maximum: 3600, description: "Seconds before the session is killed; default 300" },
448
+ },
449
+ required: ["cmd"],
450
+ },
451
+ },
452
+ {
453
+ name: "write_stdin",
454
+ access: "shell", execution: "exclusive", branch: "write", run: toolWriteStdin,
455
+ description:
456
+ "Send characters to a running exec_command session and return the output since the last call. " +
457
+ "Empty chars polls. Include \\n to submit a line; \\u0003 is Ctrl-C, \\u0004 is Ctrl-D (end of input). " +
458
+ "When the process has exited, returns its exit code and the session closes.",
459
+ input_schema: {
460
+ type: "object",
461
+ properties: {
462
+ session_id: { type: "integer", minimum: 1 },
463
+ chars: { type: "string", description: "Characters to write; empty to poll" },
464
+ yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "How long to wait for output; default 10000" },
465
+ },
466
+ required: ["session_id"],
467
+ },
468
+ },
469
+ {
470
+ name: "grep",
471
+ access: "read", execution: "parallel", branch: "read", run: toolGrep,
472
+ // Forces the TypeScript walk or ripgrep so both engines stay testable.
473
+ hiddenInputs: ["engine"],
474
+ description:
475
+ "Search file contents for a regex. Stays inside the workspace, does " +
476
+ "not follow symlinks, and skips .git, node_modules and .xeno. Uses " +
477
+ "ripgrep when it is already on PATH, otherwise a TypeScript walk. " +
478
+ "Truncates with a count of what was left out.",
479
+ input_schema: {
480
+ type: "object",
481
+ properties: {
482
+ pattern: { type: "string", description: "Regular expression to search for" },
483
+ path: { type: "string", description: "File or directory, relative to the workspace root" },
484
+ glob: { type: "string", description: "Only search files matching this glob" },
485
+ limit: { type: "number", description: "Maximum matches to return" },
486
+ },
487
+ required: ["pattern"],
488
+ },
489
+ },
490
+ {
491
+ name: "find",
492
+ access: "read", execution: "parallel", branch: "read", run: toolFind,
493
+ description:
494
+ "Find files by glob pattern. Stays inside the workspace, does not " +
495
+ "follow symlinks, and skips .git, node_modules and .xeno. Uses fd " +
496
+ "when it is already on PATH, otherwise a TypeScript walk.",
497
+ input_schema: {
498
+ type: "object",
499
+ properties: {
500
+ pattern: { type: "string", description: "Glob, e.g. '*.ts' or 'src/**/*.md'" },
501
+ path: { type: "string", description: "Directory, relative to the workspace root" },
502
+ limit: { type: "number", description: "Maximum paths to return" },
503
+ },
504
+ required: ["pattern"],
505
+ },
506
+ },
507
+ {
508
+ name: "ls",
509
+ access: "read", execution: "parallel", branch: "read", run: toolLs,
510
+ description:
511
+ "List a directory. Directories are marked with a trailing '/'. Stays " +
512
+ "inside the workspace, does not follow symlinks, and skips .git, " +
513
+ "node_modules and .xeno.",
514
+ input_schema: {
515
+ type: "object",
516
+ properties: {
517
+ path: { type: "string", description: "Directory, relative to the workspace root" },
518
+ limit: { type: "number", description: "Maximum entries to return" },
519
+ },
520
+ required: [],
521
+ },
522
+ },
523
+ ];
524
+
525
+ /** Runtime policy metadata never leaks into provider-specific wire schemas. */
526
+ export const TOOL_SCHEMAS: ToolSchema[] = TOOL_DEFINITIONS.map(({ name, description, input_schema }) => ({ name, description, input_schema }));
527
+
528
+ export function toolDefinition(name: string): ToolDefinition | undefined {
529
+ return TOOL_DEFINITIONS.find(tool => tool.name === name);
530
+ }
531
+
532
+ // ---------------------------------------------------------------------------
533
+ // Implementations
534
+ // ---------------------------------------------------------------------------
535
+
536
+ /**
537
+ * Read limits. pi uses the same 2000 lines / 2000 characters: a page large
538
+ * enough that most source files arrive whole, small enough that a log file
539
+ * or a minified bundle cannot spend the observation budget on its own.
540
+ */
541
+ const READ_DEFAULT_LINES = 2_000;
542
+ const READ_MAX_LINE_CHARS = 2_000;
543
+
544
+ function clipLine(line: string, number: number): string {
545
+ if (line.length <= READ_MAX_LINE_CHARS) return line;
546
+ return `${line.slice(0, READ_MAX_LINE_CHARS)} [line truncated: ${line.length} chars; see the rest with bash, e.g. \`sed -n '${number}p' FILE | cut -c${READ_MAX_LINE_CHARS + 1}-\`]`;
547
+ }
548
+
549
+ async function toolRead(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
550
+ const path = String(input["path"] ?? "");
551
+ const abs = confine(ctx, path);
552
+ if (typeof abs !== "string") return { content: abs.error, ok: false };
553
+
554
+ let text: string;
555
+ let bytes: Uint8Array;
556
+ try {
557
+ bytes = await readFile(abs);
558
+ text = Buffer.from(bytes).toString("utf8");
559
+ } catch (err) {
560
+ return { content: `cannot read ${path}: ${(err as Error).message}`, ok: false };
561
+ }
562
+
563
+ if (bytes.subarray(0, 8_192).includes(0)) {
564
+ return { ok: false, content: `${path} looks binary (${bytes.length} bytes, contains NUL); inspect it with bash, e.g. \`file ${path}\` or \`xxd ${path} | head\`` };
565
+ }
566
+ const lines = toView(text).text.split("\n");
567
+ // Without an end, a read stops after READ_DEFAULT_LINES so one large file
568
+ // cannot take the whole observation budget; the notice names the next start.
569
+ const first = input["start"] ?? 1;
570
+ const end = input["end"] === undefined && Number.isSafeInteger(first) ? (first as number) + READ_DEFAULT_LINES - 1 : input["end"];
571
+ const selected = readBounds(lines.length, input["start"], end);
572
+ if (!selected.ok) return { ok: false, content: selected.message };
573
+ const { start } = selected;
574
+ const slice = lines.slice(start - 1, selected.end);
575
+ const numbered = slice.map((l, i) => `${start + i}\t${clipLine(l, start + i)}`).join("\n");
576
+
577
+ const ranges: LineRange[] = [[start, selected.end]];
578
+ return {
579
+ ...await presentObservation(ctx, `${selected.notice}\nsha256: ${sha256(bytes)}\n${numbered}`),
580
+ ok: true,
581
+ data: { path, sha256: sha256(bytes), start, end: selected.end, total_lines: lines.length, next_start: selected.end < lines.length ? selected.end + 1 : null },
582
+ observation_sha256: sha256(numbered),
583
+ // The digest is of the whole file, not the slice: it answers "has this
584
+ // file changed since the agent looked", which a partial read still bears on.
585
+ effect: { kind: "read", path, sha256: sha256(bytes), ranges },
586
+ };
587
+ }
588
+
589
+ async function toolWrite(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
590
+ const path = String(input["path"] ?? "");
591
+ const content = String(input["content"] ?? "");
592
+ const abs = confine(ctx, path);
593
+ if (typeof abs !== "string") return { content: abs.error, ok: false };
594
+
595
+ let before: Uint8Array | null = null;
596
+ try {
597
+ before = await readFile(abs);
598
+ } catch (error) {
599
+ if ((error as { code?: string }).code !== "ENOENT") return { ok: false, content: `cannot fingerprint ${path}: ${String(error)}` };
600
+ }
601
+
602
+ try {
603
+ await mkdir(dirname(abs), { recursive: true });
604
+ await writeFile(abs, content, "utf8");
605
+ } catch (err) {
606
+ return { content: `cannot write ${path}: ${(err as Error).message}`, ok: false };
607
+ }
608
+
609
+ return {
610
+ content: `wrote ${new TextEncoder().encode(content).length} bytes to ${path}`,
611
+ ok: true,
612
+ data: { path, bytes: new TextEncoder().encode(content).length, sha256_before: before === null ? null : sha256(before), sha256_after: sha256(content) },
613
+ effect: {
614
+ kind: "edit",
615
+ path,
616
+ sha256_before: before === null ? null : sha256(before),
617
+ sha256_after: sha256(content),
618
+ },
619
+ };
620
+ }
621
+
622
+ async function toolEdit(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
623
+ const path = String(input["path"] ?? "");
624
+ const edits: EditSpec[] = Array.isArray(input["edits"])
625
+ ? (input["edits"] as Record<string, unknown>[]).map((edit) => ({ old_string: String(edit["old_string"] ?? ""),
626
+ new_string: String(edit["new_string"] ?? ""), replace_all: edit["replace_all"] === true }))
627
+ : typeof input["old_string"] === "string"
628
+ ? [{ old_string: input["old_string"], new_string: String(input["new_string"] ?? ""), replace_all: input["replace_all"] === true }]
629
+ : [];
630
+ if (edits.length === 0) {
631
+ const message = "edit needs old_string and new_string, or edits[]";
632
+ return { ok: false, content: message, diagnostic: { code: "invalid_input", message } };
633
+ }
634
+
635
+ const abs = confine(ctx, path);
636
+ if (typeof abs !== "string") return { content: abs.error, ok: false };
637
+
638
+ let before: string;
639
+ try {
640
+ before = await readFile(abs, "utf8");
641
+ } catch (err) {
642
+ return { content: `cannot read ${path}: ${(err as Error).message}`, ok: false };
643
+ }
644
+
645
+ const version = sha256(before);
646
+ const recover = { tool: "read", input: { path } };
647
+ if (input["expected_sha256"] !== undefined && input["expected_sha256"] !== version) {
648
+ const message = `file version changed for ${path}; current sha256: ${version}. Read the current file before retrying.`;
649
+ return { ok: false, content: message, data: { path, sha256: version },
650
+ diagnostic: { code: "version_mismatch", message, recover } };
651
+ }
652
+ const plan = planEdits(before, edits);
653
+ if (!plan.ok) {
654
+ const closest = plan.closest === undefined ? "" :
655
+ `\nclosest match at lines ${plan.closest.startLine}-${plan.closest.endLine} (similarity ${plan.closest.score.toFixed(2)}); first difference:\n` +
656
+ `-${plan.closest.expected}\n+${plan.closest.actual}\n` +
657
+ `(- is your old_string, + is the file; retry with the file's text, or read lines ${plan.closest.startLine}-${plan.closest.endLine})`;
658
+ const message = `${plan.message} in ${path}`;
659
+ return { ok: false, content: `${message}${closest}\ncurrent sha256: ${version}`,
660
+ data: { path, sha256: version, occurrences: plan.occurrences, edit: plan.edit,
661
+ ...(plan.closest === undefined ? {} : { closest: { start_line: plan.closest.startLine, end_line: plan.closest.endLine, score: plan.closest.score } }) },
662
+ diagnostic: { code: plan.code, message, recover: plan.closest === undefined ? recover
663
+ : { tool: "read", input: { path, start: plan.closest.startLine, end: plan.closest.endLine } } } };
664
+ }
665
+ const { after } = plan;
666
+ try {
667
+ await writeFile(abs, after, "utf8");
668
+ } catch (err) {
669
+ return { content: `cannot write ${path}: ${(err as Error).message}`, ok: false };
670
+ }
671
+
672
+ const fuzzy = plan.fuzzy ? " (fuzzy: matched ignoring trailing whitespace, quote and dash style; check the diff)" : "";
673
+ return {
674
+ ...await presentObservation(ctx, `replaced ${plan.replacements} occurrence(s) in ${path}${fuzzy}\nsha256: ${sha256(after)}\n${plan.diff}`),
675
+ ok: true,
676
+ data: { path, replacements: plan.replacements, fuzzy: plan.fuzzy, first_changed_line: plan.firstLine, sha256_before: version, sha256_after: sha256(after) },
677
+ effect: {
678
+ kind: "edit",
679
+ path,
680
+ sha256_before: sha256(before),
681
+ sha256_after: sha256(after),
682
+ },
683
+ };
684
+ }
685
+
686
+ async function toolBash(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
687
+ const command = String(input["command"] ?? "");
688
+ if (command.trim().length === 0) return { content: "empty command", ok: false };
689
+ const cwd = typeof input["workdir"] === "string" && input["workdir"].length > 0 ? confine(ctx, input["workdir"]) : ctx.root;
690
+ if (typeof cwd !== "string") return { content: cwd.error, ok: false };
691
+ const requestedMs = typeof input["timeout"] === "number" ? input["timeout"] * 1000 : undefined;
692
+ const timeoutMs = Math.min(requestedMs ?? ctx.timeoutMs, ctx.maxTimeoutMs ?? ctx.timeoutMs);
693
+
694
+ const captured = await observeCommand({ command, cwd,
695
+ outputStore: ctx.outputStore ?? join(ctx.root, ".xeno", "observations"),
696
+ maxOutputBytes: ctx.maxOutputBytes, timeoutMs,
697
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
698
+ });
699
+
700
+ const gate = matchGate(ctx, command);
701
+ const exit = captured.exit;
702
+ const observed = await scanWorkspace(ctx.root, FINGERPRINT_IGNORE);
703
+ const header = commandHeader({ exit, wallMs: captured.wallMs, timeoutMs,
704
+ ...(requestedMs !== undefined ? { requestedMs } : {}),
705
+ ...(captured.stopped ? { stopped: captured.stopped } : {}) });
706
+
707
+ return {
708
+ // The artifact starts with `exit N\n`; the view replaces that line with
709
+ // the full header and pages the rest, so offsets stay artifact offsets.
710
+ content: header + "\n" + observationView(captured, ctx.maxOutputBytes - utf8Length(header) - 1, captured.prefixBytes),
711
+ content_ref: captured.ref,
712
+ ok: captured.stopped === undefined && exit === 0,
713
+ outcome: executionOutcome(captured.stopped ?? (exit === 0 ? "tool_completed" : "tool_failed")),
714
+ data: { exit, output: captured.ref },
715
+ effect: {
716
+ kind: "command",
717
+ cmd: command,
718
+ exit,
719
+ // Attached from configured patterns, never guessed from the text.
720
+ ...(gate !== undefined ? { gate } : {}),
721
+ ...(observed.ok ? { workspace_sha256: workspaceVersion(observed.files) } : {}),
722
+ },
723
+ };
724
+ }
725
+
726
+ /**
727
+ * Derived output a command's fingerprint leaves out.
728
+ *
729
+ * The fingerprint keys verification evidence to the source state it ran
730
+ * against; build products and caches change on every build without the
731
+ * sources changing, and walking them (a Rust `target/` or a virtualenv) costs
732
+ * seconds after every command. Snapshots and rewind keep DEFAULT_IGNORE --
733
+ * whether to restore derived files is their decision, not this one.
734
+ */
735
+ const FINGERPRINT_IGNORE: readonly string[] = [...DEFAULT_IGNORE,
736
+ "target", ".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox",
737
+ ".gradle", ".next", ".turbo", ".cache"];
738
+
739
+ const clampYield = (value: unknown) =>
740
+ Math.min(MAX_YIELD_MS, Math.max(MIN_YIELD_MS, typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_YIELD_MS));
741
+
742
+ async function toolExec(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
743
+ const command = String(input["cmd"] ?? "");
744
+ if (command.trim().length === 0) return { content: "empty command", ok: false };
745
+ const cwd = typeof input["workdir"] === "string" && input["workdir"].length > 0 ? confine(ctx, input["workdir"]) : ctx.root;
746
+ if (typeof cwd !== "string") return { content: cwd.error, ok: false };
747
+ const requestedMs = typeof input["timeout"] === "number" ? input["timeout"] * 1000 : undefined;
748
+ const timeoutMs = Math.min(requestedMs ?? ctx.timeoutMs, ctx.maxTimeoutMs ?? ctx.timeoutMs);
749
+ const outputStore = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
750
+ const session = await startSession({ command, cwd, tty: input["tty"] === true, timeoutMs, spoolDir: outputStore });
751
+ if ("error" in session) return { ok: false, content: session.error };
752
+ await waitSession(session, clampYield(input["yield_time_ms"]), ctx.signal);
753
+ return sessionResult(ctx, session.id, command, timeoutMs, await takeOutput(session, outputStore, ctx.maxOutputBytes));
754
+ }
755
+
756
+ async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
757
+ const id = Number(input["session_id"]);
758
+ const session = findSession(id);
759
+ if (session === undefined) {
760
+ const running = runningSessions().map((s) => s.id).join(", ");
761
+ return { ok: false, content: `no running session ${id}` +
762
+ (running.length > 0 ? `; running sessions: ${running}` : "; sessions end when their process exits or xeno restarts") };
763
+ }
764
+ const chars = typeof input["chars"] === "string" ? input["chars"] : "";
765
+ if (chars.length > 0 && session.exit === null) session.write(chars);
766
+ await waitSession(session, clampYield(input["yield_time_ms"]), ctx.signal);
767
+ const outputStore = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
768
+ return sessionResult(ctx, id, session.command, session.timeoutMs, await takeOutput(session, outputStore, ctx.maxOutputBytes));
769
+ }
770
+
771
+ /**
772
+ * One call's view of a session. A running session proves nothing yet, so it
773
+ * carries no effect; only an observed exit does, exactly as bash's would (D11).
774
+ */
775
+ async function sessionResult(ctx: ToolContext, id: number, command: string, timeoutMs: number,
776
+ taken: Awaited<ReturnType<typeof takeOutput>>): Promise<ToolResult> {
777
+ const { stored, exit } = taken;
778
+ const header = exit === null
779
+ ? `running · session ${id} · ${(taken.wallMs / 1000).toFixed(1)}s elapsed; poll or send input with write_stdin(session_id=${id})`
780
+ : commandHeader({ exit, wallMs: taken.wallMs, timeoutMs, ...(taken.stopped ? { stopped: taken.stopped } : {}) }) + ` · session ${id} closed`;
781
+ const content = header + "\n" + observationView(stored, ctx.maxOutputBytes - utf8Length(header) - 1);
782
+ if (exit === null) {
783
+ return { ok: true, content, content_ref: stored.ref, data: { session_id: id, running: true, output: stored.ref } };
784
+ }
785
+ const gate = matchGate(ctx, command);
786
+ const observed = await scanWorkspace(ctx.root, FINGERPRINT_IGNORE);
787
+ return {
788
+ ok: exit === 0 && taken.stopped === undefined,
789
+ content, content_ref: stored.ref,
790
+ outcome: executionOutcome(taken.stopped ?? (exit === 0 ? "tool_completed" : "tool_failed")),
791
+ data: { session_id: id, running: false, exit, output: stored.ref },
792
+ effect: { kind: "command", cmd: command, exit, ...(gate !== undefined ? { gate } : {}),
793
+ ...(observed.ok ? { workspace_sha256: workspaceVersion(observed.files) } : {}) },
794
+ };
795
+ }
796
+
797
+ const utf8Length = (text: string) => new TextEncoder().encode(text).length;
798
+
799
+ /**
800
+ * Head and tail of an archived observation, skipping its first `skip` bytes.
801
+ *
802
+ * The marker names the artifact and the byte offset where the gap starts, so
803
+ * the missing middle is one `history` call away rather than a rerun.
804
+ */
805
+ function observationView(stored: ArchivedObservation, limit: number, skip = 0): string {
806
+ const { ref } = stored;
807
+ const marker = (omitted: number, offset: number) =>
808
+ `\n[truncated ${omitted} bytes at offset ${offset + skip}; full output: ${ref.path}; ` +
809
+ `history artifact=${ref.sha256} offset=${offset + skip}]\n`;
810
+ const total = ref.bytes - skip;
811
+ const tail = stored.tail.subarray(Math.max(0, stored.tail.length - total));
812
+ return headTailView(stored.head.subarray(skip), tail, total, Math.max(0, limit), marker);
813
+ }
814
+
815
+ export async function presentObservation(ctx: ToolContext, content: string): Promise<Pick<ToolResult, "content" | "content_ref" | "content_sha256">> {
816
+ const bytes = new TextEncoder().encode(content);
817
+ if (bytes.length <= ctx.maxOutputBytes) return { content, content_sha256: sha256(bytes) };
818
+ async function* source(): AsyncGenerator<Uint8Array> { yield bytes; }
819
+ const stored = await archiveObservation(ctx.outputStore ?? join(ctx.root, ".xeno", "observations"), source(), ctx.maxOutputBytes);
820
+ return { content: observationView(stored, ctx.maxOutputBytes), content_ref: stored.ref, content_sha256: stored.ref.sha256 };
821
+ }
822
+
823
+ function toolPath(ctx: ToolContext, input: Record<string, unknown>): string | { error: string } {
824
+ const path = typeof input["path"] === "string" && input["path"].length > 0 ? input["path"] : ".";
825
+ return confine(ctx, path);
826
+ }
827
+
828
+ function optionalLimit(input: Record<string, unknown>): number | undefined {
829
+ const n = input["limit"];
830
+ return typeof n === "number" && Number.isFinite(n) ? n : undefined;
831
+ }
832
+
833
+ function grepEngine(input: Record<string, unknown>): "auto" | "ripgrep" | "walk" {
834
+ const engine = input["engine"];
835
+ if (engine === "walk" || engine === "ripgrep") return engine;
836
+ return "auto";
837
+ }
838
+
839
+ async function toolGrep(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
840
+ const abs = toolPath(ctx, input);
841
+ if (typeof abs !== "string") return { content: abs.error, ok: false };
842
+ const glob = typeof input["glob"] === "string" ? input["glob"] : undefined;
843
+ const limit = optionalLimit(input);
844
+ const result = await grep({
845
+ root: searchRoot(ctx, abs),
846
+ path: abs,
847
+ pattern: String(input["pattern"] ?? ""),
848
+ engine: grepEngine(input),
849
+ timeoutMs: ctx.timeoutMs,
850
+ ...(glob !== undefined ? { glob } : {}),
851
+ ...(limit !== undefined ? { limit } : {}),
852
+ });
853
+ return { ...await presentObservation(ctx, result.content), ok: result.ok };
854
+ }
855
+
856
+ async function toolQuery(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
857
+ const query = parseDataQuery(input);
858
+ const { text, sources } = await readQuerySources(ctx.root, input["paths"]);
859
+ const value = { sources, ...queryData(text, query) };
860
+ if (!isJsonValue(value)) throw new Error("query produced a non-JSON value");
861
+ const observation = await presentObservation(ctx, JSON.stringify(value));
862
+ return { ok: true, ...observation, data: observation.content_ref ? { artifact: observation.content_ref, format: "json" } : value };
863
+ }
864
+
865
+ async function toolFind(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
866
+ const abs = toolPath(ctx, input);
867
+ if (typeof abs !== "string") return { content: abs.error, ok: false };
868
+ const limit = optionalLimit(input);
869
+ const result = await findFiles({
870
+ root: searchRoot(ctx, abs),
871
+ path: abs,
872
+ pattern: String(input["pattern"] ?? ""),
873
+ timeoutMs: ctx.timeoutMs,
874
+ ...(limit !== undefined ? { limit } : {}),
875
+ });
876
+ return { ...await presentObservation(ctx, result.content), ok: result.ok };
877
+ }
878
+
879
+ async function toolLs(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
880
+ const abs = toolPath(ctx, input);
881
+ if (typeof abs !== "string") return { content: abs.error, ok: false };
882
+ const limit = optionalLimit(input);
883
+ const result = await ls({
884
+ root: searchRoot(ctx, abs),
885
+ path: abs,
886
+ ...(limit !== undefined ? { limit } : {}),
887
+ });
888
+ return { ...await presentObservation(ctx, result.content), ok: result.ok };
889
+ }
890
+
891
+ /**
892
+ * Tools a read-only branch may use.
893
+ *
894
+ * Applied twice, deliberately. As a schema it tells the model what exists,
895
+ * which is what actually shapes behaviour. As `ToolContext.allow` it is
896
+ * enforced in runTool, because a narrowed schema is guidance and not a
897
+ * boundary -- a write call that arrives anyway would otherwise run.
898
+ */
899
+ export const BRANCH_TOOL_NAMES = TOOL_DEFINITIONS.filter(tool => tool.branch === "read").map(tool => tool.name);
900
+
901
+ /** Writing adds effects to the shared evidence tools; new readers cannot go missing. */
902
+ export const WRITING_BRANCH_TOOL_NAMES = TOOL_DEFINITIONS.filter(tool => tool.branch !== "none").map(tool => tool.name);
903
+
904
+ export function branchTools(writes = false): ToolSchema[] {
905
+ const allowed = new Set<string>(
906
+ writes ? WRITING_BRANCH_TOOL_NAMES : BRANCH_TOOL_NAMES,
907
+ );
908
+ return TOOL_SCHEMAS.filter((t) => allowed.has(t.name));
909
+ }
910
+
911
+ /**
912
+ * The door in front of every tool, including note/branch/resolve which
913
+ * the loop handles itself.
914
+ *
915
+ * A denial is a ToolResult, never an exception: an unanswered tool_use
916
+ * makes the next request unpairable. Same shape as a refused branch and
917
+ * an interrupt.
918
+ *
919
+ * Returns null when the call may proceed.
920
+ */
921
+ export async function gateTool(
922
+ ctx: ToolContext,
923
+ name: string,
924
+ input: unknown,
925
+ ): Promise<ToolResult | null> {
926
+ if (ctx.signal?.aborted) return cancelledTool();
927
+ // Checked before decide, so a tool that left the schema cannot reach
928
+ // the approval prompt. The model should not have seen it; if a call
929
+ // arrives anyway it is answered, not executed.
930
+ if (ctx.allow && !ctx.allow.includes(name)) {
931
+ return {
932
+ content: `refused: ${name} is not available here (allowed: ${ctx.allow.join(", ")})`,
933
+ ok: false,
934
+ };
935
+ }
936
+ const definition = toolDefinition(name);
937
+ input = repairToolInput(name, input);
938
+ const invalid = definition ? inputError(definition.input_schema, input, "input", definition.hiddenInputs) : null;
939
+ if (invalid) return { ok: false, content: invalid, diagnostic: { code: "invalid_input", message: invalid } };
940
+ if (ctx.rules === undefined) return null;
941
+
942
+ const request = permissionRequest(name, input);
943
+ const rules = [...(ctx.sessionRules ?? []), ...ctx.rules];
944
+ const decision = decide(rules, request);
945
+ switch (decision) {
946
+ case "allow":
947
+ return null;
948
+ case "deny":
949
+ return { content: deniedMessage(request), ok: false };
950
+ case "ask": {
951
+ const ask = ctx.ask ?? ctx.askSlot?.current;
952
+ if (ask === undefined) {
953
+ return { content: askWithoutTtyMessage(request), ok: false };
954
+ }
955
+ const choice = await waitForAdmission(ask(request), ctx.signal);
956
+ if (choice === undefined || ctx.signal?.aborted) return cancelledTool();
957
+ switch (choice) {
958
+ case "once":
959
+ return null;
960
+ case "command": {
961
+ if (request.command !== undefined && ctx.sessionRules) {
962
+ ctx.sessionRules.unshift({
963
+ action: "allow",
964
+ tool: request.tool,
965
+ command: request.command,
966
+ });
967
+ }
968
+ return null;
969
+ }
970
+ case "tool": {
971
+ if (ctx.sessionRules) {
972
+ ctx.sessionRules.unshift({ action: "allow", tool: request.tool });
973
+ }
974
+ return null;
975
+ }
976
+ case "deny":
977
+ return { content: deniedMessage(request), ok: false };
978
+ default:
979
+ return assertNever(choice, "gateTool");
980
+ }
981
+ }
982
+ default:
983
+ return assertNever(decision, "gateTool");
984
+ }
985
+ }
986
+
987
+ export async function runTool(
988
+ ctx: ToolContext,
989
+ name: string,
990
+ input: unknown,
991
+ ): Promise<ToolResult> {
992
+ const prepared = await prepareTool(ctx, name, input);
993
+ return typeof prepared === "function" ? prepared() : prepared;
994
+ }
995
+
996
+ /** O.5: ordered admission returns a capability for this exact call, never a policy bypass flag. */
997
+ export async function prepareTool(ctx: ToolContext, name: string, input: unknown): Promise<ToolResult | (() => Promise<ToolResult>)> {
998
+ const gated = await gateTool(ctx, name, input);
999
+ if (gated) return finishToolResult(gated);
1000
+ return () => dispatchTool(ctx, name, input);
1001
+ }
1002
+
1003
+ async function dispatchTool(ctx: ToolContext, name: string, input: unknown): Promise<ToolResult> {
1004
+ if (ctx.signal?.aborted) return finishToolResult(cancelledTool());
1005
+ const impl = toolDefinition(name)?.run;
1006
+ if (!impl) {
1007
+ const near = closestName(name, TOOL_DEFINITIONS.filter((tool) => tool.run !== undefined).map((tool) => tool.name));
1008
+ return finishToolResult({ content: `unknown tool: ${name}` + (near === undefined ? "" : `; did you mean ${near}?`), ok: false });
1009
+ }
1010
+ if (typeof input !== "object" || input === null) {
1011
+ return finishToolResult({ content: `tool ${name} needs an object input`, ok: false });
1012
+ }
1013
+ try {
1014
+ const result = await impl(ctx, repairToolInput(name, input) as Record<string, unknown>);
1015
+ return finishToolResult(result);
1016
+ } catch (err) {
1017
+ // A tool that throws must still produce a result: an unanswered tool_use
1018
+ // makes the next request unpairable.
1019
+ return finishToolResult({ content: `tool ${name} failed: ${(err as Error).message}`, ok: false });
1020
+ }
1021
+ }
1022
+
1023
+ /** One finalizer also covers session-owned tools; result text never determines success. */
1024
+ export function finishToolResult(result: ToolResult): ToolResult {
1025
+ return { ...result, outcome: result.outcome ?? executionOutcome(result.ok ? "tool_completed" : "tool_failed"),
1026
+ ...(!result.ok && !result.diagnostic ? { diagnostic: { code: "execution_failed" as const, message: result.content } } : {}) };
1027
+ }
1028
+
1029
+ function cancelledTool(): ToolResult {
1030
+ return { ok: false, content: "interrupted: cancelled before this tool started", outcome: executionOutcome("not_started") };
1031
+ }
1032
+
1033
+ export async function workspaceRoot(path: string): Promise<string> {
1034
+ const abs = resolve(path);
1035
+ const s = await stat(abs);
1036
+ if (!s.isDirectory()) throw new Error(`workspace root is not a directory: ${abs}`);
1037
+ return abs;
1038
+ }
1039
+
1040
+ // ---------------------------------------------------------------------------
1041
+ // Annotations
1042
+ // ---------------------------------------------------------------------------
1043
+
1044
+ /**
1045
+ * A `note` call, translated into the log event it records.
1046
+ *
1047
+ * Returned separately from ToolResult because these are not effects on the
1048
+ * workspace: they are the model's own account of what it is doing, which the
1049
+ * handoff carries across a boundary. They remain claims -- a `done` note is
1050
+ * resolved against exit codes in the log, so one whose gate never passed
1051
+ * surfaces as unverified rather than as completed.
1052
+ */
1053
+ export type Annotation =
1054
+ | {
1055
+ t: "claim";
1056
+ what: string;
1057
+ evidence: { gate: string } | { command: string };
1058
+ /** The raw citation, before the gate registry has had a say. */
1059
+ proof?: string;
1060
+ work_id?: string;
1061
+ }
1062
+ | { t: "next_action"; what: string; next_action: string; work_id?: string }
1063
+ | { t: "decision"; chose: string; because: string; rejected?: readonly string[] }
1064
+ | { t: "pitfall"; tried: string; failed_because: string }
1065
+ | { t: "blocked"; what: string; reason: string; work_id?: string }
1066
+ | { t: "work_status"; what: string; work_id: string; operation: "unblock" | "reopen" | "cancel"; next_action?: string; reason?: string }
1067
+ | { t: "evidence_link"; what: string; work: string; source: string; resource?: string; supersedes?: string }
1068
+ | { t: "question"; text: string };
1069
+
1070
+ export interface NoteOutcome {
1071
+ result: ToolResult;
1072
+ annotation?: Annotation;
1073
+ }
1074
+
1075
+ export function parseNote(input: unknown): NoteOutcome {
1076
+ if (typeof input !== "object" || input === null) {
1077
+ return { result: { content: "note needs an object input", ok: false } };
1078
+ }
1079
+ const o = input as Record<string, unknown>;
1080
+ const kind = String(o["kind"] ?? "");
1081
+ const what = String(o["what"] ?? "").trim();
1082
+ const why = typeof o["why"] === "string" ? o["why"].trim() : "";
1083
+ const id = o["work_id"];
1084
+ if (id !== undefined && (typeof id !== "string" || !id.trim())) {
1085
+ return { result: { ok: false, content: "work_id must be a non-empty string" } };
1086
+ }
1087
+ const identity = typeof id === "string" ? { work_id: id.trim() } : {};
1088
+ if (id !== undefined && !["next", "done", "blocked", "unblock", "reopen", "cancel", "pin"].includes(kind)) {
1089
+ return { result: { ok: false, content: "work_id applies to work lifecycle notes, not decisions, pitfalls or questions" } };
1090
+ }
1091
+
1092
+ if (what.length === 0) {
1093
+ return { result: { content: "note needs a non-empty `what`", ok: false } };
1094
+ }
1095
+
1096
+ switch (kind) {
1097
+ case "pin": {
1098
+ const source = o["event"];
1099
+ if (!identity.work_id || typeof source !== "string" || !source.trim()) return { result: { ok: false, content: "pin requires an existing work_id and original event id" } };
1100
+ for (const key of ["resource", "supersedes"]) if (o[key] !== undefined && (typeof o[key] !== "string" || !o[key].trim())) return { result: { ok: false, content: `${key} must be a non-empty string` } };
1101
+ return { result: { ok: true, content: "recorded evidence reference" }, annotation: { t: "evidence_link", what, work: identity.work_id, source,
1102
+ ...(typeof o["resource"] === "string" ? { resource: o["resource"] } : {}),
1103
+ ...(typeof o["supersedes"] === "string" ? { supersedes: o["supersedes"] } : {}) } };
1104
+ }
1105
+ case "done": {
1106
+ // Accepts the older field names too: a model that writes `gate` or
1107
+ // `command` meant the same thing, and refusing on the field name would
1108
+ // lose a real claim over a schema detail.
1109
+ const proof = ["proof", "gate", "command"]
1110
+ .map((k) => (typeof o[k] === "string" ? String(o[k]).trim() : ""))
1111
+ .find((v) => v.length > 0);
1112
+ if (!proof) {
1113
+ return {
1114
+ result: {
1115
+ content:
1116
+ "a `done` note must cite the command that proves it; run the " +
1117
+ "proving command first, then record it",
1118
+ ok: false,
1119
+ },
1120
+ };
1121
+ }
1122
+ // Whether this is a gate name or a raw command is the harness's
1123
+ // question, not the model's -- ctx decides, in the loop.
1124
+ return {
1125
+ result: { content: `recorded: done "${what}" (proof: ${proof})`, ok: true },
1126
+ annotation: { t: "claim", what, evidence: { command: proof }, proof, ...identity },
1127
+ };
1128
+ }
1129
+ case "next": {
1130
+ const next = typeof o["next_action"] === "string" ? o["next_action"].trim() : "";
1131
+ if (next.length === 0) {
1132
+ return { result: { content: "a `next` note needs a concrete next_action", ok: false } };
1133
+ }
1134
+ return {
1135
+ result: { content: `recorded: next "${what}" -> ${next}`, ok: true },
1136
+ annotation: { t: "next_action", what, next_action: next, ...identity },
1137
+ };
1138
+ }
1139
+ case "decision": {
1140
+ if (why.length === 0) {
1141
+ return { result: { content: "a `decision` note needs `why`", ok: false } };
1142
+ }
1143
+ const rejected = Array.isArray(o["rejected"])
1144
+ ? (o["rejected"] as unknown[]).map(String)
1145
+ : undefined;
1146
+ return {
1147
+ result: { content: `recorded: decision "${what}"`, ok: true },
1148
+ annotation: {
1149
+ t: "decision", chose: what, because: why,
1150
+ ...(rejected && rejected.length > 0 ? { rejected } : {}),
1151
+ },
1152
+ };
1153
+ }
1154
+ case "pitfall": {
1155
+ if (why.length === 0) {
1156
+ return { result: { content: "a `pitfall` note needs `why` it failed", ok: false } };
1157
+ }
1158
+ return {
1159
+ result: { content: `recorded: pitfall "${what}"`, ok: true },
1160
+ annotation: { t: "pitfall", tried: what, failed_because: why },
1161
+ };
1162
+ }
1163
+ case "blocked": {
1164
+ return {
1165
+ result: { content: `recorded: blocked "${what}"`, ok: true },
1166
+ annotation: { t: "blocked", what, reason: why || "no reason given", ...identity },
1167
+ };
1168
+ }
1169
+ case "unblock":
1170
+ case "reopen":
1171
+ case "cancel": {
1172
+ if (identity.work_id === undefined) return { result: { ok: false, content: `${kind} requires an existing work_id` } };
1173
+ const next = typeof o["next_action"] === "string" ? o["next_action"].trim() : "";
1174
+ if (kind !== "cancel" && !next) return { result: { ok: false, content: `${kind} requires a current next_action` } };
1175
+ return { result: { ok: true, content: `recorded: ${kind} ${identity.work_id}` },
1176
+ annotation: { t: "work_status", work_id: identity.work_id, what, operation: kind,
1177
+ ...(kind !== "cancel" ? { next_action: next } : {}), ...(why ? { reason: why } : {}) } };
1178
+ }
1179
+ case "question": {
1180
+ return {
1181
+ result: { content: `recorded: question "${what}"`, ok: true },
1182
+ annotation: { t: "question", text: what },
1183
+ };
1184
+ }
1185
+ default:
1186
+ return {
1187
+ result: {
1188
+ content: `unknown note kind "${kind}"; use done, next, decision, pitfall, blocked, question, unblock, reopen or cancel`,
1189
+ ok: false,
1190
+ },
1191
+ };
1192
+ }
1193
+ }