@visual-z/xeno 0.1.4 → 0.2.1

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 (147) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +143 -33
  3. package/docs/install.md +23 -6
  4. package/docs/providers.md +147 -0
  5. package/package.json +8 -2
  6. package/src/apps/cli.ts +45 -18
  7. package/src/apps/commands.ts +32 -4
  8. package/src/apps/completions.ts +9 -6
  9. package/src/apps/export-cli.ts +24 -0
  10. package/src/apps/extension-ui.ts +330 -0
  11. package/src/apps/input.ts +16 -1
  12. package/src/apps/model-catalog.ts +42 -0
  13. package/src/apps/project-trust.ts +49 -0
  14. package/src/apps/render.ts +1 -0
  15. package/src/apps/resource-browser.ts +75 -0
  16. package/src/apps/resources.ts +25 -0
  17. package/src/apps/session-browser.ts +113 -0
  18. package/src/apps/session-picker.ts +55 -0
  19. package/src/apps/shell.ts +1177 -378
  20. package/src/apps/startup-input.ts +94 -0
  21. package/src/apps/startup.ts +413 -295
  22. package/src/core/action-defaults.ts +306 -0
  23. package/src/core/attachment-input.ts +17 -0
  24. package/src/core/catalog-merge.ts +19 -0
  25. package/src/core/event-validation.ts +19 -2
  26. package/src/core/events.ts +45 -2
  27. package/src/core/export.ts +6 -2
  28. package/src/core/extension-flags.ts +14 -0
  29. package/src/core/images.ts +46 -0
  30. package/src/core/json-event.ts +2 -0
  31. package/src/core/keymap.ts +43 -119
  32. package/src/core/model-replay.ts +22 -0
  33. package/src/core/model-scope.ts +53 -0
  34. package/src/core/packages.ts +47 -0
  35. package/src/core/project.ts +21 -3
  36. package/src/core/prompts.ts +33 -5
  37. package/src/core/queue.ts +18 -0
  38. package/src/core/resource-selection.ts +36 -0
  39. package/src/{runtime → core}/resume.ts +15 -22
  40. package/src/core/session-archive.ts +4 -0
  41. package/src/core/session-browser.ts +82 -0
  42. package/src/core/session-entry.ts +56 -0
  43. package/src/core/session-position.ts +26 -0
  44. package/src/core/settings.ts +169 -154
  45. package/src/core/theme.ts +15 -6
  46. package/src/core/thinking.ts +26 -0
  47. package/src/core/tokens.ts +2 -0
  48. package/src/core/transcript.ts +15 -5
  49. package/src/runtime/auth.ts +116 -11
  50. package/src/runtime/catalog.ts +54 -151
  51. package/src/runtime/clipboard.ts +114 -0
  52. package/src/runtime/extension-loader.ts +37 -0
  53. package/src/runtime/extension-ui.ts +55 -0
  54. package/src/runtime/extensions.ts +273 -0
  55. package/src/runtime/images.ts +76 -0
  56. package/src/runtime/loop.ts +213 -83
  57. package/src/runtime/model-config.ts +232 -0
  58. package/src/runtime/model-data.LICENSE +27 -0
  59. package/src/runtime/model-data.ts +21 -0
  60. package/src/runtime/model-replay.ts +10 -0
  61. package/src/runtime/model-scope.ts +20 -0
  62. package/src/runtime/model-sources.ts +151 -0
  63. package/src/runtime/model.ts +74 -11
  64. package/src/runtime/models-fetch.ts +6 -104
  65. package/src/runtime/oauth.ts +4 -3
  66. package/src/runtime/openai-responses.ts +12 -4
  67. package/src/runtime/openai.ts +13 -4
  68. package/src/runtime/package-manager.ts +125 -0
  69. package/src/runtime/pi-provider.ts +235 -0
  70. package/src/runtime/progress.ts +26 -0
  71. package/src/runtime/provider-access.ts +21 -0
  72. package/src/runtime/provider-protocols.ts +29 -0
  73. package/src/runtime/provider-request.ts +73 -0
  74. package/src/runtime/provider.ts +26 -6
  75. package/src/runtime/rich-text.ts +36 -0
  76. package/src/runtime/tools.ts +41 -5
  77. package/src/store/command.ts +7 -1
  78. package/src/store/context-files.ts +1 -1
  79. package/src/store/log.ts +65 -0
  80. package/src/store/models.ts +28 -9
  81. package/src/store/observations.ts +4 -2
  82. package/src/store/packages.ts +98 -0
  83. package/src/store/recovery.ts +8 -0
  84. package/src/store/resource-index.ts +170 -0
  85. package/src/store/resources.ts +75 -142
  86. package/src/store/session-archive.ts +162 -0
  87. package/src/store/session-browser.ts +68 -0
  88. package/src/store/session-position.ts +27 -0
  89. package/src/store/sessions.ts +87 -66
  90. package/src/store/trust.ts +13 -0
  91. package/src/tui/chat-viewport.ts +47 -0
  92. package/src/tui/editor.ts +62 -15
  93. package/src/tui/host.ts +50 -9
  94. package/src/tui/input.ts +8 -2
  95. package/src/tui/select.ts +24 -6
  96. package/src/tui/settings.ts +4 -0
  97. package/src/tui/system-theme.ts +700 -0
  98. package/src/tui/transcript.ts +63 -11
  99. package/src/tui/tree.ts +161 -95
  100. package/src/vendor/pi-rich/LICENSE.upstream +21 -0
  101. package/src/vendor/pi-rich/UPSTREAM.json +17 -0
  102. package/src/vendor/pi-rich/clipboard-command.ts +44 -0
  103. package/src/vendor/pi-rich/exif-orientation.ts +182 -0
  104. package/src/vendor/pi-rich/html.ts +51 -0
  105. package/src/vendor/pi-rich/image-convert.ts +49 -0
  106. package/src/vendor/pi-rich/image-resize-core.ts +164 -0
  107. package/src/vendor/pi-rich/image-resize-worker.ts +42 -0
  108. package/src/vendor/pi-rich/image-resize.ts +123 -0
  109. package/src/vendor/pi-rich/mime.ts +116 -0
  110. package/src/vendor/pi-rich/photon.ts +139 -0
  111. package/src/vendor/pi-rich/syntax-highlight.ts +214 -0
  112. package/src/vendor/pi-tui/UPSTREAM.json +50 -0
  113. package/src/vendor/pi-tui/VENDOR.md +7 -5
  114. package/src/vendor/pi-tui/autocomplete.ts +35 -10
  115. package/src/vendor/pi-tui/colors.ts +367 -0
  116. package/src/vendor/pi-tui/components/box.ts +8 -9
  117. package/src/vendor/pi-tui/components/editor.ts +4 -2
  118. package/src/vendor/pi-tui/components/markdown.ts +8 -2
  119. package/src/vendor/pi-tui/index.ts +31 -1
  120. package/src/vendor/pi-tui/native/UPSTREAM.json +23 -0
  121. package/src/vendor/pi-tui/native/clipboard.h +208 -0
  122. package/src/vendor/pi-tui/native/darwin/README.md +22 -0
  123. package/src/vendor/pi-tui/native/darwin/build.sh +69 -0
  124. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  125. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  126. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +132 -0
  127. package/src/vendor/pi-tui/native/linux/README.md +23 -0
  128. package/src/vendor/pi-tui/native/linux/build.sh +65 -0
  129. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  130. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  131. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +70 -0
  132. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +274 -0
  133. package/src/vendor/pi-tui/native/napi.h +91 -0
  134. package/src/vendor/pi-tui/native/win32/README.md +31 -0
  135. package/src/vendor/pi-tui/native/win32/build.mjs +232 -0
  136. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  137. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  138. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +241 -0
  139. package/src/vendor/pi-tui/native-platform.ts +2 -0
  140. package/src/vendor/pi-tui/oklab.ts +233 -0
  141. package/src/vendor/pi-tui/terminal-colors.ts +26 -8
  142. package/src/vendor/pi-tui/terminal-image.ts +43 -8
  143. package/src/vendor/pi-tui/terminal.ts +16 -9
  144. package/src/vendor/pi-tui/tui-alt-screen.ts +26 -18
  145. package/src/vendor/pi-tui/tui.ts +112 -75
  146. package/src/vendor/pi-tui/utils.ts +89 -49
  147. package/src/vendor/pi-tui/wheel-scroll.ts +82 -0
