@visual-z/xeno 0.8.3 → 0.9.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 (348) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +31 -746
  3. package/bin/xeno.exe +26 -0
  4. package/install.cjs +88 -0
  5. package/package.json +18 -48
  6. package/docs/app-server.md +0 -115
  7. package/docs/foundations.md +0 -88
  8. package/docs/install.md +0 -244
  9. package/docs/providers.md +0 -172
  10. package/docs/research/foundation-clients-2026-10-09.md +0 -27
  11. package/docs/research/foundation-editing-2026-10-09.md +0 -56
  12. package/docs/research/foundation-network-2026-10-09.md +0 -37
  13. package/docs/research/foundation-workflow-2026-10-09.md +0 -37
  14. package/src/apps/acp-server.ts +0 -41
  15. package/src/apps/agent-profiles.ts +0 -19
  16. package/src/apps/app-server.ts +0 -30
  17. package/src/apps/approve.ts +0 -49
  18. package/src/apps/cli.ts +0 -99
  19. package/src/apps/commands.ts +0 -319
  20. package/src/apps/completions.ts +0 -87
  21. package/src/apps/control-transport.ts +0 -90
  22. package/src/apps/export-cli.ts +0 -24
  23. package/src/apps/goal-flow.ts +0 -70
  24. package/src/apps/image-draft.ts +0 -46
  25. package/src/apps/input.ts +0 -312
  26. package/src/apps/model-catalog.ts +0 -69
  27. package/src/apps/preferences.ts +0 -20
  28. package/src/apps/project-trust.ts +0 -9
  29. package/src/apps/provider-connect.ts +0 -346
  30. package/src/apps/question-ui.ts +0 -27
  31. package/src/apps/render.ts +0 -181
  32. package/src/apps/resources.ts +0 -66
  33. package/src/apps/review.ts +0 -109
  34. package/src/apps/session-hub.ts +0 -96
  35. package/src/apps/session-lifecycle.ts +0 -64
  36. package/src/apps/shell.ts +0 -2066
  37. package/src/apps/startup-input.ts +0 -135
  38. package/src/apps/startup.ts +0 -1677
  39. package/src/apps/ui/app.ts +0 -1713
  40. package/src/apps/ui/control-model.ts +0 -43
  41. package/src/apps/ui/extension-host.ts +0 -194
  42. package/src/apps/ui/image-cache.ts +0 -121
  43. package/src/apps/ui/palette.ts +0 -88
  44. package/src/apps/ui/project.ts +0 -127
  45. package/src/apps/ui/standalone.ts +0 -148
  46. package/src/apps/ui/status-model.ts +0 -68
  47. package/src/apps/ui/surface.ts +0 -91
  48. package/src/apps/ui/theme.ts +0 -86
  49. package/src/apps/update-check.ts +0 -112
  50. package/src/apps/workflow.ts +0 -58
  51. package/src/core/acceptance.ts +0 -139
  52. package/src/core/action-defaults.ts +0 -58
  53. package/src/core/activity-status.ts +0 -52
  54. package/src/core/agent-profile.ts +0 -130
  55. package/src/core/attachment-input.ts +0 -17
  56. package/src/core/blind-checks.ts +0 -72
  57. package/src/core/boundary.ts +0 -174
  58. package/src/core/branch-receipt.ts +0 -26
  59. package/src/core/catalog-merge.ts +0 -19
  60. package/src/core/checkpoints.ts +0 -85
  61. package/src/core/churn.ts +0 -61
  62. package/src/core/command-artifacts.ts +0 -94
  63. package/src/core/command-contract.ts +0 -151
  64. package/src/core/completion-action.ts +0 -20
  65. package/src/core/completion-review.ts +0 -98
  66. package/src/core/context-detail.ts +0 -37
  67. package/src/core/context-policy.ts +0 -133
  68. package/src/core/context-window.ts +0 -54
  69. package/src/core/continuation.ts +0 -130
  70. package/src/core/control-protocol.ts +0 -69
  71. package/src/core/control-view.ts +0 -39
  72. package/src/core/cycle.ts +0 -17
  73. package/src/core/data-query.ts +0 -201
  74. package/src/core/deadline.ts +0 -40
  75. package/src/core/derive.ts +0 -516
  76. package/src/core/draft-images.ts +0 -56
  77. package/src/core/edit-plan.ts +0 -200
  78. package/src/core/environment.ts +0 -181
  79. package/src/core/event-validation.ts +0 -220
  80. package/src/core/events.ts +0 -670
  81. package/src/core/evidence-ladder.ts +0 -62
  82. package/src/core/evidence-links.ts +0 -68
  83. package/src/core/evidence.ts +0 -240
  84. package/src/core/execution.ts +0 -18
  85. package/src/core/exhaustive.ts +0 -18
  86. package/src/core/export.ts +0 -179
  87. package/src/core/extension-flags.ts +0 -14
  88. package/src/core/external-effects.ts +0 -488
  89. package/src/core/failure-recovery.ts +0 -173
  90. package/src/core/file-precondition.ts +0 -78
  91. package/src/core/file-references.ts +0 -35
  92. package/src/core/frontmatter.ts +0 -213
  93. package/src/core/git-status.ts +0 -29
  94. package/src/core/goal-view.ts +0 -64
  95. package/src/core/goal.ts +0 -200
  96. package/src/core/handoff.ts +0 -479
  97. package/src/core/history-query.ts +0 -139
  98. package/src/core/history.ts +0 -237
  99. package/src/core/image-limits.ts +0 -72
  100. package/src/core/images.ts +0 -46
  101. package/src/core/integration-health.ts +0 -34
  102. package/src/core/json-event.ts +0 -131
  103. package/src/core/json-pointer.ts +0 -46
  104. package/src/core/keymap.ts +0 -153
  105. package/src/core/lsp-framing.ts +0 -34
  106. package/src/core/mcp-config.ts +0 -68
  107. package/src/core/model-picker.ts +0 -64
  108. package/src/core/model-preferences.ts +0 -28
  109. package/src/core/model-replay.ts +0 -22
  110. package/src/core/model-scope.ts +0 -53
  111. package/src/core/note-guidance.ts +0 -34
  112. package/src/core/oauth-fields.ts +0 -44
  113. package/src/core/observation-view.ts +0 -152
  114. package/src/core/outcome.ts +0 -44
  115. package/src/core/overflow.ts +0 -59
  116. package/src/core/packages.ts +0 -47
  117. package/src/core/patch.ts +0 -130
  118. package/src/core/path-suggestions.ts +0 -59
  119. package/src/core/permissions.ts +0 -410
  120. package/src/core/process-receipt.ts +0 -105
  121. package/src/core/process-state.ts +0 -37
  122. package/src/core/program-status.ts +0 -40
  123. package/src/core/progress.ts +0 -150
  124. package/src/core/project.ts +0 -431
  125. package/src/core/prompt-policy.ts +0 -46
  126. package/src/core/prompts.ts +0 -111
  127. package/src/core/question.ts +0 -94
  128. package/src/core/queue.ts +0 -78
  129. package/src/core/read-bounds.ts +0 -24
  130. package/src/core/read-observation.ts +0 -106
  131. package/src/core/recovery-sources.ts +0 -74
  132. package/src/core/regex-dialect.ts +0 -25
  133. package/src/core/request-budget.ts +0 -23
  134. package/src/core/request-progress.ts +0 -87
  135. package/src/core/request-state.ts +0 -273
  136. package/src/core/resource-selection.ts +0 -36
  137. package/src/core/response-recovery.ts +0 -59
  138. package/src/core/resume.ts +0 -59
  139. package/src/core/review.ts +0 -68
  140. package/src/core/ripgrep-release.ts +0 -64
  141. package/src/core/runtime-target.ts +0 -94
  142. package/src/core/scheduling.ts +0 -8
  143. package/src/core/search-observation.ts +0 -97
  144. package/src/core/secrets.ts +0 -188
  145. package/src/core/session-archive.ts +0 -4
  146. package/src/core/session-browser.ts +0 -82
  147. package/src/core/session-entry.ts +0 -61
  148. package/src/core/session-exit.ts +0 -42
  149. package/src/core/session-format.ts +0 -169
  150. package/src/core/session-position.ts +0 -26
  151. package/src/core/session-tree.ts +0 -147
  152. package/src/core/settings.ts +0 -192
  153. package/src/core/skills.ts +0 -165
  154. package/src/core/sql-query.ts +0 -211
  155. package/src/core/stall.ts +0 -128
  156. package/src/core/stop-report.ts +0 -68
  157. package/src/core/text-normalize.ts +0 -81
  158. package/src/core/theme.ts +0 -166
  159. package/src/core/thinking.ts +0 -26
  160. package/src/core/tokens.ts +0 -151
  161. package/src/core/tool-contract.ts +0 -153
  162. package/src/core/tool-discovery.ts +0 -87
  163. package/src/core/tool-failure.ts +0 -81
  164. package/src/core/tool-input.ts +0 -44
  165. package/src/core/transcript.ts +0 -108
  166. package/src/core/tui-mode.ts +0 -29
  167. package/src/core/update-check.ts +0 -157
  168. package/src/core/version.ts +0 -55
  169. package/src/core/web-content.ts +0 -80
  170. package/src/core/work-state.ts +0 -154
  171. package/src/core/workflow-mode.ts +0 -34
  172. package/src/core/working-set.ts +0 -119
  173. package/src/core/yaml.ts +0 -282
  174. package/src/runtime/acp-agent.ts +0 -289
  175. package/src/runtime/agent-profiles.ts +0 -21
  176. package/src/runtime/auth.ts +0 -367
  177. package/src/runtime/blind-check.ts +0 -36
  178. package/src/runtime/branch-jobs.ts +0 -36
  179. package/src/runtime/catalog.ts +0 -285
  180. package/src/runtime/clipboard.ts +0 -125
  181. package/src/runtime/command-artifacts.ts +0 -64
  182. package/src/runtime/connection-reuse.ts +0 -40
  183. package/src/runtime/control-client.ts +0 -149
  184. package/src/runtime/control-connection.ts +0 -139
  185. package/src/runtime/counter-check.ts +0 -74
  186. package/src/runtime/environment-tools.ts +0 -60
  187. package/src/runtime/evidence-notes.ts +0 -35
  188. package/src/runtime/exec-sessions.ts +0 -456
  189. package/src/runtime/execution-control.ts +0 -40
  190. package/src/runtime/extension-loader.ts +0 -37
  191. package/src/runtime/extension-ui.ts +0 -47
  192. package/src/runtime/extensions.ts +0 -370
  193. package/src/runtime/formatter-tool.ts +0 -38
  194. package/src/runtime/formatter.ts +0 -50
  195. package/src/runtime/foundation.ts +0 -116
  196. package/src/runtime/history-cache.ts +0 -57
  197. package/src/runtime/history-tools.ts +0 -168
  198. package/src/runtime/images.ts +0 -80
  199. package/src/runtime/integrations.ts +0 -216
  200. package/src/runtime/loop.ts +0 -3001
  201. package/src/runtime/lsp-client.ts +0 -201
  202. package/src/runtime/mcp-client.ts +0 -264
  203. package/src/runtime/mcp-oauth.ts +0 -168
  204. package/src/runtime/mcp-schema.ts +0 -51
  205. package/src/runtime/model-config.ts +0 -232
  206. package/src/runtime/model-data.LICENSE +0 -27
  207. package/src/runtime/model-data.ts +0 -21
  208. package/src/runtime/model-discovery.ts +0 -208
  209. package/src/runtime/model-replay.ts +0 -10
  210. package/src/runtime/model-scope.ts +0 -20
  211. package/src/runtime/model-sources.ts +0 -151
  212. package/src/runtime/model.ts +0 -686
  213. package/src/runtime/models-fetch.ts +0 -57
  214. package/src/runtime/oauth-device.ts +0 -86
  215. package/src/runtime/oauth.ts +0 -853
  216. package/src/runtime/open-image.ts +0 -42
  217. package/src/runtime/openai-responses.ts +0 -304
  218. package/src/runtime/openai.ts +0 -697
  219. package/src/runtime/output-schema.ts +0 -26
  220. package/src/runtime/package-manager.ts +0 -125
  221. package/src/runtime/patch-tool.ts +0 -38
  222. package/src/runtime/permission-resources.ts +0 -18
  223. package/src/runtime/pi-provider.ts +0 -423
  224. package/src/runtime/program-status.ts +0 -38
  225. package/src/runtime/progress.ts +0 -26
  226. package/src/runtime/provider-access.ts +0 -21
  227. package/src/runtime/provider-connect.ts +0 -109
  228. package/src/runtime/provider-presets.ts +0 -98
  229. package/src/runtime/provider-protocols.ts +0 -29
  230. package/src/runtime/provider-request.ts +0 -87
  231. package/src/runtime/provider-tool-policy.ts +0 -33
  232. package/src/runtime/provider.ts +0 -580
  233. package/src/runtime/question-tool.ts +0 -62
  234. package/src/runtime/retry.ts +0 -447
  235. package/src/runtime/review-tool.ts +0 -48
  236. package/src/runtime/rich-text.ts +0 -36
  237. package/src/runtime/runtime-target.ts +0 -149
  238. package/src/runtime/secrets.ts +0 -79
  239. package/src/runtime/self-update.ts +0 -74
  240. package/src/runtime/session-control.ts +0 -265
  241. package/src/runtime/session-hub.ts +0 -85
  242. package/src/runtime/stream-idle.ts +0 -80
  243. package/src/runtime/tool-broker.ts +0 -137
  244. package/src/runtime/tool-discovery.ts +0 -58
  245. package/src/runtime/tool-queue.ts +0 -77
  246. package/src/runtime/tools.ts +0 -1820
  247. package/src/runtime/turn-effects.ts +0 -31
  248. package/src/runtime/update-check.ts +0 -48
  249. package/src/runtime/usage.ts +0 -253
  250. package/src/runtime/version.ts +0 -12
  251. package/src/runtime/web-http.ts +0 -56
  252. package/src/runtime/web-tools.ts +0 -88
  253. package/src/runtime/working-notes.ts +0 -60
  254. package/src/runtime/workspace-lifecycle.ts +0 -141
  255. package/src/store/acceptance.ts +0 -15
  256. package/src/store/agent-profile-files.ts +0 -13
  257. package/src/store/atomic.ts +0 -31
  258. package/src/store/auth-store.ts +0 -50
  259. package/src/store/blind-checks.ts +0 -32
  260. package/src/store/blobs.ts +0 -143
  261. package/src/store/checkpoint-history.ts +0 -32
  262. package/src/store/command-artifacts.ts +0 -96
  263. package/src/store/command.ts +0 -111
  264. package/src/store/config.ts +0 -69
  265. package/src/store/context-files.ts +0 -436
  266. package/src/store/file-checkpoints.ts +0 -108
  267. package/src/store/file-index.ts +0 -174
  268. package/src/store/foundation-config.ts +0 -24
  269. package/src/store/git-checkpoints.ts +0 -67
  270. package/src/store/git-status.ts +0 -15
  271. package/src/store/history-search.ts +0 -19
  272. package/src/store/log.ts +0 -725
  273. package/src/store/lsp-document.ts +0 -30
  274. package/src/store/mcp-credentials.ts +0 -85
  275. package/src/store/models.ts +0 -60
  276. package/src/store/observations.ts +0 -224
  277. package/src/store/packages.ts +0 -98
  278. package/src/store/patch.ts +0 -92
  279. package/src/store/permission-paths.ts +0 -34
  280. package/src/store/process-receipts.ts +0 -34
  281. package/src/store/query-source.ts +0 -49
  282. package/src/store/recovery.ts +0 -44
  283. package/src/store/replay.ts +0 -76
  284. package/src/store/resource-index.ts +0 -170
  285. package/src/store/resources.ts +0 -285
  286. package/src/store/restore.ts +0 -110
  287. package/src/store/review.ts +0 -109
  288. package/src/store/runtime-target.ts +0 -64
  289. package/src/store/search.ts +0 -728
  290. package/src/store/secrets.ts +0 -44
  291. package/src/store/serial.ts +0 -13
  292. package/src/store/session-archive.ts +0 -165
  293. package/src/store/session-browser.ts +0 -68
  294. package/src/store/session-position.ts +0 -27
  295. package/src/store/sessions.ts +0 -525
  296. package/src/store/source-cancellation.ts +0 -6
  297. package/src/store/tool-binaries.ts +0 -90
  298. package/src/store/trust.ts +0 -82
  299. package/src/store/update-cache.ts +0 -36
  300. package/src/store/user-settings.ts +0 -30
  301. package/src/store/workspace-clone.ts +0 -42
  302. package/src/store/workspace-observation.ts +0 -113
  303. package/src/store/workspace.ts +0 -115
  304. package/src/store/worktree.ts +0 -278
  305. package/src/tui/ansi-spans.ts +0 -126
  306. package/src/tui/diff.ts +0 -160
  307. package/src/tui/exhaustive.ts +0 -14
  308. package/src/tui/fuzzy.ts +0 -75
  309. package/src/tui/style.ts +0 -208
  310. package/src/tui/ui/atomic-draft.ts +0 -121
  311. package/src/tui/ui/canvas.ts +0 -96
  312. package/src/tui/ui/colors.ts +0 -70
  313. package/src/tui/ui/composer.ts +0 -200
  314. package/src/tui/ui/control-panel.ts +0 -25
  315. package/src/tui/ui/control-sidebar.ts +0 -49
  316. package/src/tui/ui/dialog.ts +0 -398
  317. package/src/tui/ui/draft.ts +0 -23
  318. package/src/tui/ui/duration.ts +0 -16
  319. package/src/tui/ui/edge.ts +0 -186
  320. package/src/tui/ui/image-size.ts +0 -44
  321. package/src/tui/ui/ink.ts +0 -210
  322. package/src/tui/ui/items.ts +0 -42
  323. package/src/tui/ui/keys.ts +0 -40
  324. package/src/tui/ui/layout.ts +0 -74
  325. package/src/tui/ui/list-model.ts +0 -86
  326. package/src/tui/ui/list-view.ts +0 -172
  327. package/src/tui/ui/mascot.ts +0 -67
  328. package/src/tui/ui/messages.ts +0 -518
  329. package/src/tui/ui/motion.ts +0 -28
  330. package/src/tui/ui/paths.ts +0 -10
  331. package/src/tui/ui/pictures.ts +0 -108
  332. package/src/tui/ui/screen.ts +0 -548
  333. package/src/tui/ui/status.ts +0 -403
  334. package/src/tui/ui/styled.ts +0 -85
  335. package/src/tui/ui/tool-card.ts +0 -150
  336. package/src/tui/width.ts +0 -567
  337. package/src/vendor/pi-rich/LICENSE.upstream +0 -21
  338. package/src/vendor/pi-rich/UPSTREAM.json +0 -17
  339. package/src/vendor/pi-rich/clipboard-command.ts +0 -44
  340. package/src/vendor/pi-rich/exif-orientation.ts +0 -182
  341. package/src/vendor/pi-rich/html.ts +0 -51
  342. package/src/vendor/pi-rich/image-convert.ts +0 -49
  343. package/src/vendor/pi-rich/image-resize-core.ts +0 -164
  344. package/src/vendor/pi-rich/image-resize-worker.ts +0 -42
  345. package/src/vendor/pi-rich/image-resize.ts +0 -123
  346. package/src/vendor/pi-rich/mime.ts +0 -116
  347. package/src/vendor/pi-rich/photon.ts +0 -139
  348. package/src/vendor/pi-rich/syntax-highlight.ts +0 -214
