@aragon-agent/cli 0.6.24 → 0.6.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +1924 -1786
  2. package/README.md +2730 -2562
  3. package/dist/agent/controller.d.ts +49 -3
  4. package/dist/agent/controller.js +78 -3
  5. package/dist/agent/conversation-reset.d.ts +91 -0
  6. package/dist/agent/conversation-reset.js +66 -0
  7. package/dist/agent/model-probe-limits.d.ts +39 -0
  8. package/dist/agent/model-probe-limits.js +25 -0
  9. package/dist/agent/model-probe.d.ts +104 -0
  10. package/dist/agent/model-probe.js +391 -0
  11. package/dist/agent/reducer.d.ts +22 -0
  12. package/dist/agent/reducer.js +49 -14
  13. package/dist/cli.js +214 -2
  14. package/dist/commands/branch-command.d.ts +61 -0
  15. package/dist/commands/branch-command.js +117 -0
  16. package/dist/commands/builtins.js +62 -40
  17. package/dist/commands/clear-command.d.ts +27 -0
  18. package/dist/commands/clear-command.js +107 -0
  19. package/dist/commands/registry.d.ts +48 -1
  20. package/dist/commands/session-command.d.ts +54 -0
  21. package/dist/commands/session-command.js +109 -0
  22. package/dist/config/load.d.ts +15 -0
  23. package/dist/config/model-profiles.d.ts +8 -0
  24. package/dist/config/model-profiles.js +10 -0
  25. package/dist/exec/index.js +10 -1
  26. package/dist/logging/logger.d.ts +1 -1
  27. package/dist/proc/terminal-plan.d.ts +155 -0
  28. package/dist/proc/terminal-plan.js +360 -0
  29. package/dist/session/branch-invocation.d.ts +83 -0
  30. package/dist/session/branch-invocation.js +109 -0
  31. package/dist/session/branch-launch.d.ts +70 -0
  32. package/dist/session/branch-launch.js +248 -0
  33. package/dist/session/checkpoint.d.ts +257 -0
  34. package/dist/session/checkpoint.js +512 -0
  35. package/dist/session/clear-archive.d.ts +51 -0
  36. package/dist/session/clear-archive.js +160 -0
  37. package/dist/session/cli-commands.d.ts +2 -0
  38. package/dist/session/cli-commands.js +36 -5
  39. package/dist/session/identity.d.ts +72 -0
  40. package/dist/session/identity.js +148 -0
  41. package/dist/session/limits.d.ts +66 -0
  42. package/dist/session/limits.js +66 -0
  43. package/dist/session/persist.d.ts +30 -5
  44. package/dist/session/startup-session.d.ts +107 -0
  45. package/dist/session/startup-session.js +315 -0
  46. package/dist/session/store.d.ts +60 -1
  47. package/dist/session/store.js +95 -6
  48. package/dist/todo/types.d.ts +5 -3
  49. package/dist/ui/App.d.ts +54 -2
  50. package/dist/ui/App.js +154 -28
  51. package/dist/ui/Header.js +8 -16
  52. package/dist/ui/StatusBar.d.ts +8 -0
  53. package/dist/ui/StatusBar.js +12 -3
  54. package/dist/ui/entries/CompactionCard.d.ts +5 -4
  55. package/dist/ui/entries/CompactionCard.js +5 -4
  56. package/dist/ui/layout/header-hints.d.ts +68 -0
  57. package/dist/ui/layout/header-hints.js +138 -0
  58. package/dist/ui/layout/session-chip.d.ts +52 -0
  59. package/dist/ui/layout/session-chip.js +64 -0
  60. package/dist/ui/layout/status-layout.d.ts +10 -0
  61. package/dist/ui/layout/status-layout.js +8 -1
  62. package/dist/ui/overlays/HelpOverlay.js +17 -5
  63. package/dist/ui/overlays/ModelDiscoveryList.d.ts +23 -0
  64. package/dist/ui/overlays/ModelDiscoveryList.js +161 -0
  65. package/dist/ui/overlays/ModelProfileEditor.d.ts +18 -1
  66. package/dist/ui/overlays/ModelProfileEditor.js +117 -68
  67. package/dist/ui/overlays/SettingsScreen.d.ts +8 -0
  68. package/dist/ui/overlays/SettingsScreen.js +34 -2
  69. package/dist/ui/overlays/model-probe-view.d.ts +128 -0
  70. package/dist/ui/overlays/model-probe-view.js +225 -0
  71. package/dist/ui/overlays/model-profile-state.d.ts +9 -1
  72. package/dist/ui/overlays/model-profile-state.js +10 -0
  73. package/dist/ui/overlays/use-model-probe.d.ts +30 -0
  74. package/dist/ui/overlays/use-model-probe.js +109 -0
  75. package/dist/ui/session-bridge.d.ts +26 -0
  76. package/dist/ui/session-bridge.js +32 -0
  77. package/package.json +2 -2
