@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,644 @@
1
+ /**
2
+ * Workspace search: grep, find, ls.
3
+ *
4
+ * The walk reuses buildFileIndex so ignore rules, the 20000 cap, and the
5
+ * no-symlink rule stay one implementation. Ripgrep / fd are used only when
6
+ * they are already on PATH -- never downloaded -- because fetching a binary
7
+ * at runtime is a supply-chain path the Isolation section of README.md
8
+ * told the user they would not be taking.
9
+ *
10
+ * Both engines format the same way so two machines do not silently disagree.
11
+ */
12
+
13
+ import { spawn } from "node:child_process";
14
+ import { existsSync } from "node:fs";
15
+ import { lstat, readdir, readFile } from "node:fs/promises";
16
+ import { isAbsolute, join, relative, sep } from "node:path";
17
+ import { Worker } from "node:worker_threads";
18
+ import { assertNever } from "../core/exhaustive.ts";
19
+ import { buildFileIndex } from "./file-index.ts";
20
+ import { DEFAULT_IGNORE } from "./workspace.ts";
21
+
22
+ /**
23
+ * Match / entry cap. Assumed, not measured: enough for a model to read in
24
+ * one result; past this it should refine the query. A silent cut would hide
25
+ * how much evidence was dropped.
26
+ */
27
+ export const DEFAULT_MATCH_LIMIT = 100;
28
+
29
+ /** Assumed, not measured: same order of magnitude as pi's find default. */
30
+ export const DEFAULT_FIND_LIMIT = 1_000;
31
+
32
+ /** Assumed, not measured: a directory listing larger than this needs a filter. */
33
+ export const DEFAULT_LS_LIMIT = 500;
34
+
35
+ /**
36
+ * Assumed, not measured: a longer line belongs in `read`, not in a match
37
+ * list. Cutting it without a cap would let one minified file blow the budget.
38
+ */
39
+ export const MAX_LINE_CHARS = 500;
40
+
41
+ /**
42
+ * Assumed, not measured: same ballpark as the tool-output cap in tools.ts.
43
+ * Hitting it drops trailing matches and says how many.
44
+ */
45
+ export const MAX_OUTPUT_BYTES = 30_000;
46
+
47
+ /**
48
+ * Assumed, not measured: a 1 MB file is already past what a match snippet
49
+ * can usefully show; ripgrep is told the same ceiling so the two engines
50
+ * skip the same files.
51
+ */
52
+ export const MAX_FILE_BYTES = 1_000_000;
53
+
54
+ /**
55
+ * Per-file match ceiling. A linear scan of MAX_FILE_BYTES with a simple
56
+ * pattern was measured at ~0.07ms/MB on bun 1.3; 100ms is assumed slack on
57
+ * top of that. `(a+)+$` against 40 a's was measured at ~220ms here -- JSC
58
+ * aborts the backtrack around there rather than hanging forever -- so a
59
+ * ceiling above that would never fire on the card's catastrophic case, and
60
+ * the agent would see "no matches" instead of a cutoff. The pattern comes
61
+ * from the model, not a person: a hung tool_call has no result in the log.
62
+ */
63
+ export const MATCH_TIMEOUT_MS = 100;
64
+
65
+ /** Assumed: an rg/fd process that runs longer than this is stuck. */
66
+ export const PROCESS_TIMEOUT_MS = 30_000;
67
+
68
+ export type SearchEngine = "auto" | "ripgrep" | "walk" | "fd";
69
+
70
+ export interface SearchResult {
71
+ content: string;
72
+ ok: boolean;
73
+ }
74
+
75
+ export interface GrepOpts {
76
+ readonly root: string;
77
+ /** Absolute path, already confined to root. */
78
+ readonly path: string;
79
+ readonly pattern: string;
80
+ readonly glob?: string;
81
+ readonly limit?: number;
82
+ readonly engine?: "auto" | "ripgrep" | "walk";
83
+ readonly timeoutMs?: number;
84
+ }
85
+
86
+ export interface FindOpts {
87
+ readonly root: string;
88
+ readonly path: string;
89
+ readonly pattern: string;
90
+ readonly limit?: number;
91
+ readonly engine?: "auto" | "fd" | "walk";
92
+ readonly timeoutMs?: number;
93
+ }
94
+
95
+ export interface LsOpts {
96
+ readonly root: string;
97
+ readonly path: string;
98
+ readonly limit?: number;
99
+ }
100
+
101
+ export interface MatchLine {
102
+ readonly path: string;
103
+ readonly line: number;
104
+ readonly text: string;
105
+ }
106
+
107
+ const MATCH_WORKER_SOURCE = `
108
+ const { parentPort } = require("node:worker_threads");
109
+ parentPort.on("message", (msg) => {
110
+ const { pattern, flags, lines } = msg;
111
+ try {
112
+ const re = new RegExp(pattern, flags);
113
+ const hits = [];
114
+ for (let i = 0; i < lines.length; i++) {
115
+ const line = lines[i];
116
+ re.lastIndex = 0;
117
+ if (re.test(line)) hits.push({ line: i + 1, text: line });
118
+ }
119
+ parentPort.postMessage({ ok: true, hits });
120
+ } catch (err) {
121
+ parentPort.postMessage({
122
+ ok: false,
123
+ error: err instanceof Error ? err.message : String(err),
124
+ });
125
+ }
126
+ });
127
+ `;
128
+
129
+ export function findOnPath(name: string): string | null {
130
+ const pathEnv = process.env["PATH"] ?? "";
131
+ const delim = process.platform === "win32" ? ";" : ":";
132
+ for (const dir of pathEnv.split(delim)) {
133
+ if (dir.length === 0) continue;
134
+ const candidate = join(dir, name);
135
+ if (existsSync(candidate)) return candidate;
136
+ }
137
+ return null;
138
+ }
139
+
140
+ function posixRel(root: string, abs: string): string | null {
141
+ const rel = relative(root, abs);
142
+ if (rel.startsWith("..") || isAbsolute(rel)) return null;
143
+ return rel.split(sep).join("/");
144
+ }
145
+
146
+ function ignoredRel(rel: string): boolean {
147
+ if (rel.length === 0) return false;
148
+ const parts = rel.split("/");
149
+ return DEFAULT_IGNORE.some((name) => parts.includes(name));
150
+ }
151
+
152
+ /**
153
+ * Convert a glob to a fully-anchored regexp. Assumed to cover the shapes
154
+ * models actually type (basename wildcards and double-star path globs), not
155
+ * the full gitignore grammar -- a missed edge is a missed file, not a hang.
156
+ */
157
+ export function globToRegExp(glob: string): RegExp {
158
+ let i = 0;
159
+ let out = "";
160
+ while (i < glob.length) {
161
+ if (glob.startsWith("**/", i)) {
162
+ out += "(?:.*/)?";
163
+ i += 3;
164
+ continue;
165
+ }
166
+ if (glob[i] === "*" && glob[i + 1] === "*") {
167
+ out += ".*";
168
+ i += 2;
169
+ continue;
170
+ }
171
+ const c = glob[i]!;
172
+ if (c === "*") out += "[^/]*";
173
+ else if (c === "?") out += "[^/]";
174
+ else if (".+^${}()|[]\\".includes(c)) out += `\\${c}`;
175
+ else out += c;
176
+ i += 1;
177
+ }
178
+ return new RegExp(`^${out}$`);
179
+ }
180
+
181
+ export function globMatches(glob: string, relPath: string): boolean {
182
+ const posix = relPath.split(sep).join("/");
183
+ const slash = posix.lastIndexOf("/");
184
+ const base = slash === -1 ? posix : posix.slice(slash + 1);
185
+ const re = globToRegExp(glob);
186
+ if (glob.includes("/")) return re.test(posix);
187
+ return re.test(base);
188
+ }
189
+
190
+ function splitLines(text: string): string[] {
191
+ const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
192
+ if (normalized.length === 0) return [];
193
+ const trimmed = normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized;
194
+ if (trimmed.length === 0) return [];
195
+ return trimmed.split("\n");
196
+ }
197
+
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
+ async function inspectTarget(
238
+ root: string,
239
+ abs: string,
240
+ ): Promise<{ kind: "file" | "dir"; rel: string } | { error: string }> {
241
+ 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}` };
244
+
245
+ let st;
246
+ try {
247
+ st = await lstat(abs);
248
+ } catch {
249
+ return { error: `path not found: ${rel || "."}` };
250
+ }
251
+ if (st.isSymbolicLink()) {
252
+ return { error: `refusing to follow a symlink: ${rel || "."}` };
253
+ }
254
+ if (st.isDirectory()) return { kind: "dir", rel };
255
+ if (st.isFile()) return { kind: "file", rel };
256
+ return { error: `not a file or directory: ${rel || "."}` };
257
+ }
258
+
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}`));
263
+ }
264
+
265
+ interface WorkerMatch {
266
+ readonly ok: boolean;
267
+ readonly hits?: ReadonlyArray<{ readonly line: number; readonly text: string }>;
268
+ }
269
+
270
+ class LineMatcher {
271
+ private worker: Worker | null = null;
272
+
273
+ private spawn(): Worker {
274
+ return new Worker(MATCH_WORKER_SOURCE, { eval: true });
275
+ }
276
+
277
+ async match(
278
+ pattern: string,
279
+ flags: string,
280
+ lines: string[],
281
+ ): Promise<"timeout" | "invalid" | MatchLine[]> {
282
+ if (this.worker === null) this.worker = this.spawn();
283
+ const worker = this.worker;
284
+ return await new Promise((resolve) => {
285
+ let settled = false;
286
+ const finish = (value: "timeout" | "invalid" | MatchLine[]): void => {
287
+ if (settled) return;
288
+ settled = true;
289
+ clearTimeout(timer);
290
+ worker.off("message", onMessage);
291
+ worker.off("error", onError);
292
+ resolve(value);
293
+ };
294
+ const timer = setTimeout(() => {
295
+ void worker.terminate();
296
+ this.worker = null;
297
+ finish("timeout");
298
+ }, MATCH_TIMEOUT_MS);
299
+ const onMessage = (msg: WorkerMatch) => {
300
+ if (!msg.ok) {
301
+ finish("invalid");
302
+ return;
303
+ }
304
+ finish((msg.hits ?? []).map((h) => ({ path: "", line: h.line, text: h.text })));
305
+ };
306
+ const onError = () => {
307
+ void worker.terminate();
308
+ this.worker = null;
309
+ finish("invalid");
310
+ };
311
+ worker.on("message", onMessage);
312
+ worker.on("error", onError);
313
+ worker.postMessage({ pattern, flags, lines });
314
+ });
315
+ }
316
+
317
+ close(): void {
318
+ if (this.worker !== null) {
319
+ void this.worker.terminate();
320
+ this.worker = null;
321
+ }
322
+ }
323
+ }
324
+
325
+ function runCommand(
326
+ cmd: string,
327
+ args: readonly string[],
328
+ timeoutMs: number,
329
+ ): Promise<{ stdout: string; stderr: string; exit: number | null }> {
330
+ 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;
338
+ });
339
+ child.stderr.on("data", (chunk: string) => {
340
+ stderr += chunk;
341
+ });
342
+ const timer = setTimeout(() => child.kill(), timeoutMs);
343
+ child.on("close", (code) => {
344
+ clearTimeout(timer);
345
+ resolve({ stdout, stderr, exit: code });
346
+ });
347
+ child.on("error", (err) => {
348
+ clearTimeout(timer);
349
+ resolve({ stdout, stderr: err.message, exit: null });
350
+ });
351
+ });
352
+ }
353
+
354
+ function rgIgnoreArgs(): string[] {
355
+ const args: string[] = [];
356
+ for (const name of DEFAULT_IGNORE) {
357
+ args.push("--glob", `!${name}`);
358
+ args.push("--glob", `!${name}/**`);
359
+ args.push("--glob", `!**/${name}`);
360
+ args.push("--glob", `!**/${name}/**`);
361
+ }
362
+ return args;
363
+ }
364
+
365
+ function parseRgJson(root: string, stdout: string): MatchLine[] {
366
+ const hits: MatchLine[] = [];
367
+ for (const raw of stdout.split("\n")) {
368
+ if (raw.trim().length === 0) continue;
369
+ let parsed: unknown;
370
+ try {
371
+ parsed = JSON.parse(raw);
372
+ } catch {
373
+ continue;
374
+ }
375
+ if (typeof parsed !== "object" || parsed === null) continue;
376
+ const rec = parsed as Record<string, unknown>;
377
+ if (rec["type"] !== "match") continue;
378
+ const data = rec["data"];
379
+ if (typeof data !== "object" || data === null) continue;
380
+ const d = data as Record<string, unknown>;
381
+ const pathObj = d["path"];
382
+ const pathText =
383
+ typeof pathObj === "object" && pathObj !== null
384
+ ? String((pathObj as Record<string, unknown>)["text"] ?? "")
385
+ : "";
386
+ const lineNumber = d["line_number"];
387
+ const linesObj = d["lines"];
388
+ const lineText =
389
+ typeof linesObj === "object" && linesObj !== null
390
+ ? String((linesObj as Record<string, unknown>)["text"] ?? "")
391
+ : "";
392
+ if (pathText.length === 0 || typeof lineNumber !== "number") continue;
393
+ const rel = posixRel(root, pathText) ?? posixRel(root, join(root, pathText));
394
+ if (rel === null || ignoredRel(rel)) continue;
395
+ hits.push({
396
+ path: rel,
397
+ line: lineNumber,
398
+ text: lineText.replace(/\n$/, "").replace(/\r/g, ""),
399
+ });
400
+ }
401
+ return hits;
402
+ }
403
+
404
+ async function grepRipgrep(opts: GrepOpts, abs: string, limit: number): Promise<SearchResult | null> {
405
+ const rg = findOnPath("rg");
406
+ if (rg === null) return null;
407
+ const args = [
408
+ "--json",
409
+ "--color=never",
410
+ "--hidden",
411
+ "--no-follow",
412
+ "--no-ignore",
413
+ "--max-filesize",
414
+ String(MAX_FILE_BYTES),
415
+ ...rgIgnoreArgs(),
416
+ ];
417
+ if (opts.glob !== undefined) args.push("--glob", opts.glob);
418
+ 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
+ }
425
+ const hits = parseRgJson(opts.root, ran.stdout);
426
+ hits.sort(compareMatch);
427
+ const lines = hits.map(formatMatch);
428
+ if (lines.length === 0) return noMatches([]);
429
+ return { content: renderLines(lines, limit, []), ok: true };
430
+ }
431
+
432
+ function compareMatch(a: MatchLine, b: MatchLine): number {
433
+ if (a.path !== b.path) return a.path < b.path ? -1 : 1;
434
+ return a.line - b.line;
435
+ }
436
+
437
+ async function grepWalk(opts: GrepOpts, abs: string, kind: "file" | "dir", rel: string, limit: number): Promise<SearchResult> {
438
+ try {
439
+ new RegExp(opts.pattern);
440
+ } catch (err) {
441
+ return { content: `invalid regex: ${(err as Error).message}`, ok: false };
442
+ }
443
+
444
+ const files = await listedFiles(abs, rel, kind);
445
+ const glob = opts.glob;
446
+ const filtered = glob === undefined ? files : files.filter((p) => globMatches(glob, p));
447
+ const matcher = new LineMatcher();
448
+ const hits: MatchLine[] = [];
449
+ const notices: string[] = [];
450
+ try {
451
+ for (const fileRel of filtered) {
452
+ if (ignoredRel(fileRel)) continue;
453
+ const fileAbs = join(opts.root, fileRel);
454
+ let st;
455
+ try {
456
+ st = await lstat(fileAbs);
457
+ } catch {
458
+ continue;
459
+ }
460
+ if (st.isSymbolicLink() || !st.isFile()) continue;
461
+ if (st.size > MAX_FILE_BYTES) continue;
462
+ let text: string;
463
+ try {
464
+ text = await readFile(fileAbs, "utf8");
465
+ } catch {
466
+ continue;
467
+ }
468
+ if (text.includes("\0")) continue;
469
+ const lines = splitLines(text);
470
+ const matched = await matcher.match(opts.pattern, "", lines);
471
+ if (matched === "timeout") {
472
+ notices.push(`search cut off on ${fileRel} after ${MATCH_TIMEOUT_MS}ms`);
473
+ continue;
474
+ }
475
+ if (matched === "invalid") {
476
+ return { content: `invalid regex: ${opts.pattern}`, ok: false };
477
+ }
478
+ for (const hit of matched) hits.push({ path: fileRel, line: hit.line, text: hit.text });
479
+ }
480
+ } finally {
481
+ matcher.close();
482
+ }
483
+
484
+ 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 };
488
+ }
489
+
490
+ export async function grep(opts: GrepOpts): Promise<SearchResult> {
491
+ const pattern = opts.pattern;
492
+ if (pattern.length === 0) return { content: "grep needs a pattern", ok: false };
493
+
494
+ const target = await inspectTarget(opts.root, opts.path);
495
+ if ("error" in target) return { content: target.error, ok: false };
496
+
497
+ const limit = opts.limit !== undefined && opts.limit > 0 ? Math.floor(opts.limit) : DEFAULT_MATCH_LIMIT;
498
+ const engine = opts.engine ?? "auto";
499
+
500
+ switch (engine) {
501
+ case "walk":
502
+ return grepWalk(opts, opts.path, target.kind, target.rel, limit);
503
+ case "ripgrep": {
504
+ const got = await grepRipgrep(opts, opts.path, limit);
505
+ if (got === null) return { content: "ripgrep is not on PATH", ok: false };
506
+ return got;
507
+ }
508
+ case "auto": {
509
+ if (findOnPath("rg") !== null) {
510
+ const got = await grepRipgrep(opts, opts.path, limit);
511
+ if (got !== null) return got;
512
+ }
513
+ return grepWalk(opts, opts.path, target.kind, target.rel, limit);
514
+ }
515
+ default:
516
+ return assertNever(engine, "grep");
517
+ }
518
+ }
519
+
520
+ function fdExcludeArgs(): string[] {
521
+ const args: string[] = [];
522
+ for (const name of DEFAULT_IGNORE) args.push("--exclude", name);
523
+ return args;
524
+ }
525
+
526
+ async function findFd(opts: FindOpts, abs: string, limit: number): Promise<SearchResult | null> {
527
+ const fd = findOnPath("fd");
528
+ if (fd === null) return null;
529
+ const args = [
530
+ "--glob",
531
+ "--color=never",
532
+ "--hidden",
533
+ "--no-ignore",
534
+ "--absolute-path",
535
+ ...fdExcludeArgs(),
536
+ ];
537
+ let pattern = opts.pattern;
538
+ if (pattern.includes("/")) {
539
+ args.push("--full-path");
540
+ if (!pattern.startsWith("**/") && pattern !== "**") pattern = `**/${pattern}`;
541
+ }
542
+ 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
+ }
548
+ const paths: string[] = [];
549
+ for (const raw of ran.stdout.split("\n")) {
550
+ const line = raw.replace(/\r$/, "").trim();
551
+ if (line.length === 0) continue;
552
+ const rel = posixRel(opts.root, line);
553
+ if (rel === null || ignoredRel(rel)) continue;
554
+ paths.push(rel);
555
+ }
556
+ 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 };
559
+ }
560
+
561
+ 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));
564
+ 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 };
567
+ }
568
+
569
+ export async function find(opts: FindOpts): Promise<SearchResult> {
570
+ if (opts.pattern.length === 0) return { content: "find needs a pattern", ok: false };
571
+ const target = await inspectTarget(opts.root, opts.path);
572
+ if ("error" in target) return { content: target.error, ok: false };
573
+ const limit = opts.limit !== undefined && opts.limit > 0 ? Math.floor(opts.limit) : DEFAULT_FIND_LIMIT;
574
+ const engine = opts.engine ?? "auto";
575
+ switch (engine) {
576
+ case "walk":
577
+ return findWalk(opts, opts.path, target.kind, target.rel, limit);
578
+ case "fd": {
579
+ const got = await findFd(opts, opts.path, limit);
580
+ if (got === null) return { content: "fd is not on PATH", ok: false };
581
+ return got;
582
+ }
583
+ case "auto": {
584
+ if (findOnPath("fd") !== null) {
585
+ const got = await findFd(opts, opts.path, limit);
586
+ if (got !== null) return got;
587
+ }
588
+ return findWalk(opts, opts.path, target.kind, target.rel, limit);
589
+ }
590
+ default:
591
+ return assertNever(engine, "find");
592
+ }
593
+ }
594
+
595
+ export async function ls(opts: LsOpts): Promise<SearchResult> {
596
+ 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 };
599
+
600
+ let entries;
601
+ try {
602
+ entries = await readdir(opts.path, { withFileTypes: true });
603
+ } catch (err) {
604
+ return { content: `cannot list ${target.rel || "."}: ${(err as Error).message}`, ok: false };
605
+ }
606
+
607
+ const names: string[] = [];
608
+ for (const entry of entries) {
609
+ if (DEFAULT_IGNORE.includes(entry.name)) continue;
610
+ const childRel = target.rel.length === 0 ? entry.name : `${target.rel}/${entry.name}`;
611
+ if (ignoredRel(childRel)) continue;
612
+ // Do not follow: a symlink is listed as a name, never as the directory
613
+ // it points at, so a link to / cannot walk the disk.
614
+ if (entry.isSymbolicLink()) {
615
+ names.push(entry.name);
616
+ continue;
617
+ }
618
+ if (entry.isDirectory()) names.push(`${entry.name}/`);
619
+ else if (entry.isFile()) names.push(entry.name);
620
+ }
621
+ names.sort((a, b) => {
622
+ const al = a.toLowerCase();
623
+ const bl = b.toLowerCase();
624
+ if (al === bl) return a < b ? -1 : 1;
625
+ return al < bl ? -1 : 1;
626
+ });
627
+
628
+ 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 };
644
+ }
@@ -0,0 +1,13 @@
1
+ /** O.5: one in-process coordinator per journal; callers never nest the same key. */
2
+ import { resolve } from "node:path";
3
+
4
+ const tails = new Map<string, Promise<void>>();
5
+ export async function serialized<T>(key: string, run: () => Promise<T>): Promise<T> {
6
+ const id = resolve(key), previous = tails.get(id) ?? Promise.resolve();
7
+ let release!: () => void;
8
+ const current = new Promise<void>(done => { release = done; });
9
+ tails.set(id, current);
10
+ await previous;
11
+ try { return await run(); }
12
+ finally { release(); if (tails.get(id) === current) tails.delete(id); }
13
+ }