@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,512 @@
1
+ /**
2
+ * Session checkpoint writer (tui-session-id-resume §3.4 / D-2 / D-3 / D-4 /
3
+ * D-21 / D-22 / D-23).
4
+ *
5
+ * ASCII ONLY - `src/session/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * WHY THIS MODULE EXISTS AT ALL: a TUI conversation lives in memory
8
+ * (`agent.state.messages`). Another terminal can only continue it if the process
9
+ * has put it on disk, and the only serializer that exists (`saveSession`) is
10
+ * written for explicit exports. So this is the one owner of three properties an
11
+ * export never needed:
12
+ *
13
+ * 1. THE FILE ON DISK IS ALWAYS SENDABLE (D-3 / I-4). A checkpoint taken
14
+ * mid-tool would leave an assistant `tool_call` with no `tool_result`, and
15
+ * the next request of the branch that resumes it is an HTTP 400 - the very
16
+ * first thing `--resume` is for.
17
+ * 2. THE REPLACEMENT IS ATOMIC (D-4). This is the repository's first "a writer
18
+ * and a reader coexist" session file: the parent TUI writes while another
19
+ * terminal reads. A truncated read says "corrupt session", and one false
20
+ * report is enough for a user to stop trusting the feature.
21
+ * 3. NOTHING LIVE SURVIVES INTO THE FILE (D-22 / I-11). A `running` tool card
22
+ * that never settles keeps `ToolCard` spinning and keeps `Transcript`'s
23
+ * MONOTONIC settled boundary from ever moving past it, so every frame
24
+ * re-renders the tail. The settlement happens on the WRITE COPY, not on the
25
+ * load path, because `live-session.test.ts` pins the `/save` decision that
26
+ * a reloaded card keeps its status - and `/save` is an explicit export made
27
+ * with the user watching, while a checkpoint is not.
28
+ *
29
+ * NOTHING HERE MAY INTERRUPT A CONVERSATION (I-8): every filesystem failure is
30
+ * swallowed, logged, and reported as a value.
31
+ */
32
+ import { renameSync, statSync, unlinkSync } from 'node:fs';
33
+ import { randomUUID } from 'node:crypto';
34
+ import { join } from 'node:path';
35
+ import { validateHistory } from '@aragon-agent/core';
36
+ import { getSessionsDir } from '../config/app-paths.js';
37
+ import { getLogger } from '../logging/logger.js';
38
+ import { SESSION_CHECKPOINT_LIMITS } from './limits.js';
39
+ import { saveSession } from './persist.js';
40
+ import { tryAcquireSessionLock } from './store.js';
41
+ /** The literal text a repaired `tool_result` carries. Read by the MODEL next turn. */
42
+ const INTERRUPTED_TOOL_RESULT = 'interrupted before completion';
43
+ /** The literal `preview` a settled tool card carries. Rendered in the transcript. */
44
+ const INTERRUPTED_TOOL_PREVIEW = 'interrupted (session checkpoint)';
45
+ /**
46
+ * Repair every `tool_call` that never received its `tool_result` (D-3).
47
+ *
48
+ * ON A COPY, AND THAT IS THE POINT (I-2). The live history belongs to the
49
+ * engine; a host that edited it would change what the next request sends. Each
50
+ * dangling call gets an `isError` result carrying one fixed sentence, and the
51
+ * caller re-runs `validateHistory` afterwards: a repair that is still not
52
+ * sendable means the file must not be written.
53
+ */
54
+ export function closeDanglingToolCalls(messages) {
55
+ const open = new Set();
56
+ for (const message of messages) {
57
+ if (message.role === 'assistant') {
58
+ for (const block of message.content) {
59
+ if (block.type === 'tool_call')
60
+ open.add(block.toolCallId);
61
+ }
62
+ }
63
+ else if (message.role === 'tool_result') {
64
+ open.delete(message.toolCallId);
65
+ }
66
+ }
67
+ if (open.size === 0)
68
+ return { messages: [...messages], repaired: 0 };
69
+ const patched = [...messages];
70
+ for (const toolCallId of open) {
71
+ patched.push({
72
+ role: 'tool_result',
73
+ toolCallId,
74
+ isError: true,
75
+ content: INTERRUPTED_TOOL_RESULT,
76
+ });
77
+ }
78
+ return { messages: patched, repaired: open.size };
79
+ }
80
+ /**
81
+ * Settle every `running` tool card on a COPY (D-22 / I-11).
82
+ *
83
+ * `error` rather than `done`: the command did not finish, and a card that said
84
+ * otherwise would claim a result nobody saw. The live tail is cleared for the
85
+ * same reason `stripLiveToolOutput` clears it - it describes a process that died
86
+ * with the session - and `preview` names WHY, so a resumed card is not a silent
87
+ * lie. `pending` is left alone: `ToolCard` draws no spinner for it and
88
+ * `Transcript.nowSecFor` never re-clocks it, so it settles by itself.
89
+ */
90
+ export function settleRunningToolCards(entries) {
91
+ let settled = 0;
92
+ const out = entries.map((entry) => {
93
+ if (entry.kind !== 'tool' || entry.status !== 'running')
94
+ return entry;
95
+ settled += 1;
96
+ return {
97
+ ...entry,
98
+ status: 'error',
99
+ isError: true,
100
+ preview: INTERRUPTED_TOOL_PREVIEW,
101
+ live: undefined,
102
+ liveSeq: undefined,
103
+ lastOutputAt: undefined,
104
+ };
105
+ });
106
+ return { entries: out, settled };
107
+ }
108
+ /** What may reach the disk: nothing at all, or a history the engine can resend. */
109
+ export function isSendable(messages) {
110
+ if (messages.length === 0)
111
+ return true;
112
+ return validateHistory(messages).ok;
113
+ }
114
+ /**
115
+ * The checkpoint writer.
116
+ *
117
+ * ONE TIMER (I-10). Several `agent_end` events in a row - a todo follow-through
118
+ * restarts the run without the user typing - are merged into one write, and the
119
+ * timer is `unref`ed so a pending write can never hold the process open. Every
120
+ * exit path calls `flushSync`, which is idempotent so the three of them
121
+ * (`waitUntilExit().then()`, the signal hook and `process.on('exit')`) can
122
+ * coexist without writing three times.
123
+ */
124
+ export class SessionCheckpointWriter {
125
+ deps;
126
+ pathFor;
127
+ lastFingerprint = null;
128
+ lastWriteAt = 0;
129
+ createdAt = null;
130
+ pinned;
131
+ failureStreak = 0;
132
+ pending = null;
133
+ unsubscribe = null;
134
+ exited = false;
135
+ /**
136
+ * Whether the LAST WRITE FILE still says `live: true`.
137
+ *
138
+ * The fingerprint deliberately does not include the reason for the write, so
139
+ * without this the exit flush would short-circuit as "nothing changed" and the
140
+ * file would keep `live: true` forever - which is the flag `sessions prune`
141
+ * reads as "a writer is still here" (T-10's sibling, and the reason
142
+ * `flushSync` must be able to write a file whose CONTENT is unchanged but whose
143
+ * METADATA is not).
144
+ */
145
+ lastWroteLive = false;
146
+ lastResult = null;
147
+ /**
148
+ * THIS PROCESS'S CLAIM ON THE FILE IT WRITES (S-7).
149
+ *
150
+ * Without it, `meta.live` is a flag nobody can verify: `sessions prune` reads
151
+ * the `.lock` payload to decide whether the writer is still alive, and the TUI
152
+ * never took one - so `prune --origin tui --yes` deleted the checkpoint of a
153
+ * session that was running at that moment. The lock is the same one `exec`
154
+ * uses to keep two writers off one file; a killed process leaves a dead pid
155
+ * behind and the existing staleness probe collects it.
156
+ */
157
+ lock = null;
158
+ lockedId = null;
159
+ constructor(deps) {
160
+ this.deps = deps;
161
+ this.pinned = deps.startPinned === true;
162
+ this.pathFor = deps.pathFor ?? ((id) => join(getSessionsDir(), `${id}.json`));
163
+ }
164
+ /**
165
+ * Write once now: after mount, so the id on screen is resumable immediately.
166
+ *
167
+ * THE `no-identity` ANSWER IS A TIMING ANSWER, NOT A PERMANENT ONE. `render()`
168
+ * returns before `App` has run its first effects, so the session slot can still
169
+ * be empty when this is called - and "the id on screen is not resumable until
170
+ * the first turn" is exactly the failure D-2 exists to prevent (a zero-turn
171
+ * session must already be a session). One deferred retry through the SAME
172
+ * single timer (I-10) turns that window into the write; if the view was already
173
+ * published, the first call succeeded and no timer is armed at all.
174
+ */
175
+ start() {
176
+ const result = this.write('running');
177
+ if (!result.ok && result.reason === 'no-identity')
178
+ this.schedule();
179
+ return result;
180
+ }
181
+ /** Subscribe to `agent_end`. The writer does not own the controller's lifetime. */
182
+ attach(subscribe) {
183
+ this.unsubscribe?.();
184
+ this.unsubscribe = subscribe(() => this.onAgentEnd());
185
+ }
186
+ /** Write now, optionally pinning (`/session pin`) or forcing past the short circuit. */
187
+ writeNow(opts = {}) {
188
+ if (opts.pin)
189
+ this.pinned = true;
190
+ return this.write('running', opts.force === true);
191
+ }
192
+ /**
193
+ * The exit-path write. Never throws, and after the first call it is a no-op
194
+ * (I-10): all three exit paths run on a normal quit.
195
+ */
196
+ flushSync(reason) {
197
+ if (this.exited)
198
+ return this.lastResult ?? noIdentity();
199
+ this.exited = true;
200
+ this.clearPending();
201
+ const result = this.write(reason);
202
+ // AFTER THE WRITE, BEFORE ANYONE ELSE LOOKS: the exit flush is the last
203
+ // thing this process does to this file, and a lock left behind would make
204
+ // `sessions prune` skip a session nobody is writing any more.
205
+ this.releaseLock();
206
+ return result;
207
+ }
208
+ dispose() {
209
+ this.clearPending();
210
+ this.unsubscribe?.();
211
+ this.unsubscribe = null;
212
+ this.releaseLock();
213
+ }
214
+ // -------------------------------------------------------------------------
215
+ // Scheduling
216
+ // -------------------------------------------------------------------------
217
+ onAgentEnd() {
218
+ if (this.fingerprint() === this.lastFingerprint)
219
+ return;
220
+ this.schedule();
221
+ }
222
+ /**
223
+ * Coalesce into one timer. `debounceMs` is the window; `minWriteIntervalMs`
224
+ * keeps two consecutive run boundaries - which arrive closer together than a
225
+ * human can type - from landing two full session writes back to back.
226
+ */
227
+ schedule() {
228
+ if (this.pending)
229
+ return;
230
+ const since = this.now() - this.lastWriteAt;
231
+ const wait = Math.max(SESSION_CHECKPOINT_LIMITS.debounceMs, SESSION_CHECKPOINT_LIMITS.minWriteIntervalMs - since);
232
+ const timer = setTimeout(() => {
233
+ this.pending = null;
234
+ this.write('running');
235
+ }, wait);
236
+ // A checkpoint must never be the reason `aragon` does not exit.
237
+ timer.unref?.();
238
+ this.pending = timer;
239
+ }
240
+ clearPending() {
241
+ if (!this.pending)
242
+ return;
243
+ clearTimeout(this.pending);
244
+ this.pending = null;
245
+ }
246
+ now() {
247
+ return (this.deps.now ?? Date.now)();
248
+ }
249
+ // -------------------------------------------------------------------------
250
+ // Writing
251
+ // -------------------------------------------------------------------------
252
+ /**
253
+ * The full write, in the one order that keeps the file honest: read, repair,
254
+ * gate, settle, write to a temporary file, replace.
255
+ *
256
+ * `force` is ONE switch with ONE effect (D-15 / I-7): it skips the fingerprint
257
+ * short circuit below. Everything after it - the dangling-call repair, the
258
+ * sendable gate, the settle pass, the atomic replace - runs unchanged, because a
259
+ * forced write still has to produce a file `loadSession()` can read back.
260
+ */
261
+ write(reason, force = false) {
262
+ const view = this.deps.readView();
263
+ if (!view) {
264
+ this.lastResult = noIdentity();
265
+ return this.lastResult;
266
+ }
267
+ const fingerprint = this.fingerprint();
268
+ const wantsLive = reason === 'running';
269
+ if (!force && fingerprint === this.lastFingerprint && wantsLive === this.lastWroteLive) {
270
+ // No change: not touching the file is the answer, not a failure (T-11).
271
+ // `force` skips exactly this branch and nothing else (D-15 / I-7).
272
+ this.lastResult = {
273
+ ok: true,
274
+ path: this.pathFor(view.id),
275
+ bytes: 0,
276
+ repaired: 0,
277
+ settled: 0,
278
+ };
279
+ return this.lastResult;
280
+ }
281
+ const history = this.deps.readHistory();
282
+ const repaired = closeDanglingToolCalls(history.messages);
283
+ if (!isSendable(repaired.messages)) {
284
+ // SKIPPED, NOT WRITTEN (D-3). An orphan `tool_result` cannot be repaired
285
+ // host-side - inventing the `tool_call` it belongs to would forge a call
286
+ // the engine never made - so the previous good file stays, and the reason
287
+ // goes to the log rather than into the user's conversation (I-8).
288
+ const verdict = validateHistory(repaired.messages);
289
+ const why = verdict.ok ? 'unknown' : verdict.reason;
290
+ getLogger().warn('session', 'checkpoint_not_sendable', { reason: why });
291
+ this.lastResult = { ok: false, reason: 'not-sendable', message: why };
292
+ return this.lastResult;
293
+ }
294
+ const settled = settleRunningToolCards(view.entries);
295
+ this.lastResult = this.persist({
296
+ view,
297
+ messages: repaired.messages,
298
+ ...(history.compactionIdentity ? { compactionIdentity: history.compactionIdentity } : {}),
299
+ entries: settled.entries,
300
+ repaired: repaired.repaired,
301
+ settled: settled.settled,
302
+ reason,
303
+ });
304
+ if (this.lastResult.ok) {
305
+ this.lastFingerprint = fingerprint;
306
+ this.lastWroteLive = wantsLive;
307
+ this.lastWriteAt = this.now();
308
+ this.failureStreak = 0;
309
+ if (this.createdAt === null)
310
+ this.createdAt = this.lastWriteAt;
311
+ }
312
+ return this.lastResult;
313
+ }
314
+ /** Serialize to `<id>.json.tmp-<uuid>` and rename over the real file (D-4). */
315
+ persist(input) {
316
+ this.holdLock(input.view.id);
317
+ const path = this.pathFor(input.view.id);
318
+ const tmp = `${path}.tmp-${randomUUID()}`;
319
+ const data = {
320
+ model: this.deps.readModel(),
321
+ messages: input.messages,
322
+ ...(input.compactionIdentity ? { compactionIdentity: input.compactionIdentity } : {}),
323
+ entries: input.entries,
324
+ todos: this.deps.readTodos(),
325
+ meta: this.buildMeta(input),
326
+ };
327
+ try {
328
+ saveSession(tmp, data);
329
+ const bytes = replaceFile(tmp, path, () => saveSession(path, data));
330
+ if (input.repaired > 0) {
331
+ getLogger().info('session', 'checkpoint_repaired_tool_calls', { repaired: input.repaired });
332
+ }
333
+ if (input.settled > 0) {
334
+ getLogger().info('session', 'checkpoint_settled_tool_cards', { settled: input.settled });
335
+ }
336
+ return {
337
+ ok: true,
338
+ path,
339
+ bytes,
340
+ repaired: input.repaired,
341
+ settled: input.settled,
342
+ };
343
+ }
344
+ catch (error) {
345
+ unlinkQuietly(tmp);
346
+ return this.fail(error);
347
+ }
348
+ }
349
+ /**
350
+ * `meta`, field by field (§6.1). Two of these are easy to get subtly wrong:
351
+ * `createdAt` is frozen at the first write (re-reading it every time would
352
+ * make the field a second `updatedAt`), and `turns` counts assistant messages
353
+ * IN THE FILE - a quantity a reader can verify, unlike "what this cost".
354
+ */
355
+ buildMeta(input) {
356
+ const model = this.deps.readModel();
357
+ const parentId = this.deps.readParentId?.();
358
+ return {
359
+ id: input.view.id,
360
+ cwd: this.deps.readCwd(),
361
+ createdAt: this.createdAt ?? this.now(),
362
+ updatedAt: this.now(),
363
+ turns: input.messages.filter((message) => message.role === 'assistant').length,
364
+ cli: this.deps.version,
365
+ provider: model.providerId,
366
+ model: model.modelId,
367
+ usage: {
368
+ inputTokens: input.view.usageTotal.inputTokens,
369
+ outputTokens: input.view.usageTotal.outputTokens,
370
+ },
371
+ origin: 'tui',
372
+ ...(this.pinned ? { pinned: true } : {}),
373
+ // Omitted by the exit flush: `live` means "a writer believes a process is
374
+ // still here", and both exit paths know better.
375
+ ...(input.reason === 'running' ? { live: true } : {}),
376
+ ...(parentId ? { branchOf: parentId } : {}),
377
+ };
378
+ }
379
+ /**
380
+ * Take, or move, this process's lock on the id being written (S-7).
381
+ *
382
+ * BEST EFFORT, AND NEVER FATAL: a checkpoint must not fail because someone
383
+ * else holds the lock - the write is atomic and the file is this id's own, so
384
+ * the worst case is a `live` flag that `prune` declines to trust.
385
+ */
386
+ holdLock(id) {
387
+ if (this.lockedId === id && this.lock)
388
+ return;
389
+ this.releaseLock();
390
+ this.lockedId = id;
391
+ try {
392
+ this.lock = (this.deps.lockFor ?? tryAcquireSessionLock)(id);
393
+ }
394
+ catch (error) {
395
+ // An id the lock refuses is an id that could not be a file name either; the
396
+ // write below reports that on its own terms.
397
+ this.lock = null;
398
+ getLogger().debug('session', 'checkpoint_lock_skipped', {
399
+ reason: error instanceof Error ? error.message : String(error),
400
+ });
401
+ }
402
+ }
403
+ releaseLock() {
404
+ this.lock?.release();
405
+ this.lock = null;
406
+ this.lockedId = null;
407
+ }
408
+ /** A failed write is a log line and a return value, never an exception (I-8). */
409
+ fail(error) {
410
+ this.failureStreak += 1;
411
+ const message = error instanceof Error ? error.message : String(error);
412
+ const data = { reason: message, streak: this.failureStreak };
413
+ if (this.failureStreak >= SESSION_CHECKPOINT_LIMITS.loudFailureStreak) {
414
+ getLogger().error('session', 'checkpoint_write_failed', data);
415
+ }
416
+ else {
417
+ getLogger().warn('session', 'checkpoint_write_failed', data);
418
+ }
419
+ return { ok: false, reason: 'write-failed', message };
420
+ }
421
+ /**
422
+ * A cheap content fingerprint: identity, message count, last message shape,
423
+ * entry count, last entry id, todo count and the pin flag.
424
+ *
425
+ * SHORT AND STRUCTURAL ON PURPOSE. Hashing the whole conversation would cost
426
+ * as much as writing it (K-1), and the question this answers is only "did
427
+ * anything settle since the last write".
428
+ */
429
+ fingerprint() {
430
+ const view = this.deps.readView();
431
+ if (!view)
432
+ return 'none';
433
+ const { messages } = this.deps.readHistory();
434
+ const last = messages[messages.length - 1];
435
+ const lastShape = last === undefined
436
+ ? '-'
437
+ : last.role === 'assistant'
438
+ ? `a:${last.stopReason ?? '-'}:${last.content.length}`
439
+ : last.role === 'tool_result'
440
+ ? `t:${last.toolCallId}`
441
+ : 'u';
442
+ const lastEntry = view.entries[view.entries.length - 1]?.id ?? '-';
443
+ return [
444
+ view.id,
445
+ messages.length,
446
+ lastShape,
447
+ view.entries.length,
448
+ lastEntry,
449
+ this.deps.readTodos().length,
450
+ this.pinned ? 'pin' : '-',
451
+ ].join('|');
452
+ }
453
+ }
454
+ function noIdentity() {
455
+ return {
456
+ ok: false,
457
+ reason: 'no-identity',
458
+ message: 'This process has no session identity, so nothing was checkpointed.',
459
+ };
460
+ }
461
+ /**
462
+ * Replace `path` with `tmp`, retrying while Windows refuses (D-4 / K-2).
463
+ *
464
+ * `EPERM`/`EEXIST` here means a reader has the file open, which is the NORMAL
465
+ * case for this feature rather than an error. Three short synchronous waits
466
+ * cover a reader finishing a read; if all of them fail, `degrade()` writes the
467
+ * payload in place and says so in the log - a checkpoint that records its own
468
+ * degradation beats a conversation that was never saved at all.
469
+ */
470
+ function replaceFile(tmp, path, degrade) {
471
+ let lastError;
472
+ for (let attempt = 0; attempt < SESSION_CHECKPOINT_LIMITS.renameAttempts; attempt += 1) {
473
+ try {
474
+ renameSync(tmp, path);
475
+ return sizeOf(path);
476
+ }
477
+ catch (error) {
478
+ lastError = error;
479
+ sleepSync(SESSION_CHECKPOINT_LIMITS.renameRetryMs);
480
+ }
481
+ }
482
+ getLogger().warn('session', 'checkpoint_write_degraded', {
483
+ reason: 'rename-failed',
484
+ error: lastError instanceof Error ? lastError.message : String(lastError),
485
+ });
486
+ degrade();
487
+ unlinkQuietly(tmp);
488
+ return sizeOf(path);
489
+ }
490
+ /** The only synchronous sleep available without a native dependency. */
491
+ function sleepSync(ms) {
492
+ const cell = new Int32Array(new SharedArrayBuffer(4));
493
+ Atomics.wait(cell, 0, 0, ms);
494
+ }
495
+ function sizeOf(path) {
496
+ try {
497
+ // Measured AFTER the rename, so the number reported describes the file a
498
+ // reader would find rather than the buffer that was written.
499
+ return statSync(path).size;
500
+ }
501
+ catch {
502
+ return 0;
503
+ }
504
+ }
505
+ function unlinkQuietly(path) {
506
+ try {
507
+ unlinkSync(path);
508
+ }
509
+ catch {
510
+ // Already gone, or a handle still open. Neither is worth failing a run for.
511
+ }
512
+ }
@@ -21,6 +21,8 @@ export interface SessionsCliOptions {
21
21
  json?: boolean;
22
22
  lines?: string;
23
23
  all?: boolean;
24
+ /** `exec` (default) | `tui` | `all`; see the truth table in §3.7. */
25
+ origin?: string;
24
26
  olderThan?: string;
25
27
  dryRun?: boolean;
26
28
  yes?: boolean;
@@ -34,7 +34,13 @@ export function registerSessionsCommand(program) {
34
34
  .description('Manage `aragon exec` sessions: list | show | rm | prune | path')
35
35
  .option('--json', 'Machine-readable output')
36
36
  .option('-n, --lines <n>', 'Entries for `sessions list` (default 20)')
37
- .option('--all', 'Include sessions this CLI did not create (TUI `/save` files)')
37
+ // TWO ORTHOGONAL AXES, TWO FLAGS (tui-session-id-resume 3.7 / R-7).
38
+ // `--all` widens "which rows exist" to files with no metadata at all; it can no
39
+ // longer reach a TUI checkpoint, because a TUI checkpoint HAS metadata and is
40
+ // separated by `--origin` instead. One merged axis made `--all` a maintenance
41
+ // command that deleted the conversation the user was still having.
42
+ .option('--all', 'Also include files with no session metadata (hand-written /save exports)')
43
+ .option('--origin <exec|tui|all>', 'Which writer `prune` may touch (default: exec)')
38
44
  .option('--older-than <days>', 'Only prune sessions untouched for this many days')
39
45
  .option('--dry-run', 'Print what `sessions prune` would delete, and delete nothing')
40
46
  .option('--yes', 'Confirm destructive actions (required by `rm` and `prune`)')
@@ -70,12 +76,17 @@ function runList(opts) {
70
76
  if (entries.length === 0) {
71
77
  process.stdout.write(opts.all
72
78
  ? 'No sessions found.\n'
73
- : 'No `aragon exec` sessions found. Pass --all to include TUI /save files.\n');
79
+ : 'No sessions found. Pass --all to include files with no session metadata.\n');
74
80
  return 0;
75
81
  }
76
82
  for (const entry of entries) {
83
+ // THE ORIGIN COLUMN IS THE ONE VISIBLE CHANGE TO THIS LINE (I-7), and it is
84
+ // what makes `prune --origin` comprehensible: without it a user cannot tell
85
+ // which of these files a sweep would touch. `pin` follows it for the same
86
+ // reason - a file that can never be pruned should look like one.
87
+ const tags = `${entry.origin ?? 'exec'}${entry.pinned ? ' pin' : ''}`;
77
88
  process.stdout.write(`${entry.id} ${stamp(entry.updatedAt)} ${entry.turns} turns ` +
78
- `${entry.provider}/${entry.model} ${entry.cwd}\n`);
89
+ `${entry.provider}/${entry.model} ${tags} ${entry.cwd}\n`);
79
90
  }
80
91
  return 0;
81
92
  }