@@ -18,8 +18,17 @@ import { type CompactionIdentity } from '../compaction/memory-identity.js';
18
18
  * every bare-name and default `/save` target in `getSessionsDir()`. So
19
19
  * `aragon sessions list` / `prune` operate ONLY on files carrying `meta.id`,
20
20
  * which is what stops a maintenance command destroying conversations a human
21
- * saved by hand. Anything that starts writing `meta` from the TUI path must
22
- * revisit that scoping first.
21
+ * saved by hand.
22
+ *
23
+ * THE TUI NOW WRITES `meta`, AND THE RE-EXAMINATION THAT WARNING DEMANDED IS
24
+ * DONE (tui-session-id-resume 3.7 / R-7). Two axes were hiding in the one
25
+ * `managed` flag: "may a maintenance command see this row" (still `meta.id`) and
26
+ * "which writer produced it" (`origin`). `prune` now defaults to
27
+ * `origin === 'exec'`, so it behaves EXACTLY as it did before this feature on
28
+ * every file that existed before it; `--all` still only means "also consider
29
+ * files with no metadata at all" and can no longer reach a TUI checkpoint;
30
+ * `pinned` is a permanent opt-out and `live` plus a live owner pid is a
31
+ * temporary one. Four fields, four different jobs - none derivable from another.
23
32
  */
24
33
  export interface SessionMeta {
25
34
  id: string;
@@ -36,6 +45,20 @@ export interface SessionMeta {
36
45
  inputTokens: number;
37
46
  outputTokens: number;
38
47
  };
48
+ /**
49
+ * Who wrote this file. Absent (every pre-feature file) reads as 'exec'.
50
+ *
51
+ * OPTIONAL AND ADDITIVE, so SESSION_VERSION stays 1: validate-session.ts does
52
+ * not shape-check `meta` at all, so an older build reads a newer file and a
53
+ * newer build reads an older one without a migration.
54
+ */
55
+ origin?: 'exec' | 'tui';
56
+ /** `--save <id>` / `/session pin`: `sessions prune` never deletes it. */
57
+ pinned?: true;
58
+ /** The writer was alive at this write; omitted by the exit flush. */
59
+ live?: true;
60
+ /** The id this session branched from (D-5); absent for a fresh or named one. */
61
+ branchOf?: string;
39
62
  }
