@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.
- package/dist/agent/context.js +178 -0
- package/dist/agent/index.js +1 -0
- package/dist/agent/loop.js +41 -2
- package/dist/agent/mode.js +103 -0
- package/dist/agent/prompts.js +1 -1
- package/dist/agent/session.js +185 -72
- package/dist/approval/classify.js +204 -0
- package/dist/approval/policy.js +41 -3
- package/dist/approval/prompt.js +49 -22
- package/dist/checkpoint/gate.js +12 -0
- package/dist/cli/commands/run.js +374 -227
- package/dist/cli/commands/usage.js +45 -45
- package/dist/cli/onboard.js +2 -1
- package/dist/cli/program.js +60 -18
- package/dist/cli/repl.js +67 -249
- package/dist/cli/session-commands.js +755 -0
- package/dist/cli/session-factory.js +198 -76
- package/dist/cli/suggest.js +77 -0
- package/dist/components/fuzzy.js +3 -3
- package/dist/components/input.js +17 -2
- package/dist/components/keys.js +27 -3
- package/dist/components/select.js +3 -3
- package/dist/config/project.js +53 -1
- package/dist/config/schema.js +49 -16
- package/dist/jobs/log-renderer.js +47 -0
- package/dist/onboarding/steps.js +13 -22
- package/dist/plan/approve.js +36 -24
- package/dist/plan/execute.js +9 -7
- package/dist/plan/render.js +10 -23
- package/dist/plan/service.js +4 -1
- package/dist/render/capabilities.js +30 -1
- package/dist/render/context-view.js +106 -0
- package/dist/render/diff.js +198 -12
- package/dist/render/index.js +31 -5
- package/dist/render/plain-renderer.js +38 -2
- package/dist/render/plan-view.js +108 -0
- package/dist/render/resize.js +7 -2
- package/dist/render/status-view.js +66 -0
- package/dist/render/test-view.js +89 -0
- package/dist/render/tty-renderer.js +40 -0
- package/dist/routing/index.js +1 -0
- package/dist/routing/router.js +13 -4
- package/dist/routing/session-model.js +109 -0
- package/dist/routing/types.js +14 -0
- package/dist/session/export.js +88 -0
- package/dist/session/index.js +20 -0
- package/dist/session/list.js +137 -0
- package/dist/session/log.js +137 -0
- package/dist/session/paths.js +73 -0
- package/dist/session/replay.js +169 -0
- package/dist/session/resume.js +128 -0
- package/dist/session/types.js +223 -0
- package/dist/subagent/orchestrator.js +23 -0
- package/dist/testing/run-tests-tool.js +8 -0
- package/dist/tools/registry.js +3 -3
- package/dist/tui/app.js +385 -0
- package/dist/tui/approval-overlay.js +160 -0
- package/dist/tui/context-gauge.js +48 -0
- package/dist/tui/git-status.js +63 -0
- package/dist/tui/index.js +10 -0
- package/dist/tui/layout.js +269 -0
- package/dist/tui/overlay.js +105 -0
- package/dist/tui/palette.js +73 -0
- package/dist/tui/panels.js +235 -0
- package/dist/tui/renderer.js +776 -0
- package/dist/tui/supports.js +20 -0
- package/dist/tui/tool-versions.js +129 -0
- package/dist/usage/collect.js +21 -3
- package/dist/usage/index.js +10 -2
- package/dist/usage/report.js +76 -0
- package/dist/usage/store.js +7 -1
- package/dist/usage/summary.js +106 -17
- package/dist/usage/types.js +73 -4
- package/dist/usage/weighted.js +77 -0
- package/dist/utils/git.js +50 -4
- package/package.json +2 -2
- 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
|
+
}
|