@cruxy/cli 1.8.0 → 1.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/dist/cli/commands/login.js +18 -5
- package/dist/cli/commands/run.js +93 -22
- package/dist/config/credential-lifetime.js +42 -0
- package/dist/config/credentials.js +66 -0
- package/dist/errors/boundary.js +4 -4
- package/dist/errors/constructors.js +87 -13
- package/dist/errors/types.js +18 -0
- package/dist/index.js +27 -1
- package/dist/limits/cache.js +21 -5
- package/dist/onboarding/flow.js +121 -6
- package/dist/onboarding/steps.js +112 -0
- package/dist/session/index.js +3 -3
- package/dist/session/list.js +109 -23
- package/dist/session/log.js +28 -9
- package/dist/session/replay.js +71 -21
- package/dist/session/resume.js +74 -12
- package/dist/session/types.js +45 -0
- package/dist/tui/limits-panel.js +9 -0
- package/package.json +2 -2
package/dist/onboarding/flow.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { AuthError, NetworkError, createProvider } from "@cruxy/sdk";
|
|
1
|
+
import { AuthError, DeviceLoginClient, NetworkError, createProvider, } from "@cruxy/sdk";
|
|
2
2
|
import { themeForColor } from "../theme/index.js";
|
|
3
|
-
import { resolveApiKey, writeCredential } from "../config/index.js";
|
|
3
|
+
import { apiKeyEnvVar, readCredential, resolveApiKey, writeCredential, writeCredentialWithMeta, } from "../config/index.js";
|
|
4
4
|
import { newOnboardingState, readOnboardingState, writeOnboardingState, } from "./detect.js";
|
|
5
|
-
import { acquireKeyStep, firstWinStep, scaffoldStep } from "./steps.js";
|
|
5
|
+
import { acquireKeyStep, deviceLoginStep, firstWinStep, scaffoldStep, } from "./steps.js";
|
|
6
6
|
/**
|
|
7
7
|
* Orchestrate the onboarding steps (U.6) — resumable and idempotent. The key
|
|
8
8
|
* step is skipped when a key already resolves; the completion marker is written
|
|
@@ -17,14 +17,27 @@ export async function runOnboarding(opts) {
|
|
|
17
17
|
let apiKey = deps.resolveApiKey(provider);
|
|
18
18
|
// ── key (mandatory; skipped if already resolvable unless forceKey) ─────────
|
|
19
19
|
if (!apiKey || opts.forceKey) {
|
|
20
|
-
|
|
20
|
+
warnAboutOverwrite(io, deps);
|
|
21
|
+
// Device login is the default; paste stays reachable behind `--paste`, and
|
|
22
|
+
// is the only path when there is no device flow to run (a non-cruxy
|
|
23
|
+
// provider, whose keys these gateway routes know nothing about).
|
|
24
|
+
const useDevice = !opts.preferPaste && deps.deviceLogin !== undefined;
|
|
25
|
+
const result = useDevice
|
|
26
|
+
? await deviceLoginStep(io, deps, provider)
|
|
27
|
+
: await acquireKeyStep(io, deps, provider);
|
|
21
28
|
if (result.status === "aborted") {
|
|
22
29
|
return { completed: false, aborted: true };
|
|
23
30
|
}
|
|
24
31
|
if (result.status !== "ok") {
|
|
25
|
-
// Failed (unreachable / rejected) — surface guidance, no marker.
|
|
32
|
+
// Failed (unreachable / rejected / declined) — surface guidance, no marker.
|
|
26
33
|
if (result.message)
|
|
27
34
|
io.write(`${t.muted(result.message)}\n`);
|
|
35
|
+
if (useDevice) {
|
|
36
|
+
// The paste path exists for exactly the cases the device flow cannot
|
|
37
|
+
// serve, and someone who has just watched it fail is precisely who needs
|
|
38
|
+
// to know it is there.
|
|
39
|
+
io.write(t.muted("if you can't approve in a browser, `cruxy login --paste` takes a key directly.\n"));
|
|
40
|
+
}
|
|
28
41
|
return { completed: false, aborted: false };
|
|
29
42
|
}
|
|
30
43
|
apiKey = result.apiKey;
|
|
@@ -46,14 +59,53 @@ export async function runOnboarding(opts) {
|
|
|
46
59
|
io.write(`${t.success(t.strong(`${t.glyph.success} all set`))} — happy hacking.\n`);
|
|
47
60
|
return { completed: true, aborted: false, apiKey };
|
|
48
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Say, BEFORE anything is overwritten, the two things a login silently does.
|
|
64
|
+
*
|
|
65
|
+
* Both are facts a user cannot see and would otherwise discover the hard way:
|
|
66
|
+
* that this replaces a saved credential with no undo, and that an exported key
|
|
67
|
+
* will go on winning over whatever is saved here, so a perfectly successful
|
|
68
|
+
* login can change nothing at all about the next request.
|
|
69
|
+
*/
|
|
70
|
+
function warnAboutOverwrite(io, deps) {
|
|
71
|
+
const t = themeForColor(io.color);
|
|
72
|
+
const status = deps.credentialStatus?.();
|
|
73
|
+
if (!status)
|
|
74
|
+
return;
|
|
75
|
+
if (status.storedKey) {
|
|
76
|
+
io.write(t.muted("\nthis replaces the credential currently saved in ~/.cruxy — there is no undo.\n"));
|
|
77
|
+
}
|
|
78
|
+
if (status.shadowingEnvVar) {
|
|
79
|
+
io.write(t.warning(`\n${status.shadowingEnvVar} is set in your environment, and the environment always wins over the saved credential.\n`) +
|
|
80
|
+
t.muted(`whatever you sign in with will be saved, but your requests will keep using ${status.shadowingEnvVar} until you unset it.\n`));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
49
83
|
/**
|
|
50
84
|
* Build the production {@link OnboardingDeps}: live gateway validation, the
|
|
51
85
|
* credentials store, real state persistence, and a wall-clock timestamp.
|
|
52
86
|
*/
|
|
53
87
|
export function createDefaultDeps(opts) {
|
|
88
|
+
const provider = opts.config.model.provider;
|
|
89
|
+
// ONLY ON THE CRUXY PROVIDER, on the same reasoning `run.ts` uses to gate the
|
|
90
|
+
// limits probe: `/device/*` are cruxy gateway routes, and a
|
|
91
|
+
// bring-your-own-provider setup has no notion of them. Pointing them at
|
|
92
|
+
// someone else's base URL would be a request to a stranger — so there is no
|
|
93
|
+
// device flow to offer, and the key step falls through to the paste prompt.
|
|
94
|
+
const deviceLogin = provider === "cruxy"
|
|
95
|
+
? (io) => runDeviceLogin(opts.config, io)
|
|
96
|
+
: undefined;
|
|
54
97
|
return {
|
|
55
|
-
validateKey: (
|
|
98
|
+
validateKey: (p, apiKey) => validateKeyLive(p, apiKey, opts.config),
|
|
56
99
|
writeCredential,
|
|
100
|
+
writeCredentialWithMeta,
|
|
101
|
+
...(deviceLogin ? { deviceLogin } : {}),
|
|
102
|
+
credentialStatus: () => {
|
|
103
|
+
const envVar = apiKeyEnvVar(provider);
|
|
104
|
+
return {
|
|
105
|
+
storedKey: readCredential(provider) !== undefined,
|
|
106
|
+
...(process.env[envVar] ? { shadowingEnvVar: envVar } : {}),
|
|
107
|
+
};
|
|
108
|
+
},
|
|
57
109
|
resolveApiKey,
|
|
58
110
|
readState: () => readOnboardingState(),
|
|
59
111
|
writeState: (state) => writeOnboardingState(state),
|
|
@@ -62,6 +114,69 @@ export function createDefaultDeps(opts) {
|
|
|
62
114
|
now: () => new Date().toISOString(),
|
|
63
115
|
};
|
|
64
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Drive one real device login against the gateway.
|
|
119
|
+
*
|
|
120
|
+
* Everything that can go wrong on the wire collapses to `unreachable` here,
|
|
121
|
+
* because from the user's seat there is one answer to all of it — try again —
|
|
122
|
+
* and the flow's own outcomes (denied, expired, invalid) are the states that
|
|
123
|
+
* genuinely differ. The credential is returned, never written: persisting is the
|
|
124
|
+
* step's job, so there is exactly one place that decides what lands in the store.
|
|
125
|
+
*/
|
|
126
|
+
async function runDeviceLogin(config, io) {
|
|
127
|
+
const client = new DeviceLoginClient({ gatewayUrl: config.cruxy.gatewayUrl });
|
|
128
|
+
let session;
|
|
129
|
+
try {
|
|
130
|
+
session = await client.start();
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
return { status: "unreachable", message: unreachableMessage(err) };
|
|
134
|
+
}
|
|
135
|
+
io.prompt({
|
|
136
|
+
userCode: session.userCode,
|
|
137
|
+
verificationUri: session.verificationUri,
|
|
138
|
+
...(session.verificationUriComplete !== undefined
|
|
139
|
+
? { verificationUriComplete: session.verificationUriComplete }
|
|
140
|
+
: {}),
|
|
141
|
+
expiresInMs: session.expiresInMs,
|
|
142
|
+
});
|
|
143
|
+
let outcome;
|
|
144
|
+
try {
|
|
145
|
+
outcome = await client.poll(session, {
|
|
146
|
+
onProgress: (p) => io.waiting({
|
|
147
|
+
remainingMs: p.remainingMs,
|
|
148
|
+
...(p.throttled !== undefined ? { throttled: p.throttled } : {}),
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
return { status: "unreachable", message: unreachableMessage(err) };
|
|
154
|
+
}
|
|
155
|
+
switch (outcome.status) {
|
|
156
|
+
case "approved":
|
|
157
|
+
return {
|
|
158
|
+
status: "ok",
|
|
159
|
+
apiKey: outcome.credential.accessToken,
|
|
160
|
+
...(outcome.credential.expiresAt !== undefined
|
|
161
|
+
? { expiresAt: outcome.credential.expiresAt }
|
|
162
|
+
: {}),
|
|
163
|
+
...(outcome.credential.keyId !== undefined
|
|
164
|
+
? { keyId: outcome.credential.keyId }
|
|
165
|
+
: {}),
|
|
166
|
+
};
|
|
167
|
+
case "denied":
|
|
168
|
+
return { status: "denied" };
|
|
169
|
+
case "expired":
|
|
170
|
+
return { status: "expired" };
|
|
171
|
+
case "invalid":
|
|
172
|
+
return { status: "invalid" };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function unreachableMessage(err) {
|
|
176
|
+
return err instanceof NetworkError
|
|
177
|
+
? "couldn't reach the gateway — check your connection and run `cruxy login` again"
|
|
178
|
+
: "the gateway answered unexpectedly — run `cruxy login` to try again";
|
|
179
|
+
}
|
|
65
180
|
/**
|
|
66
181
|
* Validate a key with one cheap live call: start a 1-token stream and look at the
|
|
67
182
|
* first event. `AuthError` ⇒ invalid (bad key), `NetworkError` ⇒ unreachable;
|
package/dist/onboarding/steps.js
CHANGED
|
@@ -8,6 +8,118 @@ import { loadProjectInstructions, scaffoldProjectInstructions, } from "../config
|
|
|
8
8
|
*/
|
|
9
9
|
const MAX_KEY_ATTEMPTS = 3;
|
|
10
10
|
const c = (io) => themeForColor(io.color);
|
|
11
|
+
/**
|
|
12
|
+
* Sign in by approving in a browser — the default path.
|
|
13
|
+
*
|
|
14
|
+
* WHY IT IS THE DEFAULT, and why it is not merely a nicer prompt: a pasted key
|
|
15
|
+
* is minted through the admin issue-key route, which cannot produce a
|
|
16
|
+
* subscription credential — asking for one there is explicitly refused. So every
|
|
17
|
+
* pasted CLI key lands in the metered `apikey` bucket with no token pool, while
|
|
18
|
+
* the same human's web chat and desktop draw on their plan. One human, two
|
|
19
|
+
* budgets. The device flow mints through the login issuer, which is the only
|
|
20
|
+
* path to the subscription bucket, so the credential it produces draws on the
|
|
21
|
+
* pool the user already has.
|
|
22
|
+
*
|
|
23
|
+
* THE KEY IS NOT RE-VALIDATED. The gateway minted it seconds ago and returned it
|
|
24
|
+
* over the same connection; a `validateKey` call here would spend a real,
|
|
25
|
+
* billable request to re-prove a fact we were just told — against the very pool
|
|
26
|
+
* this step exists to establish. The paste path validates because there a key is
|
|
27
|
+
* an unverified claim by the user; here it is the gateway's own answer.
|
|
28
|
+
*
|
|
29
|
+
* Every outcome is reported, never thrown: a denial and an expiry are the flow
|
|
30
|
+
* working correctly, and both leave the existing credential untouched.
|
|
31
|
+
*/
|
|
32
|
+
export async function deviceLoginStep(io, deps, provider) {
|
|
33
|
+
const col = c(io);
|
|
34
|
+
if (!deps.deviceLogin)
|
|
35
|
+
return { status: "skipped" };
|
|
36
|
+
const outcome = await deps.deviceLogin(deviceIO(io));
|
|
37
|
+
switch (outcome.status) {
|
|
38
|
+
case "ok": {
|
|
39
|
+
deps.writeCredentialWithMeta(provider, outcome.apiKey, {
|
|
40
|
+
...(outcome.expiresAt !== undefined
|
|
41
|
+
? { expiresAt: outcome.expiresAt }
|
|
42
|
+
: {}),
|
|
43
|
+
...(outcome.keyId !== undefined ? { keyId: outcome.keyId } : {}),
|
|
44
|
+
source: "device",
|
|
45
|
+
});
|
|
46
|
+
io.write(`${col.success(col.glyph.success)} signed in — credential saved to ~/.cruxy\n`);
|
|
47
|
+
if (outcome.expiresAt) {
|
|
48
|
+
io.write(col.muted(` it expires on ${formatExpiry(outcome.expiresAt)}; run \`cruxy login\` again before then.\n`));
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
status: "ok",
|
|
52
|
+
apiKey: outcome.apiKey,
|
|
53
|
+
...(outcome.expiresAt !== undefined
|
|
54
|
+
? { expiresAt: outcome.expiresAt }
|
|
55
|
+
: {}),
|
|
56
|
+
...(outcome.keyId !== undefined ? { keyId: outcome.keyId } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
case "denied":
|
|
60
|
+
io.write(`${col.danger(col.glyph.failure)} the sign-in was declined in the browser.\n`);
|
|
61
|
+
return { status: "failed", message: "sign-in declined" };
|
|
62
|
+
case "expired":
|
|
63
|
+
io.write(`${col.danger(col.glyph.failure)} the code expired before it was approved.\n`);
|
|
64
|
+
return {
|
|
65
|
+
status: "failed",
|
|
66
|
+
message: "the code expired — run `cruxy login` to get a new one",
|
|
67
|
+
};
|
|
68
|
+
case "invalid":
|
|
69
|
+
// The gateway collapses four causes into one code on purpose and tells us
|
|
70
|
+
// nothing about which; all four are answered by starting over.
|
|
71
|
+
io.write(`${col.danger(col.glyph.failure)} that sign-in could not be completed.\n`);
|
|
72
|
+
return {
|
|
73
|
+
status: "failed",
|
|
74
|
+
message: "sign-in could not be completed — run `cruxy login` to retry",
|
|
75
|
+
};
|
|
76
|
+
case "unreachable":
|
|
77
|
+
io.write(`${col.danger(col.glyph.failure)} couldn't reach the gateway to sign in.\n`);
|
|
78
|
+
return { status: "failed", message: outcome.message };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Render the device flow's two messages onto the onboarding IO. */
|
|
82
|
+
function deviceIO(io) {
|
|
83
|
+
const col = c(io);
|
|
84
|
+
let lastNote = "";
|
|
85
|
+
return {
|
|
86
|
+
prompt: (session) => {
|
|
87
|
+
const link = session.verificationUriComplete ?? session.verificationUri;
|
|
88
|
+
io.write(`\nTo sign in, open ${col.accent(link)}\n` +
|
|
89
|
+
`and enter the code ${col.strong(session.userCode)}\n\n` +
|
|
90
|
+
col.muted(`waiting for approval (expires in ${humanDuration(session.expiresInMs)})…\n`));
|
|
91
|
+
},
|
|
92
|
+
waiting: (info) => {
|
|
93
|
+
// Only the throttle is worth saying out loud, and only once: a line per
|
|
94
|
+
// poll would scroll a quiet wait off the screen, and "still waiting" adds
|
|
95
|
+
// nothing to the "waiting for approval" already on it.
|
|
96
|
+
if (!info.throttled || lastNote === "throttled")
|
|
97
|
+
return;
|
|
98
|
+
lastNote = "throttled";
|
|
99
|
+
io.write(col.muted(" the gateway asked us to slow down; still waiting…\n"));
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** "2 days", "9 minutes" — coarse on purpose; this is a reassurance, not a timer. */
|
|
104
|
+
function humanDuration(ms) {
|
|
105
|
+
const minutes = Math.round(ms / 60_000);
|
|
106
|
+
if (minutes < 1)
|
|
107
|
+
return "less than a minute";
|
|
108
|
+
if (minutes < 60)
|
|
109
|
+
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
110
|
+
const hours = Math.round(minutes / 60);
|
|
111
|
+
if (hours < 24)
|
|
112
|
+
return `${hours} hour${hours === 1 ? "" : "s"}`;
|
|
113
|
+
const days = Math.round(hours / 24);
|
|
114
|
+
return `${days} day${days === 1 ? "" : "s"}`;
|
|
115
|
+
}
|
|
116
|
+
/** The date part of an RFC 3339 expiry, or the raw string if it will not parse. */
|
|
117
|
+
function formatExpiry(expiresAt) {
|
|
118
|
+
const at = Date.parse(expiresAt);
|
|
119
|
+
if (Number.isNaN(at))
|
|
120
|
+
return expiresAt;
|
|
121
|
+
return new Date(at).toISOString().slice(0, 10);
|
|
122
|
+
}
|
|
11
123
|
/**
|
|
12
124
|
* Acquire and persist a provider key: print the create-key URL, read it masked,
|
|
13
125
|
* validate it live, and **only then** write it to the credentials store. Loops on
|
package/dist/session/index.js
CHANGED
|
@@ -16,6 +16,6 @@ export { SessionLog } from "./log.js";
|
|
|
16
16
|
export { defaultExportName, exportMarkdown, } from "./export.js";
|
|
17
17
|
export { foldEvents, readEvents, readMeta, replaySession } from "./replay.js";
|
|
18
18
|
export { redactMessages } from "./redact.js";
|
|
19
|
-
export { findSession, isAmbiguous, listSessions, summarizeSession, } from "./list.js";
|
|
20
|
-
export { cwdMismatchWarning, describeSession, loadResume, relativeAge, resumeById, resumePicker, shortId, PICKER_LIMIT, } from "./resume.js";
|
|
21
|
-
export { SESSION_FILE_VERSION, SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
|
19
|
+
export { findSession, isAmbiguous, listSessionRefs, listSessions, matchSessionRefs, summarizeSession, } from "./list.js";
|
|
20
|
+
export { cwdMismatchWarning, describeSession, loadResume, priorDirectoriesWarning, relativeAge, resolveSessionId, resumeById, resumePicker, shortId, PICKER_LIMIT, } from "./resume.js";
|
|
21
|
+
export { KNOWN_EVENT_KINDS, SESSION_FILE_VERSION, ResumedEventSchema, SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
package/dist/session/list.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { projectDir, SESSION_FILE_EXT } from "./paths.js";
|
|
4
|
+
import { readMeta } from "./replay.js";
|
|
4
5
|
import { MessageSchema, SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
|
5
6
|
/**
|
|
6
7
|
* Listing sessions for the resume picker and the TUI sidebar (P2). Both read
|
|
@@ -22,10 +23,18 @@ function toTitle(text) {
|
|
|
22
23
|
* Summarize one session file: its meta, its first user prompt (the title) and
|
|
23
24
|
* how many user turns it holds.
|
|
24
25
|
*
|
|
25
|
-
* This reads the whole file, which is the honest cost of counting turns
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* This reads the whole file, which is the honest cost of counting turns — a
|
|
27
|
+
* partial read cannot produce a correct count, and reporting a wrong one is
|
|
28
|
+
* worse than paying for a right one.
|
|
29
|
+
*
|
|
30
|
+
* What changed: this used to claim the cost was "bounded in practice — the
|
|
31
|
+
* picker asks for ten". It was not. {@link listSessions} summarized EVERY file
|
|
32
|
+
* and sliced afterwards, so the limit bounded the rows and not the work; a
|
|
33
|
+
* project of 200 sessions paid 200 full parses to show 10. The limit now bounds
|
|
34
|
+
* the work (see {@link sessionRefsByRecency}), which is what finally makes that
|
|
35
|
+
* sentence true. A sidecar index is still the answer if per-file cost ever
|
|
36
|
+
* stops being acceptable — but the ordering fix had to come first, because an
|
|
37
|
+
* index would have made the same mistake faster.
|
|
29
38
|
*/
|
|
30
39
|
export function summarizeSession(file) {
|
|
31
40
|
let raw;
|
|
@@ -89,10 +98,18 @@ export function summarizeSession(file) {
|
|
|
89
98
|
return { ...meta, title: title ?? UNTITLED, turns };
|
|
90
99
|
}
|
|
91
100
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
101
|
+
* Session files in `cwd`'s project, most-recently-modified first.
|
|
102
|
+
*
|
|
103
|
+
* `readdir` plus one `stat` each — NO file is opened. This is the ordering step
|
|
104
|
+
* that lets every caller below bound its own work: recency is knowable from the
|
|
105
|
+
* directory alone, so the expensive per-file read only ever has to happen for
|
|
106
|
+
* the files a caller is actually going to show.
|
|
107
|
+
*
|
|
108
|
+
* Missing directory → empty list (not an error: no sessions yet is normal). A
|
|
109
|
+
* file that vanishes between the `readdir` and the `stat` is skipped rather
|
|
110
|
+
* than throwing — listing races an active session by definition.
|
|
94
111
|
*/
|
|
95
|
-
|
|
112
|
+
function sessionRefsByRecency(cwd) {
|
|
96
113
|
const dir = projectDir(cwd);
|
|
97
114
|
let names;
|
|
98
115
|
try {
|
|
@@ -101,37 +118,106 @@ export function listSessions(cwd, limit = Infinity) {
|
|
|
101
118
|
catch {
|
|
102
119
|
return [];
|
|
103
120
|
}
|
|
104
|
-
const
|
|
121
|
+
const files = [];
|
|
105
122
|
for (const name of names) {
|
|
106
123
|
if (!name.endsWith(SESSION_FILE_EXT))
|
|
107
124
|
continue;
|
|
108
|
-
const
|
|
125
|
+
const file = path.join(dir, name);
|
|
126
|
+
try {
|
|
127
|
+
files.push({ file, mtimeMs: statSync(file).mtimeMs });
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
continue; // deleted mid-listing
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Newest first, with the path as a tiebreak so two files written in the same
|
|
134
|
+
// millisecond list in a stable order rather than whatever `readdir` returned.
|
|
135
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs || a.file.localeCompare(b.file));
|
|
136
|
+
return files;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Every session recorded for `cwd`'s project, most-recently-updated first,
|
|
140
|
+
* capped at `limit`.
|
|
141
|
+
*
|
|
142
|
+
* `limit` bounds the WORK, not just the rows. Files are ordered by mtime first
|
|
143
|
+
* (cheap — see {@link sessionRefsByRecency}) and summarized one at a time until
|
|
144
|
+
* `limit` valid summaries exist, so the picker asking for ten reads ten files
|
|
145
|
+
* and not two hundred.
|
|
146
|
+
*
|
|
147
|
+
* OVER-FETCH AND REFILL is why this is a loop and not a `slice`. A file with no
|
|
148
|
+
* readable meta summarizes to `null` and is skipped — that tolerance is the
|
|
149
|
+
* point of the format and `tree.test.ts` pins it. Taking the ten most recent
|
|
150
|
+
* files and summarizing those would let one junk file among them silently
|
|
151
|
+
* return nine rows; walking until ten SUMMARIES exist costs one wasted parse
|
|
152
|
+
* per junk file and always returns ten when ten are there.
|
|
153
|
+
*/
|
|
154
|
+
export function listSessions(cwd, limit = Infinity) {
|
|
155
|
+
const summaries = [];
|
|
156
|
+
for (const { file } of sessionRefsByRecency(cwd)) {
|
|
157
|
+
if (summaries.length >= limit)
|
|
158
|
+
break;
|
|
159
|
+
const summary = summarizeSession(file);
|
|
109
160
|
if (summary)
|
|
110
161
|
summaries.push(summary);
|
|
111
162
|
}
|
|
112
|
-
summaries
|
|
113
|
-
|
|
163
|
+
return summaries;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Every session's IDENTITY for `cwd`'s project, most-recently-updated first.
|
|
167
|
+
*
|
|
168
|
+
* One meta line per file, via {@link readMeta} — no turn counting, no title.
|
|
169
|
+
* This is the index `--resume <id>` resolves against, and it exists because
|
|
170
|
+
* resolution never needed the expensive half: on a 200-session project it costs
|
|
171
|
+
* ~10ms against ~120ms for the equivalent {@link listSessions}, and the resume
|
|
172
|
+
* path used to build the expensive one two or three times over.
|
|
173
|
+
*
|
|
174
|
+
* `meta` stays authoritative for the id, deliberately — the FILENAME also
|
|
175
|
+
* carries it, and matching on that would be cheaper still, but it would make a
|
|
176
|
+
* file with no readable meta resolvable and then unloadable. A session that
|
|
177
|
+
* cannot be summarized must not be offerable either.
|
|
178
|
+
*/
|
|
179
|
+
export function listSessionRefs(cwd) {
|
|
180
|
+
const refs = [];
|
|
181
|
+
for (const { file, mtimeMs } of sessionRefsByRecency(cwd)) {
|
|
182
|
+
const meta = readMeta(file);
|
|
183
|
+
if (meta)
|
|
184
|
+
refs.push({ sessionId: meta.sessionId, file, mtimeMs });
|
|
185
|
+
}
|
|
186
|
+
return refs;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Match an id (or id prefix) against an already-built index.
|
|
190
|
+
*
|
|
191
|
+
* Pure, and separate from the reading, so ONE index can answer both "is this
|
|
192
|
+
* ambiguous?" and "which one is it?" — the two questions the resume path asks
|
|
193
|
+
* back to back, and used to rebuild the whole listing to answer separately.
|
|
194
|
+
*
|
|
195
|
+
* An exact id always wins outright: `abc` names the session called `abc` even
|
|
196
|
+
* when `abcdef` also exists, so a full id is never ambiguous against something
|
|
197
|
+
* that merely starts the same way.
|
|
198
|
+
*/
|
|
199
|
+
export function matchSessionRefs(refs, id) {
|
|
200
|
+
const exact = refs.find((r) => r.sessionId === id);
|
|
201
|
+
if (exact)
|
|
202
|
+
return [exact];
|
|
203
|
+
return refs.filter((r) => r.sessionId.startsWith(id));
|
|
114
204
|
}
|
|
115
205
|
/**
|
|
116
206
|
* Find one session by id (or unambiguous id prefix) within `cwd`'s project.
|
|
117
|
-
* Returns null when nothing matches
|
|
118
|
-
* loudly to fail.
|
|
207
|
+
* Returns null when nothing matches or the prefix is ambiguous; throws nothing
|
|
208
|
+
* — the caller decides how loudly to fail.
|
|
119
209
|
*
|
|
120
210
|
* Prefix matching exists because the ids are UUIDs and nobody is going to type
|
|
121
211
|
* one; the picker and the sidebar both show a short form.
|
|
212
|
+
*
|
|
213
|
+
* Builds its own index. `resolveSessionId` in `resume.ts` is the path that
|
|
214
|
+
* builds ONE and asks both questions of it; prefer that when you need both.
|
|
122
215
|
*/
|
|
123
216
|
export function findSession(cwd, id) {
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
if (exact)
|
|
127
|
-
return exact;
|
|
128
|
-
const matches = all.filter((s) => s.sessionId.startsWith(id));
|
|
129
|
-
return matches.length === 1 ? matches[0] : null;
|
|
217
|
+
const matches = matchSessionRefs(listSessionRefs(cwd), id);
|
|
218
|
+
return matches.length === 1 ? summarizeSession(matches[0].file) : null;
|
|
130
219
|
}
|
|
131
220
|
/** Whether an id prefix matches more than one session (an ambiguous resume). */
|
|
132
221
|
export function isAmbiguous(cwd, id) {
|
|
133
|
-
|
|
134
|
-
if (all.some((s) => s.sessionId === id))
|
|
135
|
-
return false;
|
|
136
|
-
return all.filter((s) => s.sessionId.startsWith(id)).length > 1;
|
|
222
|
+
return matchSessionRefs(listSessionRefs(cwd), id).length > 1;
|
|
137
223
|
}
|
package/dist/session/log.js
CHANGED
|
@@ -15,23 +15,42 @@ export class SessionLog {
|
|
|
15
15
|
this.currentRunId = opts.currentRunId;
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
|
-
* Open (or create) a session log.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
18
|
+
* Open (or create) a session log.
|
|
19
|
+
*
|
|
20
|
+
* A NEW file gets its `meta` line. Reopening an existing one (a `--resume`)
|
|
21
|
+
* still does NOT get a second `meta` — that ruling is unchanged and is the
|
|
22
|
+
* reason this method has always branched: `meta` describes where and when the
|
|
23
|
+
* conversation began, and a second copy written from wherever it was resumed
|
|
24
|
+
* would make "the session's directory" ambiguous. Replay takes the first
|
|
25
|
+
* `meta`, so a duplicate could only ever mislead a later reader.
|
|
26
|
+
*
|
|
27
|
+
* What a reopen gets instead is a `resumed` event (#172 item 2). It carries
|
|
28
|
+
* the directory this run is in, which is the fact that used to vanish: a
|
|
29
|
+
* session begun in one directory and continued in another recorded nothing
|
|
30
|
+
* about the second, so `cwdMismatchWarning` told the user at the time and the
|
|
31
|
+
* file forgot immediately. `meta` stays singular and authoritative; the
|
|
32
|
+
* reopen is a separate kind, and no reader can confuse the two.
|
|
24
33
|
*
|
|
25
34
|
* Returns `null` when a new log cannot be created at all — the caller then
|
|
26
35
|
* runs without persistence rather than failing, exactly as a corrupt usage
|
|
27
|
-
* store is survivable. A reopen never returns null:
|
|
28
|
-
*
|
|
36
|
+
* store is survivable. A reopen still never returns null: the `resumed` write
|
|
37
|
+
* is now the first append, and a write problem there warns and goes inert
|
|
38
|
+
* exactly as any other failed append does. A session that cannot record its
|
|
39
|
+
* own reopen is not a session worth refusing to continue.
|
|
29
40
|
*/
|
|
30
41
|
static open(opts) {
|
|
31
42
|
const file = opts.file ?? sessionFile(opts.cwd, opts.sessionId);
|
|
32
43
|
const log = new SessionLog(file, opts);
|
|
33
|
-
if (hasContent(file))
|
|
44
|
+
if (hasContent(file)) {
|
|
45
|
+
log.write({
|
|
46
|
+
kind: "resumed",
|
|
47
|
+
at: new Date().toISOString(),
|
|
48
|
+
cwd: opts.cwd,
|
|
49
|
+
roots: opts.roots ?? [],
|
|
50
|
+
cliVersion: APP_VERSION,
|
|
51
|
+
});
|
|
34
52
|
return log;
|
|
53
|
+
}
|
|
35
54
|
const ok = log.write({
|
|
36
55
|
kind: "meta",
|
|
37
56
|
version: SESSION_FILE_VERSION,
|