40
63
  export interface SavedSession {
41
64
  version: number;
@@ -53,13 +76,15 @@ export interface SavedSession {
53
76
  */
54
77
  todos?: TodoItem[];
55
78
  /**
56
- * Written only by `aragon exec` (cli-integration-surface §5.2 / D-10).
79
+ * Written by `aragon exec` (cli-integration-surface §5.2 / D-10) AND by the
80
+ * TUI's session checkpoint (tui-session-id-resume §6.1).
57
81
  *
58
82
  * `SESSION_VERSION` STAYS 1 for the reason `todos?` records above, and this is
59
83
  * the precedent that file's own comments already set: the field is additive
60
84
  * and optional. Boundary validation preserves it without changing its shape.
61
- * A TUI `/save` file simply has no `meta`, which is exactly how
62
- * `sessions list` tells the two apart.
85
+ * A TUI `/save` EXPORT still has no `meta` at all, which is what keeps a
86
+ * hand-made file out of the maintenance commands' scope; a TUI CHECKPOINT has
87
+ * one, and is told apart from an exec file by `meta.origin`.
63
88
  */
64
89
  meta?: SessionMeta;
65
90
  }
@@ -0,0 +1,107 @@
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 type { Message } from '@aragon-agent/core';
22
+ import type { Entry } from '../agent/reducer.js';
23
+ import type { TodoItem } from '../todo/types.js';
24
+ import type { CompactionIdentity } from '../compaction/memory-identity.js';
25
+ import { type SavedSession } from './persist.js';
26
+ /** Who this process is, as the rest of the CLI sees it. */
27
+ export interface SessionIdentity {
28
+ id: string;
29
+ kind: 'fresh' | 'named' | 'branch';
30
+ /** The id this one branched from; absent unless `kind === 'branch'`. */
31
+ parentId?: string;
32
+ /** `--save` / `/session pin`: `sessions prune` will not delete it. */
33
+ pinned?: boolean;
34
+ }
35
+ export interface StartupSessionInput {
36
+ /** `--resume <id|path>` - both spellings are legal (D-5 / R-8). */
37
+ resume?: string;
38
+ /** `--save <id>` - an ID, never a path (D-6). */
39
+ saveAs?: string;
40
+ cwd: string;
41
+ /** True when the user named `--model` / `--provider` explicitly (D-9). */
42
+ explicitModel?: boolean;
43
+ }
44
+ /**
45
+ * The minimum controller surface the restore needs.
46
+ *
47
+ * THREE METHODS, and `getConfig` is deliberately not one of them: the three
48
+ * calls below need nothing else, and a wider port only makes the startup path
49
+ * look like it knows more about the controller than it does.
50
+ */
51
+ export interface SessionRestorePort {
52
+ replaceMessages(messages: Message[], identity?: CompactionIdentity): void;
53
+ restoreTodos(items: TodoItem[]): void;
54
+ setModel(providerId: string, modelId: string, baseUrl?: string): void;
55
+ }
56
+ export type StartupSessionResolution = {
57
+ ok: true;
58
+ identity: SessionIdentity;
59
+ initialEntries: Entry[];
60
+ notice?: string;
61
+ /** The file this session was branched from, if any. */
62
+ saved?: SavedSession;
63
+ apply(port: SessionRestorePort): void;
64
+ } | {
65
+ ok: false;
66
+ exitCode: 2;
67
+ message: string;
68
+ };
69
+ /**
70
+ * Adopt a saved conversation. THE ONLY IMPLEMENTATION of the three calls
71
+ * `/resume` makes; the slash command and both startup paths call it.
72
+ *
73
+ * `skipModel` exists because an explicit `--model` / `--provider` on this run
74
+ * outranks the model recorded in the file: a user who says which model they want
75
+ * has said it more recently than the file did.
76
+ *
77
+ * `clearAllQueues()` and the `restoreEntries` dispatch are NOT here and belong at
78
+ * the call sites: the first is a no-op on the startup path (there are no queues
79
+ * yet) and indispensable inside a live TUI, and the second is a VIEW action with
80
+ * no controller to attach it to - the startup path seeds the reducer instead.
81
+ */
82
+ export declare function applyRestoredSession(port: SessionRestorePort, saved: SavedSession, opts?: {
83
+ skipModel?: boolean;
84
+ }): void;
85
+ /**
86
+ * Work out which session this process is, and what it starts from.
87
+ *
88
+ * FAILURE IS EXIT 2 WITH ONE COPYABLE SENTENCE, and it happens BEFORE any write
89
+ * (the only write is the id claim, and it is last). Sources: a `--save` value
90
+ * that is not an id (D-6), a `--save` id that is already taken (D-6), a `--save`
91
+ * naming the id being resumed (D-7), and a `--resume` that is missing or
92
+ * unreadable (D-8).
93
+ */
94
+ export declare function resolveStartupSession(input: StartupSessionInput): StartupSessionResolution;
95
+ /**
96
+ * Move to a derived id after a `/resume` inside the TUI (D-15 / R-9).
97
+ *
98
+ * THE ROTATION IS NOT COSMETIC. `/resume` adopts another conversation, so the
99
+ * next checkpoint would otherwise write THAT conversation into THIS id's file -
100
+ * silently destroying the disk record the user was building. The id therefore
101
+ * changes with the content, exactly as it does on a startup branch, and the
102
+ * caller resets the spend in the same breath (D-10).
103
+ *
104
+ * The parent id comes from the FILE NAME, never from `meta.id` (D-5): a file's
105
+ * contents are input.
106
+ */
107
+ export declare function rotateToChildOf(loadedPath: string): SessionIdentity | undefined;
@@ -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[];