@aragon-agent/cli 0.6.25 → 0.6.27

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 (48) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +194 -13
  3. package/dist/agent/reducer.d.ts +22 -0
  4. package/dist/agent/reducer.js +4 -1
  5. package/dist/cli.js +214 -2
  6. package/dist/commands/branch-command.d.ts +61 -0
  7. package/dist/commands/branch-command.js +117 -0
  8. package/dist/commands/builtins.js +48 -6
  9. package/dist/commands/registry.d.ts +28 -0
  10. package/dist/commands/session-command.d.ts +54 -0
  11. package/dist/commands/session-command.js +109 -0
  12. package/dist/config/load.d.ts +15 -0
  13. package/dist/exec/index.js +10 -1
  14. package/dist/logging/logger.d.ts +1 -1
  15. package/dist/proc/terminal-plan.d.ts +173 -0
  16. package/dist/proc/terminal-plan.js +393 -0
  17. package/dist/session/branch-invocation.d.ts +127 -0
  18. package/dist/session/branch-invocation.js +153 -0
  19. package/dist/session/branch-launch.d.ts +79 -0
  20. package/dist/session/branch-launch.js +265 -0
  21. package/dist/session/checkpoint.d.ts +257 -0
  22. package/dist/session/checkpoint.js +512 -0
  23. package/dist/session/cli-commands.d.ts +2 -0
  24. package/dist/session/cli-commands.js +36 -5
  25. package/dist/session/identity.d.ts +72 -0
  26. package/dist/session/identity.js +148 -0
  27. package/dist/session/limits.d.ts +66 -0
  28. package/dist/session/limits.js +66 -0
  29. package/dist/session/persist.d.ts +30 -5
  30. package/dist/session/startup-session.d.ts +107 -0
  31. package/dist/session/startup-session.js +315 -0
  32. package/dist/session/store.d.ts +60 -1
  33. package/dist/session/store.js +95 -6
  34. package/dist/ui/App.d.ts +54 -2
  35. package/dist/ui/App.js +115 -21
  36. package/dist/ui/Header.js +8 -16
  37. package/dist/ui/StatusBar.d.ts +8 -0
  38. package/dist/ui/StatusBar.js +12 -3
  39. package/dist/ui/layout/header-hints.d.ts +68 -0
  40. package/dist/ui/layout/header-hints.js +138 -0
  41. package/dist/ui/layout/session-chip.d.ts +52 -0
  42. package/dist/ui/layout/session-chip.js +64 -0
  43. package/dist/ui/layout/status-layout.d.ts +10 -0
  44. package/dist/ui/layout/status-layout.js +8 -1
  45. package/dist/ui/overlays/HelpOverlay.js +9 -3
  46. package/dist/ui/session-bridge.d.ts +26 -0
  47. package/dist/ui/session-bridge.js +32 -0
  48. package/package.json +2 -2
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Startup-time session resolution: `--resume <id|path>` and `--save <id>`
3
+ * (tui-session-id-resume §3.8 / D-5..D-9 / D-20 / D-23).
4
+ *
5
+ * ASCII ONLY - `src/session/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * ONE RESOLUTION, TWO CALLERS. The TUI (`runInteractive`) and the headless
8
+ * one-shot (`runOneShot`) both come through here, and the in-TUI `/resume`
9
+ * command shares `applyRestoredSession` with them, because two copies of "adopt
10
+ * a saved conversation" is how `/clear` and `/reset` drifted apart once already.
11
+ *
12
+ * THIS MODULE DOES NO I/O BEYOND READING THE FILE AND CLAIMING THE NEW ID. It
13
+ * builds no controller and renders nothing, so every rule below is testable as a
14
+ * pure function: a caller gets either an identity plus an `apply` thunk, or one
15
+ * message and exit code 2.
16
+ *
17
+ * THE PARENT IS NEVER WRITTEN (I-3). The id comes from the file NAME, the new id
18
+ * is always derived, and the only write this module performs is the `wx` claim
19
+ * of that new id - which is a different file by construction.
20
+ */
21
+ import { existsSync } from 'node:fs';
22
+ import { getSessionsDir } from '../config/app-paths.js';
23
+ import { branchSessionId, createSessionId, derivedChildId, isGeneratedSessionId, reserveSessionId, } from './identity.js';
24
+ import { SESSION_ID_LIMITS } from './limits.js';
25
+ import { loadSession } from './persist.js';
26
+ import { basenameId, isValidSessionId, looksLikeSessionPath, resolveSessionRef, sessionPathFor, } from './store.js';
27
+ /**
28
+ * Adopt a saved conversation. THE ONLY IMPLEMENTATION of the three calls
29
+ * `/resume` makes; the slash command and both startup paths call it.
30
+ *
31
+ * `skipModel` exists because an explicit `--model` / `--provider` on this run
32
+ * outranks the model recorded in the file: a user who says which model they want
33
+ * has said it more recently than the file did.
34
+ *
35
+ * `clearAllQueues()` and the `restoreEntries` dispatch are NOT here and belong at
36
+ * the call sites: the first is a no-op on the startup path (there are no queues
37
+ * yet) and indispensable inside a live TUI, and the second is a VIEW action with
38
+ * no controller to attach it to - the startup path seeds the reducer instead.
39
+ */
40
+ export function applyRestoredSession(port, saved, opts = {}) {
41
+ port.replaceMessages(saved.messages, saved.compactionIdentity);
42
+ // Absence clears the previous conversation's list, including old files.
43
+ port.restoreTodos(saved.todos ?? []);
44
+ if (!opts.skipModel && saved.model?.providerId && saved.model?.modelId) {
45
+ port.setModel(saved.model.providerId, saved.model.modelId, saved.model.baseUrl);
46
+ }
47
+ }
48
+ /**
49
+ * Work out which session this process is, and what it starts from.
50
+ *
51
+ * FAILURE IS EXIT 2 WITH ONE COPYABLE SENTENCE, and it happens BEFORE any write
52
+ * (the only write is the id claim, and it is last). Sources: a `--save` value
53
+ * that is not an id (D-6), a `--save` id that is already taken (D-6), a `--save`
54
+ * naming the id being resumed (D-7), and a `--resume` that is missing or
55
+ * unreadable (D-8).
56
+ */
57
+ export function resolveStartupSession(input) {
58
+ const rawSaveAs = input.saveAs?.trim();
59
+ if (rawSaveAs !== undefined && rawSaveAs.length > 0 && !isValidSessionId(rawSaveAs)) {
60
+ return invalidSaveId(rawSaveAs);
61
+ }
62
+ const saveAs = rawSaveAs !== undefined && rawSaveAs.length > 0 ? rawSaveAs : undefined;
63
+ const resume = input.resume?.trim();
64
+ if (resume !== undefined && resume.length > 0)
65
+ return resolveBranch(input, resume, saveAs);
66
+ return resolveFresh(saveAs);
67
+ }
68
+ /** A brand new session: optionally named by `--save`, otherwise auto-minted. */
69
+ function resolveFresh(saveAs) {
70
+ if (saveAs !== undefined) {
71
+ const fate = tryReserve(saveAs);
72
+ if (fate === 'taken')
73
+ return alreadyExists(saveAs);
74
+ if (fate === 'unwritable')
75
+ return unwritable(saveAs);
76
+ return {
77
+ ok: true,
78
+ identity: { id: saveAs, kind: 'named', pinned: true },
79
+ initialEntries: [],
80
+ notice: `Session ${saveAs} (pinned). Resume it from any terminal: aragon --resume ${saveAs}`,
81
+ apply: () => { },
82
+ };
83
+ }
84
+ // A collision is not a failure for an auto-minted id: another process took the
85
+ // name in the last few milliseconds, so minting again is the whole fix.
86
+ for (let attempt = 0; attempt < SESSION_ID_LIMITS.reserveAttempts; attempt += 1) {
87
+ const id = createSessionId();
88
+ const fate = tryReserve(id);
89
+ // Retrying cannot repair a directory that refuses writes, and burning the
90
+ // remaining attempts only delays the same answer.
91
+ if (fate === 'unwritable')
92
+ return unwritable(id);
93
+ if (fate === 'ok') {
94
+ return { ok: true, identity: { id, kind: 'fresh' }, initialEntries: [], apply: () => { } };
95
+ }
96
+ }
97
+ return {
98
+ ok: false,
99
+ exitCode: 2,
100
+ message: `Could not allocate a session id after ${SESSION_ID_LIMITS.reserveAttempts} attempts. ` +
101
+ `Try: aragon sessions prune --origin tui --yes (sessions live in ${getSessionsDir()}).`,
102
+ };
103
+ }
104
+ /** `--resume`: read the parent, derive a new id, claim it (D-5 / D-8). */
105
+ function resolveBranch(input, reference, saveAs) {
106
+ const path = resolveSessionRef(reference, input.cwd);
107
+ if (path === null)
108
+ return notFound(reference);
109
+ let saved;
110
+ try {
111
+ saved = loadSession(path);
112
+ }
113
+ catch (error) {
114
+ // A file that exists but cannot be read is NOT the same absence: this third
115
+ // message exists because a raw stack trace here is a user staring at a
116
+ // puzzle, and the only way out of it is deleting the file (D-8 / R-3).
117
+ const id = basenameId(path);
118
+ const reason = error instanceof Error ? error.message : String(error);
119
+ const next = isValidSessionId(id)
120
+ ? `Delete it with: aragon sessions rm ${id} --yes`
121
+ : 'Delete the file, or resume another one.';
122
+ return {
123
+ ok: false,
124
+ exitCode: 2,
125
+ message: `Session "${id}" exists but could not be read (${reason}). ${next}`,
126
+ };
127
+ }
128
+ // D-7 compares IDS, not the text the user typed: `--resume ./x/a4k2m9.json
129
+ // --save a4k2m9` is the same session (D-5 derives the parent from the file
130
+ // NAME), so the path spelling must be refused identically.
131
+ const loadedId = basenameId(path);
132
+ if (saveAs !== undefined && saveAs === loadedId) {
133
+ return {
134
+ ok: false,
135
+ exitCode: 2,
136
+ message: `--save ${saveAs} names the session you are resuming. ` +
137
+ 'Pick a different id to branch into.',
138
+ };
139
+ }
140
+ // LINEAGE ONLY EXISTS FOR A GENERATED NAME (D-5 / §5.1). A `/save` export, a
141
+ // `/clear` archive and a user-chosen name all land in this directory under
142
+ // names this CLI did not mint; recording one of those as `meta.branchOf` would
143
+ // write a parent that is not a session, and announcing it would claim a
144
+ // lineage that does not exist. `derivedChildId` already draws that line for the
145
+ // NEW id; the recorded parent must be drawn on the same line.
146
+ const parentId = isGeneratedSessionId(loadedId) ? loadedId : undefined;
147
+ const derived = saveAs ?? derivedChildId(path);
148
+ const claimed = claimBranchId(derived, {
149
+ named: saveAs !== undefined,
150
+ // A retry must keep the branch SHAPE: losing the `-xxxx` suffix on a
151
+ // collision would drop the one thing on disk that says where this came from.
152
+ mint: () => (parentId ? branchSessionId(parentId) : createSessionId()),
153
+ });
154
+ if (!claimed.ok)
155
+ return claimed.resolution;
156
+ const identity = {
157
+ id: claimed.id,
158
+ // Not a "branch" when the file it came from has no id of ours to name, and
159
+ // not a "branch" that the `-p` notice should announce.
160
+ kind: parentId !== undefined ? 'branch' : saveAs !== undefined ? 'named' : 'fresh',
161
+ ...(parentId !== undefined ? { parentId } : {}),
162
+ // Written only when TRUE, like `SessionMeta.pinned`: the field means "the
163
+ // user asked for this name", and `pinned: false` would be a second spelling
164
+ // of the same absence.
165
+ ...(saveAs !== undefined ? { pinned: true } : {}),
166
+ };
167
+ const notice = parentId !== undefined
168
+ ? `Resumed ${parentId} as a new branch: session ${claimed.id}. ` +
169
+ 'The session you branched from is unchanged.'
170
+ : saveAs !== undefined
171
+ ? `Session ${claimed.id} (pinned). ` +
172
+ `Resume it from any terminal: aragon --resume ${claimed.id}`
173
+ : undefined;
174
+ return {
175
+ ok: true,
176
+ identity,
177
+ initialEntries: saved.entries,
178
+ ...(notice === undefined ? {} : { notice }),
179
+ saved,
180
+ apply: (port) => applyRestoredSession(port, saved, { skipModel: input.explicitModel === true }),
181
+ };
182
+ }
183
+ /**
184
+ * Claim the branch id, retrying only when it was auto-derived.
185
+ *
186
+ * A `--save` name that is taken is the user's decision to lose, so it is refused
187
+ * (D-6); an auto-derived suffix colliding means nothing to anyone, so it is
188
+ * re-minted - WITH THE SAME ROOT (`mint`), because the suffix is the disposable
189
+ * half of a branch id and the root is the part that carries the lineage.
190
+ */
191
+ function claimBranchId(derived, opts) {
192
+ if (derived !== null) {
193
+ const fate = tryReserve(derived);
194
+ if (fate === 'ok')
195
+ return { ok: true, id: derived };
196
+ if (fate === 'unwritable')
197
+ return { ok: false, resolution: unwritable(derived) };
198
+ if (opts.named)
199
+ return { ok: false, resolution: alreadyExists(derived) };
200
+ }
201
+ for (let attempt = 0; attempt < SESSION_ID_LIMITS.reserveAttempts; attempt += 1) {
202
+ const id = opts.mint();
203
+ const fate = tryReserve(id);
204
+ if (fate === 'ok')
205
+ return { ok: true, id };
206
+ if (fate === 'unwritable')
207
+ return { ok: false, resolution: unwritable(id) };
208
+ }
209
+ return {
210
+ ok: false,
211
+ resolution: {
212
+ ok: false,
213
+ exitCode: 2,
214
+ message: `Could not allocate a branch id after ${SESSION_ID_LIMITS.reserveAttempts} attempts.`,
215
+ },
216
+ };
217
+ }
218
+ /**
219
+ * `reserveSessionId` answers "did I get this name"; the CALLER needs to know
220
+ * "who refused, and what should I tell the user".
221
+ *
222
+ * A read-only (or full) session directory fails the reservation for every id
223
+ * exactly like a collision does, and reporting that as `Session "myfix" already
224
+ * exists` sends the user to `--resume myfix`, which then reports that nothing is
225
+ * there. One `existsSync` between the two is enough to tell "somebody holds this
226
+ * name" from "nothing can be written here at all".
227
+ */
228
+ function tryReserve(id) {
229
+ if (reserveSessionId(id))
230
+ return 'ok';
231
+ return existsSync(sessionPathFor(id)) ? 'taken' : 'unwritable';
232
+ }
233
+ /**
234
+ * Move to a derived id after a `/resume` inside the TUI (D-15 / R-9).
235
+ *
236
+ * THE ROTATION IS NOT COSMETIC. `/resume` adopts another conversation, so the
237
+ * next checkpoint would otherwise write THAT conversation into THIS id's file -
238
+ * silently destroying the disk record the user was building. The id therefore
239
+ * changes with the content, exactly as it does on a startup branch, and the
240
+ * caller resets the spend in the same breath (D-10).
241
+ *
242
+ * The parent id comes from the FILE NAME, never from `meta.id` (D-5): a file's
243
+ * contents are input.
244
+ */
245
+ export function rotateToChildOf(loadedPath) {
246
+ // Same lineage rule as a startup branch: a name this CLI did not mint has no
247
+ // id to record, so it records none (D-5 / §5.1).
248
+ const loadedId = basenameId(loadedPath);
249
+ const parentId = isGeneratedSessionId(loadedId) ? loadedId : undefined;
250
+ const claimed = claimBranchId(derivedChildId(loadedPath), {
251
+ named: false,
252
+ mint: () => (parentId ? branchSessionId(parentId) : createSessionId()),
253
+ });
254
+ if (!claimed.ok)
255
+ return undefined;
256
+ return {
257
+ id: claimed.id,
258
+ kind: parentId !== undefined ? 'branch' : 'fresh',
259
+ ...(parentId !== undefined ? { parentId } : {}),
260
+ };
261
+ }
262
+ function invalidSaveId(value) {
263
+ return {
264
+ ok: false,
265
+ exitCode: 2,
266
+ message: `Invalid session id "${value}". --save takes an id ` +
267
+ '(letters, digits, ".", "_", "-"), not a path. ' +
268
+ 'Use /save inside the TUI to export to a file.',
269
+ };
270
+ }
271
+ function alreadyExists(id) {
272
+ return {
273
+ ok: false,
274
+ exitCode: 2,
275
+ message: `Session "${id}" already exists. ` +
276
+ `Use --resume ${id} to continue it as a new branch, or pick a new id.`,
277
+ };
278
+ }
279
+ /**
280
+ * The id was free but the file could not be created: a read-only or full
281
+ * directory. Naming the directory is the only actionable half of this sentence -
282
+ * "already exists" (the answer a bare `false` used to produce) would send the
283
+ * user to `--resume <id>`, which reports that nothing is there.
284
+ */
285
+ function unwritable(id) {
286
+ return {
287
+ ok: false,
288
+ exitCode: 2,
289
+ message: `Could not create session "${id}" in ${getSessionsDir()} ` +
290
+ '(the directory is not writable).',
291
+ };
292
+ }
293
+ /**
294
+ * `--resume` found nothing. TWO SPELLINGS, TWO SENTENCES, because the next step
295
+ * differs: an id is looked up in a directory, a path is looked at directly.
296
+ */
297
+ function notFound(reference) {
298
+ // THE SAME PREDICATE THE LOOKUP USED (D-5 / R-8), not a second copy of it: two
299
+ // copies would eventually disagree about one spelling and send the user to the
300
+ // wrong next step (`sessions list --all` for a path, or `sessions path` for an
301
+ // id).
302
+ if (looksLikeSessionPath(reference)) {
303
+ return {
304
+ ok: false,
305
+ exitCode: 2,
306
+ message: `No session file at "${reference}". Try: aragon sessions path`,
307
+ };
308
+ }
309
+ return {
310
+ ok: false,
311
+ exitCode: 2,
312
+ message: `No session "${reference}". Try: aragon sessions list --all ` +
313
+ `(sessions live in ${getSessionsDir()}).`,
314
+ };
315
+ }
@@ -41,6 +41,16 @@ export declare function isValidSessionId(id: string): boolean;
41
41
  * feature exists for.