@@ -0,0 +1,162 @@
1
+ /** Portable log graphs. No provider calls, workspace writes, or executable package payloads (D3/D8). */
2
+ import { createReadStream } from "node:fs";
3
+ import { lstat, mkdir, mkdtemp, open, readdir, rename, rm } from "node:fs/promises";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { archivePath } from "../core/session-archive.ts";
7
+ import { blobFilePath, digestFile, verifyBlob } from "./blobs.ts";
8
+ import { listNodes, readCursor, readCursorAt, readManifest, readNode, relocateSession, type Cursor } from "./log.ts";
9
+ import { readNodeHistory } from "./restore.ts";
10
+ import { replayContext } from "./replay.ts";
11
+
12
+ // Wire chunk size, not a content limit: bounded JSON lines keep large snapshots streamable.
13
+ const CHUNK = 64 * 1024;
14
+ type FileEntry = { path: string; source: string };
15
+
16
+ async function regular(path: string): Promise<void> {
17
+ if (!(await lstat(path)).isFile()) throw new Error(`Archive requires a regular file: ${path}`);
18
+ }
19
+
20
+ async function storedFiles(root: string, prefix = ""): Promise<FileEntry[]> {
21
+ const files: FileEntry[] = [];
22
+ for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) {
23
+ const path = prefix ? `${prefix}/${entry.name}` : entry.name;
24
+ if (archivePath(path)) { await regular(join(root, path)); files.push({ path, source: join(root, path) }); }
25
+ else if ((path === "artifacts" || /^artifacts\/[a-f0-9]{2}$/.test(path)) && entry.isDirectory()) files.push(...await storedFiles(root, path));
26
+ else if (path === "artifacts" || path === "blobs") {
27
+ if (!entry.isDirectory()) throw new Error(`Invalid archive store: ${path}`);
28
+ }
29
+ }
30
+ return files;
31
+ }
32
+
33
+ async function referencedBlobs(dir: string, fallback?: string): Promise<Map<string, string[]>> {
34
+ const refs = new Map<string, string[]>(), cursor = await readCursor(dir);
35
+ for (const node of await listNodes(dir)) {
36
+ const read = await readNode(dir, node);
37
+ if (!read.header || read.corrupt.length) throw new Error(`Corrupt archive node ${node}`);
38
+ const historical = await readCursorAt(dir, node);
39
+ const roots = [...new Set([historical?.blob_dir, cursor?.blob_dir, fallback, join(dir, "blobs")].filter((p): p is string => !!p))];
40
+ const add = (sha: string) => { refs.set(sha, [...new Set([...(refs.get(sha) ?? []), ...roots])]); };
41
+ for (const event of read.events) {
42
+ if (event.t === "snapshot") for (const sha of [...Object.values(event.added), ...Object.values(event.changed)]) add(sha);
43
+ if (event.t === "user" || event.t === "tool_result") for (const image of event.images ?? []) { add(image.sha256); if (image.original) add(image.original.sha256); }
44
+ }
45
+ }
46
+ return refs;
47
+ }
48
+
49
+ async function validateSession(dir: string): Promise<Cursor> {
50
+ const cursor = await readCursor(dir);
51
+ if (!cursor) throw new Error("Archive has no resumable cursor");
52
+ const manifest = await readManifest(dir), nodes = await listNodes(dir);
53
+ if (!nodes.includes(cursor.node)) throw new Error("Archive cursor points to a missing node");
54
+ for (const closed of manifest.nodes) {
55
+ if (!nodes.includes(closed.node) || await digestFile(join(dir, `node-${String(closed.node).padStart(4, "0")}.jsonl`)) !== closed.sha256) throw new Error("Archive closed node digest mismatch");
56
+ if (closed.handoff_ref) await regular(join(dir, `handoff-${String(closed.node).padStart(4, "0")}.yaml`));
57
+ }
58
+ const contexts = new Set<string>();
59
+ for (const node of nodes) {
60
+ const history = await readNodeHistory(dir, node);
61
+ if (!history.ok) throw new Error(`Invalid archive graph: ${history.error}`);
62
+ await replayContext(dir, node, manifest);
63
+ const state = await readCursorAt(dir, node); if (state?.context_ref) contexts.add(state.context_ref);
64
+ for (const event of (await readNode(dir, node)).events) if (event.t === "cut" && event.next_context_ref) contexts.add(event.next_context_ref);
65
+ }
66
+ if (cursor.context_ref) contexts.add(cursor.context_ref);
67
+ for (const sha of contexts) if (!await verifyBlob(join(dir, "artifacts"), sha)) throw new Error(`Missing frozen context ${sha}`);
68
+ return cursor;
69
+ }
70
+
71
+ export async function exportSessionArchive(dir: string, destination: string, blobDir?: string): Promise<void> {
72
+ await validateSession(dir);
73
+ const files = await storedFiles(dir);
74
+ for (const [sha, roots] of await referencedBlobs(dir, blobDir)) {
75
+ let source: string | undefined;
76
+ for (const root of roots) if (await verifyBlob(root, sha)) { source = blobFilePath(root, sha); break; }
77
+ if (!source) throw new Error(`Missing session blob ${sha}`);
78
+ files.push({ path: `blobs/${sha.slice(0, 2)}/${sha.slice(2)}`, source });
79
+ }
80
+ if (files.some(file => resolve(file.source) === resolve(destination))) throw new Error("Cannot export over session storage");
81
+ const temp = `${destination}.${randomUUID()}.tmp`, out = await open(temp, "wx", 0o600);
82
+ const digest = createHash("sha256"); let count = 0;
83
+ const emit = async (record: unknown, hashed = true) => { const line = JSON.stringify(record) + "\n"; if (hashed) digest.update(line); await out.writeFile(line); };
84
+ try {
85
+ await emit({ format: "xeno-session", version: 1 });
86
+ for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) {
87
+ await regular(file.source);
88
+ const hash = createHash("sha256"); let size = 0;
89
+ await emit({ file: file.path });
90
+ for await (const chunk of createReadStream(file.source, { highWaterMark: CHUNK })) {
91
+ const bytes = chunk as Buffer; size += bytes.length; hash.update(bytes); await emit({ data: bytes.toString("base64") });
92
+ }
93
+ const sha256 = hash.digest("hex");
94
+ if (/^(artifacts|blobs)\//.test(file.path) && file.path.split("/").slice(1).join("") !== sha256) throw new Error(`Corrupt archive blob ${file.path}`);
95
+ await emit({ end: file.path, size, sha256 }); count++;
96
+ }
97
+ await emit({ complete: count, sha256: digest.digest("hex") }, false);
98
+ await out.sync(); await out.close(); await rename(temp, destination);
99
+ } finally { await out.close(); await rm(temp, { force: true }); }
100
+ }
101
+
102
+ /** Bound a hostile or truncated line before JSON parsing, without a whole-archive size cap. */
103
+ async function* lines(path: string): AsyncGenerator<string> {
104
+ let pending = "";
105
+ for await (const chunk of createReadStream(path, { encoding: "utf8", highWaterMark: CHUNK })) {
106
+ pending += chunk; let end: number;
107
+ while ((end = pending.indexOf("\n")) !== -1) {
108
+ if (end > CHUNK * 2) throw new Error("Archive line exceeds chunk envelope");
109
+ yield pending.slice(0, end); pending = pending.slice(end + 1);
110
+ }
111
+ if (pending.length > CHUNK * 2) throw new Error("Archive line exceeds chunk envelope");
112
+ }
113
+ if (pending) throw new Error("Incomplete archive tail");
114
+ }
115
+
116
+ /** Only a validated staging directory is published; failed imports never become browser entries. */
117
+ export async function importSessionArchive(file: string, sessionRoot: string, workspace: string): Promise<string> {
118
+ await regular(file); await mkdir(sessionRoot, { recursive: true });
119
+ const stage = await mkdtemp(join(sessionRoot, ".xeno-import-"));
120
+ const destination = join(sessionRoot, `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`);
121
+ const seen = new Set<string>(), digest = createHash("sha256");
122
+ let header = false, complete = false, active: { path: string; file: Awaited<ReturnType<typeof open>>; hash: ReturnType<typeof createHash>; size: number } | undefined;
123
+ try {
124
+ for await (const line of lines(file)) {
125
+ if (complete) throw new Error("Unexpected data after archive footer");
126
+ const value: unknown = JSON.parse(line);
127
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid archive record");
128
+ const record = value as Record<string, unknown>;
129
+ if (!header) {
130
+ if (record["format"] !== "xeno-session" || record["version"] !== 1) throw new Error("Expected xeno-session v1 archive. Legacy event-only JSONL lacks recovery metadata; re-export the original session.");
131
+ header = true;
132
+ } else if (record["complete"] !== undefined) {
133
+ if (active || record["complete"] !== seen.size || record["sha256"] !== digest.digest("hex")) throw new Error("Archive completeness digest mismatch");
134
+ complete = true; continue;
135
+ } else if (record["file"] !== undefined) {
136
+ const path = record["file"];
137
+ if (active || !archivePath(path) || seen.has(path)) throw new Error("Invalid or duplicate archive path");
138
+ seen.add(path); await mkdir(dirname(join(stage, path)), { recursive: true });
139
+ active = { path, file: await open(join(stage, path), "wx", 0o600), hash: createHash("sha256"), size: 0 };
140
+ } else if (record["data"] !== undefined) {
141
+ const data = record["data"];
142
+ if (!active || typeof data !== "string" || data.length > Math.ceil(CHUNK / 3) * 4) throw new Error("Invalid archive chunk");
143
+ const bytes = Buffer.from(data, "base64");
144
+ if (bytes.toString("base64") !== data) throw new Error("Invalid archive base64");
145
+ active.hash.update(bytes); active.size += bytes.length; await active.file.writeFile(bytes);
146
+ } else if (record["end"] !== undefined) {
147
+ if (!active || record["end"] !== active.path || record["size"] !== active.size || record["sha256"] !== active.hash.digest("hex")) throw new Error("Archive file digest mismatch");
148
+ if (/^(artifacts|blobs)\//.test(active.path) && active.path.split("/").slice(1).join("") !== record["sha256"]) throw new Error("Archive blob address mismatch");
149
+ await active.file.sync(); await active.file.close(); active = undefined;
150
+ } else throw new Error("Unknown archive record");
151
+ digest.update(line + "\n");
152
+ }
153
+ if (!complete) throw new Error("Incomplete archive: missing footer");
154
+ await validateSession(stage);
155
+ for (const sha of (await referencedBlobs(stage)).keys()) if (!await verifyBlob(join(stage, "blobs"), sha)) throw new Error(`Missing session blob ${sha}`);
156
+ await mkdir(join(stage, "blobs"), { recursive: true });
157
+ await relocateSession(stage, workspace);
158
+ await rename(stage, destination);
159
+ const parent = await open(sessionRoot, "r"); try { await parent.sync(); } finally { await parent.close(); }
160
+ return destination;
161
+ } finally { await active?.file.close(); await rm(stage, { recursive: true, force: true }); }
162
+ }
@@ -0,0 +1,68 @@
1
+ /** Location discovery only; session data is always read from its append-only journals (D3/D8). */
2
+ import { readdir, readFile, mkdir, rename, realpath } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { configDir } from "./config.ts";
6
+ import { atomicWrite } from "./atomic.ts";
7
+ import { serialized } from "./serial.ts";
8
+ import { readListed, type ListedSession } from "./sessions.ts";
9
+ import { readCursor, writeCursor } from "./log.ts";
10
+
11
+ export interface SessionRoot { path: string; workspace: string }
12
+ const registryPath = () => join(configDir(), "session-roots.json");
13
+ export async function sessionRoots(current: SessionRoot): Promise<SessionRoot[]> {
14
+ let rows: SessionRoot[] = [];
15
+ try {
16
+ const data: unknown = JSON.parse(await readFile(registryPath(), "utf8"));
17
+ if (!Array.isArray(data)) throw new Error("Session root registry is not an array");
18
+ rows = data.filter((row): row is SessionRoot => row !== null && typeof row === "object" && typeof row.path === "string" && typeof row.workspace === "string");
19
+ } catch (error) { if ((error as { code?: string }).code !== "ENOENT") throw error; }
20
+ return [...new Map([...rows, current].map(row => [resolve(row.path), { ...row, path: resolve(row.path), workspace: resolve(row.workspace) }])).values()];
21
+ }
22
+ export async function registerSessionRoot(root: SessionRoot): Promise<void> {
23
+ const path = registryPath();
24
+ return serialized(path, async () => {
25
+ const rows = await sessionRoots(root);
26
+ await mkdir(dirname(path), { recursive: true });
27
+ await atomicWrite(path, JSON.stringify(rows, null, 2) + "\n");
28
+ });
29
+ }
30
+ export interface SessionScan { sessions: ListedSession[]; loaded: number; total: number; root: string }
31
+ export async function* scanSessionRoots(roots: readonly SessionRoot[], signal?: AbortSignal): AsyncGenerator<SessionScan> {
32
+ const found: { id: string; path: string; workspace: string }[] = [];
33
+ for (const root of roots) {
34
+ if (signal?.aborted) return;
35
+ try {
36
+ const entries = await readdir(root.path, { withFileTypes: true });
37
+ found.push(...entries.filter(entry => entry.isDirectory() && !entry.name.startsWith(".")).map(entry => ({ id: entry.name, path: join(root.path, entry.name), workspace: root.workspace })));
38
+ } catch (error) {
39
+ if ((error as { code?: string }).code !== "ENOENT") yield { sessions: [{ ok: false, id: root.path, path: root.path, error: String(error) }], loaded: 0, total: 0, root: root.path };
40
+ }
41
+ }
42
+ const unique = [...new Map(found.map(row => [resolve(row.path), row])).values()];
43
+ // Eight concurrent reads bound descriptors and let cancellation run between batches; no total-result cap.
44
+ for (let offset = 0; offset < unique.length; offset += 8) {
45
+ if (signal?.aborted) return;
46
+ const batch = unique.slice(offset, offset + 8);
47
+ const sessions = await Promise.all(batch.map(async row => {
48
+ try { return await readListed(row.id, row.path, { workspace: row.workspace, ...(signal ? { signal } : {}) }); }
49
+ catch (error) { return { ok: false as const, id: row.id, path: row.path, error: String(error) }; }
50
+ }));
51
+ if (signal?.aborted) return;
52
+ yield { sessions, loaded: Math.min(offset + batch.length, unique.length), total: unique.length, root: batch[0]!.workspace };
53
+ }
54
+ }
55
+ export async function renameSession(path: string, name: string): Promise<void> {
56
+ const cursor = await readCursor(path);
57
+ if (!cursor) throw new Error("This empty session has no saved cursor to rename");
58
+ await writeCursor(path, { ...cursor, name: name.trim() });
59
+ }
60
+ export async function archiveSession(path: string, active?: string): Promise<string> {
61
+ const target = await realpath(path);
62
+ if (active && target === await realpath(active)) throw new Error("Cannot remove the currently active session");
63
+ const trash = join(dirname(target), ".trash");
64
+ await mkdir(trash, { recursive: true });
65
+ const destination = join(trash, `${target.split(/[\\/]/).at(-1)!}-${randomUUID()}`);
66
+ await rename(target, destination);
67
+ return destination;
68
+ }
@@ -0,0 +1,27 @@
1
+ /** Position lookup is shared by in-session navigation and copied forks (D8, Pi before/at semantics). */
2
+ import type { NodeRef } from "../core/events.ts";
3
+ import { digestNode, listNodes, nodePath, readCursorAt, readNode, type Basis, type Cursor } from "./log.ts";
4
+
5
+ export async function resolveHistoryPosition(dir: string, eventId: string, position: "at" | "before" = "at"): Promise<{
6
+ node: number; parent: NodeRef | null; basis: Basis; selection: Cursor | null; editorText?: string;
7
+ }> {
8
+ let found: Awaited<ReturnType<typeof readNode>> | undefined;
9
+ let node = -1;
10
+ for (const candidate of await listNodes(dir)) {
11
+ const read = await readNode(dir, candidate);
12
+ if (!read.events.some(e => e.id === eventId)) continue;
13
+ if (found) throw new Error(`ambiguous history event ${eventId}`);
14
+ if (!read.header || read.corrupt.length) throw new Error(`corrupt history node ${candidate}`);
15
+ node = candidate; found = read;
16
+ }
17
+ if (!found?.header) throw new Error(`NO_SUCH_EVENT: ${eventId}`);
18
+ const index = found.events.findIndex(e => e.id === eventId);
19
+ const event = found.events[index]!;
20
+ if (position === "before" && event.t !== "user") throw new Error("fork before requires a user message");
21
+ const selection = await readCursorAt(dir, node, eventId);
22
+ const cutoff = position === "before" ? found.events[index - 1]?.id : eventId;
23
+ const parent = cutoff === undefined ? found.header.parent
24
+ : { node, log_ref: nodePath(dir, node), sha256: await digestNode(dir, node), at_event: cutoff };
25
+ return { node, parent, basis: cutoff === undefined ? found.header.basis : "inherit", selection,
26
+ ...(position === "before" && event.t === "user" ? { editorText: event.text } : {}) };
27
+ }
@@ -22,7 +22,7 @@
22
22
  * and silently truncate a log.
23
23
  */
24
24
 
25
- import { cp, mkdir, mkdtemp, open, readdir, rename, rm, stat } from "node:fs/promises";
25
+ import { cp, lstat, mkdir, mkdtemp, open, readdir, rename, rm, stat } from "node:fs/promises";
26
26
  import type { Dirent } from "node:fs";
27
27
  import { tmpdir } from "node:os";
28
28
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
@@ -33,13 +33,19 @@ import {
33
33
  initNode,
34
34
  readManifest,
35
35
  readCursor,
36
+ readCursorAt,
36
37
  readNode,
37
38
  writeCursor,
39
+ setParentSession,
38
40
  type Manifest,
39
41
  } from "./log.ts";
40
42
  import { readNodeHistory } from "./restore.ts";
41
43
  import { replayContext } from "./replay.ts";
42
44
  import { recoverCut } from "./recovery.ts";
45
+ import { planSessionEntry, type SessionEntryFlags } from "../core/session-entry.ts";
46
+ import { assertNever } from "../core/exhaustive.ts";
47
+ import { resolveHistoryPosition } from "./session-position.ts";
48
+ import { importSessionArchive } from "./session-archive.ts";
43
49
 
44
50
  /**
45
51
  * A listing that reads every session ever is unbounded. Two hundred is
@@ -77,6 +83,9 @@ export type ListedSession =
77
83
  events: number;
78
84
  updated_at: string | null;
79
85
  path: string;
86
+ workspace?: string;
87
+ parent_session?: string;
88
+ text?: string;
80
89
  }
81
90
  | {
82
91
  ok: false;
@@ -150,7 +159,7 @@ export async function createSession(
150
159
  );
151
160
  }
152
161
 
153
- export async function listSessions(workspace: string, sessionDir?: string): Promise<SessionList> {
162
+ export async function listSessions(workspace: string, sessionDir?: string, limit = MAX_SESSION_LIST): Promise<SessionList> {
154
163
  const dir = sessionsDir(workspace, sessionDir);
155
164
  let entries: Dirent[];
156
165
  try {
@@ -160,7 +169,7 @@ export async function listSessions(workspace: string, sessionDir?: string): Prom
160
169
  throw err;
161
170
  }
162
171
 
163
- const dirs = entries.filter((e) => e.isDirectory());
172
+ const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith("."));
164
173
  const withMtime = await Promise.all(
165
174
  dirs.map(async (d) => {
166
175
  const path = join(dir, d.name);
@@ -173,8 +182,8 @@ export async function listSessions(workspace: string, sessionDir?: string): Prom
173
182
  // session recorded: a backup restore can make an old session the
174
183
  // newest directory on disk, and that must not hide the one that ran last.
175
184
  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);
185
+ const truncated = Math.max(0, withMtime.length - limit);
186
+ const chosen = withMtime.slice(0, limit);
178
187
 
179
188
  const sessions: ListedSession[] = [];
180
189
  for (const d of chosen) {
@@ -218,7 +227,7 @@ export async function findSessionByPrefix(
218
227
  return { ok: false, reason: "empty session id", candidates: [] };
219
228
  }
220
229
 
221
- const { sessions } = await listSessions(workspace, sessionDir);
230
+ const { sessions } = await listSessions(workspace, sessionDir, Infinity);
222
231
  const ok = sessions.filter((s): s is Extract<ListedSession, { ok: true }> => s.ok);
223
232
 
224
233
  const exactId = ok.filter((s) => s.id === needle);
@@ -277,6 +286,7 @@ export async function copyAncestryTo(
277
286
  destDir: string,
278
287
  createdAt: string,
279
288
  untilEvent?: string,
289
+ position: "at" | "before" = "at",
280
290
  ): Promise<void> {
281
291
  const rel = relative(resolve(sourceDir), resolve(destDir));
282
292
  if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) {
@@ -292,26 +302,25 @@ export async function copyAncestryTo(
292
302
  const tip = cursor?.node ?? 0;
293
303
  const history = await readNodeHistory(stage, tip);
294
304
  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
305
  const nodes = await listNodes(stage);
306
+ const resolved = untilEvent === undefined ? { basis: "inherit" as const, selection: await readCursorAt(stage, tip),
307
+ parent: { node: tip, log_ref: nodePath(destDir, tip), sha256: await digestNode(stage, tip) } }
308
+ : await resolveHistoryPosition(stage, untilEvent, position);
309
+ const { selection: historical, basis, parent } = resolved;
302
310
  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");
311
+ await initNode(stage, next, parent, createdAt, basis);
312
+ const selection = { ...(historical ?? cursor) };
313
+ delete selection.at_event;
307
314
  await writeCursor(stage, {
308
- ...cursor, node: next, next_node: next + 1, model: cursor?.model ?? "unknown", updated_at: createdAt,
315
+ ...selection, node: next, next_node: next + 1, model: historical?.model ?? cursor?.model ?? "unknown", updated_at: createdAt,
316
+ ...(historical === null && parent !== null ? { needs_model_checkpoint: true } : {}),
309
317
  });
310
318
  await replayContext(stage, next);
319
+ await setParentSession(stage, resolve(sourceDir));
311
320
  await syncCopiedTree(stage);
312
321
  await rename(stage, destDir);
313
- const parent = await open(dirname(destDir), "r");
314
- try { await parent.sync(); } finally { await parent.close(); }
322
+ const directory = await open(dirname(destDir), "r");
323
+ try { await directory.sync(); } finally { await directory.close(); }
315
324
  } finally { await rm(stage, { recursive: true, force: true }); }
316
325
  }
317
326
 
@@ -328,21 +337,7 @@ async function syncCopiedTree(dir: string): Promise<void> {
328
337
  try { await directory.sync(); } finally { await directory.close(); }
329
338
  }
330
339
 
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;
340
+ export interface ResolveLogOpts extends SessionEntryFlags {
346
341
  sessionDir?: string;
347
342
  }
348
343
 
@@ -350,45 +345,59 @@ export interface ResolveLogOpts {
350
345
  * Picks the log directory for a process that did not pass a path, or
351
346
  * honours one that did.
352
347
  *
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.
348
+ * Like Pi, a new invocation starts a new session unless a resume intent
349
+ * was explicit. --fresh remains a non-destructive alias for that default.
350
+ * Legacy .xeno/log stays opt-in; storage layout never implies user intent.
358
351
  */
359
352
  export async function resolveLogDir(
360
353
  workspace: string,
361
354
  opts: ResolveLogOpts = {},
362
355
  ): Promise<{ logDir: string; created: boolean; legacyNotice: string | null }> {
356
+ const entry = planSessionEntry(opts);
363
357
  // Computed first: createSession will mkdir `.xeno/sessions/`, after which
364
358
  // the leftover-log condition is no longer true.
365
359
  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");
360
+ switch (entry.kind) {
361
+ case "named": {
362
+ const root = sessionsDir(workspace, opts.sessionDir), logDir = join(root, entry.id);
363
+ await mkdir(root, { recursive: true });
364
+ try { await mkdir(logDir); return { logDir, created: true, legacyNotice }; }
365
+ catch (error) {
366
+ if ((error as { code?: string }).code !== "EEXIST" || !(await lstat(logDir)).isDirectory()) throw error;
367
+ return { logDir, created: false, legacyNotice };
368
+ }
383
369
  }
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 };
370
+ case "ephemeral": {
371
+ const logDir = await mkdtemp(join(tmpdir(), "xeno-nosession-"));
372
+ return { logDir, created: true, legacyNotice };
391
373
  }
374
+ case "open": {
375
+ if (entry.explicit) return { logDir: entry.target, created: false, legacyNotice };
376
+ const archive = resolve(workspace, entry.target);
377
+ if (await stat(archive).then(info => info.isFile(), () => false)) {
378
+ return { logDir: await importSessionArchive(archive, sessionsDir(workspace, opts.sessionDir), workspace), created: true, legacyNotice };
379
+ }
380
+ const found = await findSessionByPrefix(workspace, entry.target, opts.sessionDir);
381
+ if (!found.ok) throw new Error(found.reason);
382
+ return { logDir: found.session.path, created: false, legacyNotice };
383
+ }
384
+ case "fork": {
385
+ const found = await findSessionByPrefix(workspace, entry.target, opts.sessionDir);
386
+ if (!found.ok) throw new Error(found.reason);
387
+ const created = await createSession(workspace, systemIdClock, opts.sessionDir);
388
+ await copyAncestryTo(found.session.path, created.path, new Date().toISOString());
389
+ return { logDir: created.path, created: true, legacyNotice };
390
+ }
391
+ case "continue": {
392
+ const { sessions } = await listSessions(workspace, opts.sessionDir, Infinity);
393
+ const newest = sessions.find((s) => s.ok);
394
+ if (newest !== undefined) return { logDir: newest.path, created: false, legacyNotice };
395
+ break;
396
+ }
397
+ case "select":
398
+ throw new Error("--resume needs an interactive terminal. Pass --session <id> instead");
399
+ case "new": break;
400
+ default: return assertNever(entry, "resolveLogDir");
392
401
  }
393
402
  const created = await createSession(workspace, systemIdClock, opts.sessionDir);
394
403
  return { logDir: created.path, created: true, legacyNotice };
@@ -401,7 +410,7 @@ async function existingDir(workspace: string, prefix: string): Promise<string |
401
410
  return null;
402
411
  }
