@visual-z/xeno 0.2.1 → 0.4.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 (282) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +126 -34
  3. package/docs/app-server.md +84 -0
  4. package/docs/install.md +51 -13
  5. package/docs/providers.md +24 -3
  6. package/package.json +7 -5
  7. package/src/apps/app-server.ts +28 -0
  8. package/src/apps/approve.ts +3 -3
  9. package/src/apps/cli.ts +25 -10
  10. package/src/apps/commands.ts +45 -8
  11. package/src/apps/completions.ts +78 -22
  12. package/src/apps/control-transport.ts +89 -0
  13. package/src/apps/image-draft.ts +46 -0
  14. package/src/apps/model-catalog.ts +2 -1
  15. package/src/apps/project-trust.ts +2 -42
  16. package/src/apps/provider-connect.ts +346 -0
  17. package/src/apps/render.ts +22 -47
  18. package/src/apps/resources.ts +5 -2
  19. package/src/apps/shell.ts +333 -1542
  20. package/src/apps/startup-input.ts +45 -9
  21. package/src/apps/startup.ts +149 -94
  22. package/src/apps/ui/app.ts +1553 -0
  23. package/src/apps/ui/control-model.ts +26 -0
  24. package/src/apps/ui/extension-host.ts +194 -0
  25. package/src/apps/ui/image-cache.ts +78 -0
  26. package/src/apps/ui/palette.ts +88 -0
  27. package/src/apps/ui/project.ts +127 -0
  28. package/src/apps/ui/standalone.ts +146 -0
  29. package/src/apps/ui/status-model.ts +68 -0
  30. package/src/apps/ui/surface.ts +86 -0
  31. package/src/apps/ui/theme.ts +86 -0
  32. package/src/core/acceptance.ts +107 -0
  33. package/src/core/action-defaults.ts +47 -295
  34. package/src/core/benchmark.ts +2 -2
  35. package/src/core/branch-receipt.ts +24 -0
  36. package/src/core/checkpoints.ts +64 -0
  37. package/src/core/churn.ts +2 -0
  38. package/src/core/command-artifacts.ts +94 -0
  39. package/src/core/command-contract.ts +35 -0
  40. package/src/core/completion-action.ts +20 -0
  41. package/src/core/completion-review.ts +37 -21
  42. package/src/core/context-detail.ts +37 -0
  43. package/src/core/context-policy.ts +74 -1
  44. package/src/core/continuation.ts +17 -2
  45. package/src/core/control-protocol.ts +62 -0
  46. package/src/core/control-view.ts +42 -0
  47. package/src/core/derive.ts +29 -3
  48. package/src/core/draft-images.ts +56 -0
  49. package/src/core/environment.ts +145 -0
  50. package/src/core/evaluation-coverage.ts +74 -0
  51. package/src/core/event-validation.ts +81 -6
  52. package/src/core/events.ts +99 -3
  53. package/src/core/evidence-links.ts +7 -0
  54. package/src/core/evidence.ts +13 -3
  55. package/src/core/execution.ts +8 -2
  56. package/src/core/experiment.ts +133 -0
  57. package/src/core/export.ts +9 -0
  58. package/src/core/failure-recovery.ts +173 -0
  59. package/src/core/file-precondition.ts +72 -0
  60. package/src/core/handoff.ts +65 -4
  61. package/src/core/harness-diagnostics.ts +206 -0
  62. package/src/core/history-query.ts +38 -2
  63. package/src/core/history.ts +38 -1
  64. package/src/core/integration-health.ts +34 -0
  65. package/src/core/keymap.ts +8 -20
  66. package/src/core/model-picker.ts +64 -0
  67. package/src/core/native-eval.ts +25 -0
  68. package/src/core/note-guidance.ts +34 -0
  69. package/src/core/oauth-fields.ts +44 -0
  70. package/src/core/observation-view.ts +49 -8
  71. package/src/core/permissions.ts +24 -2
  72. package/src/core/process-receipt.ts +105 -0
  73. package/src/core/process-state.ts +37 -0
  74. package/src/core/program-status.ts +31 -0
  75. package/src/core/project.ts +70 -19
  76. package/src/core/prompt-policy.ts +37 -0
  77. package/src/core/query-eval-contract.ts +14 -0
  78. package/src/core/read-observation.ts +82 -0
  79. package/src/core/recovery-corpus.ts +17 -0
  80. package/src/core/recovery-evaluation.ts +60 -0
  81. package/src/core/recovery-sources.ts +74 -0
  82. package/src/core/request-progress.ts +87 -0
  83. package/src/core/request-state.ts +114 -0
  84. package/src/core/runtime-target.ts +94 -0
  85. package/src/core/search-observation.ts +97 -0
  86. package/src/core/session-tree.ts +147 -0
  87. package/src/core/settings.ts +33 -10
  88. package/src/core/skills.ts +44 -0
  89. package/src/core/sql-query.ts +211 -0
  90. package/src/core/theme.ts +36 -58
  91. package/src/core/tool-contract.ts +51 -2
  92. package/src/core/tool-discovery.ts +87 -0
  93. package/src/core/tool-failure.ts +60 -0
  94. package/src/core/tool-input.ts +6 -0
  95. package/src/core/tool-mode-protocol.ts +25 -0
  96. package/src/core/transcript.ts +19 -4
  97. package/src/core/trial-lifecycle.ts +14 -0
  98. package/src/core/tui-mode.ts +29 -0
  99. package/src/core/update-check.ts +97 -0
  100. package/src/core/version.ts +55 -0
  101. package/src/core/work-state.ts +49 -3
  102. package/src/core/working-set.ts +91 -0
  103. package/src/runtime/auth.ts +11 -4
  104. package/src/runtime/branch-jobs.ts +36 -0
  105. package/src/runtime/catalog.ts +13 -3
  106. package/src/runtime/clipboard.ts +15 -4
  107. package/src/runtime/command-artifacts.ts +64 -0
  108. package/src/runtime/control-connection.ts +104 -0
  109. package/src/runtime/environment-tools.ts +60 -0
  110. package/src/runtime/evidence-notes.ts +4 -2
  111. package/src/runtime/exec-sessions.ts +206 -61
  112. package/src/runtime/execution-control.ts +1 -0
  113. package/src/runtime/extension-ui.ts +22 -30
  114. package/src/runtime/extensions.ts +95 -16
  115. package/src/runtime/harness-comparison.ts +27 -7
  116. package/src/runtime/harness-eval.ts +462 -0
  117. package/src/runtime/history-cache.ts +57 -0
  118. package/src/runtime/history-tools.ts +62 -12
  119. package/src/runtime/integrations.ts +210 -0
  120. package/src/runtime/loop.ts +1088 -282
  121. package/src/runtime/model-discovery.ts +208 -0
  122. package/src/runtime/model.ts +9 -0
  123. package/src/runtime/native-eval-fixtures.ts +33 -0
  124. package/src/runtime/native-eval.ts +146 -0
  125. package/src/runtime/oauth.ts +80 -3
  126. package/src/runtime/pi-provider.ts +169 -12
  127. package/src/runtime/program-status.ts +38 -0
  128. package/src/runtime/provider-connect.ts +109 -0
  129. package/src/runtime/provider-presets.ts +87 -0
  130. package/src/runtime/provider-request.ts +13 -2
  131. package/src/runtime/provider.ts +23 -9
  132. package/src/runtime/query-eval-verifier.ts +28 -0
  133. package/src/runtime/recovery-coverage.ts +71 -0
  134. package/src/runtime/recovery-evidence.ts +76 -0
  135. package/src/runtime/recovery-fixtures.ts +116 -0
  136. package/src/runtime/runtime-target.ts +149 -0
  137. package/src/runtime/session-control.ts +198 -0
  138. package/src/runtime/tool-broker.ts +137 -0
  139. package/src/runtime/tool-discovery.ts +57 -0
  140. package/src/runtime/tool-mode-eval.ts +146 -0
  141. package/src/runtime/tool-queue.ts +77 -0
  142. package/src/runtime/tools.ts +628 -183
  143. package/src/runtime/trial-control.ts +45 -0
  144. package/src/runtime/update-check.ts +47 -0
  145. package/src/runtime/window-trial.ts +2 -1
  146. package/src/runtime/working-notes.ts +33 -3
  147. package/src/runtime/workspace-lifecycle.ts +97 -0
  148. package/src/store/acceptance.ts +15 -0
  149. package/src/store/checkpoint-history.ts +43 -0
  150. package/src/store/command-artifacts.ts +96 -0
  151. package/src/store/command.ts +65 -37
  152. package/src/store/file-checkpoints.ts +104 -0
  153. package/src/store/file-index.ts +13 -1
  154. package/src/store/git-checkpoints.ts +67 -0
  155. package/src/store/observations.ts +61 -20
  156. package/src/store/process-receipts.ts +34 -0
  157. package/src/store/query-source.ts +13 -8
  158. package/src/store/recovery.ts +2 -3
  159. package/src/store/replay.ts +16 -4
  160. package/src/store/runtime-target.ts +64 -0
  161. package/src/store/search.ts +171 -132
  162. package/src/store/session-archive.ts +4 -0
  163. package/src/store/source-cancellation.ts +6 -0
  164. package/src/store/update-cache.ts +36 -0
  165. package/src/store/workspace-observation.ts +113 -0
  166. package/src/tui/ansi-spans.ts +126 -0
  167. package/src/tui/style.ts +19 -16
  168. package/src/tui/ui/atomic-draft.ts +121 -0
  169. package/src/tui/ui/canvas.ts +96 -0
  170. package/src/tui/ui/colors.ts +70 -0
  171. package/src/tui/ui/composer.ts +200 -0
  172. package/src/tui/ui/control-panel.ts +23 -0
  173. package/src/tui/ui/dialog.ts +379 -0
  174. package/src/tui/ui/draft.ts +23 -0
  175. package/src/tui/ui/edge.ts +186 -0
  176. package/src/tui/ui/image-size.ts +40 -0
  177. package/src/tui/ui/ink.ts +210 -0
  178. package/src/tui/ui/items.ts +40 -0
  179. package/src/tui/ui/keys.ts +40 -0
  180. package/src/tui/ui/layout.ts +74 -0
  181. package/src/tui/ui/list-model.ts +86 -0
  182. package/src/tui/ui/list-view.ts +172 -0
  183. package/src/tui/ui/mascot.ts +67 -0
  184. package/src/tui/ui/messages.ts +518 -0
  185. package/src/tui/ui/motion.ts +28 -0
  186. package/src/tui/ui/paths.ts +10 -0
  187. package/src/tui/ui/pictures.ts +78 -0
  188. package/src/tui/ui/screen.ts +421 -0
  189. package/src/tui/ui/status.ts +279 -0
  190. package/src/tui/ui/styled.ts +85 -0
  191. package/src/tui/ui/testing.ts +5 -0
  192. package/src/tui/ui/tool-card.ts +150 -0
  193. package/src/apps/extension-ui.ts +0 -330
  194. package/src/apps/resource-browser.ts +0 -75
  195. package/src/apps/session-browser.ts +0 -113
  196. package/src/apps/session-picker.ts +0 -55
  197. package/src/tui/ansi.ts +0 -67
  198. package/src/tui/autocomplete.ts +0 -29
  199. package/src/tui/chat-viewport.ts +0 -47
  200. package/src/tui/complete.ts +0 -87
  201. package/src/tui/editor.ts +0 -940
  202. package/src/tui/host.ts +0 -264
  203. package/src/tui/input.ts +0 -75
  204. package/src/tui/keys.ts +0 -378
  205. package/src/tui/markdown.ts +0 -17
  206. package/src/tui/render.ts +0 -153
  207. package/src/tui/select.ts +0 -258
  208. package/src/tui/settings.ts +0 -44
  209. package/src/tui/statusline.ts +0 -162
  210. package/src/tui/system-theme.ts +0 -700
  211. package/src/tui/terminal.ts +0 -30
  212. package/src/tui/text.ts +0 -52
  213. package/src/tui/toolview.ts +0 -386
  214. package/src/tui/transcript.ts +0 -123
  215. package/src/tui/tree.ts +0 -471
  216. package/src/vendor/pi-tui/LICENSE.upstream +0 -21
  217. package/src/vendor/pi-tui/UPSTREAM.json +0 -50
  218. package/src/vendor/pi-tui/VENDOR.md +0 -25
  219. package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
  220. package/src/vendor/pi-tui/autocomplete.ts +0 -860
  221. package/src/vendor/pi-tui/colors.ts +0 -367
  222. package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
  223. package/src/vendor/pi-tui/components/box.ts +0 -166
  224. package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
  225. package/src/vendor/pi-tui/components/editor.ts +0 -2472
  226. package/src/vendor/pi-tui/components/h-stack.ts +0 -44
  227. package/src/vendor/pi-tui/components/image.ts +0 -127
  228. package/src/vendor/pi-tui/components/input.ts +0 -494
  229. package/src/vendor/pi-tui/components/loader.ts +0 -101
  230. package/src/vendor/pi-tui/components/markdown.ts +0 -1021
  231. package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
  232. package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
  233. package/src/vendor/pi-tui/components/select-list.ts +0 -273
  234. package/src/vendor/pi-tui/components/settings-list.ts +0 -328
  235. package/src/vendor/pi-tui/components/spacer.ts +0 -28
  236. package/src/vendor/pi-tui/components/stack.ts +0 -154
  237. package/src/vendor/pi-tui/components/text.ts +0 -107
  238. package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
  239. package/src/vendor/pi-tui/components/v-stack.ts +0 -33
  240. package/src/vendor/pi-tui/editor-component.ts +0 -74
  241. package/src/vendor/pi-tui/fuzzy.ts +0 -138
  242. package/src/vendor/pi-tui/index.ts +0 -186
  243. package/src/vendor/pi-tui/keybindings.ts +0 -320
  244. package/src/vendor/pi-tui/keys.ts +0 -1401
  245. package/src/vendor/pi-tui/kill-ring.ts +0 -46
  246. package/src/vendor/pi-tui/latex.ts +0 -1506
  247. package/src/vendor/pi-tui/layout-node.ts +0 -51
  248. package/src/vendor/pi-tui/layout.ts +0 -449
  249. package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
  250. package/src/vendor/pi-tui/native/clipboard.h +0 -208
  251. package/src/vendor/pi-tui/native/darwin/README.md +0 -22
  252. package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
  253. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  254. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  255. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
  256. package/src/vendor/pi-tui/native/linux/README.md +0 -23
  257. package/src/vendor/pi-tui/native/linux/build.sh +0 -65
  258. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  259. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  260. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
  261. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
  262. package/src/vendor/pi-tui/native/napi.h +0 -91
  263. package/src/vendor/pi-tui/native/win32/README.md +0 -31
  264. package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
  265. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  266. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  267. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
  268. package/src/vendor/pi-tui/native-modifiers.ts +0 -13
  269. package/src/vendor/pi-tui/native-module-path.ts +0 -31
  270. package/src/vendor/pi-tui/native-platform.ts +0 -65
  271. package/src/vendor/pi-tui/oklab.ts +0 -233
  272. package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
  273. package/src/vendor/pi-tui/terminal-colors.ts +0 -91
  274. package/src/vendor/pi-tui/terminal-image.ts +0 -731
  275. package/src/vendor/pi-tui/terminal.ts +0 -554
  276. package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
  277. package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
  278. package/src/vendor/pi-tui/tui.ts +0 -1493
  279. package/src/vendor/pi-tui/undo-stack.ts +0 -28
  280. package/src/vendor/pi-tui/utils.ts +0 -1385
  281. package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
  282. package/src/vendor/pi-tui/word-navigation.ts +0 -117