@@ -1,525 +0,0 @@
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, lstat, mkdir, mkdtemp, open, readdir, rename, rm, stat, writeFile } 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
- readCursorAt,
37
- readNode,
38
- forkedAt,
39
- writeCursor,
40
- setParentSession,
41
- type Manifest,
42
- } from "./log.ts";
43
- import { readNodeHistory } from "./restore.ts";
44
- import { replayContext } from "./replay.ts";
45
- import { recoverCut } from "./recovery.ts";
46
- import { planSessionEntry, type SessionEntryFlags } from "../core/session-entry.ts";
47
- import { assertNever } from "../core/exhaustive.ts";
48
- import type { LogEvent } from "../core/events.ts";
49
- import { resolveHistoryPosition } from "./session-position.ts";
50
- import { importSessionArchive } from "./session-archive.ts";
51
-
52
- /**
53
- * A listing that reads every session ever is unbounded. Two hundred is
54
- * enough to find the one you were in last week; beyond that the scan would
55
- * be hiding a missing search feature, not serving one. Assumed, not
56
- * measured: this is a product cap, not a performance number. The rest stay
57
- * on disk -- the listing says it truncated, so they are not silent.
58
- */
59
- export const MAX_SESSION_LIST = 200;
60
-
61
- /**
62
- * Exclusive-create retries when the id is already on disk.
63
- *
64
- * The suffix space is 36^4 (about 1.7 million). A collision is a bug in
65
- * the clock -- same millisecond AND same random -- not a thing we wait
66
- * out. Four tries then refuse, rather than loop.
67
- */
68
- const MAX_ID_ATTEMPTS = 4;
69
-
70
- const SUFFIX_SPAN = 36 ** 4;
71
-
72
- export interface SessionIdClock {
73
- nowMs: () => number;
74
- /** In [0, 1), same range as Math.random. */
75
- random: () => number;
76
- }
77
-
78
- export type ListedSession =
79
- | {
80
- ok: true;
81
- id: string;
82
- name: string | null;
83
- model: string | null;
84
- node: number | null;
85
- events: number;
86
- updated_at: string | null;
87
- path: string;
88
- workspace?: string;
89
- parent_session?: string;
90
- text?: string;
91
- /** Details only: whether any node holds a user event (fix-skip-empty-sessions). */
92
- conversation?: boolean;
93
- /** Details only, forks only: when the copy was made (fix-fork-start-time). */
94
- forked_at?: string;
95
- }
96
- | {
97
- ok: false;
98
- id: string;
99
- path: string;
100
- error: string;
101
- };
102
-
103
- export interface SessionList {
104
- sessions: ListedSession[];
105
- /** Session directories not read because the listing cap was hit. */
106
- truncated: number;
107
- }
108
-
109
- const systemIdClock: SessionIdClock = {
110
- nowMs: () => Date.now(),
111
- random: () => Math.random(),
112
- };
113
-
114
- export function sessionsDir(workspace: string, override?: string): string {
115
- if (override !== undefined && override.length > 0) {
116
- return isAbsolute(override) ? override : join(workspace, override);
117
- }
118
- return join(workspace, ".xeno", "sessions");
119
- }
120
-
121
- /**
122
- * `<base36 ms>-<4 digit base36 random>`.
123
- *
124
- * Time first so ids sort by creation. The suffix is what makes two calls
125
- * in the same millisecond distinct; without it the second session is the
126
- * first session's directory.
127
- */
128
- export function formatSessionId(ms: number, random: number): string {
129
- const n = Math.min(SUFFIX_SPAN - 1, Math.max(0, Math.floor(random * SUFFIX_SPAN)));
130
- return `${ms.toString(36)}-${n.toString(36).padStart(4, "0")}`;
131
- }
132
-
133
- export function truncationNote(truncated: number): string | null {
134
- if (truncated <= 0) return null;
135
- const noun = truncated === 1 ? "session" : "sessions";
136
- return `listing capped at ${MAX_SESSION_LIST}; ${truncated} older ${noun} not shown`;
137
- }
138
-
139
- export async function createSession(
140
- workspace: string,
141
- clock: SessionIdClock = systemIdClock,
142
- sessionDir?: string,
143
- ): Promise<{ id: string; path: string }> {
144
- const dir = sessionsDir(workspace, sessionDir);
145
- await mkdir(dir, { recursive: true });
146
- // The first session makes the state directory exist; keep it out of git from that moment.
147
- await ignoreStateDir(dirname(dir));
148
- for (let attempt = 0; attempt < MAX_ID_ATTEMPTS; attempt++) {
149
- const id = formatSessionId(clock.nowMs(), clock.random());
150
- const path = join(dir, id);
151
- try {
152
- // Exclusive. recursive would succeed on an existing directory and the
153
- // second session would write into the first -- the collision this
154
- // suffix exists to make unrepresentable.
155
- await mkdir(path);
156
- return { id, path };
157
- } catch (err) {
158
- if ((err as { code?: string }).code !== "EEXIST") throw err;
159
- }
160
- }
161
- throw new Error(
162
- `could not allocate a unique session id under ${dir} after ${MAX_ID_ATTEMPTS} attempts`,
163
- );
164
- }
165
-
166
- export async function listSessions(workspace: string, sessionDir?: string, limit = MAX_SESSION_LIST): Promise<SessionList> {
167
- const dir = sessionsDir(workspace, sessionDir);
168
- let entries: Dirent[];
169
- try {
170
- entries = await readdir(dir, { withFileTypes: true });
171
- } catch (err) {
172
- if ((err as { code?: string }).code === "ENOENT") return { sessions: [], truncated: 0 };
173
- throw err;
174
- }
175
-
176
- const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith("."));
177
- const withMtime = await Promise.all(
178
- dirs.map(async (d) => {
179
- const path = join(dir, d.name);
180
- const st = await stat(path);
181
- return { id: d.name, path, mtime: st.mtimeMs };
182
- }),
183
- );
184
- // The cap is "which 200 do we even open", and mtime is the cheap proxy
185
- // for "likely recent". After that, the list is ordered by what each
186
- // session recorded: a backup restore can make an old session the
187
- // newest directory on disk, and that must not hide the one that ran last.
188
- withMtime.sort((a, b) => b.mtime - a.mtime);
189
- const truncated = Math.max(0, withMtime.length - limit);
190
- const chosen = withMtime.slice(0, limit);
191
-
192
- const sessions: ListedSession[] = [];
193
- for (const d of chosen) {
194
- sessions.push(await readListed(d.id, d.path));
195
- }
196
- sessions.sort(byRecordedTime);
197
- return { sessions, truncated };
198
- }
199
-
200
- export type SessionPrefixMatch =
201
- | { ok: true; session: Extract<ListedSession, { ok: true }> }
202
- | { ok: false; reason: string; candidates: string[] };
203
-
204
- /**
205
- * Unique prefix or exact path only. Two matches is a list, never catalog
206
- * order -- the same rule as findModelByPattern. Guessing here would open
207
- * someone else's session because ids share a timestamp prefix.
208
- */
209
- export async function findSessionByPrefix(
210
- workspace: string,
211
- prefix: string,
212
- sessionDir?: string,
213
- ): Promise<SessionPrefixMatch> {
214
- const needle = prefix.trim();
215
- if (needle.length === 0) {
216
- return { ok: false, reason: "empty session id", candidates: [] };
217
- }
218
-
219
- const { sessions } = await listSessions(workspace, sessionDir, Infinity);
220
- const ok = sessions.filter((s): s is Extract<ListedSession, { ok: true }> => s.ok);
221
-
222
- const exactId = ok.filter((s) => s.id === needle);
223
- if (exactId.length === 1) return { ok: true, session: exactId[0]! };
224
-
225
- const exactPath = ok.filter((s) => s.path === needle);
226
- if (exactPath.length === 1) return { ok: true, session: exactPath[0]! };
227
-
228
- const existing = await existingDir(workspace, needle);
229
- if (existing !== null) {
230
- const listed = ok.find((s) => s.path === existing);
231
- if (listed !== undefined) return { ok: true, session: listed };
232
- const read = await readListed(basename(existing), existing);
233
- if (read.ok) return { ok: true, session: read };
234
- return {
235
- ok: true,
236
- session: {
237
- ok: true,
238
- id: basename(existing),
239
- path: existing,
240
- name: null,
241
- model: null,
242
- node: null,
243
- events: 0,
244
- updated_at: null,
245
- },
246
- };
247
- }
248
-
249
- const hits = ok.filter((s) => s.id.startsWith(needle));
250
- if (hits.length === 1) return { ok: true, session: hits[0]! };
251
- if (hits.length === 0) {
252
- return { ok: false, reason: `unknown session: ${needle}`, candidates: [] };
253
- }
254
- const ids = hits.map((s) => s.id);
255
- return {
256
- ok: false,
257
- reason:
258
- `ambiguous session id prefix ${JSON.stringify(needle)}: matches ${ids.join(", ")}. ` +
259
- "Be more specific rather than guessing",
260
- candidates: ids,
261
- };
262
- }
263
-
264
- /**
265
- * Copy the log graph and its content-addressed observations, then open a new
266
- * inherit node at the chosen point. Like copying Git objects and moving a ref,
267
- * this keeps immutable evidence intact (D3/D8 / M.2). Flattening just the current
268
- * context loses earlier handoffs; flattening every event loses cut semantics.
269
- * Publication is atomic into an empty destination, so a partial copy cannot be
270
- * mistaken for a resumable session. Historical log_ref paths retain provenance;
271
- * readers resolve node IDs and artifact hashes inside the new session.
272
- */
273
- export async function copyAncestryTo(
274
- sourceDir: string,
275
- destDir: string,
276
- createdAt: string,
277
- untilEvent?: string,
278
- position: "at" | "before" = "at",
279
- ): Promise<void> {
280
- const rel = relative(resolve(sourceDir), resolve(destDir));
281
- if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) {
282
- throw new Error("cannot copy a session into itself or its descendants");
283
- }
284
- await mkdir(destDir, { recursive: true });
285
- if ((await readdir(destDir)).length > 0) throw new Error("cannot copy session into a nonempty destination");
286
- const stage = await mkdtemp(join(dirname(destDir), ".xeno-copy-"));
287
- try {
288
- await cp(sourceDir, stage, { recursive: true, force: false, errorOnExist: true });
289
- const recorded = await readCursor(stage);
290
- const cursor = recorded === null ? null : await recoverCut(stage, recorded);
291
- const tip = cursor?.node ?? 0;
292
- const history = await readNodeHistory(stage, tip);
293
- if (!history.ok) throw new Error(`cannot copy session: ${history.error}`);
294
- const nodes = await listNodes(stage);
295
- const resolved = untilEvent === undefined ? { basis: "inherit" as const, selection: await readCursorAt(stage, tip),
296
- parent: { node: tip, log_ref: nodePath(destDir, tip), sha256: await digestNode(stage, tip) } }
297
- : await resolveHistoryPosition(stage, untilEvent, position);
298
- const { selection: historical, basis, parent } = resolved;
299
- const next = Math.max(cursor?.next_node ?? 1, ...nodes.map((n) => n + 1));
300
- await initNode(stage, next, parent, createdAt, basis);
301
- const selection = { ...(historical ?? cursor) };
302
- delete selection.at_event;
303
- await writeCursor(stage, {
304
- ...selection, node: next, next_node: next + 1, model: historical?.model ?? cursor?.model ?? "unknown", updated_at: createdAt,
305
- ...(historical === null && parent !== null ? { needs_model_checkpoint: true } : {}),
306
- });
307
- await replayContext(stage, next);
308
- await setParentSession(stage, resolve(sourceDir));
309
- await syncCopiedTree(stage);
310
- await rename(stage, destDir);
311
- const directory = await open(dirname(destDir), "r");
312
- try { await directory.sync(); } finally { await directory.close(); }
313
- } finally { await rm(stage, { recursive: true, force: true }); }
314
- }
315
-
316
- async function syncCopiedTree(dir: string): Promise<void> {
317
- for (const entry of await readdir(dir, { withFileTypes: true })) {
318
- const path = join(dir, entry.name);
319
- if (entry.isDirectory()) await syncCopiedTree(path);
320
- else if (entry.isFile()) {
321
- const file = await open(path, "r");
322
- try { await file.sync(); } finally { await file.close(); }
323
- } else throw new Error(`cannot copy unsupported session entry: ${path}`);
324
- }
325
- const directory = await open(dir, "r");
326
- try { await directory.sync(); } finally { await directory.close(); }
327
- }
328
-
329
- export interface ResolveLogOpts extends SessionEntryFlags {
330
- sessionDir?: string;
331
- }
332
-
333
- /**
334
- * Picks the log directory for a process that did not pass a path, or
335
- * honours one that did.
336
- *
337
- * Like Pi, a new invocation starts a new session unless a resume intent
338
- * was explicit. --fresh remains a non-destructive alias for that default.
339
- * Legacy .xeno/log stays opt-in; storage layout never implies user intent.
340
- */
341
- export async function resolveLogDir(
342
- workspace: string,
343
- opts: ResolveLogOpts = {},
344
- ): Promise<{ logDir: string; created: boolean }> {
345
- const entry = planSessionEntry(opts);
346
- switch (entry.kind) {
347
- case "named": {
348
- const root = sessionsDir(workspace, opts.sessionDir), logDir = join(root, entry.id);
349
- await mkdir(root, { recursive: true });
350
- await ignoreStateDir(dirname(root));
351
- try { await mkdir(logDir); return { logDir, created: true }; }
352
- catch (error) {
353
- if ((error as { code?: string }).code !== "EEXIST" || !(await lstat(logDir)).isDirectory()) throw error;
354
- return { logDir, created: false };
355
- }
356
- }
357
- case "ephemeral": {
358
- const logDir = await mkdtemp(join(tmpdir(), "xeno-nosession-"));
359
- return { logDir, created: true };
360
- }
361
- case "open": {
362
- if (entry.explicit) return { logDir: entry.target, created: false };
363
- const archive = resolve(workspace, entry.target);
364
- if (await stat(archive).then(info => info.isFile(), () => false)) {
365
- return { logDir: await importSessionArchive(archive, sessionsDir(workspace, opts.sessionDir), workspace), created: true };
366
- }
367
- const found = await findSessionByPrefix(workspace, entry.target, opts.sessionDir);
368
- if (!found.ok) throw new Error(found.reason);
369
- return { logDir: found.session.path, created: false };
370
- }
371
- case "fork": {
372
- const found = await findSessionByPrefix(workspace, entry.target, opts.sessionDir);
373
- if (!found.ok) throw new Error(found.reason);
374
- const created = await createSession(workspace, systemIdClock, opts.sessionDir);
375
- await copyAncestryTo(found.session.path, created.path, new Date().toISOString());
376
- return { logDir: created.path, created: true };
377
- }
378
- case "continue": {
379
- const newest = await newestConversation(workspace, opts.sessionDir);
380
- if (newest !== undefined) return { logDir: newest.path, created: false };
381
- break;
382
- }
383
- case "select":
384
- throw new Error("--resume needs an interactive terminal. Pass --session <id> instead");
385
- case "new": break;
386
- default: return assertNever(entry, "resolveLogDir");
387
- }
388
- const created = await createSession(workspace, systemIdClock, opts.sessionDir);
389
- return { logDir: created.path, created: true };
390
- }
391
-
392
- async function existingDir(workspace: string, prefix: string): Promise<string | null> {
393
- if (await isDirectory(prefix)) return prefix;
394
- const rel = join(workspace, prefix);
395
- if (rel !== prefix && (await isDirectory(rel))) return rel;
396
- return null;
397
- }
398
-
399
- export async function readListed(id: string, path: string, details?: { workspace: string; signal?: AbortSignal }): Promise<ListedSession> {
400
- let parsed: unknown;
401
- try {
402
- parsed = await readManifest(path);
403
- } catch (err) {
404
- if ((err as { code?: string }).code === "ENOENT") {
405
- return {
406
- ok: true, id, path,
407
- name: null, model: null, node: null, events: 0, updated_at: null,
408
- ...(details ? { workspace: details.workspace, conversation: await hasConversation(path) } : {}),
409
- };
410
- }
411
- return { ok: false, id, path, error: `${path} could not be read: ${(err as Error).message}` };
412
- }
413
-
414
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
415
- return { ok: false, id, path, error: `${path} manifest is not an object` };
416
- }
417
-
418
- const manifest = parsed as Manifest;
419
- const cursor = manifest.cursor;
420
- if (cursor === undefined) {
421
- return {
422
- ok: true, id, path,
423
- name: null, model: null, node: null, events: 0, updated_at: null,
424
- ...(details ? { workspace: details.workspace, conversation: await hasConversation(path) } : {}),
425
- };
426
- }
427
-
428
- // The open node's size, not an ancestry walk. Walking can fail the whole
429
- // listing (a cycle, a missing parent), and a list that throws for one
430
- // bad tree is the same shape as a list that throws for one bad file.
431
- const events = (await readNode(path, cursor.node)).events.length;
432
- const text: string[] = [];
433
- let conversation = false;
434
- if (details) for (const node of await listNodes(path)) {
435
- if (details.signal?.aborted) break;
436
- const read = await readNode(path, node);
437
- if (read.corrupt.length) throw new Error(`${path} has corrupt events`);
438
- for (const event of read.events) {
439
- if (userActivity(event)) conversation = true;
440
- if (event.t === "user") text.push(event.text);
441
- else if (event.t === "assistant") for (const block of event.blocks) if (block.type === "text") text.push(block.text);
442
- }
443
- }
444
- const forked = details ? await forkedAt(path) : null;
445
- return {
446
- ok: true,
447
- id,
448
- path,
449
- name: cursor.name ?? null,
450
- model: cursor.model,
451
- node: cursor.node,
452
- events,
453
- updated_at: cursor.updated_at,
454
- ...(details ? { workspace: cursor.workspace ?? details.workspace, text: text.join("\n"), conversation,
455
- ...(manifest.parent_session ? { parent_session: manifest.parent_session } : {}),
456
- ...(forked === null ? {} : { forked_at: forked }) } : {}),
457
- };
458
- }
459
-
460
- /**
461
- * A session the user opened and left without a word still has a header,
462
- * cursor and manifest: Session.start writes them before the first message.
463
- * Pi avoids such files by not persisting until a message exists; xeno keeps
464
- * its write order and filters on read instead, like Codex's resume picker,
465
- * so -c and the pickers land on a conversation (fix-skip-empty-sessions).
466
- * A shell command the user ran counts: it is their activity, not setup.
467
- */
468
- function userActivity(event: LogEvent): boolean {
469
- return event.t === "user" || event.t === "user_bash";
470
- }
471
-
472
- /**
473
- * Stops at the first node holding user activity, so a real session costs one
474
- * node read. An unreadable node counts as activity: hiding a session we
475
- * cannot judge would silently skip it, while opening it reports the error.
476
- */
477
- export async function hasConversation(path: string): Promise<boolean> {
478
- for (const node of await listNodes(path)) {
479
- try { if ((await readNode(path, node)).events.some(userActivity)) return true; }
480
- catch { return true; }
481
- }
482
- return false;
483
- }
484
-
485
- /** What -c continues: the newest readable session the user actually spoke in. */
486
- export async function newestConversation(workspace: string, sessionDir?: string): Promise<Extract<ListedSession, { ok: true }> | undefined> {
487
- const { sessions } = await listSessions(workspace, sessionDir, Infinity);
488
- for (const session of sessions) if (session.ok && await hasConversation(session.path)) return session;
489
- return undefined;
490
- }
491
-
492
- function byRecordedTime(a: ListedSession, b: ListedSession): number {
493
- const ta = a.ok ? a.updated_at : null;
494
- const tb = b.ok ? b.updated_at : null;
495
- if (ta !== null && tb !== null) return tb < ta ? -1 : tb > ta ? 1 : 0;
496
- if (ta !== null) return -1;
497
- if (tb !== null) return 1;
498
- return 0;
499
- }
500
-
501
- async function isDirectory(path: string): Promise<boolean> {
502
- try {
503
- return (await stat(path)).isDirectory();
504
- } catch (err) {
505
- if ((err as { code?: string }).code === "ENOENT") return false;
506
- throw err;
507
- }
508
- }
509
-
510
- /**
511
- * Keeps a state directory out of `git status` the way pytest, ruff and mypy
512
- * keep their caches out: a `.gitignore` holding `*` inside the directory
513
- * itself. Checks people and models write ("only plan.md changed") read git,
514
- * and a visible session directory made such a check fail forever
515
- * (fix-state-dir-self-ignore). The user's own ignore files are never edited,
516
- * and .git/info/exclude is avoided: worktrees do not read it, and a sandboxed
517
- * agent could use it to hide files. An existing file is left alone; failure
518
- * to write one never blocks a session.
519
- */
520
- export async function ignoreStateDir(stateRoot: string): Promise<void> {
521
- try {
522
- await mkdir(stateRoot, { recursive: true });
523
- await writeFile(join(stateRoot, ".gitignore"), "# Created by xeno: session state is not project source.\n*\n", { flag: "wx" });
524
- } catch { /* present already, or not writable: either way nothing to do */ }
525
- }
@@ -1,6 +0,0 @@
1
- /** D11: cancelled source capture never becomes a partial successful observation. */
2
- import { ToolFault, READ_ATTEMPTED } from "../core/tool-failure.ts";
3
-
4
- export function checkSourceCancellation(signal?: AbortSignal): void {
5
- if (signal?.aborted) throw new ToolFault("cancelled", "source capture cancelled", READ_ATTEMPTED);
6
- }
@@ -1,90 +0,0 @@
1
- /**
2
- * xeno's own copy of ripgrep, for machines that have none.
3
- *
4
- * Where it lives: `<configDir>/bin`, next to settings and auth, so one
5
- * install serves every workspace. Who sees it: the grep tool (after PATH)
6
- * and shell commands, whose PATH gets the directory appended -- appended so a
7
- * user who later installs rg keeps theirs (core/command-contract.ts).
8
- *
9
- * The network fetch is injected: store owns the filesystem, the caller owns
10
- * the request (as runtime/update-check.ts does for the registry).
11
- */
12
- import { existsSync } from "node:fs";
13
- import { chmod, lstat, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
14
- import { createHash } from "node:crypto";
15
- import { join } from "node:path";
16
- import { ripgrepAsset, type RipgrepAsset } from "../core/ripgrep-release.ts";
17
-
18
- export function xenoBinDir(configDir: string): string {
19
- return join(configDir, "bin");
20
- }
21
-
22
- function onPath(name: string, pathEnv: string): string | null {
23
- for (const dir of pathEnv.split(process.platform === "win32" ? ";" : ":")) {
24
- if (dir.length === 0) continue;
25
- const candidate = join(dir, name);
26
- if (existsSync(candidate)) return candidate;
27
- }
28
- return null;
29
- }
30
-
31
- /** The user's rg first, then xeno's; null when neither exists. */
32
- export function findRipgrep(configDir: string, pathEnv = process.env["PATH"] ?? ""): string | null {
33
- const own = join(xenoBinDir(configDir), "rg");
34
- return onPath("rg", pathEnv) ?? (existsSync(own) ? own : null);
35
- }
36
-
37
- export type ProvisionResult =
38
- | { ok: true; path: string; installed: boolean }
39
- | { ok: false; error: string };
40
-
41
- export interface ProvisionOptions {
42
- configDir: string;
43
- fetchBytes: (url: string, signal?: AbortSignal) => Promise<Uint8Array>;
44
- /** Defaults to the user's PATH; injected so tests are not decided by the machine. */
45
- path?: string;
46
- /** Defaults to the pinned build for this platform. */
47
- asset?: RipgrepAsset | null;
48
- signal?: AbortSignal;
49
- }
50
-
51
- /**
52
- * Installs the pinned ripgrep unless one is already reachable.
53
- *
54
- * Nothing becomes visible until it is proven: the archive is hashed before
55
- * extraction, only the one member is extracted, and the executable reaches
56
- * `bin/rg` by rename, so a crash or a mismatch never leaves a partial or
57
- * unverified binary where the shell would find it.
58
- */
59
- export async function provisionRipgrep(options: ProvisionOptions): Promise<ProvisionResult> {
60
- const existing = findRipgrep(options.configDir, options.path);
61
- if (existing !== null) return { ok: true, path: existing, installed: false };
62
- const asset = options.asset === undefined ? ripgrepAsset(process.platform, process.arch) : options.asset;
63
- if (asset === null) return { ok: false, error: `no pinned ripgrep build for ${process.platform} ${process.arch}` };
64
-
65
- const bin = xenoBinDir(options.configDir);
66
- await mkdir(bin, { recursive: true });
67
- // Inside bin, so the final rename stays on one filesystem and is atomic.
68
- const stage = await mkdtemp(join(bin, ".rg-install-"));
69
- try {
70
- const bytes = await options.fetchBytes(asset.url, options.signal);
71
- const digest = createHash("sha256").update(bytes).digest("hex");
72
- if (digest !== asset.sha256) return { ok: false, error: `ripgrep archive sha256 ${digest} does not match pinned ${asset.sha256}` };
73
- const archive = join(stage, "rg.tar.gz");
74
- await writeFile(archive, bytes);
75
- const extracted = Bun.spawnSync(["tar", "-xzf", archive, "-C", stage, asset.member], { stdout: "ignore", stderr: "pipe" });
76
- if (extracted.exitCode !== 0) return { ok: false, error: `cannot extract ripgrep: ${extracted.stderr.toString().trim()}` };
77
- const executable = join(stage, asset.member);
78
- // The digest pins the archive, but chmod and rename follow what tar
79
- // wrote; a member that is not a plain file is refused before either.
80
- if (!(await lstat(executable)).isFile()) return { ok: false, error: "ripgrep archive member is not a regular file" };
81
- await chmod(executable, 0o755);
82
- const target = join(bin, "rg");
83
- await rename(executable, target);
84
- return { ok: true, path: target, installed: true };
85
- } catch (error) {
86
- return { ok: false, error: `cannot install ripgrep: ${error instanceof Error ? error.message : String(error)}` };
87
- } finally {
88
- await rm(stage, { recursive: true, force: true });
89
- }
90
- }