403
412
 
404
- async function readListed(id: string, path: string): Promise<ListedSession> {
413
+ export async function readListed(id: string, path: string, details?: { workspace: string; signal?: AbortSignal }): Promise<ListedSession> {
405
414
  let parsed: unknown;
406
415
  try {
407
416
  parsed = await readManifest(path);
@@ -409,7 +418,7 @@ async function readListed(id: string, path: string): Promise<ListedSession> {
409
418
  if ((err as { code?: string }).code === "ENOENT") {
410
419
  return {
411
420
  ok: true, id, path,
412
- name: null, model: null, node: null, events: 0, updated_at: null,
421
+ name: null, model: null, node: null, events: 0, updated_at: null, ...(details ? { workspace: details.workspace } : {}),
413
422
  };
414
423
  }
415
424
  return { ok: false, id, path, error: `${path} could not be read: ${(err as Error).message}` };
@@ -424,7 +433,7 @@ async function readListed(id: string, path: string): Promise<ListedSession> {
424
433
  if (cursor === undefined) {
425
434
  return {
426
435
  ok: true, id, path,
427
- name: null, model: null, node: null, events: 0, updated_at: null,
436
+ name: null, model: null, node: null, events: 0, updated_at: null, ...(details ? { workspace: details.workspace } : {}),
428
437
  };
429
438
  }
430
439
 
@@ -432,6 +441,16 @@ async function readListed(id: string, path: string): Promise<ListedSession> {
432
441
  // listing (a cycle, a missing parent), and a list that throws for one
433
442
  // bad tree is the same shape as a list that throws for one bad file.
434
443
  const events = (await readNode(path, cursor.node)).events.length;
444
+ const text: string[] = [];
445
+ if (details) for (const node of await listNodes(path)) {
446
+ if (details.signal?.aborted) break;
447
+ const read = await readNode(path, node);
448
+ if (read.corrupt.length) throw new Error(`${path} has corrupt events`);
449
+ for (const event of read.events) {
450
+ if (event.t === "user") text.push(event.text);
451
+ else if (event.t === "assistant") for (const block of event.blocks) if (block.type === "text") text.push(block.text);
452
+ }
453
+ }
435
454
  return {
436
455
  ok: true,
437
456
  id,
@@ -441,6 +460,8 @@ async function readListed(id: string, path: string): Promise<ListedSession> {
441
460
  node: cursor.node,
442
461
  events,
443
462
  updated_at: cursor.updated_at,
463
+ ...(details ? { workspace: cursor.workspace ?? details.workspace, text: text.join("\n"),
464
+ ...(manifest.parent_session ? { parent_session: manifest.parent_session } : {}) } : {}),
444
465
  };
445
466
  }
446
467
 
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { dirname, join, resolve } from "node:path";
11
11
  import { configDir, readJsonFile, writeJsonFile } from "./config.ts";
12
+ import { parseSettings } from "../core/settings.ts";
12
13
 
13
14
  export type TrustFile = { readonly [dir: string]: boolean };
14
15
 
@@ -38,6 +39,18 @@ export function lookupTrust(decisions: TrustFile, dir: string): boolean | undefi
38
39
  }
39
40
  }
40
41
 
42
+ export async function projectTrust(workspace: string, temporary?: boolean): Promise<{ trusted: boolean; saved?: { path: string; trusted: boolean }; policy: "ask" | "always" | "never" }> {
43
+ const [saved, settings] = await Promise.all([readTrust(), readJsonFile<unknown>(join(configDir(), "settings.json"))]);
44
+ const policy = parseSettings(settings.value).settings.defaultProjectTrust ?? "ask";
45
+ let path = resolve(workspace);
46
+ for (;;) {
47
+ const decision = saved.value[path];
48
+ if (decision !== undefined) return { trusted: temporary ?? decision, saved: { path, trusted: decision }, policy };
49
+ const parent = dirname(path); if (parent === path) break; path = parent;
50
+ }
51
+ return { trusted: temporary ?? policy === "always", policy };
52
+ }
53
+
41
54
  export async function readTrust(): Promise<{ value: TrustFile; error: string | null }> {
42
55
  const { value, error } = await readJsonFile<unknown>(trustPath());
43
56
  if (error) return { value: {}, error };
@@ -0,0 +1,47 @@
1
+ /** Ported from Pi (MIT), coding-agent/chat-viewport.ts at 02eed88. */
2
+ import { type Component, ScrollView, type ScrollViewScrollbar, VStack } from "../vendor/pi-tui/index.ts";
3
+
4
+ export interface ChatViewportOptions {
5
+ readonly document: Component;
6
+ readonly pendingMessages: Component;
7
+ readonly status: Component;
8
+ readonly editor: Component;
9
+ readonly footer: Component;
10
+ readonly widgetsAbove?: Component;
11
+ readonly widgetsBelow?: Component;
12
+ readonly scrollbar?: ScrollViewScrollbar;
13
+ readonly scrollbarTrackStyle?: (text: string) => string;
14
+ readonly scrollbarThumbStyle?: (text: string) => string;
15
+ }
16
+
17
+ export interface ChatViewport {
18
+ readonly root: Component;
19
+ readonly transcript: ScrollView;
20
+ }
21
+
22
+ /** Shared fullscreen transcript and fixed input-dock layout. */
23
+ export function createChatViewport(options: ChatViewportOptions): ChatViewport {
24
+ const transcript = new ScrollView(options.document, {
25
+ follow: "end",
26
+ primary: true,
27
+ overscroll: "chain",
28
+ scrollbar: options.scrollbar ?? "auto",
29
+ ...(options.scrollbarTrackStyle === undefined ? {} : { scrollbarTrackStyle: options.scrollbarTrackStyle }),
30
+ ...(options.scrollbarThumbStyle === undefined ? {} : { scrollbarThumbStyle: options.scrollbarThumbStyle }),
31
+ });
32
+ const dock = new VStack([
33
+ { component: options.pendingMessages, shrink: 1, minSize: 0 },
34
+ { component: options.status, shrink: 1, minSize: 0 },
35
+ ...(options.widgetsAbove === undefined ? [] : [{ component: options.widgetsAbove, shrink: 1, minSize: 0 }]),
36
+ { component: options.editor, shrink: 1, minSize: 3 },
37
+ ...(options.widgetsBelow === undefined ? [] : [{ component: options.widgetsBelow, shrink: 1, minSize: 0 }]),
38
+ { component: options.footer, shrink: 1, minSize: 0 },
39
+ ]);
40
+ return {
41
+ transcript,
42
+ root: new VStack([
43
+ { component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 },
44
+ { component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
45
+ ]),
46
+ };
47
+ }