42
42
  */
43
43
  export declare const SESSION_LOCK_TTL_MS = 600000;
44
+ /**
45
+ * Who wrote a session file (tui-session-id-resume §3.7 / D-20).
46
+ *
47
+ * A SECOND, ORTHOGONAL AXIS from `managed`. `managed` answers "may a maintenance
48
+ * command see this row at all" (it means "the file carries `meta.id`"); `origin`
49
+ * answers "which writer produced it". TUI checkpoints are managed AND
50
+ * `origin: 'tui'`, which is exactly why `--all` must no longer be able to reach
51
+ * them: widening axis A must not widen axis B.
52
+ */
53
+ export type SessionOrigin = 'exec' | 'tui';
44
54
  export interface SessionListEntry {
45
55
  id: string;
46
56
  path: string;
@@ -50,8 +60,14 @@ export interface SessionListEntry {
50
60
  turns: number;
51
61
  provider: string;
52
62
  model: string;
53
- /** `true` when the file carries `meta.id`, i.e. `aragon exec` wrote it. */
63
+ /** `true` when the file carries `meta.id`, i.e. `aragon exec` or the TUI wrote it. */
54
64
  managed: boolean;
65
+ /** Who wrote it; absent/unknown metadata reads as `'exec'` (older files). */
66
+ origin?: SessionOrigin;
67
+ /** `--save <id>` or `/session pin`: `sessions prune` never deletes it. */
68
+ pinned?: boolean;
69
+ /** The writer considered itself live at its last write. */
70
+ live?: boolean;
55
71
  }
56
72
  export declare function sessionsDir(): string;
57
73
  export declare function sessionPathFor(id: string): string;
@@ -66,6 +82,19 @@ export declare function sessionPathFor(id: string): string;
66
82
  * prevent, arriving through the flag next to it. `meta.id` is no safer: it comes
67
83
  * out of a file, and files are inputs.
68
84
  *
85
+ * A TUI CHECKPOINT NEVER NAMES ITSELF (D-20 / R-1). This is the single line that
86
+ * keeps `aragon exec --resume <tui-id>` from OVERWRITING the parent checkpoint:
87
+ * before this check, a TUI file carrying `meta.id` made `exec` take that id,
88
+ * write its own result into `a4k2m9.json` and move the `--continue` pointer -
89
+ * silent, unrecoverable loss of the one conversation the user was still having,
90
+ * arriving through a directory the design had declared out of scope. The rule
91
+ * belongs HERE rather than in `exec/**` because every exec write-back path
92
+ * already funnels through this function, so one check covers all of them and
93
+ * `exec/**` stays untouched.
94
+ *
95
+ * The fallthrough is the "stranger file" branch that already existed below:
96
+ * mint a fresh id rather than build a path out of the leftovers.
97
+ *
69
98
  * The order is the one that keeps every legitimate resume landing where the user
70
99
  * expects: an exec-written session names itself, a TUI `/save` is named by its
71
100
  * file, and anything else gets a fresh id rather than a guess - reported back in
@@ -73,6 +102,15 @@ export declare function sessionPathFor(id: string): string;
73
102
  * wondering which session it just wrote.
74
103
  */
75
104
  export declare function resumedSessionId(path: string, saved: SavedSession): string;
105
+ /**
106
+ * Whether a `--resume` value is meant as a PATH rather than an id.
107
+ *
108
+ * EXPORTED SO THE FAILURE MESSAGE CANNOT DISAGREE WITH THE LOOKUP: the startup
109
+ * path prints one sentence for "no such id" and another for "no such file", and
110
+ * a second copy of this predicate is a copy that can drift (`isAbsolute` was
111
+ * missing from exactly such a copy).
112
+ */
113
+ export declare function looksLikeSessionPath(value: string): boolean;
76
114
  /** Read one file, or `null` for anything unreadable. NEVER THROWS. */
77
115
  export declare function readSessionFile(path: string): SavedSession | null;
78
116
  /**
@@ -100,6 +138,14 @@ export interface WriteSessionInput {
100
138
  */
101
139
  export declare function writeSession(input: WriteSessionInput): string;
102
140
  export declare function removeSession(id: string): boolean;
141
+ /**
142
+ * The file name without its directory or `.json` suffix.
143
+ *
144
+ * Exported because session IDENTITY derives the parent of a branch from this
145
+ * value and NOT from `meta.id` (D-5): the contents of a file are input, so a
146
+ * hand-written `meta.id` must not be able to name where this process writes.
147
+ */
148
+ export declare function basenameId(path: string): string;
103
149
  /**
104
150
  * Every session in the directory, newest first.
105
151
  *
@@ -114,7 +160,20 @@ export interface PruneOptions {
114
160
  olderThanDays?: number;
115
161
  dryRun?: boolean;
116
162
  all?: boolean;
163
+ /**
164
+ * Which WRITER's files this sweep may touch: `'exec'` (default), `'tui'`, or
165
+ * `'all'` (tui-session-id-resume §3.7 / R-7).
166
+ *
167
+ * A SECOND AXIS, NOT A THIRD CHOICE. `all` above widens "which rows exist"
168
+ * (files with no metadata too); this narrows "which writers are in scope".
169
+ * Keeping them separate is what makes `--all` mean, forever, exactly what its
170
+ * help text says - and never "also delete every TUI checkpoint", which is what
171
+ * a single merged axis produced when TUI files started carrying `meta`.
172
+ */
173
+ origin?: SessionOrigin | 'all';
117
174
  now?: () => number;
175
+ /** Injected for tests; defaults to `process.kill(pid, 0)` liveness probing. */
176
+ isProcessAlive?: (pid: number) => boolean;
118
177
  }