@@ -112,7 +123,9 @@ function runShow(argument, opts) {
112
123
  `model ${meta?.provider ?? session.model?.providerId ?? '?'}/${meta?.model ?? session.model?.modelId ?? '?'}`,
113
124
  `messages ${session.messages.length}`,
114
125
  `todos ${session.todos?.length ?? 0}`,
115
- `managed ${meta?.id ? 'yes (aragon exec)' : 'no (TUI /save)'}`,
126
+ `origin ${meta?.origin === 'tui' ? 'tui (session checkpoint)' : 'exec'}`,
127
+ `pinned ${meta?.pinned === true ? 'yes' : 'no'}`,
128
+ `managed ${meta?.id ? 'yes' : 'no (TUI /save)'}`,
116
129
  '',
117
130
  ].join('\n'));
118
131
  return 0;
@@ -142,6 +155,11 @@ function runRemove(argument, opts) {
142
155
  return 0;
143
156
  }
144
157
  function runPrune(opts) {
158
+ const originScope = parseOrigin(opts.origin);
159
+ if (originScope === null && opts.origin !== undefined) {
160
+ process.stderr.write('--origin takes exec, tui or all.\n');
161
+ return 2;
162
+ }
145
163
  const days = parseCount(opts.olderThan);
146
164
  if (opts.olderThan !== undefined && days === null) {
147
165
  process.stderr.write('--older-than takes a non-negative number of days.\n');
@@ -155,6 +173,7 @@ function runPrune(opts) {
155
173
  ...(days !== null ? { olderThanDays: days } : {}),
156
174
  ...(opts.dryRun ? { dryRun: true } : {}),
157
175
  ...(opts.all ? { all: true } : {}),
176
+ ...(originScope !== null ? { origin: originScope } : {}),
158
177
  });
159
178
  if (opts.json) {
160
179
  process.stdout.write(`${JSON.stringify(doomed, null, 2)}\n`);
@@ -163,12 +182,24 @@ function runPrune(opts) {
163
182
  const verb = opts.dryRun ? 'Would remove' : 'Removed';
164
183
  for (const entry of doomed)
165
184
  process.stdout.write(`${verb} ${entry.id} ${stamp(entry.updatedAt)}\n`);
185
+ // `foreign` NOW MEANS EXACTLY ONE THING: files with NO metadata, i.e. a
186
+ // hand-written `/save` export reached only because `--all` was given. TUI
187
+ // checkpoints are managed and are reported through `--origin tui` instead, so
188
+ // this sentence can no longer claim something about a file it does not describe
189
+ // (R-7).
166
190
  const foreign = doomed.filter((e) => !e.managed).length;
167
191
  process.stdout.write(`${verb.toLowerCase()} ${doomed.length} session(s)` +
168
- (foreign > 0 ? `, ${foreign} of them saved from the TUI (--all was given).` : '.') +
192
+ (foreign > 0 ? `, ${foreign} with no session metadata (--all was given).` : '.') +
169
193
  '\n');
170
194
  return 0;
171
195
  }
196
+ /** `--origin` accepts the two writers plus `all`; anything else is refused. */
197
+ function parseOrigin(raw) {
198
+ if (raw === undefined)
199
+ return null;
200
+ const value = raw.trim().toLowerCase();
201
+ return value === 'exec' || value === 'tui' || value === 'all' ? value : null;
202
+ }
172
203
  function parseCount(raw) {
173
204
  if (raw === undefined)
174
205
  return null;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Session identity: minting, branching, deriving, and reserving
3
+ * (tui-session-id-resume §3.5 / D-1 / D-5 / D-23).
4
+ *
5
+ * ASCII ONLY - `src/session/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * ONE AUTHORITY FOR "WHAT IS A SESSION CALLED". `store.ts` already owns the
8
+ * SYNTAX (`SESSION_ID_RE`) and the path (`sessionPathFor`); this module owns the
9
+ * generator and the branch rule, and it produces ids that pass that syntax by
10
+ * construction rather than by a second regex nobody re-checks.
11
+ *
12
+ * BRANCHING NEVER REUSES THE PARENT ID (D-5 / I-3). A `--resume` always derives
13
+ * a NEW id, so two processes can never take turns overwriting one file: the
14
+ * parent keeps writing its own id, the branch writes its own. That is the whole
15
+ * technical content of "a branch must not affect the session it branched from".
16
+ */
17
+ import type { ModelRef } from '@aragon-agent/core';
18
+ /**
19
+ * 31 characters: the ten digits and the lowercase alphabet MINUS `0 o 1 l i`.
20
+ *
21
+ * Those five are the pairs a human misreads when copying an id out of the
22
+ * bottom-right corner of a terminal, and copying an id by eye is the one
23
+ * irreducible failure mode this feature has (D-14 exists to remove it, and this
24
+ * alphabet removes most of the rest).
25
+ */
26
+ export declare const SESSION_ID_ALPHABET = "23456789abcdefghjkmnpqrstuvwxyz";
27
+ /** A fresh 6-character id. Satisfies `SESSION_ID_RE` by construction (D-1). */
28
+ export declare function createSessionId(random?: () => number): string;
29
+ /**
30
+ * The child id for `parentId`: 6-character root plus `-` plus 4 characters.
31
+ *
32
+ * THE ROOT DOES NOT GROW. `a4k2m9` and `a4k2m9-x7f2` both branch to
33
+ * `a4k2m9-<new>`, so a chain of branches keeps a name that still fits the
34
+ * status bar and still points at the conversation it came from.
35
+ */
36
+ export declare function branchSessionId(parentId: string, random?: () => number): string;
37
+ /** Whether `id` is a shape this CLI mints (as opposed to a hand-chosen name). */
38
+ export declare function isGeneratedSessionId(id: string): boolean;
39
+ /**
40
+ * The next id for a session LOADED FROM `loadedPath`, or `null` when the file
41
+ * name is not a generated id.
42
+ *
43
+ * THE INPUT IS A PATH, NOT AN ID, AND THE PARENT'S IDENTITY COMES FROM THE FILE
44
+ * NAME (D-5 / R-8). `meta.id` lives inside the file, and a file is input - a
45
+ * hand-written `meta.id` must not be able to name where this process writes.
46
+ * `null` means "this was a `/save` export or an archive": the caller mints a
47
+ * fresh id and the new file has no `branchOf`.
48
+ */
49
+ export declare function derivedChildId(loadedPath: string, random?: () => number): string | null;
50
+ /**
51
+ * Claim `id` with an atomic `wx` create, writing a READABLE EMPTY SESSION into
52
+ * it (D-23 / R-3).
53
+ *
54
+ * `wx` IS THE MUTUAL EXCLUSION and stays that way: `existsSync` followed by a
55
+ * write has a window in which two processes both see "absent". What changed in
56
+ * v2 is the CONTENT. A 0-byte placeholder is unparseable, so `loadSession()`
57
+ * throws a raw `SyntaxError` on it, `listSessions` silently skips it, and
58
+ * `--save` refuses the name forever - a file the user can neither see nor
59
+ * resume. A legal empty `SavedSession` fixes all three at once: it loads, it
60
+ * lists, and `sessions rm` can delete it.
61
+ *
62
+ * NO `meta` IS WRITTEN, deliberately: "has `meta`" stays equivalent to "at least
63
+ * one checkpoint has been written", which is the predicate `sessions list` /
64
+ * `prune` are built on. `model` is written only when the caller has one - it is
65
+ * optional in the format, and the startup path resolves an identity BEFORE the
66
+ * controller exists, so a fabricated model would be a lie on disk for a field
67
+ * nothing reads back.
68
+ */
69
+ export declare function reserveSessionId(id: string, opts?: {
70
+ model?: ModelRef;
71
+ pathFor?: (id: string) => string;
72
+ }): boolean;