@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,256 @@
1
+ /**
2
+ * Workspace scanning, reconciliation and restore.
3
+ *
4
+ * The tree can move a cursor between nodes; this is what lets the files move
5
+ * with it. Two properties shape everything here:
6
+ *
7
+ * TURN GRANULARITY. The workspace is scanned once per turn, not per tool
8
+ * call. Per-call is too fine to be meaningful -- the documented criticism
9
+ * of character-level versioning is that a single keystroke is not a change
10
+ * -- and a turn is the same unit as "a message and the edit it produced".
11
+ *
12
+ * TWO INDEPENDENT SOURCES. A tool's effect says what the tool CLAIMED to
13
+ * do; a scan says what ACTUALLY changed. bash can write anywhere and its
14
+ * effect records only an exit code, so without the scan an edit outside
15
+ * every declared path would be invisible -- and a restore would silently
16
+ * fail to undo it. Comparing the two makes that detectable, which is the
17
+ * same rule the handoff applies to the model: claims are checked, not
18
+ * believed.
19
+ *
20
+ * A restore moves the workspace and nothing else. Migrations, sent requests
21
+ * and started processes do not come back, and applyRestore says so rather
22
+ * than implying it undid the world.
23
+ */
24
+
25
+ import { readdir, rm, stat, lstat } from "node:fs/promises";
26
+ import type { Dirent } from "node:fs";
27
+ import { join, dirname, relative, sep, isAbsolute } from "node:path";
28
+ import type { Effect } from "../core/events.ts";
29
+ import { blobAddress, digestFile, verifyBlob, restoreBlob } from "./blobs.ts";
30
+
31
+ export interface ScanLimits {
32
+ maxFiles: number;
33
+ maxFileBytes: number;
34
+ maxTotalBytes: number;
35
+ }
36
+
37
+ /**
38
+ * Hashing and blob capture stream regular files, so file size has no default
39
+ * product cap. Callers may impose explicit resource limits. Exceeding one,
40
+ * losing a directory, or meeting an unsupported file type fails the whole scan;
41
+ * a partial listing could make a restore delete files it never recorded.
42
+ */
43
+ export const DEFAULT_SCAN_LIMITS: ScanLimits = {
44
+ maxFiles: Infinity,
45
+ maxFileBytes: Infinity,
46
+ maxTotalBytes: Infinity,
47
+ };
48
+
49
+ /**
50
+ * Directories never scanned, snapshotted or restored.
51
+ *
52
+ * One constant rather than a default repeated at each call site: the snapshot
53
+ * and the restore have to agree exactly. A path the snapshot skipped is absent
54
+ * from every target file set, and applyRestore deletes whatever is not in the
55
+ * target -- so a restore using a shorter list than the snapshot would delete
56
+ * the log, the git directory, or the user's dependencies.
57
+ */
58
+ export const DEFAULT_IGNORE: readonly string[] = [".xeno", ".git", "node_modules"];
59
+
60
+ /** Relative path -> content digest. Same address space as the blob store. */
61
+ export type FileSet = Record<string, string>;
62
+
63
+ export function workspaceVersion(files: FileSet): string {
64
+ return blobAddress(JSON.stringify(Object.entries(files).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)));
65
+ }
66
+
67
+ export type ScanResult =
68
+ | { ok: true; files: FileSet }
69
+ | { ok: false; error: string };
70
+
71
+ export interface ScanDelta {
72
+ added: FileSet;
73
+ changed: FileSet;
74
+ removed: string[];
75
+ }
76
+
77
+ export interface Reconciliation {
78
+ /** Changed on disk with no tool claiming it -- usually a shell command. */
79
+ undeclared: string[];
80
+ /** A tool claimed an edit the disk does not show. */
81
+ declaredButUnchanged: string[];
82
+ }
83
+
84
+ export interface RestoreResult {
85
+ restored: string[];
86
+ removed: string[];
87
+ /** Target files whose content is no longer in the blob store. */
88
+ unrecoverable: string[];
89
+ /** What this restore did not and could not touch. */
90
+ note: string;
91
+ }
92
+
93
+ function isIgnored(rel: string, ignore: readonly string[]): boolean {
94
+ const parts = rel.split(sep);
95
+ return ignore.some((i) => parts.includes(i));
96
+ }
97
+
98
+ export async function scanWorkspace(
99
+ root: string,
100
+ ignore: readonly string[],
101
+ limits: ScanLimits = DEFAULT_SCAN_LIMITS,
102
+ ): Promise<ScanResult> {
103
+ const files: FileSet = {};
104
+ let count = 0;
105
+ let total = 0;
106
+
107
+ async function walk(dir: string): Promise<string | null> {
108
+ let entries: Dirent[];
109
+ try {
110
+ entries = await readdir(dir, { withFileTypes: true });
111
+ } catch (err) {
112
+ return `cannot scan ${dir}: ${String(err)}`;
113
+ }
114
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
115
+ const abs = join(dir, entry.name);
116
+ const rel = relative(root, abs);
117
+ if (isIgnored(rel, ignore)) continue;
118
+ if (entry.isDirectory()) {
119
+ const err = await walk(abs);
120
+ if (err) return err;
121
+ continue;
122
+ }
123
+ if (!entry.isFile()) return `cannot snapshot unsupported file type: ${rel}`;
124
+
125
+ const info = await stat(abs);
126
+ if (info.size > limits.maxFileBytes) {
127
+ return `${rel} is ${info.size} bytes, over maxFileBytes (${limits.maxFileBytes})`;
128
+ }
129
+ if (++count > limits.maxFiles) {
130
+ return `workspace has more than maxFiles (${limits.maxFiles}) files`;
131
+ }
132
+ total += info.size;
133
+ if (total > limits.maxTotalBytes) {
134
+ return `workspace exceeds maxTotalBytes (${limits.maxTotalBytes})`;
135
+ }
136
+ files[rel.split(sep).join("/")] = await digestFile(abs);
137
+ }
138
+ return null;
139
+ }
140
+
141
+ try {
142
+ const error = await walk(root);
143
+ return error ? { ok: false, error } : { ok: true, files };
144
+ } catch (error) { return { ok: false, error: `incomplete workspace scan: ${String(error)}` }; }
145
+ }
146
+
147
+ export function diffScans(before: FileSet, after: FileSet): ScanDelta {
148
+ const added: FileSet = {};
149
+ const changed: FileSet = {};
150
+ const removed: string[] = [];
151
+
152
+ for (const [path, sha] of Object.entries(after)) {
153
+ const was = before[path];
154
+ if (was === undefined) added[path] = sha;
155
+ else if (was !== sha) changed[path] = sha;
156
+ }
157
+ for (const path of Object.keys(before)) {
158
+ if (!(path in after)) removed.push(path);
159
+ }
160
+ return { added, changed, removed: removed.sort() };
161
+ }
162
+
163
+ export function reconcile(effects: readonly Effect[], observed: ScanDelta): Reconciliation {
164
+ // Only edits claim to change the workspace. A read claims nothing, and a
165
+ // command claims only its exit code -- which is exactly why the scan is
166
+ // needed to see what a command did.
167
+ const declared = new Set(
168
+ effects.filter((e) => e.kind === "edit").map((e) => e.path),
169
+ );
170
+ const touched = [
171
+ ...Object.keys(observed.added),
172
+ ...Object.keys(observed.changed),
173
+ ...observed.removed,
174
+ ];
175
+
176
+ return {
177
+ undeclared: touched.filter((p) => !declared.has(p)).sort(),
178
+ declaredButUnchanged: [...declared].filter((p) => !touched.includes(p)).sort(),
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Replays deltas up to and including `toNode` to get the file set at that
184
+ * point. Pure, so a restore can be planned and inspected before anything is
185
+ * written.
186
+ */
187
+ export function planRestore(
188
+ deltas: readonly (ScanDelta & { node: number })[],
189
+ toNode: number,
190
+ ): FileSet {
191
+ const files: FileSet = {};
192
+ for (const d of deltas) {
193
+ if (d.node > toNode) break;
194
+ for (const [path, sha] of Object.entries(d.added)) files[path] = sha;
195
+ for (const [path, sha] of Object.entries(d.changed)) files[path] = sha;
196
+ for (const path of d.removed) delete files[path];
197
+ }
198
+ return files;
199
+ }
200
+
201
+ export async function applyRestore(
202
+ root: string,
203
+ store: string,
204
+ target: FileSet,
205
+ ignore: readonly string[],
206
+ ): Promise<RestoreResult> {
207
+ const restored: string[] = [];
208
+ const unrecoverable: string[] = [];
209
+
210
+ // Validate the complete plan before its first write or deletion. Missing
211
+ // objects and a failed scan cannot be interpreted as an empty target (H).
212
+ const current = await scanWorkspace(root, ignore);
213
+ if (!current.ok) throw new Error(current.error);
214
+ for (const [path, sha] of Object.entries(target)) {
215
+ if (isAbsolute(path) || path.split(/[\\/]/).some((p) => p === "..") || isIgnored(path, ignore)) {
216
+ throw new Error(`unsafe restore path: ${path}`);
217
+ }
218
+ let parent = dirname(join(root, path));
219
+ while (parent !== root && parent.startsWith(root + sep)) {
220
+ try { if ((await lstat(parent)).isSymbolicLink()) throw new Error(`restore path crosses symlink: ${path}`); }
221
+ catch (error) { if ((error as { code?: string }).code !== "ENOENT") throw error; }
222
+ parent = dirname(parent);
223
+ }
224
+ if (!await verifyBlob(store, sha)) unrecoverable.push(path);
225
+ }
226
+ if (unrecoverable.length > 0) return {
227
+ restored: [], removed: [], unrecoverable: unrecoverable.sort(),
228
+ note: "Restore not applied: missing snapshot objects. Workspace and cursor remain unchanged.",
229
+ };
230
+
231
+ for (const [path, sha] of Object.entries(target)) {
232
+ const abs = join(root, path);
233
+ await restoreBlob(store, sha, abs);
234
+ restored.push(path);
235
+ }
236
+
237
+ // Anything present but not in the target was created after this point.
238
+ const removed: string[] = [];
239
+ if (current.ok) {
240
+ for (const path of Object.keys(current.files)) {
241
+ if (path in target) continue;
242
+ await rm(join(root, path), { force: true });
243
+ removed.push(path);
244
+ }
245
+ }
246
+
247
+ return {
248
+ restored: restored.sort(),
249
+ removed: removed.sort(),
250
+ unrecoverable: unrecoverable.sort(),
251
+ note:
252
+ "Restored the workspace only. Anything outside it is not undone: " +
253
+ "database migrations, requests already sent, processes already " +
254
+ "started, and files outside the workspace root all remain as they are.",
255
+ };
256
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Git worktree isolation for branches that write.
3
+ *
4
+ * A worktree is a second checkout sharing one object database, which is why
5
+ * it is the isolation primitive parallel agents converged on: each branch
6
+ * edits its own files, and conflicts surface at merge time through ordinary
7
+ * git tooling instead of as silent overwrites during the work.
8
+ *
9
+ * What this does NOT isolate, stated because assuming otherwise is how these
10
+ * setups break:
11
+ *
12
+ * ENVIRONMENT. Ports, databases, and anything else outside the working
13
+ * directory are still shared. Two branches running a dev server or a
14
+ * migration will collide, and a test can fail because a sibling took the
15
+ * port rather than because the branch is wrong. That needs containers.
16
+ *
17
+ * ESCAPE. A branch can point git back at the shared checkout with `git -C`
18
+ * or GIT_DIR, which has been an actual reported escape. Preventing it is a
19
+ * sandbox's job, and this project has no sandbox layer; the isolation here
20
+ * is for cooperative branches, not adversarial ones.
21
+ */
22
+
23
+ import { mkdtemp, rm, realpath } from "node:fs/promises";
24
+ import { join } from "node:path";
25
+ import { tmpdir } from "node:os";
26
+
27
+ /** Branch prefix, so a stale worktree is identifiable months later. */
28
+ export const WORKTREE_BRANCH_PREFIX = "xeno/";
29
+
30
+ export interface WorktreeInfo {
31
+ path: string;
32
+ branch: string | null;
33
+ locked: boolean;
34
+ }
35
+
36
+ export type CreateResult =
37
+ | { ok: true; path: string; branch: string; snapshot: { head: string; tree: string } }
38
+ | { ok: false; error: string };
39
+
40
+ export type RemoveResult =
41
+ | { ok: true }
42
+ | { ok: false; error: string; dirty: boolean };
43
+
44
+ interface GitRun {
45
+ code: number;
46
+ stdout: string;
47
+ stderr: string;
48
+ }
49
+
50
+ function git(cwd: string, args: string[], env?: Record<string, string | undefined>): GitRun {
51
+ const p = Bun.spawnSync(["git", ...args], { cwd, ...(env ? { env } : {}) });
52
+ const dec = new TextDecoder();
53
+ return {
54
+ code: p.exitCode,
55
+ stdout: dec.decode(p.stdout),
56
+ stderr: dec.decode(p.stderr),
57
+ };
58
+ }
59
+
60
+ export async function isGitRepo(dir: string): Promise<boolean> {
61
+ return git(dir, ["rev-parse", "--git-dir"]).code === 0;
62
+ }
63
+
64
+ export async function createWorktree(repo: string, name: string): Promise<CreateResult> {
65
+ // Refused rather than degraded. Falling back to the shared directory is the
66
+ // exact failure isolation exists to prevent: parallel writing branches
67
+ // would overwrite each other with nothing to indicate isolation was never
68
+ // in place.
69
+ if (!(await isGitRepo(repo))) {
70
+ return {
71
+ ok: false,
72
+ error:
73
+ `${repo} is not a git repository, so a writing branch cannot be isolated. ` +
74
+ "Initialise one, or run the branch read-only.",
75
+ };
76
+ }
77
+
78
+ // O.4: snapshot the working copy through a private index. Never stash, reset,
79
+ // or stage the user's index merely to construct a child input.
80
+ const root = git(repo, ["rev-parse", "--show-toplevel"]);
81
+ if (root.code !== 0 || await realpath(repo) !== await realpath(root.stdout.trim())) {
82
+ return { ok: false, error: "writing branch input must be the repository root" };
83
+ }
84
+ const head = git(repo, ["rev-parse", "--verify", "HEAD"]);
85
+ if (head.code !== 0) return { ok: false, error: "writing branch input requires an existing HEAD commit" };
86
+ const unmerged = git(repo, ["ls-files", "--unmerged"]);
87
+ if (unmerged.code !== 0 || unmerged.stdout.length > 0) return { ok: false, error: "resolve index conflicts before creating a writing branch" };
88
+ const staged = git(repo, ["ls-files", "--stage", "-z"]);
89
+ if (staged.code !== 0 || staged.stdout.split("\0").some(entry => entry.startsWith("160000 "))) {
90
+ return { ok: false, error: "writing branch snapshots do not yet support submodules" };
91
+ }
92
+ const scratch = await mkdtemp(join(tmpdir(), "xeno-input-index-"));
93
+ let tree: string;
94
+ try {
95
+ const env = { ...process.env, GIT_INDEX_FILE: join(scratch, "index") };
96
+ for (const args of [["read-tree", head.stdout.trim()], ["add", "--all", "--", "."]]) {
97
+ const result = git(repo, args, env);
98
+ if (result.code !== 0) return { ok: false, error: `cannot capture branch input: ${result.stderr.trim()}` };
99
+ }
100
+ const captured = git(repo, ["ls-files", "--stage", "-z"], env);
101
+ if (captured.code !== 0 || captured.stdout.split("\0").some(entry => entry.startsWith("160000 "))) {
102
+ return { ok: false, error: "writing branch snapshots do not yet support nested repositories or submodules" };
103
+ }
104
+ const written = git(repo, ["write-tree"], env);
105
+ if (written.code !== 0) return { ok: false, error: `cannot capture branch input tree: ${written.stderr.trim()}` };
106
+ tree = written.stdout.trim();
107
+ } finally { await rm(scratch, { recursive: true, force: true }); }
108
+
109
+ const branch = `${WORKTREE_BRANCH_PREFIX}${name}`;
110
+ const created = await mkdtemp(join(tmpdir(), `xeno-wt-${name}-`));
111
+ // Resolved through symlinks, because git reports the real path and a
112
+ // caller comparing the two would never match. On macOS /var is a symlink
113
+ // to /private/var, so every temp path differs by default.
114
+ const path = await realpath(created);
115
+ // mkdtemp created it; git worktree add needs the path not to exist.
116
+ await rm(path, { recursive: true, force: true });
117
+
118
+ // --lock in the same call: locking afterwards leaves a window in which a
119
+ // concurrent prune can remove the worktree that was just created.
120
+ const add = git(repo, ["worktree", "add", "--lock", "-b", branch, path, head.stdout.trim()]);
121
+ if (add.code !== 0) {
122
+ return { ok: false, error: `git worktree add failed: ${add.stderr.trim()}` };
123
+ }
124
+ // Only this freshly created disposable checkout is reset. The child index
125
+ // carries the parent's uncommitted input, so normal diff/inspection still sees it.
126
+ const hydrate = git(path, ["read-tree", "--reset", "-u", tree]);
127
+ if (hydrate.code !== 0) {
128
+ const removed = await removeWorktree(repo, path, branch, { force: true });
129
+ return { ok: false, error: `cannot materialize branch input: ${hydrate.stderr.trim()}` +
130
+ (removed.ok ? "" : `; cleanup required at ${path}: ${removed.error}`) };
131
+ }
132
+ return { ok: true, path, branch, snapshot: { head: head.stdout.trim(), tree } };
133
+ }
134
+
135
+ /**
136
+ * Parses `--porcelain -z`, the only output format documented as stable
137
+ * across git versions and user configuration. `-z` additionally survives a
138
+ * path containing a newline, which a line-based parse would split into two
139
+ * bogus entries.
140
+ */
141
+ export async function listWorktrees(repo: string): Promise<WorktreeInfo[]> {
142
+ const out = git(repo, ["worktree", "list", "--porcelain", "-z"]);
143
+ if (out.code !== 0) return [];
144
+
145
+ const worktrees: WorktreeInfo[] = [];
146
+ let current: Partial<WorktreeInfo> = {};
147
+ for (const record of out.stdout.split("\0")) {
148
+ if (record === "") {
149
+ // A blank record terminates one worktree's attributes.
150
+ if (current.path) {
151
+ worktrees.push({
152
+ path: current.path,
153
+ branch: current.branch ?? null,
154
+ locked: current.locked ?? false,
155
+ });
156
+ }
157
+ current = {};
158
+ continue;
159
+ }
160
+ const [key, ...rest] = record.split(" ");
161
+ const value = rest.join(" ");
162
+ if (key === "worktree") current.path = value;
163
+ else if (key === "branch") current.branch = value.replace(/^refs\/heads\//, "");
164
+ else if (key === "locked") current.locked = true;
165
+ }
166
+ if (current.path) {
167
+ worktrees.push({
168
+ path: current.path,
169
+ branch: current.branch ?? null,
170
+ locked: current.locked ?? false,
171
+ });
172
+ }
173
+ return worktrees;
174
+ }
175
+
176
+ /**
177
+ * Removes a worktree, its registration and its branch.
178
+ *
179
+ * A dirty worktree is reported rather than forced. The refusal is evidence
180
+ * to look at: uncommitted changes there may be exactly the work the branch
181
+ * was for, and discarding them silently is the destructive-restore mistake
182
+ * in a different costume. `force` exists for a caller that has decided.
183
+ */
184
+ export async function removeWorktree(
185
+ repo: string,
186
+ path: string,
187
+ branch: string | null,
188
+ opts: { force?: boolean } = {},
189
+ ): Promise<RemoveResult> {
190
+ // Created with --lock, so unlock first; a locked worktree otherwise needs
191
+ // --force twice, which would conflate "locked" with "dirty".
192
+ git(repo, ["worktree", "unlock", path]);
193
+
194
+ const args = ["worktree", "remove", path];
195
+ if (opts.force) args.push("--force");
196
+ const removed = git(repo, args);
197
+
198
+ if (removed.code !== 0) {
199
+ const dirty = /contains modified or untracked files|is dirty/i.test(removed.stderr);
200
+ return {
201
+ ok: false,
202
+ error: dirty
203
+ ? `${path} has uncommitted changes. Inspect them before removing, ` +
204
+ "or pass force to discard them."
205
+ : `git worktree remove failed: ${removed.stderr.trim()}`,
206
+ dirty,
207
+ };
208
+ }
209
+
210
+ // Removing a worktree never deletes its branch, so a run of branches would
211
+ // otherwise leave a branch behind for every one of them.
212
+ if (branch) git(repo, ["branch", "-D", branch]);
213
+ return { ok: true };
214
+ }
215
+
216
+ /**
217
+ * Clears registrations left behind by hand-deleted directories.
218
+ *
219
+ * Only for that case. A worktree that was MOVED must be repaired instead --
220
+ * pruning it discards a working directory that still exists.
221
+ */
222
+ export async function pruneWorktrees(repo: string): Promise<void> {
223
+ // A lock exists to stop pruning, and createWorktree takes one -- so a
224
+ // hand-deleted worktree of ours would stay registered forever without
225
+ // unlocking first. Only ours: the prefix check keeps this from unlocking
226
+ // a worktree someone locked deliberately.
227
+ for (const wt of await listWorktrees(repo)) {
228
+ if (wt.locked && wt.branch?.startsWith(WORKTREE_BRANCH_PREFIX)) {
229
+ git(repo, ["worktree", "unlock", wt.path]);
230
+ }
231
+ }
232
+ git(repo, ["worktree", "prune"]);
233
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Escape sequences the renderer emits. Each constant names the standard it
3
+ * comes from and what breaks without it -- a bare number in a CSI is otherwise
4
+ * a magic that nobody can audit.
5
+ */
6
+
7
+ /**
8
+ * DECSET 2026 — begin synchronized output.
9
+ * Spec: https://gist.github.com/christianparpart/d8d62cc110656ba92d4e171cec8a547a
10
+ * (mode 2026, adopted by xterm, tmux, kitty, iTerm2, Windows Terminal).
11
+ * Without this, tmux paints each cursor move as its own frame and the user
12
+ * sees the cursor jump and the frame tear while a diff is applied.
13
+ */
14
+ export const SYNC_BEGIN = "\x1b[?2026h";
15
+
16
+ /**
17
+ * DECRST 2026 — end synchronized output, the matching close of SYNC_BEGIN.
18
+ * Omitting it leaves the terminal buffering subsequent output forever, so
19
+ * the next frame (and everything after) never appears.
20
+ */
21
+ export const SYNC_END = "\x1b[?2026l";
22
+
23
+ /**
24
+ * CR — carriage return. ASCII 0x0D / ECMA-48. Returns to column 0 of the
25
+ * current row so a changed line can be overwritten in place.
26
+ */
27
+ export const CR = "\r";
28
+
29
+ /**
30
+ * LF — line feed. ASCII 0x0A / ECMA-48. Advances onto a newly grown row of
31
+ * the live region; a CSI CUD at the bottom of the screen is clamped and
32
+ * would overwrite the last line instead of extending the region.
33
+ */
34
+ export const LF = "\n";
35
+
36
+ /**
37
+ * CSI CUU — cursor up N rows. ECMA-48 §8.3.14. Relative moves are what make
38
+ * a line-diff possible without knowing the absolute row of the live region
39
+ * (a main-screen TUI does not own the viewport origin).
40
+ */
41
+ export function cursorUp(n: number): string {
42
+ return n > 0 ? `\x1b[${n}A` : "";
43
+ }
44
+
45
+ /**
46
+ * CSI CUD — cursor down N rows. ECMA-48 §8.3.13. Used to skip unchanged
47
+ * lines when the cursor sits above them, not to grow the region (see LF).
48
+ */
49
+ export function cursorDown(n: number): string {
50
+ return n > 0 ? `\x1b[${n}B` : "";
51
+ }
52
+
53
+ /**
54
+ * CSI EL 0 — erase from cursor to end of line. ECMA-48 §8.3.41.
55
+ * Without it a shorter replacement leaves the tail of the previous longer
56
+ * line sitting on screen.
57
+ */
58
+ export const ERASE_LINE_TAIL = "\x1b[K";
59
+
60
+ /**
61
+ * CSI ED 0 — erase from cursor to end of display. ECMA-48 §8.3.39.
62
+ * Used when the new frame is shorter than the old one. Safe only because
63
+ * the live region sits at the bottom of the main screen: anything below it
64
+ * is leftover rows of a taller previous frame, not the user's scrollback
65
+ * (scrollback is above us).
66
+ */
67
+ export const ERASE_DOWN = "\x1b[J";
@@ -0,0 +1,29 @@
1
+ import { CombinedAutocompleteProvider, type AutocompleteItem, type AutocompleteSuggestions, type SlashCommand } from "../vendor/pi-tui/autocomplete.ts";
2
+ import { fuzzyFilter } from "../vendor/pi-tui/fuzzy.ts";
3
+
4
+ /** Pi's completion rules, with xeno's workspace index in place of an fd dependency. */
5
+ export class WorkspaceAutocomplete extends CombinedAutocompleteProvider {
6
+ triggerCharacters = ["/", "@"];
7
+ private readonly paths: (refresh?: boolean) => Promise<readonly string[]>;
8
+
9
+ constructor(commands: SlashCommand[], workspace: string, paths: (refresh?: boolean) => Promise<readonly string[]>) {
10
+ super(commands, workspace);
11
+ this.paths = paths;
12
+ }
13
+
14
+ override async getSuggestions(lines: string[], line: number, col: number,
15
+ options: { signal: AbortSignal; force?: boolean }): Promise<AutocompleteSuggestions | null> {
16
+ const before = (lines[line] ?? "").slice(0, col);
17
+ const prefix = /(?:^|\s)(@(?:"[^"]*|[^\s]*))$/.exec(before)?.[1];
18
+ if (prefix !== undefined) {
19
+ const paths = await this.paths(options.force === true || prefix === "@");
20
+ if (options.signal.aborted) return null;
21
+ const query = prefix.slice(1).replace(/^"/, "");
22
+ const items: AutocompleteItem[] = fuzzyFilter([...paths], query, (p) => p).map((path) => ({
23
+ value: `@${/\s/.test(path) ? `"${path}"` : path}`, label: path,
24
+ }));
25
+ return items.length > 0 ? { items, prefix } : null;
26
+ }
27
+ return super.getSuggestions(lines, line, col, options);
28
+ }
29
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Path completion as a pure function: the caller reads the directory,
3
+ * this module only matches names. I/O in applyKey would make "tab on
4
+ * src/fo" a test that needs a disk.
5
+ */
6
+
7
+ export interface PathCompletion {
8
+ readonly completed: string;
9
+ readonly candidates: readonly string[];
10
+ }
11
+
12
+ export interface PathToken {
13
+ readonly start: number;
14
+ readonly end: number;
15
+ readonly prefix: string;
16
+ }
17
+
18
+ /**
19
+ * Longest prefix of `entries` that still starts with `prefix`. One match
20
+ * completes fully; several matches complete only as far as they agree,
21
+ * so a later Tab can still distinguish them.
22
+ */
23
+ export function completePath(prefix: string, entries: readonly string[]): PathCompletion {
24
+ const candidates = entries.filter((e) => e.startsWith(prefix));
25
+ if (candidates.length === 0) return { completed: prefix, candidates: [] };
26
+ if (candidates.length === 1) return { completed: candidates[0]!, candidates };
27
+ return { completed: longestCommonPrefix(candidates), candidates };
28
+ }
29
+
30
+ /**
31
+ * The whitespace-delimited token the cursor is in. Null when it does not
32
+ * look like a path, so Tab in ordinary prose keeps its existing no-op
33
+ * rather than swallowing the key.
34
+ */
35
+ export function pathTokenAtCursor(text: string, cursor: number): PathToken | null {
36
+ let start = cursor;
37
+ while (start > 0 && !isTokenSep(text[start - 1]!)) start--;
38
+ let end = cursor;
39
+ while (end < text.length && !isTokenSep(text[end]!)) end++;
40
+ const prefix = text.slice(start, end);
41
+ if (!looksLikePath(prefix)) return null;
42
+ return { start, end, prefix };
43
+ }
44
+
45
+ export function looksLikePath(s: string): boolean {
46
+ if (s.length === 0) return false;
47
+ // @-mentions are a different picker; stealing them here would hide
48
+ // the file-index path the user already has.
49
+ if (s.startsWith("@")) return false;
50
+ return s.includes("/") || s.startsWith("~") || s.startsWith(".");
51
+ }
52
+
53
+ /** Directory to list for a typed prefix. "src/fo" → "src"; "fo" → ".". */
54
+ export function dirOfPrefix(prefix: string): string {
55
+ if (prefix.endsWith("/")) return prefix.slice(0, -1) || ".";
56
+ const i = prefix.lastIndexOf("/");
57
+ if (i < 0) return ".";
58
+ if (i === 0) return "/";
59
+ return prefix.slice(0, i);
60
+ }
61
+
62
+ /** Join listing names back onto the prefix's directory, matching completePath. */
63
+ export function entriesFromNames(prefix: string, names: readonly string[]): readonly string[] {
64
+ const dir = dirOfPrefix(prefix);
65
+ return names.map((name) => {
66
+ if (dir === ".") return name;
67
+ if (dir === "/") return `/${name}`;
68
+ return `${dir}/${name}`;
69
+ });
70
+ }
71
+
72
+ function isTokenSep(ch: string): boolean {
73
+ return /\s/.test(ch);
74
+ }
75
+
76
+ function longestCommonPrefix(xs: readonly string[]): string {
77
+ const first = xs[0];
78
+ if (first === undefined) return "";
79
+ let p = first;
80
+ for (const s of xs) {
81
+ while (!s.startsWith(p)) {
82
+ if (p.length === 0) return "";
83
+ p = p.slice(0, -1);
84
+ }
85
+ }
86
+ return p;
87
+ }