@cruxy/cli 1.2.0 → 1.3.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 (77) hide show
  1. package/dist/agent/context.js +178 -0
  2. package/dist/agent/index.js +1 -0
  3. package/dist/agent/loop.js +41 -2
  4. package/dist/agent/mode.js +103 -0
  5. package/dist/agent/prompts.js +1 -1
  6. package/dist/agent/session.js +185 -72
  7. package/dist/approval/classify.js +204 -0
  8. package/dist/approval/policy.js +41 -3
  9. package/dist/approval/prompt.js +49 -22
  10. package/dist/checkpoint/gate.js +12 -0
  11. package/dist/cli/commands/run.js +374 -227
  12. package/dist/cli/commands/usage.js +45 -45
  13. package/dist/cli/onboard.js +2 -1
  14. package/dist/cli/program.js +60 -18
  15. package/dist/cli/repl.js +67 -249
  16. package/dist/cli/session-commands.js +755 -0
  17. package/dist/cli/session-factory.js +198 -76
  18. package/dist/cli/suggest.js +77 -0
  19. package/dist/components/fuzzy.js +3 -3
  20. package/dist/components/input.js +17 -2
  21. package/dist/components/keys.js +27 -3
  22. package/dist/components/select.js +3 -3
  23. package/dist/config/project.js +53 -1
  24. package/dist/config/schema.js +49 -16
  25. package/dist/jobs/log-renderer.js +47 -0
  26. package/dist/onboarding/steps.js +13 -22
  27. package/dist/plan/approve.js +36 -24
  28. package/dist/plan/execute.js +9 -7
  29. package/dist/plan/render.js +10 -23
  30. package/dist/plan/service.js +4 -1
  31. package/dist/render/capabilities.js +30 -1
  32. package/dist/render/context-view.js +106 -0
  33. package/dist/render/diff.js +198 -12
  34. package/dist/render/index.js +31 -5
  35. package/dist/render/plain-renderer.js +38 -2
  36. package/dist/render/plan-view.js +108 -0
  37. package/dist/render/resize.js +7 -2
  38. package/dist/render/status-view.js +66 -0
  39. package/dist/render/test-view.js +89 -0
  40. package/dist/render/tty-renderer.js +40 -0
  41. package/dist/routing/index.js +1 -0
  42. package/dist/routing/router.js +13 -4
  43. package/dist/routing/session-model.js +109 -0
  44. package/dist/routing/types.js +14 -0
  45. package/dist/session/export.js +88 -0
  46. package/dist/session/index.js +20 -0
  47. package/dist/session/list.js +137 -0
  48. package/dist/session/log.js +137 -0
  49. package/dist/session/paths.js +73 -0
  50. package/dist/session/replay.js +169 -0
  51. package/dist/session/resume.js +128 -0
  52. package/dist/session/types.js +223 -0
  53. package/dist/subagent/orchestrator.js +23 -0
  54. package/dist/testing/run-tests-tool.js +8 -0
  55. package/dist/tools/registry.js +3 -3
  56. package/dist/tui/app.js +385 -0
  57. package/dist/tui/approval-overlay.js +160 -0
  58. package/dist/tui/context-gauge.js +48 -0
  59. package/dist/tui/git-status.js +63 -0
  60. package/dist/tui/index.js +10 -0
  61. package/dist/tui/layout.js +269 -0
  62. package/dist/tui/overlay.js +105 -0
  63. package/dist/tui/palette.js +73 -0
  64. package/dist/tui/panels.js +235 -0
  65. package/dist/tui/renderer.js +776 -0
  66. package/dist/tui/supports.js +20 -0
  67. package/dist/tui/tool-versions.js +129 -0
  68. package/dist/usage/collect.js +21 -3
  69. package/dist/usage/index.js +10 -2
  70. package/dist/usage/report.js +76 -0
  71. package/dist/usage/store.js +7 -1
  72. package/dist/usage/summary.js +106 -17
  73. package/dist/usage/types.js +73 -4
  74. package/dist/usage/weighted.js +77 -0
  75. package/dist/utils/git.js +50 -4
  76. package/package.json +2 -2
  77. package/dist/usage/cost.js +0 -29