@@ -18,6 +18,11 @@ import { Worker } from "node:worker_threads";
18
18
  import { assertNever } from "../core/exhaustive.ts";
19
19
  import { buildFileIndex } from "./file-index.ts";
20
20
  import { DEFAULT_IGNORE } from "./workspace.ts";
21
+ import { blobAddress } from "./blobs.ts";
22
+ import { previewSearchObservation, type SearchObservation } from "../core/search-observation.ts";
23
+ import { failureDiagnostic, failureFromError, READ_ATTEMPTED, NOT_STARTED } from "../core/tool-failure.ts";
24
+ import type { ToolDiagnostic } from "../core/tool-contract.ts";
25
+ import { COMMAND_TERMINATION_GRACE_MS } from "../core/command-contract.ts";
21
26
 
22
27
  /**
23
28
  * Match / entry cap. Assumed, not measured: enough for a model to read in
@@ -64,12 +69,16 @@ export const MATCH_TIMEOUT_MS = 100;
64
69
 
65
70
  /** Assumed: an rg/fd process that runs longer than this is stuck. */
66
71
  export const PROCESS_TIMEOUT_MS = 30_000;
72
+ /** Assumed execution budget, independent of display caps; reaching it makes coverage partial. */
73
+ export const MAX_SEARCH_CAPTURE_BYTES = 32 * 1024 * 1024;
67
74
 