119
178
  /** `--dry-run` reports and deletes nothing; nothing is ever pruned automatically. */
120
179
  export declare function pruneSessions(opts?: PruneOptions): SessionListEntry[];
@@ -91,6 +91,19 @@ export function sessionPathFor(id) {
91
91
  * prevent, arriving through the flag next to it. `meta.id` is no safer: it comes
92
92
  * out of a file, and files are inputs.
93
93
  *
94
+ * A TUI CHECKPOINT NEVER NAMES ITSELF (D-20 / R-1). This is the single line that
95
+ * keeps `aragon exec --resume <tui-id>` from OVERWRITING the parent checkpoint:
96
+ * before this check, a TUI file carrying `meta.id` made `exec` take that id,
97
+ * write its own result into `a4k2m9.json` and move the `--continue` pointer -
98
+ * silent, unrecoverable loss of the one conversation the user was still having,
99
+ * arriving through a directory the design had declared out of scope. The rule
100
+ * belongs HERE rather than in `exec/**` because every exec write-back path
101
+ * already funnels through this function, so one check covers all of them and
102
+ * `exec/**` stays untouched.
103
+ *
104
+ * The fallthrough is the "stranger file" branch that already existed below:
105
+ * mint a fresh id rather than build a path out of the leftovers.
106
+ *
94
107
  * The order is the one that keeps every legitimate resume landing where the user
95
108
  * expects: an exec-written session names itself, a TUI `/save` is named by its
96
109
  * file, and anything else gets a fresh id rather than a guess - reported back in
@@ -98,6 +111,8 @@ export function sessionPathFor(id) {
98
111
  * wondering which session it just wrote.
99
112
  */
100
113
  export function resumedSessionId(path, saved) {
114
+ if (saved.meta?.origin === 'tui')
115
+ return randomUUID();
101
116
  const declared = saved.meta?.id;
102
117
  if (typeof declared === 'string' && isValidSessionId(declared))
103
118
  return declared;
@@ -106,6 +121,17 @@ export function resumedSessionId(path, saved) {
106
121
  return base;
107
122
  return randomUUID();
108
123
  }
124
+ /**
125
+ * Whether a `--resume` value is meant as a PATH rather than an id.
126
+ *
127
+ * EXPORTED SO THE FAILURE MESSAGE CANNOT DISAGREE WITH THE LOOKUP: the startup
128
+ * path prints one sentence for "no such id" and another for "no such file", and
129
+ * a second copy of this predicate is a copy that can drift (`isAbsolute` was
130
+ * missing from exactly such a copy).
131
+ */
132
+ export function looksLikeSessionPath(value) {
133
+ return (isAbsolute(value) || value.includes('/') || value.includes('\\') || value.endsWith('.json'));
134
+ }
109
135
  function ensureDir() {
110
136
  const dir = getSessionsDir();
111
137
  if (!existsSync(dir))
@@ -135,8 +161,7 @@ export function resolveSessionRef(idOrPath, cwd) {
135
161
  const value = idOrPath.trim();
136
162
  if (value.length === 0)
137
163
  return null;
138
- const looksLikePath = isAbsolute(value) || value.includes('/') || value.includes('\\') || value.endsWith('.json');
139
- const path = looksLikePath
164
+ const path = looksLikeSessionPath(value)
140
165
  ? resolve(cwd, value)
141
166
  : isValidSessionId(value)
142
167
  ? sessionPathFor(value)
@@ -199,9 +224,24 @@ function describe(path, session) {
199
224
  provider: meta?.provider ?? session.model?.providerId ?? '',
200
225
  model: meta?.model ?? session.model?.modelId ?? '',
201
226
  managed: typeof meta?.id === 'string' && meta.id.length > 0,
227
+ // UNTRUSTED INPUT, DEGRADED TO THE OLD BEHAVIOUR. `origin` is read out of a
228
+ // file, so an unknown or hostile value must not be able to make a TUI
229
+ // checkpoint look like an exec one (or the reverse); anything that is not
230
+ // exactly `'tui'` reads as `'exec'`, which is what every pre-feature file
231
+ // means.
232
+ origin: meta?.origin === 'tui' ? 'tui' : 'exec',
233
+ pinned: meta?.pinned === true,
234
+ live: meta?.live === true,
202
235
  };
203
236
  }
204
- function basenameId(path) {
237
+ /**
238
+ * The file name without its directory or `.json` suffix.
239
+ *
240
+ * Exported because session IDENTITY derives the parent of a branch from this
241
+ * value and NOT from `meta.id` (D-5): the contents of a file are input, so a
242
+ * hand-written `meta.id` must not be able to name where this process writes.
243
+ */
244
+ export function basenameId(path) {
205
245
  const name = path.replace(/\\/g, '/').split('/').pop() ?? path;
206
246
  return name.endsWith('.json') ? name.slice(0, -'.json'.length) : name;
207
247
  }
@@ -242,8 +282,12 @@ function safeReaddir(dir) {
242
282
  /** `--dry-run` reports and deletes nothing; nothing is ever pruned automatically. */
243
283
  export function pruneSessions(opts = {}) {
244
284
  const now = (opts.now ?? Date.now)();
285
+ const isAlive = opts.isProcessAlive ?? defaultIsProcessAlive;
245
286
  const cutoff = opts.olderThanDays === undefined ? now : now - opts.olderThanDays * 24 * 60 * 60 * 1000;
246
- const doomed = listSessions({ ...(opts.all ? { all: true } : {}) }).filter((entry) => entry.updatedAt <= cutoff);
287
+ const doomed = listSessions({ ...(opts.all ? { all: true } : {}) }).filter((entry) => entry.updatedAt <= cutoff &&
288
+ isInPruneScope(entry, opts.origin ?? 'exec') &&
289
+ !entry.pinned &&
290
+ !isOwnedByALiveProcess(entry, isAlive));
247
291
  if (opts.dryRun)
248
292
  return doomed;
249
293
  for (const entry of doomed) {
@@ -257,6 +301,41 @@ export function pruneSessions(opts = {}) {
257
301
  }
258
302
  return doomed;
259
303
  }
304
+ /**
305
+ * The origin half of the truth table. Files WITHOUT metadata are governed by
306
+ * `all` alone (they were already included or already excluded by the listing),
307
+ * so they pass here rather than being re-judged by a `meta` they do not have.
308
+ */
309
+ function isInPruneScope(entry, origin) {
310
+ if (origin === 'all')
311
+ return true;
312
+ if (!entry.managed)
313
+ return true;
314
+ return (entry.origin ?? 'exec') === origin;
315
+ }
316
+ /**
317
+ * `live: true` PLUS A LIVE OWNER PID IS NEVER DELETED (§3.7 / T-33).
318
+ *
319
+ * The pid comes from the file's `.lock` payload, which is the only per-session
320
+ * process identity this directory carries and the same source
321
+ * `tryAcquireSessionLock` trusts. A `live: true` file with NO lock is deletable,
322
+ * and that is the honest answer rather than a gap: the flag says a writer
323
+ * believed itself live, not that one is alive now - a process killed in the
324
+ * night leaves the flag behind, and a protection that could never expire is a
325
+ * file nobody can ever clean up.
326
+ */
327
+ function isOwnedByALiveProcess(entry, isAlive) {
328
+ if (!entry.live)
329
+ return false;
330
+ // THE FILE NAME, NOT `meta.id`: the lock is named after the file, and `meta.id`
331
+ // comes out of that file (I-12). Reading it here would probe the wrong lock for
332
+ // a file whose metadata disagrees with its name - and, since `join` normalises,
333
+ // a hostile id containing separators would read outside this directory.
334
+ const holder = readLock(join(getSessionsDir(), `${basenameId(entry.path)}.lock`));
335
+ if (!holder || holder.host !== hostname())
336
+ return false;
337
+ return isAlive(holder.pid);
338
+ }
260
339
  // ---------------------------------------------------------------------------
261
340
  // `--continue`: the pointer, and the scan that makes it safe to keep
262
341
  // ---------------------------------------------------------------------------
@@ -299,16 +378,26 @@ export function latestSessionForCwd(cwd) {
299
378
  if (hinted && isValidSessionId(hinted.id)) {
300
379
  const path = sessionPathFor(hinted.id);
301
380
  const session = existsSync(path) ? readSessionFile(path) : null;
302
- if (session?.meta && sameCwd(session.meta.cwd, cwd))
381
+ // BOTH BRANCHES EXCLUDE TUI CHECKPOINTS (tui-session-id-resume §3.7). The
382
+ // pointer branch is not defensive padding: `--continue` promises "the newest
383
+ // EXEC session", and TUI sessions are resumed by id. Today only
384
+ // `writeSession()` moves the pointer, so a TUI file cannot be named there -
385
+ // and that is precisely the kind of "nobody writes it today" assumption this
386
+ // repository has already paid for. One predicate, applied twice, is cheap.
387
+ if (session?.meta && originOf(session.meta) === 'exec' && sameCwd(session.meta.cwd, cwd)) {
303
388
  return describe(path, session);
389
+ }
304
390
  }
305
391
  const target = resolve(cwd);
306
392
  for (const entry of listSessions()) {
307
- if (sameCwd(entry.cwd, target))
393
+ if (originOf(entry) === 'exec' && sameCwd(entry.cwd, target))
308
394
  return entry;
309
395
  }
310
396
  return null;
311
397
  }
398
+ function originOf(value) {
399
+ return value.origin === 'tui' ? 'tui' : 'exec';
400
+ }
312
401
  function sameCwd(a, b) {
313
402
  if (!a || !b)
314
403
  return false;
package/dist/ui/App.d.ts CHANGED
@@ -8,10 +8,13 @@ import React from 'react';
8
8
  import type { ScrollbarBridge } from './scrollbar-controller.js';
9
9
  import type { AgentController } from '../agent/controller.js';
10
10
  import type { ConfirmRequest } from '../tools/index.js';
11
- import { type Overlay, type ToastLevel } from '../agent/reducer.js';
11
+ import { type Entry, type Overlay, type ToastLevel } from '../agent/reducer.js';
12
12
  import type { UpdateBridge } from '../update/types.js';
13
13
  import type { MouseSource } from '../input/stdin-filter.js';
14
14
  import { type PasteBridge } from '../input/limits.js';
15
+ import type { CheckpointResult } from '../session/checkpoint.js';
16
+ import type { BranchCommandPort } from '../commands/branch-command.js';
17
+ import type { SessionIdentity } from '../session/startup-session.js';
15
18
  import type { SelectionBridge } from './selection/selection-controller.js';
16
19
  import type { HumanInputBridge } from '../tools/human-input.js';
17
20
  export interface ConfirmBridge {
@@ -83,6 +86,55 @@ export interface AppProps {
83
86
  */
84
87
  pasteBridge?: PasteBridge;
85
88
  terminal?: TerminalBridge;
89
+ /**
90
+ * The transcript to seed the first frame with, from a resumed session
91
+ * (tui-session-id-resume 3.12).
92
+ *
93
+ * ABSENT IN EVERY ORDINARY LAUNCH, and absent is what keeps the first frame
94
+ * byte-identical to the pre-feature build (I-6).
95
+ */
96
+ initialEntries?: Entry[];
97
+ /** This process's session identity. `undefined` means "no chip, no checkpoint". */
98
+ session?: SessionIdentity;
99
+ /** The two actions `cli.tsx` owns: checkpoint now, and rotate after `/resume`. */
100
+ sessionBridge?: SessionBridge;
101
+ /**
102
+ * One line to show when the identity is NOT the obvious one (D-13): a branch,
103
+ * or a `--save` name. An ordinary new session says nothing - the chip is the
104
+ * affordance, and a notice on every launch trains users to ignore notices.
105
+ */
106
+ sessionNotice?: string;
107
+ }
108
+ /**
109
+ * `App`'s and `cli.tsx`'s small two-way surface for the session, shaped exactly
110
+ * like `confirmBridge` / `pasteBridge`.
111
+ *
112
+ * `checkpoint` and `rotate` are INSTALLED BY `cli.tsx` because the writer lives
113
+ * outside React (two of its callers are process-level); `onIdentity` is
114
+ * installed by the App so a rotation can move the chip, which is the only piece
115
+ * of the identity the view owns.
116
+ */
117
+ export interface SessionBridge {
118
+ /**
119
+ * Write a checkpoint now; `pin` also marks the file as never-prunable, and
120
+ * `force` skips the fingerprint short circuit so `/branch` can guarantee that
121
+ * the file the new process is about to read is current (D-15).
122
+ */
123
+ checkpoint?: (opts?: {
124
+ pin?: boolean;
125
+ force?: boolean;
126
+ }) => CheckpointResult;
127
+ /** Adopt another conversation's identity (D-15); `undefined` if it cannot. */
128
+ rotate?: (loadedPath: string) => SessionIdentity | undefined;
129
+ /**
130
+ * Ctrl+L / `/branch`: branch this session into a NEW terminal. Installed by
131
+ * `cli.tsx`, because opening a window is a process-level act the App only asks
132
+ * for (tui-branch-terminal-shortcut 3.11). Absent in a build with no session,
133
+ * which is what lets `/branch` say so instead of throwing.
134
+ */
135
+ branch?: BranchCommandPort;
136
+ /** Installed by `App`; called by `rotate` after the new id is claimed. */
137
+ onIdentity?: (identity: SessionIdentity) => void;
86
138
  }
87
139
  export interface TerminalBridge {
88
140
  scrollbar?: ScrollbarBridge;
@@ -96,4 +148,4 @@ export interface TerminalBridge {
96
148
  writeForeign?: (text: string) => void;
97
149
  selection?: SelectionBridge;
98
150
  }
99
- export declare function App({ controller, version, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, }: AppProps): React.ReactElement;
151
+ export declare function App({ controller, version, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, initialEntries, session, sessionBridge, sessionNotice, }: AppProps): React.ReactElement;