@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.
- package/CHANGELOG.md +104 -0
- package/README.md +194 -13
- package/dist/agent/reducer.d.ts +22 -0
- package/dist/agent/reducer.js +4 -1
- package/dist/cli.js +214 -2
- package/dist/commands/branch-command.d.ts +61 -0
- package/dist/commands/branch-command.js +117 -0
- package/dist/commands/builtins.js +48 -6
- package/dist/commands/registry.d.ts +28 -0
- package/dist/commands/session-command.d.ts +54 -0
- package/dist/commands/session-command.js +109 -0
- package/dist/config/load.d.ts +15 -0
- package/dist/exec/index.js +10 -1
- package/dist/logging/logger.d.ts +1 -1
- package/dist/proc/terminal-plan.d.ts +173 -0
- package/dist/proc/terminal-plan.js +393 -0
- package/dist/session/branch-invocation.d.ts +127 -0
- package/dist/session/branch-invocation.js +153 -0
- package/dist/session/branch-launch.d.ts +79 -0
- package/dist/session/branch-launch.js +265 -0
- package/dist/session/checkpoint.d.ts +257 -0
- package/dist/session/checkpoint.js +512 -0
- package/dist/session/cli-commands.d.ts +2 -0
- package/dist/session/cli-commands.js +36 -5
- package/dist/session/identity.d.ts +72 -0
- package/dist/session/identity.js +148 -0
- package/dist/session/limits.d.ts +66 -0
- package/dist/session/limits.js +66 -0
- package/dist/session/persist.d.ts +30 -5
- package/dist/session/startup-session.d.ts +107 -0
- package/dist/session/startup-session.js +315 -0
- package/dist/session/store.d.ts +60 -1
- package/dist/session/store.js +95 -6
- package/dist/ui/App.d.ts +54 -2
- package/dist/ui/App.js +115 -21
- package/dist/ui/Header.js +8 -16
- package/dist/ui/StatusBar.d.ts +8 -0
- package/dist/ui/StatusBar.js +12 -3
- package/dist/ui/layout/header-hints.d.ts +68 -0
- package/dist/ui/layout/header-hints.js +138 -0
- package/dist/ui/layout/session-chip.d.ts +52 -0
- package/dist/ui/layout/session-chip.js +64 -0
- package/dist/ui/layout/status-layout.d.ts +10 -0
- package/dist/ui/layout/status-layout.js +8 -1
- package/dist/ui/overlays/HelpOverlay.js +9 -3
- package/dist/ui/session-bridge.d.ts +26 -0
- package/dist/ui/session-bridge.js +32 -0
- 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
|
+
}
|
package/dist/session/store.d.ts
CHANGED
|
@@ -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[];
|
package/dist/session/store.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|