68
75
  export type SearchEngine = "auto" | "ripgrep" | "walk" | "fd";
69
76
 
70
77
  export interface SearchResult {
71
78
  content: string;
72
79
  ok: boolean;
80
+ observation?: SearchObservation;
81
+ diagnostic?: ToolDiagnostic;
73
82
  }
74
83
 
75
84
  export interface GrepOpts {
@@ -81,6 +90,10 @@ export interface GrepOpts {
81
90
  readonly limit?: number;
82
91
  readonly engine?: "auto" | "ripgrep" | "walk";
83
92
  readonly timeoutMs?: number;
93
+ readonly signal?: AbortSignal;
94
+ /** Trusted harness budgets, not model arguments. */
95
+ readonly maxCaptureBytes?: number;
96
+ readonly maxIndexEntries?: number;
84
97
  }
85
98
 
86
99
  export interface FindOpts {
@@ -90,12 +103,25 @@ export interface FindOpts {
90
103
  readonly limit?: number;
91
104
  readonly engine?: "auto" | "fd" | "walk";
92
105
  readonly timeoutMs?: number;
106
+ readonly signal?: AbortSignal;
107
+ readonly maxCaptureBytes?: number;
108
+ readonly maxIndexEntries?: number;
93
109
  }
94
110
 
95
111
  export interface LsOpts {
96
112
  readonly root: string;
97
113
  readonly path: string;
98
114
  readonly limit?: number;
115
+ readonly signal?: AbortSignal;
116
+ }
117
+
118
+ function observed(observation: SearchObservation, diagnostic?: ToolDiagnostic): SearchResult {
119
+ return { ok: diagnostic === undefined, content: previewSearchObservation(observation, "pending-archive", MAX_OUTPUT_BYTES, MAX_LINE_CHARS).text,
120
+ observation, ...(diagnostic ? { diagnostic } : {}) };
121
+ }
122
+
123
+ function searchFailure(code: ToolDiagnostic["code"], content: string): SearchResult {
124
+ return { ok: false, content, diagnostic: failureDiagnostic(code, content, NOT_STARTED) };
99
125
  }
100
126
 
101
127
  export interface MatchLine {
@@ -139,7 +165,7 @@ export function findOnPath(name: string): string | null {
139
165
 
140
166
  function posixRel(root: string, abs: string): string | null {
141
167
  const rel = relative(root, abs);
142
- if (rel.startsWith("..") || isAbsolute(rel)) return null;
168
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
143
169
  return rel.split(sep).join("/");
144
170
  }
145
171
 
@@ -195,71 +221,39 @@ function splitLines(text: string): string[] {
195
221
  return trimmed.split("\n");
196
222
  }
197
223
 
198
- function truncateLine(text: string): string {
199
- const flat = text.replace(/\r/g, "");
200
- if (flat.length <= MAX_LINE_CHARS) return flat;
201
- return flat.slice(0, MAX_LINE_CHARS) + "…";
202
- }
203
-
204
- function formatMatch(m: MatchLine): string {
205
- return `${m.path}:${m.line}: ${truncateLine(m.text)}`;
206
- }
207
-
208
- /**
209
- * Apply the match-count cap and the byte cap. Overflow is counted, never
210
- * dropped silently: a test that only asserted "at most N" would pass an
211
- * implementation that threw the rest away.
212
- */
213
- function renderLines(lines: string[], limit: number, extraNotices: string[]): string {
214
- const omittedFromCap = Math.max(0, lines.length - limit);
215
- let shown = lines.slice(0, limit);
216
- let omitted = omittedFromCap;
217
-
218
- let body = shown.join("\n");
219
- while (shown.length > 0 && body.length > MAX_OUTPUT_BYTES) {
220
- shown = shown.slice(0, -1);
221
- omitted += 1;
222
- body = shown.join("\n");
223
- }
224
-
225
- const notices = [...extraNotices];
226
- if (omitted > 0) notices.push(`${omitted} more matches omitted`);
227
- if (notices.length === 0) return body;
228
- const note = `[${notices.join(". ")}]`;
229
- return body.length > 0 ? `${body}\n${note}` : note;
230
- }
231
-
232
- function noMatches(notices: string[]): SearchResult {
233
- if (notices.length === 0) return { content: "No matches found", ok: true };
234
- return { content: `[${notices.join(". ")}]`, ok: true };
235
- }
236
-
237
224
  async function inspectTarget(
238
225
  root: string,
239
226
  abs: string,
240
- ): Promise<{ kind: "file" | "dir"; rel: string } | { error: string }> {
227
+ ): Promise<{ kind: "file" | "dir"; rel: string } | { error: string; diagnostic: ToolDiagnostic }> {
241
228
  const rel = posixRel(root, abs);
242
- if (rel === null) return { error: `path escapes the workspace root: ${abs}` };
243
- if (ignoredRel(rel)) return { error: `path is ignored: ${rel}` };
229
+ const refuse = (message: string) => ({ error: message, diagnostic: failureDiagnostic("permission_denied", message, NOT_STARTED) });
230
+ if (rel === null) return refuse(`path escapes the workspace root: ${abs}`);
231
+ if (ignoredRel(rel)) return refuse(`path is ignored: ${rel}`);
244
232
 
245
233
  let st;
246
234
  try {
235
+ let current = root;
236
+ for (const part of rel.split("/").slice(0, -1)) {
237
+ current = join(current, part);
238
+ if ((await lstat(current)).isSymbolicLink()) return refuse(`refusing to follow a symlink in path: ${rel || "."}`);
239
+ }
247
240
  st = await lstat(abs);
248
- } catch {
249
- return { error: `path not found: ${rel || "."}` };
241
+ } catch (error) {
242
+ return { error: `cannot inspect path: ${rel || "."}`, diagnostic: failureFromError(error, READ_ATTEMPTED) };
250
243
  }
251
244
  if (st.isSymbolicLink()) {
252
- return { error: `refusing to follow a symlink: ${rel || "."}` };
245
+ return refuse(`refusing to follow a symlink: ${rel || "."}`);
253
246
  }
254
247
  if (st.isDirectory()) return { kind: "dir", rel };
255
248
  if (st.isFile()) return { kind: "file", rel };
256
- return { error: `not a file or directory: ${rel || "."}` };
249
+ const message = `not a file or directory: ${rel || "."}`;
250
+ return { error: message, diagnostic: failureDiagnostic("invalid_input", message, READ_ATTEMPTED) };
257
251
  }
258
252
 
259
- async function listedFiles(abs: string, rel: string, kind: "file" | "dir"): Promise<string[]> {
260
- if (kind === "file") return ignoredRel(rel) ? [] : [rel];
261
- const index = await buildFileIndex(abs);
262
- return index.paths.map((p) => (rel.length === 0 ? p : `${rel}/${p}`));
253
+ async function listedFiles(abs: string, rel: string, kind: "file" | "dir", options: Parameters<typeof buildFileIndex>[1] = {}) {
254
+ if (kind === "file") return { paths: ignoredRel(rel) ? [] : [rel], issues: [] };
255
+ const index = await buildFileIndex(abs, options);
256
+ return { paths: index.paths.map((p) => (rel.length === 0 ? p : `${rel}/${p}`)), issues: [...(index.issues ?? [])] };
263
257
  }
264
258
 
265
259
  interface WorkerMatch {
@@ -278,24 +272,29 @@ class LineMatcher {
278
272
  pattern: string,
279
273
  flags: string,
280
274
  lines: string[],
281
- ): Promise<"timeout" | "invalid" | MatchLine[]> {
275
+ budgetMs = MATCH_TIMEOUT_MS,
276
+ signal?: AbortSignal,
277
+ ): Promise<"timeout" | "invalid" | "cancelled" | MatchLine[]> {
278
+ if (signal?.aborted) return "cancelled";
282
279
  if (this.worker === null) this.worker = this.spawn();
283
280
  const worker = this.worker;
284
281
  return await new Promise((resolve) => {
285
282
  let settled = false;
286
- const finish = (value: "timeout" | "invalid" | MatchLine[]): void => {
283
+ const finish = (value: "timeout" | "invalid" | "cancelled" | MatchLine[]): void => {
287
284
  if (settled) return;
288
285
  settled = true;
289
286
  clearTimeout(timer);
290
287
  worker.off("message", onMessage);
291
288
  worker.off("error", onError);
289
+ signal?.removeEventListener("abort", abort);
292
290
  resolve(value);
293
291
  };
292
+ const abort = () => { void worker.terminate(); this.worker = null; finish("cancelled"); };
294
293
  const timer = setTimeout(() => {
295
294
  void worker.terminate();
296
295
  this.worker = null;
297
296
  finish("timeout");
298
- }, MATCH_TIMEOUT_MS);
297
+ }, Math.max(0, Math.min(MATCH_TIMEOUT_MS, budgetMs)));
299
298
  const onMessage = (msg: WorkerMatch) => {
300
299
  if (!msg.ok) {
301
300
  finish("invalid");
@@ -310,6 +309,7 @@ class LineMatcher {
310
309
  };
311
310
  worker.on("message", onMessage);
312
311
  worker.on("error", onError);
312
+ signal?.addEventListener("abort", abort, { once: true });
313
313
  worker.postMessage({ pattern, flags, lines });
314
314
  });
315
315
  }
@@ -326,27 +326,51 @@ function runCommand(
326
326
  cmd: string,
327
327
  args: readonly string[],
328
328
  timeoutMs: number,
329
- ): Promise<{ stdout: string; stderr: string; exit: number | null }> {
329
+ signal?: AbortSignal,
330
+ maxBytes = MAX_SEARCH_CAPTURE_BYTES,
331
+ ): Promise<{ stdout: string; raw: Uint8Array; rawErrors?: Uint8Array; stderr: string; exit: number | null; diagnostic?: ToolDiagnostic; stopped?: "timeout" | "cancelled" | "output_limit" }> {
332
+ if (signal?.aborted) return Promise.resolve({ stdout: "", raw: new Uint8Array(), stderr: "", exit: null, stopped: "cancelled" });
330
333
  return new Promise((resolve) => {
331
- const child = spawn(cmd, [...args], { stdio: ["ignore", "pipe", "pipe"] });
332
- let stdout = "";
333
- let stderr = "";
334
- child.stdout.setEncoding("utf8");
335
- child.stderr.setEncoding("utf8");
336
- child.stdout.on("data", (chunk: string) => {
337
- stdout += chunk;
334
+ const child = spawn(cmd, [...args], { stdio: ["ignore", "pipe", "pipe"], detached: true });
335
+ const stdout: Buffer[] = [], stderr: Buffer[] = [];
336
+ let size = 0, errorSize = 0, stopped: "timeout" | "cancelled" | "output_limit" | undefined;
337
+ let escalation: ReturnType<typeof setTimeout> | undefined;
338
+ const kill = (value: "SIGTERM" | "SIGKILL") => { try {
339
+ if (child.pid && process.platform !== "win32") process.kill(-child.pid, value); else child.kill(value);
340
+ } catch { /* the process group already exited */ } };
341
+ const stop = (reason: "timeout" | "cancelled" | "output_limit") => {
342
+ if (stopped) return; stopped = reason; kill("SIGTERM");
343
+ escalation = setTimeout(() => kill("SIGKILL"), COMMAND_TERMINATION_GRACE_MS);
344
+ };
345
+ const abort = () => stop("cancelled");
346
+ child.stdout.on("data", (chunk: Buffer) => {
347
+ const kept = chunk.subarray(0, Math.max(0, maxBytes - size));
348
+ if (kept.length) stdout.push(Buffer.from(kept)); size += kept.length;
349
+ if (kept.length < chunk.length) stop("output_limit");
338
350
  });
339
- child.stderr.on("data", (chunk: string) => {
340
- stderr += chunk;
351
+ child.stderr.on("data", (chunk: Buffer) => {
352
+ // Errors are bounded too; stderr is explanatory, never a source of success or completeness.
353
+ const kept = chunk.subarray(0, Math.max(0, 65536 - errorSize));
354
+ if (kept.length) stderr.push(Buffer.from(kept)); errorSize += kept.length;
355
+ if (kept.length < chunk.length) stop("output_limit");
341
356
  });
342
- const timer = setTimeout(() => child.kill(), timeoutMs);
357
+ const timer = setTimeout(() => stop("timeout"), timeoutMs);
358
+ signal?.addEventListener("abort", abort, { once: true });
359
+ if (signal?.aborted) abort();
343
360
  child.on("close", (code) => {
344
- clearTimeout(timer);
345
- resolve({ stdout, stderr, exit: code });
361
+ clearTimeout(timer); clearTimeout(escalation); signal?.removeEventListener("abort", abort);
362
+ if (stopped) kill("SIGKILL");
363
+ const raw = Buffer.concat(stdout);
364
+ const rawErrors = Buffer.concat(stderr);
365
+ resolve({ stdout: raw.toString("utf8"), raw, rawErrors, stderr: rawErrors.toString("utf8"), exit: code, ...(stopped ? { stopped } : {}) });
346
366
  });
347
367
  child.on("error", (err) => {
348
- clearTimeout(timer);
349
- resolve({ stdout, stderr: err.message, exit: null });
368
+ const raw = Buffer.concat(stdout);
369
+ clearTimeout(timer); clearTimeout(escalation); signal?.removeEventListener("abort", abort);
370
+ const facts = child.pid ? READ_ATTEMPTED : NOT_STARTED;
371
+ const os = (err as { code?: string }).code;
372
+ const diagnostic = !child.pid && (os === "ENOENT" || os === "ENOTDIR") ? failureDiagnostic("environment_missing", err.message, facts) : failureFromError(err, facts);
373
+ resolve({ stdout: raw.toString("utf8"), raw, stderr: err.message, exit: null, diagnostic, ...(stopped ? { stopped } : {}) });
350
374
  });
351
375
  });
352
376
  }
@@ -381,13 +405,13 @@ function parseRgJson(root: string, stdout: string): MatchLine[] {
381
405
  const pathObj = d["path"];
382
406
  const pathText =
383
407
  typeof pathObj === "object" && pathObj !== null
384
- ? String((pathObj as Record<string, unknown>)["text"] ?? "")
408
+ ? rgText(pathObj as Record<string, unknown>)
385
409
  : "";
386
410
  const lineNumber = d["line_number"];
387
411
  const linesObj = d["lines"];
388
412
  const lineText =
389
413
  typeof linesObj === "object" && linesObj !== null
390
- ? String((linesObj as Record<string, unknown>)["text"] ?? "")
414
+ ? rgText(linesObj as Record<string, unknown>)
391
415
  : "";
392
416
  if (pathText.length === 0 || typeof lineNumber !== "number") continue;
393
417
  const rel = posixRel(root, pathText) ?? posixRel(root, join(root, pathText));
@@ -401,6 +425,10 @@ function parseRgJson(root: string, stdout: string): MatchLine[] {
401
425
  return hits;
402
426
  }
403
427
 
428
+ function rgText(value: Record<string, unknown>): string {
429
+ return typeof value["text"] === "string" ? value["text"] : typeof value["bytes"] === "string" ? Buffer.from(value["bytes"], "base64").toString("utf8") : "";
430
+ }
431
+
404
432
  async function grepRipgrep(opts: GrepOpts, abs: string, limit: number): Promise<SearchResult | null> {
405
433
  const rg = findOnPath("rg");
406
434
  if (rg === null) return null;
@@ -416,17 +444,16 @@ async function grepRipgrep(opts: GrepOpts, abs: string, limit: number): Promise<
416
444
  ];
417
445
  if (opts.glob !== undefined) args.push("--glob", opts.glob);
418
446
  args.push("--", opts.pattern, abs);
419
- const ran = await runCommand(rg, args, opts.timeoutMs ?? PROCESS_TIMEOUT_MS);
420
- // 0 = matches, 1 = none; anything else is a real failure (bad pattern, etc.).
421
- if (ran.exit !== 0 && ran.exit !== 1) {
422
- const err = ran.stderr.trim() || `ripgrep exited ${ran.exit ?? "null"}`;
423
- return { content: err, ok: false };
424
- }
447
+ const ran = await runCommand(rg, args, opts.timeoutMs ?? PROCESS_TIMEOUT_MS, opts.signal, opts.maxCaptureBytes);
425
448
  const hits = parseRgJson(opts.root, ran.stdout);
426
449
  hits.sort(compareMatch);
427
- const lines = hits.map(formatMatch);
428
- if (lines.length === 0) return noMatches([]);
429
- return { content: renderLines(lines, limit, []), ok: true };
450
+ const failed = ran.stopped !== undefined || ran.exit !== 0 && ran.exit !== 1;
451
+ const reason = ran.stopped ?? "engine_error";
452
+ const observation: SearchObservation = { engine: "ripgrep", raw: ran.raw, ...(ran.rawErrors ? { rawErrors: ran.rawErrors } : {}), rows: hits.map(hit => ({ prefix: `${hit.path}:${hit.line}: `, text: hit.text })),
453
+ sources: [], limit, empty: "No matches found", scan: { status: failed ? "partial" : "unknown", examined_files: null, source_versions: "unknown",
454
+ reasons: [{ code: "engine_does_not_report_all_skipped_files" }, ...(failed ? [{ code: reason }] : [])] } };
455
+ return observed(observation, ran.diagnostic ?? (failed ? failureDiagnostic(reason === "timeout" || reason === "cancelled" ? reason : "execution_failed",
456
+ ran.stderr.trim() || `ripgrep ${reason}; exit ${ran.exit ?? "unknown"}`, READ_ATTEMPTED) : undefined));
430
457
  }
431
458
 
432
459
  function compareMatch(a: MatchLine, b: MatchLine): number {
@@ -438,61 +465,80 @@ async function grepWalk(opts: GrepOpts, abs: string, kind: "file" | "dir", rel:
438
465
  try {
439
466
  new RegExp(opts.pattern);
440
467
  } catch (err) {
441
- return { content: `invalid regex: ${(err as Error).message}`, ok: false };
468
+ return searchFailure("invalid_input", `invalid regex: ${(err as Error).message}`);
442
469
  }
443
470
 
444
- const files = await listedFiles(abs, rel, kind);
471
+ const deadline = performance.now() + (opts.timeoutMs ?? PROCESS_TIMEOUT_MS);
472
+ const files = await listedFiles(abs, rel, kind, { deadline, ...(opts.signal ? { signal: opts.signal } : {}),
473
+ ...(opts.maxIndexEntries === undefined ? {} : { maxEntries: opts.maxIndexEntries }) });
445
474
  const glob = opts.glob;
446
- const filtered = glob === undefined ? files : files.filter((p) => globMatches(glob, p));
475
+ const filtered = glob === undefined ? files.paths : files.paths.filter((p) => globMatches(glob, p));
447
476
  const matcher = new LineMatcher();
448
477
  const hits: MatchLine[] = [];
449
- const notices: string[] = [];
478
+ const reasons: SearchObservation["scan"]["reasons"] = [...files.issues];
479
+ const sources: SearchObservation["sources"] = [];
480
+ let examined = 0, capturedBytes = 0;
481
+ const budget = Math.min(opts.maxCaptureBytes ?? MAX_SEARCH_CAPTURE_BYTES, MAX_SEARCH_CAPTURE_BYTES);
450
482
  try {
451
483
  for (const fileRel of filtered) {
484
+ if (opts.signal?.aborted || performance.now() >= deadline) { reasons.push({ code: opts.signal?.aborted ? "cancelled" : "timeout" }); break; }
452
485
  if (ignoredRel(fileRel)) continue;
453
486
  const fileAbs = join(opts.root, fileRel);
454
487
  let st;
455
488
  try {
456
489
  st = await lstat(fileAbs);
457
490
  } catch {
491
+ reasons.push({ code: "unreadable_file", path: fileRel });
458
492
  continue;
459
493
  }
460
- if (st.isSymbolicLink() || !st.isFile()) continue;
461
- if (st.size > MAX_FILE_BYTES) continue;
494
+ if (st.isSymbolicLink() || !st.isFile()) { reasons.push({ code: "changed_file_type", path: fileRel }); continue; }
495
+ if (st.size > MAX_FILE_BYTES) { reasons.push({ code: "file_size_limit", path: fileRel }); continue; }
462
496
  let text: string;
463
497
  try {
464
- text = await readFile(fileAbs, "utf8");
498
+ const raw = await readFile(fileAbs);
499
+ if (raw.length > MAX_FILE_BYTES) { reasons.push({ code: "file_size_limit", path: fileRel }); continue; }
500
+ text = new TextDecoder("utf-8", { fatal: true }).decode(raw);
501
+ sources.push({ path: fileRel, sha256: blobAddress(raw) }); examined++;
465
502
  } catch {
503
+ reasons.push({ code: "unreadable_or_non_utf8_file", path: fileRel });
466
504
  continue;
467
505
  }
468
- if (text.includes("\0")) continue;
506
+ if (text.includes("\0")) { reasons.push({ code: "binary_file", path: fileRel }); continue; }
469
507
  const lines = splitLines(text);
470
- const matched = await matcher.match(opts.pattern, "", lines);
508
+ // Reserve the worst-case formatted hits before matching, never discard already observed hits.
509
+ const reservation = Buffer.byteLength(text) + lines.length * (Buffer.byteLength(fileRel) + String(MAX_FILE_BYTES).length + 3);
510
+ if (capturedBytes + reservation > budget) { reasons.push({ code: "capture_budget", path: fileRel }); break; }
511
+ const matched = await matcher.match(opts.pattern, "", lines, deadline - performance.now(), opts.signal);
512
+ if (matched === "cancelled") { reasons.push({ code: "cancelled", path: fileRel }); break; }
471
513
  if (matched === "timeout") {
472
- notices.push(`search cut off on ${fileRel} after ${MATCH_TIMEOUT_MS}ms`);
514
+ reasons.push({ code: "match_timeout", path: fileRel });
473
515
  continue;
474
516
  }
475
517
  if (matched === "invalid") {
476
- return { content: `invalid regex: ${opts.pattern}`, ok: false };
518
+ reasons.push({ code: "matcher_failed", path: fileRel }); break;
519
+ }
520
+ for (const hit of matched) {
521
+ hits.push({ path: fileRel, line: hit.line, text: hit.text });
522
+ capturedBytes += Buffer.byteLength(`${fileRel}:${hit.line}: ${hit.text}\n`);
477
523
  }
478
- for (const hit of matched) hits.push({ path: fileRel, line: hit.line, text: hit.text });
479
524
  }
480
525
  } finally {
481
526
  matcher.close();
482
527
  }
483
528
 
484
529
  hits.sort(compareMatch);
485
- const formatted = hits.map(formatMatch);
486
- if (formatted.length === 0) return noMatches(notices);
487
- return { content: renderLines(formatted, limit, notices), ok: true };
530
+ const stopped = reasons.find(reason => ["timeout", "cancelled"].includes(reason.code));
531
+ return observed({ engine: "walk", rows: hits.map(hit => ({ prefix: `${hit.path}:${hit.line}: `, text: hit.text })), sources, limit, empty: "No matches found",
532
+ scan: { status: reasons.length ? "partial" : "complete", reasons, examined_files: examined, source_versions: "recorded" } },
533
+ stopped ? failureDiagnostic(stopped.code === "cancelled" ? "cancelled" : "timeout", `search ${stopped.code}`, READ_ATTEMPTED) : undefined);
488
534
  }
489
535
 
490
536
  export async function grep(opts: GrepOpts): Promise<SearchResult> {
491
537
  const pattern = opts.pattern;
492
- if (pattern.length === 0) return { content: "grep needs a pattern", ok: false };
538
+ if (pattern.length === 0) return searchFailure("invalid_input", "grep needs a pattern");
493
539
 
494
540
  const target = await inspectTarget(opts.root, opts.path);
495
- if ("error" in target) return { content: target.error, ok: false };
541
+ if ("error" in target) return { content: target.error, ok: false, diagnostic: target.diagnostic };
496
542
 
497
543
  const limit = opts.limit !== undefined && opts.limit > 0 ? Math.floor(opts.limit) : DEFAULT_MATCH_LIMIT;
498
544
  const engine = opts.engine ?? "auto";
@@ -502,7 +548,7 @@ export async function grep(opts: GrepOpts): Promise<SearchResult> {
502
548
  return grepWalk(opts, opts.path, target.kind, target.rel, limit);
503
549
  case "ripgrep": {
504
550
  const got = await grepRipgrep(opts, opts.path, limit);
505
- if (got === null) return { content: "ripgrep is not on PATH", ok: false };
551
+ if (got === null) return searchFailure("environment_missing", "ripgrep is not on PATH");
506
552
  return got;
507
553
  }
508
554
  case "auto": {
@@ -532,6 +578,7 @@ async function findFd(opts: FindOpts, abs: string, limit: number): Promise<Searc
532
578
  "--hidden",
533
579
  "--no-ignore",
534
580
  "--absolute-path",
581
+ "--print0",
535
582
  ...fdExcludeArgs(),
536
583
  ];
537
584
  let pattern = opts.pattern;
@@ -540,36 +587,40 @@ async function findFd(opts: FindOpts, abs: string, limit: number): Promise<Searc
540
587
  if (!pattern.startsWith("**/") && pattern !== "**") pattern = `**/${pattern}`;
541
588
  }
542
589
  args.push("--", pattern, abs);
543
- const ran = await runCommand(fd, args, opts.timeoutMs ?? PROCESS_TIMEOUT_MS);
544
- if (ran.exit !== 0 && ran.stdout.trim().length === 0) {
545
- const err = ran.stderr.trim() || `fd exited ${ran.exit ?? "null"}`;
546
- return { content: err, ok: false };
547
- }
590
+ const ran = await runCommand(fd, args, opts.timeoutMs ?? PROCESS_TIMEOUT_MS, opts.signal, opts.maxCaptureBytes);
548
591
  const paths: string[] = [];
549
- for (const raw of ran.stdout.split("\n")) {
550
- const line = raw.replace(/\r$/, "").trim();
592
+ // A partial final record is not a path. NUL preserves spaces and newlines in real filenames.
593
+ for (const line of ran.stdout.split("\0").slice(0, -1)) {
551
594
  if (line.length === 0) continue;
552
595
  const rel = posixRel(opts.root, line);
553
596
  if (rel === null || ignoredRel(rel)) continue;
554
597
  paths.push(rel);
555
598
  }
556
599
  paths.sort((a, b) => (a < b ? -1 : 1));
557
- if (paths.length === 0) return { content: "No files found matching pattern", ok: true };
558
- return { content: renderLines(paths, limit, []), ok: true };
600
+ const failed = ran.stopped !== undefined || ran.exit !== 0;
601
+ const reason = ran.stopped ?? "engine_error";
602
+ return observed({ engine: "fd", raw: ran.raw, ...(ran.rawErrors ? { rawErrors: ran.rawErrors } : {}), rows: paths.map(text => ({ prefix: "", text })), limit, empty: "No files found matching pattern", sources: [],
603
+ scan: { status: failed ? "partial" : "unknown", examined_files: null, source_versions: "unknown",
604
+ reasons: [{ code: "engine_does_not_report_all_skipped_files" }, ...(failed ? [{ code: reason }] : [])] } },
605
+ ran.diagnostic ?? (failed ? failureDiagnostic(reason === "timeout" || reason === "cancelled" ? reason : "execution_failed",
606
+ ran.stderr.trim() || `fd ${reason}; exit ${ran.exit ?? "unknown"}`, READ_ATTEMPTED) : undefined));
559
607
  }
560
608
 
561
609
  async function findWalk(opts: FindOpts, abs: string, kind: "file" | "dir", rel: string, limit: number): Promise<SearchResult> {
562
- const files = await listedFiles(abs, rel, kind);
563
- const paths = files.filter((p) => !ignoredRel(p) && globMatches(opts.pattern, p));
610
+ const files = await listedFiles(abs, rel, kind, { deadline: performance.now() + (opts.timeoutMs ?? PROCESS_TIMEOUT_MS),
611
+ ...(opts.signal ? { signal: opts.signal } : {}), ...(opts.maxIndexEntries === undefined ? {} : { maxEntries: opts.maxIndexEntries }) });
612
+ const paths = files.paths.filter((p) => !ignoredRel(p) && globMatches(opts.pattern, p));
564
613
  paths.sort((a, b) => (a < b ? -1 : 1));
565
- if (paths.length === 0) return { content: "No files found matching pattern", ok: true };
566
- return { content: renderLines(paths, limit, []), ok: true };
614
+ const stopped = files.issues.find(reason => ["timeout", "cancelled"].includes(reason.code));
615
+ return observed({ engine: "walk", rows: paths.map(text => ({ prefix: "", text })), sources: [], limit, empty: "No files found matching pattern",
616
+ scan: { status: files.issues.length ? "partial" : "complete", reasons: files.issues, examined_files: files.paths.length, source_versions: "unknown" } },
617
+ stopped ? failureDiagnostic(stopped.code === "cancelled" ? "cancelled" : "timeout", `search ${stopped.code}`, READ_ATTEMPTED) : undefined);
567
618
  }
568
619
 
569
620
  export async function find(opts: FindOpts): Promise<SearchResult> {
570
- if (opts.pattern.length === 0) return { content: "find needs a pattern", ok: false };
621
+ if (opts.pattern.length === 0) return searchFailure("invalid_input", "find needs a pattern");
571
622
  const target = await inspectTarget(opts.root, opts.path);
572
- if ("error" in target) return { content: target.error, ok: false };
623
+ if ("error" in target) return { content: target.error, ok: false, diagnostic: target.diagnostic };
573
624
  const limit = opts.limit !== undefined && opts.limit > 0 ? Math.floor(opts.limit) : DEFAULT_FIND_LIMIT;
574
625
  const engine = opts.engine ?? "auto";
575
626
  switch (engine) {
@@ -577,7 +628,7 @@ export async function find(opts: FindOpts): Promise<SearchResult> {
577
628
  return findWalk(opts, opts.path, target.kind, target.rel, limit);
578
629
  case "fd": {
579
630
  const got = await findFd(opts, opts.path, limit);
580
- if (got === null) return { content: "fd is not on PATH", ok: false };
631
+ if (got === null) return searchFailure("environment_missing", "fd is not on PATH");
581
632
  return got;
582
633
  }
583
634
  case "auto": {
@@ -593,15 +644,16 @@ export async function find(opts: FindOpts): Promise<SearchResult> {
593
644
  }
594
645
 
595
646
  export async function ls(opts: LsOpts): Promise<SearchResult> {
647
+ if (opts.signal?.aborted) return searchFailure("cancelled", "directory listing cancelled");
596
648
  const target = await inspectTarget(opts.root, opts.path);
597
- if ("error" in target) return { content: target.error, ok: false };
598
- if (target.kind !== "dir") return { content: `not a directory: ${target.rel || "."}`, ok: false };
649
+ if ("error" in target) return { content: target.error, ok: false, diagnostic: target.diagnostic };
650
+ if (target.kind !== "dir") return searchFailure("invalid_input", `not a directory: ${target.rel || "."}`);
599
651
 
600
652
  let entries;
601
653
  try {
602
654
  entries = await readdir(opts.path, { withFileTypes: true });
603
655
  } catch (err) {
604
- return { content: `cannot list ${target.rel || "."}: ${(err as Error).message}`, ok: false };
656
+ return { content: `cannot list ${target.rel || "."}: ${(err as Error).message}`, ok: false, diagnostic: failureFromError(err, READ_ATTEMPTED) };
605
657
  }
606
658
 
607
659
  const names: string[] = [];
@@ -626,19 +678,6 @@ export async function ls(opts: LsOpts): Promise<SearchResult> {
626
678
  });
627
679
 
628
680
  const limit = opts.limit !== undefined && opts.limit > 0 ? Math.floor(opts.limit) : DEFAULT_LS_LIMIT;
629
- if (names.length === 0) return { content: "(empty directory)", ok: true };
630
- const omitted = Math.max(0, names.length - limit);
631
- const shown = names.slice(0, limit);
632
- let body = shown.join("\n");
633
- let extra = omitted;
634
- while (shown.length > 0 && body.length > MAX_OUTPUT_BYTES) {
635
- shown.pop();
636
- extra += 1;
637
- body = shown.join("\n");
638
- }
639
- if (extra > 0) {
640
- const note = `[${extra} more matches omitted]`;
641
- body = body.length > 0 ? `${body}\n${note}` : note;
642
- }
643
- return { content: body, ok: true };
681
+ return observed({ engine: "directory", rows: names.map(text => ({ prefix: "", text })), sources: [], limit, empty: "(empty directory)",
682
+ scan: { status: "complete", reasons: [], examined_files: names.length, source_versions: "unknown" } });
644
683
  }
@@ -40,6 +40,10 @@ async function referencedBlobs(dir: string, fallback?: string): Promise<Map<stri
40
40
  const add = (sha: string) => { refs.set(sha, [...new Set([...(refs.get(sha) ?? []), ...roots])]); };
41
41
  for (const event of read.events) {
42
42
  if (event.t === "snapshot") for (const sha of [...Object.values(event.added), ...Object.values(event.changed)]) add(sha);
43
+ if (event.t === "checkpoint") for (const change of Object.values(event.changes)) {
44
+ if (change.before) add(change.before.sha256);
45
+ if (change.after) add(change.after.sha256);
46
+ }
43
47
  if (event.t === "user" || event.t === "tool_result") for (const image of event.images ?? []) { add(image.sha256); if (image.original) add(image.original.sha256); }
44
48
  }
45
49
  }
@@ -0,0 +1,6 @@
1
+ /** D11: cancelled source capture never becomes a partial successful observation. */
2
+ import { ToolFault, READ_ATTEMPTED } from "../core/tool-failure.ts";
3
+
4
+ export function checkSourceCancellation(signal?: AbortSignal): void {
5
+ if (signal?.aborted) throw new ToolFault("cancelled", "source capture cancelled", READ_ATTEMPTED);
6
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The last update check, kept in the config directory (docs/tasks/feat-update-check.md).
3
+ *
4
+ * `{ checkedAt, latest? }` and nothing else: no identifiers, no history. A
5
+ * missing or unreadable file means "never checked"; it is rewritten whole.
6
+ */
7
+ import { readFile } from "node:fs/promises";
8
+ import { existsSync, realpathSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import type { UpdateCache } from "../core/update-check.ts";
12
+ import { atomicWrite } from "./atomic.ts";
13
+ import { configDir } from "./config.ts";
14
+
15
+ export function updateCachePath(): string { return join(configDir(), "update-check.json"); }
16
+
17
+ export async function readUpdateCache(): Promise<UpdateCache | null> {
18
+ try {
19
+ const value = JSON.parse(await readFile(updateCachePath(), "utf8")) as Record<string, unknown>;
20
+ if (typeof value["checkedAt"] !== "number") return null;
21
+ return { checkedAt: value["checkedAt"], ...(typeof value["latest"] === "string" ? { latest: value["latest"] } : {}) };
22
+ } catch { return null; }
23
+ }
24
+
25
+ export async function writeUpdateCache(cache: UpdateCache): Promise<void> {
26
+ try { await atomicWrite(updateCachePath(), JSON.stringify(cache) + "\n"); } catch { /* a read-only config dir only means checking again next time */ }
27
+ }
28
+
29
+ /** This copy's package root, resolved through symlinks, and whether it is a git checkout. */
30
+ export function packageLocation(): { root: string; git: boolean } {
31
+ // src/store/update-cache.ts → the package root two directories up.
32
+ const here = dirname(fileURLToPath(import.meta.url));
33
+ let root = join(here, "..", "..");
34
+ try { root = realpathSync(root); } catch { /* keep the unresolved path */ }
35
+ return { root, git: existsSync(join(root, ".git")) };
36
+ }