@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,463 @@
1
+ /**
2
+ * Many sessions under one project.
3
+ *
4
+ * A workspace used to hold a single log at `.xeno/log`. That made `/new`,
5
+ * `/resume`, `/name` and the rest unimplementable: there was nothing to
6
+ * list, name, or pick. Each session is now its own log tree under
7
+ * `.xeno/sessions/<id>/`, and the blob store stays at `.xeno/blobs` --
8
+ * content-addressed bytes are the same bytes no matter which session
9
+ * snapshotted them, and splitting the store would throw the dedup away
10
+ * without buying any isolation.
11
+ *
12
+ * There is no index file. An index is a second source of truth and will
13
+ * disagree with the directories; this project's whole position is that the
14
+ * log is the only truth, and a session list that can lie is the same class
15
+ * of error as a handoff that can lie. Listing is a scan. The cost is one
16
+ * small read per session, capped, which is the point of the cap.
17
+ *
18
+ * Why the id has a random suffix, not just a timestamp: two sessions
19
+ * created in the same millisecond would otherwise share a directory, and
20
+ * the second write would land in the first session's tree. That is the
21
+ * same collision shape that once had two node allocators agree on id 1
22
+ * and silently truncate a log.
23
+ */
24
+
25
+ import { cp, mkdir, mkdtemp, open, readdir, rename, rm, stat } from "node:fs/promises";
26
+ import type { Dirent } from "node:fs";
27
+ import { tmpdir } from "node:os";
28
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
29
+ import {
30
+ digestNode,
31
+ listNodes,
32
+ nodePath,
33
+ initNode,
34
+ readManifest,
35
+ readCursor,
36
+ readNode,
37
+ writeCursor,
38
+ type Manifest,
39
+ } from "./log.ts";
40
+ import { readNodeHistory } from "./restore.ts";
41
+ import { replayContext } from "./replay.ts";
42
+ import { recoverCut } from "./recovery.ts";
43
+
44
+ /**
45
+ * A listing that reads every session ever is unbounded. Two hundred is
46
+ * enough to find the one you were in last week; beyond that the scan would
47
+ * be hiding a missing search feature, not serving one. Assumed, not
48
+ * measured: this is a product cap, not a performance number. The rest stay
49
+ * on disk -- the listing says it truncated, so they are not silent.
50
+ */
51
+ export const MAX_SESSION_LIST = 200;
52
+
53
+ /**
54
+ * Exclusive-create retries when the id is already on disk.
55
+ *
56
+ * The suffix space is 36^4 (about 1.7 million). A collision is a bug in
57
+ * the clock -- same millisecond AND same random -- not a thing we wait
58
+ * out. Four tries then refuse, rather than loop.
59
+ */
60
+ const MAX_ID_ATTEMPTS = 4;
61
+
62
+ const SUFFIX_SPAN = 36 ** 4;
63
+
64
+ export interface SessionIdClock {
65
+ nowMs: () => number;
66
+ /** In [0, 1), same range as Math.random. */
67
+ random: () => number;
68
+ }
69
+
70
+ export type ListedSession =
71
+ | {
72
+ ok: true;
73
+ id: string;
74
+ name: string | null;
75
+ model: string | null;
76
+ node: number | null;
77
+ events: number;
78
+ updated_at: string | null;
79
+ path: string;
80
+ }
81
+ | {
82
+ ok: false;
83
+ id: string;
84
+ path: string;
85
+ error: string;
86
+ };
87
+
88
+ export interface SessionList {
89
+ sessions: ListedSession[];
90
+ /** Session directories not read because the listing cap was hit. */
91
+ truncated: number;
92
+ }
93
+
94
+ const systemIdClock: SessionIdClock = {
95
+ nowMs: () => Date.now(),
96
+ random: () => Math.random(),
97
+ };
98
+
99
+ export function sessionsDir(workspace: string, override?: string): string {
100
+ if (override !== undefined && override.length > 0) {
101
+ return isAbsolute(override) ? override : join(workspace, override);
102
+ }
103
+ return join(workspace, ".xeno", "sessions");
104
+ }
105
+
106
+ export function legacyLogDir(workspace: string): string {
107
+ return join(workspace, ".xeno", "log");
108
+ }
109
+
110
+ /**
111
+ * `<base36 ms>-<4 digit base36 random>`.
112
+ *
113
+ * Time first so ids sort by creation. The suffix is what makes two calls
114
+ * in the same millisecond distinct; without it the second session is the
115
+ * first session's directory.
116
+ */
117
+ export function formatSessionId(ms: number, random: number): string {
118
+ const n = Math.min(SUFFIX_SPAN - 1, Math.max(0, Math.floor(random * SUFFIX_SPAN)));
119
+ return `${ms.toString(36)}-${n.toString(36).padStart(4, "0")}`;
120
+ }
121
+
122
+ export function truncationNote(truncated: number): string | null {
123
+ if (truncated <= 0) return null;
124
+ const noun = truncated === 1 ? "session" : "sessions";
125
+ return `listing capped at ${MAX_SESSION_LIST}; ${truncated} older ${noun} not shown`;
126
+ }
127
+
128
+ export async function createSession(
129
+ workspace: string,
130
+ clock: SessionIdClock = systemIdClock,
131
+ sessionDir?: string,
132
+ ): Promise<{ id: string; path: string }> {
133
+ const dir = sessionsDir(workspace, sessionDir);
134
+ await mkdir(dir, { recursive: true });
135
+ for (let attempt = 0; attempt < MAX_ID_ATTEMPTS; attempt++) {
136
+ const id = formatSessionId(clock.nowMs(), clock.random());
137
+ const path = join(dir, id);
138
+ try {
139
+ // Exclusive. recursive would succeed on an existing directory and the
140
+ // second session would write into the first -- the collision this
141
+ // suffix exists to make unrepresentable.
142
+ await mkdir(path);
143
+ return { id, path };
144
+ } catch (err) {
145
+ if ((err as { code?: string }).code !== "EEXIST") throw err;
146
+ }
147
+ }
148
+ throw new Error(
149
+ `could not allocate a unique session id under ${dir} after ${MAX_ID_ATTEMPTS} attempts`,
150
+ );
151
+ }
152
+
153
+ export async function listSessions(workspace: string, sessionDir?: string): Promise<SessionList> {
154
+ const dir = sessionsDir(workspace, sessionDir);
155
+ let entries: Dirent[];
156
+ try {
157
+ entries = await readdir(dir, { withFileTypes: true });
158
+ } catch (err) {
159
+ if ((err as { code?: string }).code === "ENOENT") return { sessions: [], truncated: 0 };
160
+ throw err;
161
+ }
162
+
163
+ const dirs = entries.filter((e) => e.isDirectory());
164
+ const withMtime = await Promise.all(
165
+ dirs.map(async (d) => {
166
+ const path = join(dir, d.name);
167
+ const st = await stat(path);
168
+ return { id: d.name, path, mtime: st.mtimeMs };
169
+ }),
170
+ );
171
+ // The cap is "which 200 do we even open", and mtime is the cheap proxy
172
+ // for "likely recent". After that, the list is ordered by what each
173
+ // session recorded: a backup restore can make an old session the
174
+ // newest directory on disk, and that must not hide the one that ran last.
175
+ withMtime.sort((a, b) => b.mtime - a.mtime);
176
+ const truncated = Math.max(0, withMtime.length - MAX_SESSION_LIST);
177
+ const chosen = withMtime.slice(0, MAX_SESSION_LIST);
178
+
179
+ const sessions: ListedSession[] = [];
180
+ for (const d of chosen) {
181
+ sessions.push(await readListed(d.id, d.path));
182
+ }
183
+ sessions.sort(byRecordedTime);
184
+ return { sessions, truncated };
185
+ }
186
+
187
+ /**
188
+ * A leftover `.xeno/log` is not migrated. Moving someone else's data cannot
189
+ * be a startup side effect; the person keeps using it by pointing `--log`
190
+ * at it. Once `.xeno/sessions/` exists they are already in the new layout
191
+ * and the leftover is just leftover, so the notice stays quiet.
192
+ */
193
+ export async function legacyLogNotice(workspace: string): Promise<string | null> {
194
+ if (!(await isDirectory(legacyLogDir(workspace)))) return null;
195
+ if (await isDirectory(sessionsDir(workspace))) return null;
196
+ return (
197
+ "This workspace still has a log at .xeno/log from before named sessions existed. " +
198
+ "It has not been moved. Continue it with --log .xeno/log"
199
+ );
200
+ }
201
+
202
+ export type SessionPrefixMatch =
203
+ | { ok: true; session: Extract<ListedSession, { ok: true }> }
204
+ | { ok: false; reason: string; candidates: string[] };
205
+
206
+ /**
207
+ * Unique prefix or exact path only. Two matches is a list, never catalog
208
+ * order -- the same rule as findModelByPattern. Guessing here would open
209
+ * someone else's session because ids share a timestamp prefix.
210
+ */
211
+ export async function findSessionByPrefix(
212
+ workspace: string,
213
+ prefix: string,
214
+ sessionDir?: string,
215
+ ): Promise<SessionPrefixMatch> {
216
+ const needle = prefix.trim();
217
+ if (needle.length === 0) {
218
+ return { ok: false, reason: "empty session id", candidates: [] };
219
+ }
220
+
221
+ const { sessions } = await listSessions(workspace, sessionDir);
222
+ const ok = sessions.filter((s): s is Extract<ListedSession, { ok: true }> => s.ok);
223
+
224
+ const exactId = ok.filter((s) => s.id === needle);
225
+ if (exactId.length === 1) return { ok: true, session: exactId[0]! };
226
+
227
+ const exactPath = ok.filter((s) => s.path === needle);
228
+ if (exactPath.length === 1) return { ok: true, session: exactPath[0]! };
229
+
230
+ const existing = await existingDir(workspace, needle);
231
+ if (existing !== null) {
232
+ const listed = ok.find((s) => s.path === existing);
233
+ if (listed !== undefined) return { ok: true, session: listed };
234
+ const read = await readListed(basename(existing), existing);
235
+ if (read.ok) return { ok: true, session: read };
236
+ return {
237
+ ok: true,
238
+ session: {
239
+ ok: true,
240
+ id: basename(existing),
241
+ path: existing,
242
+ name: null,
243
+ model: null,
244
+ node: null,
245
+ events: 0,
246
+ updated_at: null,
247
+ },
248
+ };
249
+ }
250
+
251
+ const hits = ok.filter((s) => s.id.startsWith(needle));
252
+ if (hits.length === 1) return { ok: true, session: hits[0]! };
253
+ if (hits.length === 0) {
254
+ return { ok: false, reason: `unknown session: ${needle}`, candidates: [] };
255
+ }
256
+ const ids = hits.map((s) => s.id);
257
+ return {
258
+ ok: false,
259
+ reason:
260
+ `ambiguous session id prefix ${JSON.stringify(needle)}: matches ${ids.join(", ")}. ` +
261
+ "Be more specific rather than guessing",
262
+ candidates: ids,
263
+ };
264
+ }
265
+
266
+ /**
267
+ * Copy the log graph and its content-addressed observations, then open a new
268
+ * inherit node at the chosen point. Like copying Git objects and moving a ref,
269
+ * this keeps immutable evidence intact (D3/D8 / M.2). Flattening just the current
270
+ * context loses earlier handoffs; flattening every event loses cut semantics.
271
+ * Publication is atomic into an empty destination, so a partial copy cannot be
272
+ * mistaken for a resumable session. Historical log_ref paths retain provenance;
273
+ * readers resolve node IDs and artifact hashes inside the new session.
274
+ */
275
+ export async function copyAncestryTo(
276
+ sourceDir: string,
277
+ destDir: string,
278
+ createdAt: string,
279
+ untilEvent?: string,
280
+ ): Promise<void> {
281
+ const rel = relative(resolve(sourceDir), resolve(destDir));
282
+ if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) {
283
+ throw new Error("cannot copy a session into itself or its descendants");
284
+ }
285
+ await mkdir(destDir, { recursive: true });
286
+ if ((await readdir(destDir)).length > 0) throw new Error("cannot copy session into a nonempty destination");
287
+ const stage = await mkdtemp(join(dirname(destDir), ".xeno-copy-"));
288
+ try {
289
+ await cp(sourceDir, stage, { recursive: true, force: false, errorOnExist: true });
290
+ const recorded = await readCursor(stage);
291
+ const cursor = recorded === null ? null : await recoverCut(stage, recorded);
292
+ const tip = cursor?.node ?? 0;
293
+ const history = await readNodeHistory(stage, tip);
294
+ if (!history.ok) throw new Error(`cannot copy session: ${history.error}`);
295
+ let target = tip;
296
+ if (untilEvent !== undefined) {
297
+ const matches = history.nodes.filter((n) => n.events.some((e) => e.id === untilEvent));
298
+ if (matches.length !== 1) throw new Error(`cannot copy session: event ${untilEvent} is missing or ambiguous on the current path`);
299
+ target = matches[0]!.node;
300
+ }
301
+ const nodes = await listNodes(stage);
302
+ const next = Math.max(cursor?.next_node ?? 1, ...nodes.map((n) => n + 1));
303
+ await initNode(stage, next, {
304
+ node: target, log_ref: nodePath(destDir, target), sha256: await digestNode(stage, target),
305
+ ...(untilEvent === undefined ? {} : { at_event: untilEvent }),
306
+ }, createdAt, "inherit");
307
+ await writeCursor(stage, {
308
+ ...cursor, node: next, next_node: next + 1, model: cursor?.model ?? "unknown", updated_at: createdAt,
309
+ });
310
+ await replayContext(stage, next);
311
+ await syncCopiedTree(stage);
312
+ await rename(stage, destDir);
313
+ const parent = await open(dirname(destDir), "r");
314
+ try { await parent.sync(); } finally { await parent.close(); }
315
+ } finally { await rm(stage, { recursive: true, force: true }); }
316
+ }
317
+
318
+ async function syncCopiedTree(dir: string): Promise<void> {
319
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
320
+ const path = join(dir, entry.name);
321
+ if (entry.isDirectory()) await syncCopiedTree(path);
322
+ else if (entry.isFile()) {
323
+ const file = await open(path, "r");
324
+ try { await file.sync(); } finally { await file.close(); }
325
+ } else throw new Error(`cannot copy unsupported session entry: ${path}`);
326
+ }
327
+ const directory = await open(dir, "r");
328
+ try { await directory.sync(); } finally { await directory.close(); }
329
+ }
330
+
331
+ export interface ResolveLogOpts {
332
+ explicit?: string;
333
+ fresh?: boolean;
334
+ /**
335
+ * `-c` / `--continue`. Pick the newest session and refuse if there is
336
+ * none -- creating one would make `-c` indistinguishable from a first run.
337
+ */
338
+ continueLatest?: boolean;
339
+ /**
340
+ * `--no-session`. Log goes to a temp directory, so the workspace never
341
+ * grows a `.xeno/sessions/` folder. A flag that still called createSession
342
+ * would pass every assertion on the flag and still leave a directory.
343
+ */
344
+ noSession?: boolean;
345
+ session?: string;
346
+ sessionDir?: string;
347
+ }
348
+
349
+ /**
350
+ * Picks the log directory for a process that did not pass a path, or
351
+ * honours one that did.
352
+ *
353
+ * Newest-by-recorded-time is the default because that is the session the
354
+ * person was just in. `--fresh` always allocates a new one; it never
355
+ * deletes. A leftover `.xeno/log` is not picked -- that would make the
356
+ * `--log .xeno/log` the card requires for continuing it unnecessary, and
357
+ * it would mix the old layout into the new one without anyone asking.
358
+ */
359
+ export async function resolveLogDir(
360
+ workspace: string,
361
+ opts: ResolveLogOpts = {},
362
+ ): Promise<{ logDir: string; created: boolean; legacyNotice: string | null }> {
363
+ // Computed first: createSession will mkdir `.xeno/sessions/`, after which
364
+ // the leftover-log condition is no longer true.
365
+ const legacyNotice = await legacyLogNotice(workspace);
366
+ if (opts.noSession === true) {
367
+ const logDir = await mkdtemp(join(tmpdir(), "xeno-nosession-"));
368
+ return { logDir, created: true, legacyNotice };
369
+ }
370
+ if (opts.explicit !== undefined) {
371
+ return { logDir: opts.explicit, created: false, legacyNotice };
372
+ }
373
+ if (opts.session !== undefined && opts.session.length > 0) {
374
+ const found = await findSessionByPrefix(workspace, opts.session, opts.sessionDir);
375
+ if (!found.ok) throw new Error(found.reason);
376
+ return { logDir: found.session.path, created: false, legacyNotice };
377
+ }
378
+ if (opts.continueLatest === true) {
379
+ const { sessions } = await listSessions(workspace, opts.sessionDir);
380
+ const newest = sessions.find((s) => s.ok);
381
+ if (newest === undefined) {
382
+ throw new Error("no session to continue");
383
+ }
384
+ return { logDir: newest.path, created: false, legacyNotice };
385
+ }
386
+ if (opts.fresh !== true) {
387
+ const { sessions } = await listSessions(workspace, opts.sessionDir);
388
+ const newest = sessions.find((s) => s.ok);
389
+ if (newest !== undefined) {
390
+ return { logDir: newest.path, created: false, legacyNotice };
391
+ }
392
+ }
393
+ const created = await createSession(workspace, systemIdClock, opts.sessionDir);
394
+ return { logDir: created.path, created: true, legacyNotice };
395
+ }
396
+
397
+ async function existingDir(workspace: string, prefix: string): Promise<string | null> {
398
+ if (await isDirectory(prefix)) return prefix;
399
+ const rel = join(workspace, prefix);
400
+ if (rel !== prefix && (await isDirectory(rel))) return rel;
401
+ return null;
402
+ }
403
+
404
+ async function readListed(id: string, path: string): Promise<ListedSession> {
405
+ let parsed: unknown;
406
+ try {
407
+ parsed = await readManifest(path);
408
+ } catch (err) {
409
+ if ((err as { code?: string }).code === "ENOENT") {
410
+ return {
411
+ ok: true, id, path,
412
+ name: null, model: null, node: null, events: 0, updated_at: null,
413
+ };
414
+ }
415
+ return { ok: false, id, path, error: `${path} could not be read: ${(err as Error).message}` };
416
+ }
417
+
418
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
419
+ return { ok: false, id, path, error: `${path} manifest is not an object` };
420
+ }
421
+
422
+ const manifest = parsed as Manifest;
423
+ const cursor = manifest.cursor;
424
+ if (cursor === undefined) {
425
+ return {
426
+ ok: true, id, path,
427
+ name: null, model: null, node: null, events: 0, updated_at: null,
428
+ };
429
+ }
430
+
431
+ // The open node's size, not an ancestry walk. Walking can fail the whole
432
+ // listing (a cycle, a missing parent), and a list that throws for one
433
+ // bad tree is the same shape as a list that throws for one bad file.
434
+ const events = (await readNode(path, cursor.node)).events.length;
435
+ return {
436
+ ok: true,
437
+ id,
438
+ path,
439
+ name: cursor.name ?? null,
440
+ model: cursor.model,
441
+ node: cursor.node,
442
+ events,
443
+ updated_at: cursor.updated_at,
444
+ };
445
+ }
446
+
447
+ function byRecordedTime(a: ListedSession, b: ListedSession): number {
448
+ const ta = a.ok ? a.updated_at : null;
449
+ const tb = b.ok ? b.updated_at : null;
450
+ if (ta !== null && tb !== null) return tb < ta ? -1 : tb > ta ? 1 : 0;
451
+ if (ta !== null) return -1;
452
+ if (tb !== null) return 1;
453
+ return 0;
454
+ }
455
+
456
+ async function isDirectory(path: string): Promise<boolean> {
457
+ try {
458
+ return (await stat(path)).isDirectory();
459
+ } catch (err) {
460
+ if ((err as { code?: string }).code === "ENOENT") return false;
461
+ throw err;
462
+ }
463
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Project trust: whether to load `.xeno/settings.json` for a directory.
3
+ *
4
+ * Trust does not affect tool execution. Path tools stay confined to the
5
+ * workspace root regardless, and bash still goes through the permission
6
+ * gate. This file only records which directories may contribute project
7
+ * settings. AGENTS.md is loaded whether the project is trusted or not.
8
+ */
9
+
10
+ import { dirname, join, resolve } from "node:path";
11
+ import { configDir, readJsonFile, writeJsonFile } from "./config.ts";
12
+
13
+ export type TrustFile = { readonly [dir: string]: boolean };
14
+
15
+ export function trustPath(): string {
16
+ return join(configDir(), "trust.json");
17
+ }
18
+
19
+ export function projectSettingsPath(workspace: string): string {
20
+ return join(workspace, ".xeno", "settings.json");
21
+ }
22
+
23
+ /**
24
+ * Closest saved decision on `dir` or a parent. Undefined if none.
25
+ *
26
+ * A parent applies to its children so trusting a repo root covers the
27
+ * packages underneath; a closer entry wins so a single untrusted nested
28
+ * project can opt out.
29
+ */
30
+ export function lookupTrust(decisions: TrustFile, dir: string): boolean | undefined {
31
+ let current = resolve(dir);
32
+ for (;;) {
33
+ const value = decisions[current];
34
+ if (value !== undefined) return value;
35
+ const parent = dirname(current);
36
+ if (parent === current) return undefined;
37
+ current = parent;
38
+ }
39
+ }
40
+
41
+ export async function readTrust(): Promise<{ value: TrustFile; error: string | null }> {
42
+ const { value, error } = await readJsonFile<unknown>(trustPath());
43
+ if (error) return { value: {}, error };
44
+ if (value === null) return { value: {}, error: null };
45
+ if (!isTrustFile(value)) {
46
+ return { value: {}, error: `${trustPath()} is not a directory-to-boolean map` };
47
+ }
48
+ return { value, error: null };
49
+ }
50
+
51
+ export async function writeTrust(decisions: TrustFile): Promise<void> {
52
+ await writeJsonFile(trustPath(), decisions, false);
53
+ }
54
+
55
+ export async function saveTrust(dir: string, trusted: boolean): Promise<void> {
56
+ const { value } = await readTrust();
57
+ const next: { [dir: string]: boolean } = { ...value };
58
+ next[resolve(dir)] = trusted;
59
+ await writeTrust(next);
60
+ }
61
+
62
+ function isTrustFile(value: unknown): value is TrustFile {
63
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
64
+ const rec = value as { readonly [k: string]: unknown };
65
+ for (const key of Object.keys(rec)) {
66
+ if (typeof rec[key] !== "boolean") return false;
67
+ }
68
+ return true;
69
+ }