@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.
@@ -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
- const result = await acquireKeyStep(io, deps, provider);
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: (provider, apiKey) => validateKeyLive(provider, apiKey, opts.config),
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;
@@ -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
@@ -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";
@@ -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. It is
26
- * bounded in practice the picker asks for ten — and a session file is text
27
- * measured in tens of kilobytes. If it ever stops being cheap the fix is a
28
- * sidecar index, not a partial read that reports a wrong count.
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
- * Every session recorded for `cwd`'s project, most-recently-updated first.
93
- * Missing directory → empty list (not an error: no sessions yet is normal).
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
- export function listSessions(cwd, limit = Infinity) {
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 summaries = [];
121
+ const files = [];
105
122
  for (const name of names) {
106
123
  if (!name.endsWith(SESSION_FILE_EXT))
107
124
  continue;
108
- const summary = summarizeSession(path.join(dir, name));
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.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
113
- return Number.isFinite(limit) ? summaries.slice(0, limit) : summaries;
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; throws nothing the caller decides how
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 all = listSessions(cwd);
125
- const exact = all.find((s) => s.sessionId === id);
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
- const all = listSessions(cwd);
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
  }
@@ -15,23 +15,42 @@ export class SessionLog {
15
15
  this.currentRunId = opts.currentRunId;
16
16
  }
17
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.
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: a write problem on an
28
- * existing file surfaces on the first append, which warns and goes inert.
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,