@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.
- package/CHANGELOG.md +24 -0
- package/README.md +31 -746
- package/bin/xeno.exe +26 -0
- package/install.cjs +88 -0
- package/package.json +18 -48
- package/docs/app-server.md +0 -115
- package/docs/foundations.md +0 -88
- package/docs/install.md +0 -244
- package/docs/providers.md +0 -172
- package/docs/research/foundation-clients-2026-10-09.md +0 -27
- package/docs/research/foundation-editing-2026-10-09.md +0 -56
- package/docs/research/foundation-network-2026-10-09.md +0 -37
- package/docs/research/foundation-workflow-2026-10-09.md +0 -37
- package/src/apps/acp-server.ts +0 -41
- package/src/apps/agent-profiles.ts +0 -19
- package/src/apps/app-server.ts +0 -30
- package/src/apps/approve.ts +0 -49
- package/src/apps/cli.ts +0 -99
- package/src/apps/commands.ts +0 -319
- package/src/apps/completions.ts +0 -87
- package/src/apps/control-transport.ts +0 -90
- package/src/apps/export-cli.ts +0 -24
- package/src/apps/goal-flow.ts +0 -70
- package/src/apps/image-draft.ts +0 -46
- package/src/apps/input.ts +0 -312
- package/src/apps/model-catalog.ts +0 -69
- package/src/apps/preferences.ts +0 -20
- package/src/apps/project-trust.ts +0 -9
- package/src/apps/provider-connect.ts +0 -346
- package/src/apps/question-ui.ts +0 -27
- package/src/apps/render.ts +0 -181
- package/src/apps/resources.ts +0 -66
- package/src/apps/review.ts +0 -109
- package/src/apps/session-hub.ts +0 -96
- package/src/apps/session-lifecycle.ts +0 -64
- package/src/apps/shell.ts +0 -2066
- package/src/apps/startup-input.ts +0 -135
- package/src/apps/startup.ts +0 -1677
- package/src/apps/ui/app.ts +0 -1713
- package/src/apps/ui/control-model.ts +0 -43
- package/src/apps/ui/extension-host.ts +0 -194
- package/src/apps/ui/image-cache.ts +0 -121
- package/src/apps/ui/palette.ts +0 -88
- package/src/apps/ui/project.ts +0 -127
- package/src/apps/ui/standalone.ts +0 -148
- package/src/apps/ui/status-model.ts +0 -68
- package/src/apps/ui/surface.ts +0 -91
- package/src/apps/ui/theme.ts +0 -86
- package/src/apps/update-check.ts +0 -112
- package/src/apps/workflow.ts +0 -58
- package/src/core/acceptance.ts +0 -139
- package/src/core/action-defaults.ts +0 -58
- package/src/core/activity-status.ts +0 -52
- package/src/core/agent-profile.ts +0 -130
- package/src/core/attachment-input.ts +0 -17
- package/src/core/blind-checks.ts +0 -72
- package/src/core/boundary.ts +0 -174
- package/src/core/branch-receipt.ts +0 -26
- package/src/core/catalog-merge.ts +0 -19
- package/src/core/checkpoints.ts +0 -85
- package/src/core/churn.ts +0 -61
- package/src/core/command-artifacts.ts +0 -94
- package/src/core/command-contract.ts +0 -151
- package/src/core/completion-action.ts +0 -20
- package/src/core/completion-review.ts +0 -98
- package/src/core/context-detail.ts +0 -37
- package/src/core/context-policy.ts +0 -133
- package/src/core/context-window.ts +0 -54
- package/src/core/continuation.ts +0 -130
- package/src/core/control-protocol.ts +0 -69
- package/src/core/control-view.ts +0 -39
- package/src/core/cycle.ts +0 -17
- package/src/core/data-query.ts +0 -201
- package/src/core/deadline.ts +0 -40
- package/src/core/derive.ts +0 -516
- package/src/core/draft-images.ts +0 -56
- package/src/core/edit-plan.ts +0 -200
- package/src/core/environment.ts +0 -181
- package/src/core/event-validation.ts +0 -220
- package/src/core/events.ts +0 -670
- package/src/core/evidence-ladder.ts +0 -62
- package/src/core/evidence-links.ts +0 -68
- package/src/core/evidence.ts +0 -240
- package/src/core/execution.ts +0 -18
- package/src/core/exhaustive.ts +0 -18
- package/src/core/export.ts +0 -179
- package/src/core/extension-flags.ts +0 -14
- package/src/core/external-effects.ts +0 -488
- package/src/core/failure-recovery.ts +0 -173
- package/src/core/file-precondition.ts +0 -78
- package/src/core/file-references.ts +0 -35
- package/src/core/frontmatter.ts +0 -213
- package/src/core/git-status.ts +0 -29
- package/src/core/goal-view.ts +0 -64
- package/src/core/goal.ts +0 -200
- package/src/core/handoff.ts +0 -479
- package/src/core/history-query.ts +0 -139
- package/src/core/history.ts +0 -237
- package/src/core/image-limits.ts +0 -72
- package/src/core/images.ts +0 -46
- package/src/core/integration-health.ts +0 -34
- package/src/core/json-event.ts +0 -131
- package/src/core/json-pointer.ts +0 -46
- package/src/core/keymap.ts +0 -153
- package/src/core/lsp-framing.ts +0 -34
- package/src/core/mcp-config.ts +0 -68
- package/src/core/model-picker.ts +0 -64
- package/src/core/model-preferences.ts +0 -28
- package/src/core/model-replay.ts +0 -22
- package/src/core/model-scope.ts +0 -53
- package/src/core/note-guidance.ts +0 -34
- package/src/core/oauth-fields.ts +0 -44
- package/src/core/observation-view.ts +0 -152
- package/src/core/outcome.ts +0 -44
- package/src/core/overflow.ts +0 -59
- package/src/core/packages.ts +0 -47
- package/src/core/patch.ts +0 -130
- package/src/core/path-suggestions.ts +0 -59
- package/src/core/permissions.ts +0 -410
- package/src/core/process-receipt.ts +0 -105
- package/src/core/process-state.ts +0 -37
- package/src/core/program-status.ts +0 -40
- package/src/core/progress.ts +0 -150
- package/src/core/project.ts +0 -431
- package/src/core/prompt-policy.ts +0 -46
- package/src/core/prompts.ts +0 -111
- package/src/core/question.ts +0 -94
- package/src/core/queue.ts +0 -78
- package/src/core/read-bounds.ts +0 -24
- package/src/core/read-observation.ts +0 -106
- package/src/core/recovery-sources.ts +0 -74
- package/src/core/regex-dialect.ts +0 -25
- package/src/core/request-budget.ts +0 -23
- package/src/core/request-progress.ts +0 -87
- package/src/core/request-state.ts +0 -273
- package/src/core/resource-selection.ts +0 -36
- package/src/core/response-recovery.ts +0 -59
- package/src/core/resume.ts +0 -59
- package/src/core/review.ts +0 -68
- package/src/core/ripgrep-release.ts +0 -64
- package/src/core/runtime-target.ts +0 -94
- package/src/core/scheduling.ts +0 -8
- package/src/core/search-observation.ts +0 -97
- package/src/core/secrets.ts +0 -188
- package/src/core/session-archive.ts +0 -4
- package/src/core/session-browser.ts +0 -82
- package/src/core/session-entry.ts +0 -61
- package/src/core/session-exit.ts +0 -42
- package/src/core/session-format.ts +0 -169
- package/src/core/session-position.ts +0 -26
- package/src/core/session-tree.ts +0 -147
- package/src/core/settings.ts +0 -192
- package/src/core/skills.ts +0 -165
- package/src/core/sql-query.ts +0 -211
- package/src/core/stall.ts +0 -128
- package/src/core/stop-report.ts +0 -68
- package/src/core/text-normalize.ts +0 -81
- package/src/core/theme.ts +0 -166
- package/src/core/thinking.ts +0 -26
- package/src/core/tokens.ts +0 -151
- package/src/core/tool-contract.ts +0 -153
- package/src/core/tool-discovery.ts +0 -87
- package/src/core/tool-failure.ts +0 -81
- package/src/core/tool-input.ts +0 -44
- package/src/core/transcript.ts +0 -108
- package/src/core/tui-mode.ts +0 -29
- package/src/core/update-check.ts +0 -157
- package/src/core/version.ts +0 -55
- package/src/core/web-content.ts +0 -80
- package/src/core/work-state.ts +0 -154
- package/src/core/workflow-mode.ts +0 -34
- package/src/core/working-set.ts +0 -119
- package/src/core/yaml.ts +0 -282
- package/src/runtime/acp-agent.ts +0 -289
- package/src/runtime/agent-profiles.ts +0 -21
- package/src/runtime/auth.ts +0 -367
- package/src/runtime/blind-check.ts +0 -36
- package/src/runtime/branch-jobs.ts +0 -36
- package/src/runtime/catalog.ts +0 -285
- package/src/runtime/clipboard.ts +0 -125
- package/src/runtime/command-artifacts.ts +0 -64
- package/src/runtime/connection-reuse.ts +0 -40
- package/src/runtime/control-client.ts +0 -149
- package/src/runtime/control-connection.ts +0 -139
- package/src/runtime/counter-check.ts +0 -74
- package/src/runtime/environment-tools.ts +0 -60
- package/src/runtime/evidence-notes.ts +0 -35
- package/src/runtime/exec-sessions.ts +0 -456
- package/src/runtime/execution-control.ts +0 -40
- package/src/runtime/extension-loader.ts +0 -37
- package/src/runtime/extension-ui.ts +0 -47
- package/src/runtime/extensions.ts +0 -370
- package/src/runtime/formatter-tool.ts +0 -38
- package/src/runtime/formatter.ts +0 -50
- package/src/runtime/foundation.ts +0 -116
- package/src/runtime/history-cache.ts +0 -57
- package/src/runtime/history-tools.ts +0 -168
- package/src/runtime/images.ts +0 -80
- package/src/runtime/integrations.ts +0 -216
- package/src/runtime/loop.ts +0 -3001
- package/src/runtime/lsp-client.ts +0 -201
- package/src/runtime/mcp-client.ts +0 -264
- package/src/runtime/mcp-oauth.ts +0 -168
- package/src/runtime/mcp-schema.ts +0 -51
- package/src/runtime/model-config.ts +0 -232
- package/src/runtime/model-data.LICENSE +0 -27
- package/src/runtime/model-data.ts +0 -21
- package/src/runtime/model-discovery.ts +0 -208
- package/src/runtime/model-replay.ts +0 -10
- package/src/runtime/model-scope.ts +0 -20
- package/src/runtime/model-sources.ts +0 -151
- package/src/runtime/model.ts +0 -686
- package/src/runtime/models-fetch.ts +0 -57
- package/src/runtime/oauth-device.ts +0 -86
- package/src/runtime/oauth.ts +0 -853
- package/src/runtime/open-image.ts +0 -42
- package/src/runtime/openai-responses.ts +0 -304
- package/src/runtime/openai.ts +0 -697
- package/src/runtime/output-schema.ts +0 -26
- package/src/runtime/package-manager.ts +0 -125
- package/src/runtime/patch-tool.ts +0 -38
- package/src/runtime/permission-resources.ts +0 -18
- package/src/runtime/pi-provider.ts +0 -423
- package/src/runtime/program-status.ts +0 -38
- package/src/runtime/progress.ts +0 -26
- package/src/runtime/provider-access.ts +0 -21
- package/src/runtime/provider-connect.ts +0 -109
- package/src/runtime/provider-presets.ts +0 -98
- package/src/runtime/provider-protocols.ts +0 -29
- package/src/runtime/provider-request.ts +0 -87
- package/src/runtime/provider-tool-policy.ts +0 -33
- package/src/runtime/provider.ts +0 -580
- package/src/runtime/question-tool.ts +0 -62
- package/src/runtime/retry.ts +0 -447
- package/src/runtime/review-tool.ts +0 -48
- package/src/runtime/rich-text.ts +0 -36
- package/src/runtime/runtime-target.ts +0 -149
- package/src/runtime/secrets.ts +0 -79
- package/src/runtime/self-update.ts +0 -74
- package/src/runtime/session-control.ts +0 -265
- package/src/runtime/session-hub.ts +0 -85
- package/src/runtime/stream-idle.ts +0 -80
- package/src/runtime/tool-broker.ts +0 -137
- package/src/runtime/tool-discovery.ts +0 -58
- package/src/runtime/tool-queue.ts +0 -77
- package/src/runtime/tools.ts +0 -1820
- package/src/runtime/turn-effects.ts +0 -31
- package/src/runtime/update-check.ts +0 -48
- package/src/runtime/usage.ts +0 -253
- package/src/runtime/version.ts +0 -12
- package/src/runtime/web-http.ts +0 -56
- package/src/runtime/web-tools.ts +0 -88
- package/src/runtime/working-notes.ts +0 -60
- package/src/runtime/workspace-lifecycle.ts +0 -141
- package/src/store/acceptance.ts +0 -15
- package/src/store/agent-profile-files.ts +0 -13
- package/src/store/atomic.ts +0 -31
- package/src/store/auth-store.ts +0 -50
- package/src/store/blind-checks.ts +0 -32
- package/src/store/blobs.ts +0 -143
- package/src/store/checkpoint-history.ts +0 -32
- package/src/store/command-artifacts.ts +0 -96
- package/src/store/command.ts +0 -111
- package/src/store/config.ts +0 -69
- package/src/store/context-files.ts +0 -436
- package/src/store/file-checkpoints.ts +0 -108
- package/src/store/file-index.ts +0 -174
- package/src/store/foundation-config.ts +0 -24
- package/src/store/git-checkpoints.ts +0 -67
- package/src/store/git-status.ts +0 -15
- package/src/store/history-search.ts +0 -19
- package/src/store/log.ts +0 -725
- package/src/store/lsp-document.ts +0 -30
- package/src/store/mcp-credentials.ts +0 -85
- package/src/store/models.ts +0 -60
- package/src/store/observations.ts +0 -224
- package/src/store/packages.ts +0 -98
- package/src/store/patch.ts +0 -92
- package/src/store/permission-paths.ts +0 -34
- package/src/store/process-receipts.ts +0 -34
- package/src/store/query-source.ts +0 -49
- package/src/store/recovery.ts +0 -44
- package/src/store/replay.ts +0 -76
- package/src/store/resource-index.ts +0 -170
- package/src/store/resources.ts +0 -285
- package/src/store/restore.ts +0 -110
- package/src/store/review.ts +0 -109
- package/src/store/runtime-target.ts +0 -64
- package/src/store/search.ts +0 -728
- package/src/store/secrets.ts +0 -44
- package/src/store/serial.ts +0 -13
- package/src/store/session-archive.ts +0 -165
- package/src/store/session-browser.ts +0 -68
- package/src/store/session-position.ts +0 -27
- package/src/store/sessions.ts +0 -525
- package/src/store/source-cancellation.ts +0 -6
- package/src/store/tool-binaries.ts +0 -90
- package/src/store/trust.ts +0 -82
- package/src/store/update-cache.ts +0 -36
- package/src/store/user-settings.ts +0 -30
- package/src/store/workspace-clone.ts +0 -42
- package/src/store/workspace-observation.ts +0 -113
- package/src/store/workspace.ts +0 -115
- package/src/store/worktree.ts +0 -278
- package/src/tui/ansi-spans.ts +0 -126
- package/src/tui/diff.ts +0 -160
- package/src/tui/exhaustive.ts +0 -14
- package/src/tui/fuzzy.ts +0 -75
- package/src/tui/style.ts +0 -208
- package/src/tui/ui/atomic-draft.ts +0 -121
- package/src/tui/ui/canvas.ts +0 -96
- package/src/tui/ui/colors.ts +0 -70
- package/src/tui/ui/composer.ts +0 -200
- package/src/tui/ui/control-panel.ts +0 -25
- package/src/tui/ui/control-sidebar.ts +0 -49
- package/src/tui/ui/dialog.ts +0 -398
- package/src/tui/ui/draft.ts +0 -23
- package/src/tui/ui/duration.ts +0 -16
- package/src/tui/ui/edge.ts +0 -186
- package/src/tui/ui/image-size.ts +0 -44
- package/src/tui/ui/ink.ts +0 -210
- package/src/tui/ui/items.ts +0 -42
- package/src/tui/ui/keys.ts +0 -40
- package/src/tui/ui/layout.ts +0 -74
- package/src/tui/ui/list-model.ts +0 -86
- package/src/tui/ui/list-view.ts +0 -172
- package/src/tui/ui/mascot.ts +0 -67
- package/src/tui/ui/messages.ts +0 -518
- package/src/tui/ui/motion.ts +0 -28
- package/src/tui/ui/paths.ts +0 -10
- package/src/tui/ui/pictures.ts +0 -108
- package/src/tui/ui/screen.ts +0 -548
- package/src/tui/ui/status.ts +0 -403
- package/src/tui/ui/styled.ts +0 -85
- package/src/tui/ui/tool-card.ts +0 -150
- package/src/tui/width.ts +0 -567
- package/src/vendor/pi-rich/LICENSE.upstream +0 -21
- package/src/vendor/pi-rich/UPSTREAM.json +0 -17
- package/src/vendor/pi-rich/clipboard-command.ts +0 -44
- package/src/vendor/pi-rich/exif-orientation.ts +0 -182
- package/src/vendor/pi-rich/html.ts +0 -51
- package/src/vendor/pi-rich/image-convert.ts +0 -49
- package/src/vendor/pi-rich/image-resize-core.ts +0 -164
- package/src/vendor/pi-rich/image-resize-worker.ts +0 -42
- package/src/vendor/pi-rich/image-resize.ts +0 -123
- package/src/vendor/pi-rich/mime.ts +0 -116
- package/src/vendor/pi-rich/photon.ts +0 -139
- package/src/vendor/pi-rich/syntax-highlight.ts +0 -214
package/src/store/sessions.ts
DELETED
|
@@ -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
|
-
}
|