@@ -0,0 +1,137 @@
1
+ import { appendFileSync, mkdirSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { APP_VERSION } from "../constants.js";
4
+ import { sessionFile } from "./paths.js";
5
+ import { SESSION_FILE_VERSION, } from "./types.js";
6
+ export class SessionLog {
7
+ file;
8
+ logger;
9
+ currentRunId;
10
+ /** Set once a write fails: the log goes inert rather than warning per turn. */
11
+ broken = false;
12
+ constructor(file, opts) {
13
+ this.file = file;
14
+ this.logger = opts.logger;
15
+ this.currentRunId = opts.currentRunId;
16
+ }
17
+ /**
18
+ * Open (or create) a session log. A NEW file gets its `meta` line; reopening
19
+ * an existing one (a `--resume`) does not — `meta` describes where and when
20
+ * the conversation began, and a second copy written from wherever it was
21
+ * resumed would make "the session's directory" ambiguous. Replay already
22
+ * takes the first `meta`, so a duplicate could only ever mislead a later
23
+ * reader, never help one.
24
+ *
25
+ * Returns `null` when a new log cannot be created at all — the caller then
26
+ * runs without persistence rather than failing, exactly as a corrupt usage
27
+ * store is survivable. A reopen never returns null: a write problem on an
28
+ * existing file surfaces on the first append, which warns and goes inert.
29
+ */
30
+ static open(opts) {
31
+ const file = opts.file ?? sessionFile(opts.cwd, opts.sessionId);
32
+ const log = new SessionLog(file, opts);
33
+ if (hasContent(file))
34
+ return log;
35
+ const ok = log.write({
36
+ kind: "meta",
37
+ version: SESSION_FILE_VERSION,
38
+ sessionId: opts.sessionId,
39
+ startedAt: new Date().toISOString(),
40
+ cwd: opts.cwd,
41
+ roots: opts.roots ?? [],
42
+ cliVersion: APP_VERSION,
43
+ ...(opts.provider !== undefined ? { provider: opts.provider } : {}),
44
+ ...(opts.model !== undefined ? { model: opts.model } : {}),
45
+ });
46
+ return ok ? log : null;
47
+ }
48
+ /** Messages appended to the history since the last event. */
49
+ append(messages) {
50
+ if (messages.length === 0)
51
+ return;
52
+ this.write({
53
+ kind: "append",
54
+ at: new Date().toISOString(),
55
+ ...this.runId(),
56
+ messages,
57
+ });
58
+ }
59
+ /** An older prefix of `replaced` messages was folded into `summary`. */
60
+ compaction(replaced, summary) {
61
+ this.write({
62
+ kind: "compaction",
63
+ at: new Date().toISOString(),
64
+ ...this.runId(),
65
+ replaced,
66
+ summary,
67
+ });
68
+ }
69
+ /** `/clear` — history dropped, session kept. */
70
+ clear() {
71
+ this.write({ kind: "clear", at: new Date().toISOString() });
72
+ }
73
+ /**
74
+ * The session mode changed (P5 track 3). Replaces the `plan-mode` event,
75
+ * which is still READ for sessions recorded before modes existed but is no
76
+ * longer written.
77
+ */
78
+ mode(mode) {
79
+ this.write({ kind: "mode", at: new Date().toISOString(), mode });
80
+ }
81
+ /**
82
+ * One turn's token usage. Copied here rather than referenced, because the
83
+ * usage store keeps only its newest 50 runs while sessions are kept
84
+ * indefinitely — see the note on `UsageEventSchema`.
85
+ */
86
+ usage(inputTokens, outputTokens) {
87
+ this.write({
88
+ kind: "usage",
89
+ at: new Date().toISOString(),
90
+ ...this.runId(),
91
+ inputTokens,
92
+ outputTokens,
93
+ });
94
+ }
95
+ runId() {
96
+ const id = this.currentRunId?.();
97
+ return id === undefined ? {} : { runId: id };
98
+ }
99
+ /**
100
+ * Append one event as a single line. Returns whether it landed. The first
101
+ * failure warns and latches `broken`, so a persistent problem (a full disk)
102
+ * produces one diagnostic rather than one per turn.
103
+ */
104
+ write(event) {
105
+ if (this.broken)
106
+ return false;
107
+ try {
108
+ mkdirSync(path.dirname(this.file), { recursive: true });
109
+ // `mode` applies only when the file is created — 0600 from the first
110
+ // line, matching credentials/usage/memory.
111
+ appendFileSync(this.file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
112
+ return true;
113
+ }
114
+ catch (err) {
115
+ this.broken = true;
116
+ this.logger?.warn(`session not being saved: ${err.message} (the conversation continues in memory)`);
117
+ return false;
118
+ }
119
+ }
120
+ }
121
+ /**
122
+ * Whether `file` is an existing log with content — i.e. this is a reopen.
123
+ *
124
+ * `isFile()` matters: a DIRECTORY at this path reports a non-zero size, so a
125
+ * size check alone would read an unwritable path as a healthy existing log,
126
+ * skip the meta write, and hand back a recorder that silently fails on its
127
+ * first append instead of declining up front.
128
+ */
129
+ function hasContent(file) {
130
+ try {
131
+ const stat = statSync(file);
132
+ return stat.isFile() && stat.size > 0;
133
+ }
134
+ catch {
135
+ return false;
136
+ }
137
+ }
@@ -0,0 +1,73 @@
1
+ import path from "node:path";
2
+ import { globalDir } from "../config/paths.js";
3
+ /**
4
+ * Where sessions live on disk (P2).
5
+ *
6
+ * ```
7
+ * ~/.cruxy/projects/<project>/
8
+ * <session-id>.jsonl ← the event log (P2)
9
+ * subagents/ ← reserved, P3+
10
+ * tool-results/ ← reserved, P3+
11
+ * file-history/ ← reserved, P3+
12
+ * memory/ ← reserved, P3+
13
+ * ```
14
+ *
15
+ * Everything resolves through {@link globalDir}, never a fresh `homedir()` —
16
+ * one accessor owns the location of `~/.cruxy` and the tests can point it
17
+ * somewhere else by pointing that.
18
+ *
19
+ * The reserved subdirectories are DECLARED here and not created: an empty
20
+ * directory tree is noise until something writes to it, but naming the paths
21
+ * now is what stops P3 from inventing a second, conflicting layout.
22
+ */
23
+ /** `~/.cruxy/projects` */
24
+ export const PROJECTS_DIR_NAME = "projects";
25
+ /** Session event-log extension. */
26
+ export const SESSION_FILE_EXT = ".jsonl";
27
+ /** Reserved subtrees — declared for P3+, deliberately not created by P2. */
28
+ export const RESERVED_SUBDIRS = [
29
+ "subagents",
30
+ "tool-results",
31
+ "file-history",
32
+ "memory",
33
+ ];
34
+ /**
35
+ * The directory key for a working directory: every run of non-alphanumerics
36
+ * collapses to a single `-`, with leading/trailing separators trimmed.
37
+ *
38
+ * Flattening the path rather than nesting it keeps one directory per project
39
+ * (so listing is a single readdir) and cannot escape the projects root the way
40
+ * a path fragment could. It is deliberately NOT reversible — the true `cwd` is
41
+ * recorded in each session's `meta` event, which is what resume validates
42
+ * against; the key is only an index.
43
+ *
44
+ * Two different directories CAN collide onto one key (`/a/b` and `/a-b`). That
45
+ * is why resume compares `meta.cwd`, not the key, before trusting a session.
46
+ */
47
+ export function projectKey(cwd) {
48
+ const key = path.resolve(cwd).replace(/[^a-zA-Z0-9]+/g, "-");
49
+ const trimmed = key.replace(/^-+/, "").replace(/-+$/, "");
50
+ // A path of nothing but separators (`/`) collapses to empty; give it a name
51
+ // rather than writing into the projects root itself.
52
+ return trimmed === "" ? "root" : trimmed;
53
+ }
54
+ /** `~/.cruxy/projects` */
55
+ export function projectsDir() {
56
+ return path.join(globalDir(), PROJECTS_DIR_NAME);
57
+ }
58
+ /**
59
+ * `~/.cruxy/projects/<project>` for a working directory. Multi-root sessions
60
+ * pass the PRIMARY root — the session belongs to one project even when it can
61
+ * read several, and `meta.roots` is what records the rest.
62
+ */
63
+ export function projectDir(cwd) {
64
+ return path.join(projectsDir(), projectKey(cwd));
65
+ }
66
+ /** `~/.cruxy/projects/<project>/<session-id>.jsonl` */
67
+ export function sessionFile(cwd, sessionId) {
68
+ return path.join(projectDir(cwd), `${sessionId}${SESSION_FILE_EXT}`);
69
+ }
70
+ /** A reserved subtree's path (P3+). Declared, not created. */
71
+ export function reservedDir(cwd, name) {
72
+ return path.join(projectDir(cwd), name);
73
+ }
@@ -0,0 +1,169 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { modeFromFlags } from "../agent/mode.js";
3
+ import { SessionEventSchema, SessionMetaSchema, } from "./types.js";
4
+ /**
5
+ * Replay: fold an append-only event log back into the state a session needs to
6
+ * resume (P2).
7
+ *
8
+ * The fold is total and order-dependent — `append` grows the array,
9
+ * `compaction` replaces its head, `clear` empties it — so the result is exactly
10
+ * the array the model last saw, including the synthetic compaction summaries.
11
+ * Nothing is re-derived or re-summarised on the way back in.
12
+ *
13
+ * TOLERANCE IS THE POINT. A line that will not parse is SKIPPED and counted,
14
+ * never fatal:
15
+ * - a torn final line from a crash mid-append would otherwise cost the whole
16
+ * conversation (this is the failure temp-then-rename buys off at write time;
17
+ * an append-only log buys it off here instead);
18
+ * - a line from a NEWER cruxy carrying an event kind this build has never
19
+ * heard of is skipped rather than rejected, which together with the
20
+ * `.passthrough()` schemas is what keeps two CLI versions able to share one
21
+ * home directory.
22
+ * `SessionState.skipped` carries the count so a caller can say so out loud
23
+ * rather than presenting a partial history as complete.
24
+ */
25
+ /**
26
+ * The one cast in this module, isolated and explained.
27
+ *
28
+ * `MessageSchema` is `.passthrough()`, so its inferred type carries an index
29
+ * signature that the SDK's `Message` interface does not declare. The values are
30
+ * structurally identical — the schema validates every field `Message` requires
31
+ * and preserves the rest — so this narrows a validated value to the nominal
32
+ * type the rest of the CLI speaks. It is sound precisely BECAUSE the schema ran
33
+ * first; do not move it above the parse.
34
+ */
35
+ function asMessages(validated) {
36
+ return validated;
37
+ }
38
+ /** Parse one line, or `null` when it is unusable (torn, or an unknown kind). */
39
+ function parseLine(line) {
40
+ const trimmed = line.trim();
41
+ if (trimmed === "")
42
+ return null;
43
+ let raw;
44
+ try {
45
+ raw = JSON.parse(trimmed);
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ const parsed = SessionEventSchema.safeParse(raw);
51
+ return parsed.success ? parsed.data : null;
52
+ }
53
+ /**
54
+ * Fold events into {@link SessionState}. Exported separately from file reading
55
+ * so the fold is directly testable without a filesystem — the same split as
56
+ * `render/state.ts` (pure) versus the renderers.
57
+ *
58
+ * Throws only when there is no usable `meta` line: without it we do not know
59
+ * which directory the history refers to, and resuming a conversation whose
60
+ * origin is unknown is precisely what ruling 4 forbids.
61
+ */
62
+ export function foldEvents(events, skipped = 0) {
63
+ const metaEvent = events.find((e) => e.kind === "meta");
64
+ if (!metaEvent) {
65
+ throw new Error("session log has no readable meta line");
66
+ }
67
+ const meta = metaEvent;
68
+ let messages = [];
69
+ // Both are folded, and `mode` wins when present. A journal written before P5
70
+ // has only `plan-mode` events; one written after has only `mode` events; a
71
+ // session resumed by a P5 build and then continued has both, in that order.
72
+ // Last-writer-wins across the two would be wrong — the pre-P5 events are all
73
+ // earlier, so the newer vocabulary must take precedence rather than the newer
74
+ // timestamp.
75
+ let planMode = false;
76
+ let mode = null;
77
+ const usage = { input_tokens: 0, output_tokens: 0 };
78
+ for (const event of events) {
79
+ switch (event.kind) {
80
+ case "append":
81
+ messages = [...messages, ...asMessages(event.messages)];
82
+ break;
83
+ case "compaction":
84
+ // Exactly what `Session.runCompaction` did in memory: drop the head,
85
+ // splice the synthetic pair in front of what was kept.
86
+ messages = [
87
+ ...asMessages(event.summary),
88
+ ...messages.slice(event.replaced),
89
+ ];
90
+ break;
91
+ case "clear":
92
+ messages = [];
93
+ break;
94
+ case "plan-mode":
95
+ planMode = event.enabled;
96
+ break;
97
+ case "mode":
98
+ mode = event.mode;
99
+ break;
100
+ case "usage":
101
+ usage.input_tokens += event.inputTokens;
102
+ usage.output_tokens += event.outputTokens;
103
+ break;
104
+ case "meta":
105
+ break;
106
+ }
107
+ }
108
+ return {
109
+ meta,
110
+ messages,
111
+ usage,
112
+ // A pre-P5 journal's plan flag becomes the mode it was equivalent to.
113
+ // Auto-approve reads false there, which is right: it was not a thing that
114
+ // could be on, so nothing is being inferred.
115
+ mode: mode ?? modeFromFlags(planMode, false),
116
+ skipped,
117
+ };
118
+ }
119
+ /** Read and parse a session file into its events, counting unusable lines. */
120
+ export function readEvents(file) {
121
+ const raw = readFileSync(file, "utf8");
122
+ const events = [];
123
+ let skipped = 0;
124
+ for (const line of raw.split("\n")) {
125
+ if (line.trim() === "")
126
+ continue;
127
+ const event = parseLine(line);
128
+ if (event)
129
+ events.push(event);
130
+ else
131
+ skipped++;
132
+ }
133
+ return { events, skipped };
134
+ }
135
+ /** Read a session file and fold it into resumable state. */
136
+ export function replaySession(file) {
137
+ const { events, skipped } = readEvents(file);
138
+ return foldEvents(events, skipped);
139
+ }
140
+ /**
141
+ * Read ONLY the meta line — enough to list a session without folding its whole
142
+ * history. Returns null when the file has no usable meta.
143
+ */
144
+ export function readMeta(file) {
145
+ let raw;
146
+ try {
147
+ raw = readFileSync(file, "utf8");
148
+ }
149
+ catch {
150
+ return null;
151
+ }
152
+ for (const line of raw.split("\n")) {
153
+ if (line.trim() === "")
154
+ continue;
155
+ try {
156
+ const parsed = SessionMetaSchema.safeParse(JSON.parse(line));
157
+ if (parsed.success)
158
+ return parsed.data;
159
+ }
160
+ catch {
161
+ // keep scanning: a torn first line must not hide a good meta below it
162
+ }
163
+ // Only the meta line is expected first; if the first usable line is some
164
+ // other event the file is malformed for our purposes, but keep scanning
165
+ // rather than giving up — the cost is bounded and the payoff is a session
166
+ // that lists instead of vanishing.
167
+ }
168
+ return null;
169
+ }
@@ -0,0 +1,128 @@
1
+ import { selectList } from "../components/index.js";
2
+ import { usageError } from "../errors/index.js";
3
+ import { findSession, isAmbiguous, listSessions } from "./list.js";
4
+ import { replaySession } from "./replay.js";
5
+ /** How many sessions the bare-`--resume` picker offers. */
6
+ export const PICKER_LIMIT = 10;
7
+ /** Short, stable id form — enough to identify a session, short enough to type. */
8
+ export function shortId(sessionId) {
9
+ return sessionId.slice(0, 8);
10
+ }
11
+ /** `2h ago`, `3d ago` — relative age for the picker and the sidebar. */
12
+ export function relativeAge(iso, now = Date.now()) {
13
+ const then = Date.parse(iso);
14
+ if (Number.isNaN(then))
15
+ return "unknown";
16
+ const seconds = Math.max(0, Math.floor((now - then) / 1000));
17
+ if (seconds < 60)
18
+ return "just now";
19
+ const minutes = Math.floor(seconds / 60);
20
+ if (minutes < 60)
21
+ return `${minutes}m ago`;
22
+ const hours = Math.floor(minutes / 60);
23
+ if (hours < 24)
24
+ return `${hours}h ago`;
25
+ return `${Math.floor(hours / 24)}d ago`;
26
+ }
27
+ /** One picker/sidebar row: `3f2a1b0c 2h ago fix the failing test (4 turns)`. */
28
+ export function describeSession(s, now = Date.now()) {
29
+ const turns = `${s.turns} turn${s.turns === 1 ? "" : "s"}`;
30
+ return `${shortId(s.sessionId)} ${relativeAge(s.updatedAt, now)} ${s.title} (${turns})`;
31
+ }
32
+ /**
33
+ * Compare the session's recorded directory with the one we are resuming into.
34
+ * Returns a warning to print, or null when they agree.
35
+ *
36
+ * This is ruling 4's "warn loudly, never silently mismatch". The history is
37
+ * full of file paths, diffs and tool results that only mean anything relative
38
+ * to the directory they were produced in; replaying it somewhere else does not
39
+ * corrupt anything, but it does mean the model is reasoning about a tree that
40
+ * is not the one in front of it. So the resume PROCEEDS — the user asked for
41
+ * it, and refusing a resume because a repo moved would be worse — but it says
42
+ * exactly what is off.
43
+ */
44
+ export function cwdMismatchWarning(state, cwd) {
45
+ if (state.meta.cwd === cwd)
46
+ return null;
47
+ return (`this session was recorded in ${state.meta.cwd}, but you are in ${cwd} — ` +
48
+ `its history refers to files and paths from the original directory`);
49
+ }
50
+ /**
51
+ * Load one session and collect its warnings. Throws a usage error when the
52
+ * file cannot be replayed at all (no meta line) — an unreadable session is
53
+ * worth failing loudly on, unlike an individual torn line.
54
+ */
55
+ export function loadResume(session, cwd) {
56
+ let state;
57
+ try {
58
+ state = replaySession(session.file);
59
+ }
60
+ catch (err) {
61
+ throw usageError(`could not resume session ${shortId(session.sessionId)}`, [
62
+ `the log at ${session.file} is not readable: ${err.message}`,
63
+ "start a new session with `cruxy`",
64
+ ]);
65
+ }
66
+ const warnings = [];
67
+ const mismatch = cwdMismatchWarning(state, cwd);
68
+ if (mismatch)
69
+ warnings.push(mismatch);
70
+ if (state.skipped > 0) {
71
+ warnings.push(`${state.skipped} unreadable line${state.skipped === 1 ? "" : "s"} in the session log were skipped — ` +
72
+ `the restored history may be incomplete`);
73
+ }
74
+ return { session, state, warnings };
75
+ }
76
+ /**
77
+ * Resolve `--resume <id>`. Fails loud on an unknown or ambiguous id rather than
78
+ * silently starting a new session — the user named something specific.
79
+ */
80
+ export function resumeById(cwd, id) {
81
+ if (isAmbiguous(cwd, id)) {
82
+ const matches = listSessions(cwd)
83
+ .filter((s) => s.sessionId.startsWith(id))
84
+ .map((s) => shortId(s.sessionId));
85
+ throw usageError(`\`${id}\` matches more than one session`, [
86
+ `did you mean one of: ${matches.join(", ")}?`,
87
+ "run `cruxy --resume` to pick from a list",
88
+ ]);
89
+ }
90
+ const found = findSession(cwd, id);
91
+ if (!found) {
92
+ throw usageError(`no session \`${id}\` in this project`, [
93
+ "run `cruxy --resume` to pick from recent sessions",
94
+ "sessions are per-directory; check you are in the right one",
95
+ ]);
96
+ }
97
+ return loadResume(found, cwd);
98
+ }
99
+ /**
100
+ * Bare `--resume`: pick from the most recent sessions, or start a new one.
101
+ * Returns null when the user chose "new session" (or cancelled) — the caller
102
+ * then proceeds exactly as an unresumed run.
103
+ */
104
+ export async function resumePicker(cwd, opts = {}) {
105
+ const sessions = listSessions(cwd, PICKER_LIMIT);
106
+ if (sessions.length === 0) {
107
+ opts.logger?.info("no saved sessions for this project — starting a new one");
108
+ return null;
109
+ }
110
+ const now = opts.now ?? Date.now();
111
+ const rows = [
112
+ ...sessions.map((session) => ({ kind: "session", session })),
113
+ { kind: "new" },
114
+ ];
115
+ const picked = await selectList(rows, {
116
+ title: "resume a session",
117
+ toLabel: (row) => row.kind === "new"
118
+ ? "+ new session"
119
+ : describeSession(row.session, now),
120
+ // Non-interactive with no id named: starting fresh is the safe default,
121
+ // never an arbitrary session picked on the user's behalf.
122
+ defaultValue: { kind: "new" },
123
+ nonInteractiveHint: ["pass an id: `cruxy --resume <id>`"],
124
+ }, opts.io);
125
+ if (picked.kind === "cancelled" || picked.value.kind === "new")
126
+ return null;
127
+ return loadResume(picked.value.session, cwd);
128
+ }