@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
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Archive the conversation a `/clear` is about to destroy
3
+ * (clear-command-context-reset §3.8 / D-5 / I-5).
4
+ *
5
+ * ASCII ONLY: `src/session/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * WHY THIS EXISTS AT ALL: a command whose whole job is to destroy context is a
8
+ * command a user will eventually fire by accident, one keystroke from `/cwd`. The
9
+ * difference between "I lost an hour of work" and "I resumed it" is one file, and
10
+ * `saveSession()` already knows how to write it.
11
+ *
12
+ * WHY THIS IS NOT `session/store.ts::writeSession()`: that function MOVES THE
13
+ * `--continue` POINTER and writes `meta`. An archive is not a session you continue;
14
+ * it is a record you resume BY PATH. Writing `meta` would also make the file look
15
+ * `aragon exec`-managed to `sessions list` / `prune`, which is the scoping rule
16
+ * `persist.ts` records at the top of `SessionMeta` - so a maintenance command would
17
+ * start deleting the user's cleared conversations.
18
+ *
19
+ * ISOLATION IS TWO DEEP (I-6 / P2-4). The directory is the first fence:
20
+ * `listSessions` / `pruneSessions` / `latestSessionForCwd` read `sessionsDir()`
21
+ * non-recursively and skip anything not ending in `.json` (`store.ts`), so a
22
+ * SUBDIRECTORY name is invisible to all three by construction. Omitting `meta` is
23
+ * the second fence, for the day someone makes that listing recursive or gives
24
+ * `--all` a wider meaning.
25
+ */
26
+ import { mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import process from 'node:process';
29
+ import { getLogger } from '../logging/logger.js';
30
+ import { saveSession } from './persist.js';
31
+ import { sessionsDir } from './store.js';
32
+ /** Bounded retention: the newest N cleared conversations survive. */
33
+ export const CLEAR_ARCHIVE_LIMITS = { maxArchives: 20 };
34
+ /** `<sessions>/cleared` - a subdirectory, which is the first isolation fence. */
35
+ const CLEARED_DIR_NAME = 'cleared';
36
+ /** Every archive's filename starts with this, which is what `prune` selects on. */
37
+ const ARCHIVE_PREFIX = 'cleared-';
38
+ /**
39
+ * Best-effort archive of the conversation being cleared.
40
+ *
41
+ * @returns the absolute path of the written file, or `null` when there was
42
+ * nothing to save (an empty conversation) or the write failed. NEVER THROWS:
43
+ * a full disk must not be able to stop a user from starting a new conversation
44
+ * (I-5), and a caller that had to wrap this in `try` would eventually forget.
45
+ */
46
+ export function archiveClearedConversation(input) {
47
+ // Clearing an empty conversation produces no file. Without this, every
48
+ // `/clear` on a fresh session would litter the directory with empty archives
49
+ // and push a real one out of the retention window.
50
+ if (input.messages.length === 0)
51
+ return null;
52
+ const dir = join(sessionsDir(), CLEARED_DIR_NAME);
53
+ const path = join(dir, `${archiveId(new Date())}.json`);
54
+ try {
55
+ mkdirSync(dir, { recursive: true });
56
+ }
57
+ catch (err) {
58
+ getLogger().debug('agent', 'clear_archive_failed', { reason: reasonOf(err) });
59
+ return null;
60
+ }
61
+ // DEGRADE AND RETRY (P1-6 / C-8). `saveSession` VALIDATES the compaction
62
+ // identity and THROWS on a damaged or stale one, and an interrupted
63
+ // conversation - the one a user most wants back - is exactly the one most
64
+ // likely to carry a stale identity. Losing a whole conversation to an
65
+ // accessory field would break the only promise this module makes, so the
66
+ // second attempt drops it: `messages` is what makes the archive recoverable,
67
+ // the identity only lets the NEXT compaction reuse a prefix.
68
+ //
69
+ // The retry runs ONLY when there is an identity to drop. A disk that refused
70
+ // the first write will refuse an identical second one, so retrying without one
71
+ // would just double the log line and the wait without changing the outcome.
72
+ const wrote = writeArchive(path, input, true)
73
+ || (input.compactionIdentity !== undefined && writeArchive(path, input, false));
74
+ if (!wrote)
75
+ return null;
76
+ pruneArchives(dir);
77
+ return path;
78
+ }
79
+ /**
80
+ * One write attempt. `withIdentity` is the only difference between the first and
81
+ * the degraded second attempt, which is why it is a parameter rather than a
82
+ * duplicated body.
83
+ */
84
+ function writeArchive(path, input, withIdentity) {
85
+ const identity = withIdentity ? input.compactionIdentity : undefined;
86
+ try {
87
+ saveSession(path, {
88
+ model: input.model,
89
+ messages: input.messages,
90
+ ...(identity ? { compactionIdentity: identity } : {}),
91
+ entries: input.entries,
92
+ todos: input.todos,
93
+ });
94
+ return true;
95
+ }
96
+ catch (err) {
97
+ getLogger().debug('agent', 'clear_archive_write_failed', {
98
+ withIdentity, reason: reasonOf(err),
99
+ });
100
+ return false;
101
+ }
102
+ }
103
+ /**
104
+ * Deterministic, collision-free id (P2-3).
105
+ *
106
+ * `cleared-<yyyyMMdd>-<HHmmss>-<pid>` is ASCII, matches `isValidSessionId`
107
+ * (`[A-Za-z0-9][A-Za-z0-9._-]*`), and is legible enough to type after
108
+ * `/resume `. The pid removes cross-process collisions; within one process a
109
+ * second `cleared-` file in the same second is unreachable because the FIRST
110
+ * clear leaves an empty conversation and `archiveClearedConversation` returns
111
+ * early on that. So no `-2`/`-3` suffix ladder is needed, and none is here.
112
+ */
113
+ function archiveId(at) {
114
+ const pad = (n) => String(n).padStart(2, '0');
115
+ const day = `${at.getFullYear()}${pad(at.getMonth() + 1)}${pad(at.getDate())}`;
116
+ const clock = `${pad(at.getHours())}${pad(at.getMinutes())}${pad(at.getSeconds())}`;
117
+ return `${ARCHIVE_PREFIX}${day}-${clock}-${process.pid}`;
118
+ }
119
+ /**
120
+ * Keep the newest `maxArchives` files; delete the rest.
121
+ *
122
+ * A sweep, not a scheduled job: retention that runs on the write path cannot be
123
+ * forgotten, and the failure mode of a missed sweep is bounded disk usage rather
124
+ * than unbounded. Every error here is swallowed on purpose - pruning is
125
+ * housekeeping and must never be able to fail a clear that already succeeded.
126
+ */
127
+ function pruneArchives(dir) {
128
+ const survivors = [];
129
+ for (const name of safeReaddir(dir)) {
130
+ if (!name.startsWith(ARCHIVE_PREFIX) || !name.endsWith('.json'))
131
+ continue;
132
+ const path = join(dir, name);
133
+ try {
134
+ survivors.push({ path, modified: statSync(path).mtimeMs });
135
+ }
136
+ catch {
137
+ // A file that vanished between listing and stat is already deleted.
138
+ }
139
+ }
140
+ survivors.sort((a, b) => b.modified - a.modified);
141
+ for (const doomed of survivors.slice(CLEAR_ARCHIVE_LIMITS.maxArchives)) {
142
+ try {
143
+ unlinkSync(doomed.path);
144
+ }
145
+ catch (err) {
146
+ getLogger().debug('agent', 'clear_archive_prune_failed', { reason: reasonOf(err) });
147
+ }
148
+ }
149
+ }
150
+ function safeReaddir(dir) {
151
+ try {
152
+ return readdirSync(dir);
153
+ }
154
+ catch {
155
+ return [];
156
+ }
157
+ }
158
+ function reasonOf(err) {
159
+ return err instanceof Error ? err.message : String(err);
160
+ }
@@ -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;
@@ -0,0 +1,148 @@
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 { closeSync, mkdirSync, openSync, unlinkSync, writeSync } from 'node:fs';
18
+ import { dirname } from 'node:path';
19
+ import { SESSION_ID_LIMITS } from './limits.js';
20
+ import { basenameId, sessionPathFor } from './store.js';
21
+ /**
22
+ * 31 characters: the ten digits and the lowercase alphabet MINUS `0 o 1 l i`.
23
+ *
24
+ * Those five are the pairs a human misreads when copying an id out of the
25
+ * bottom-right corner of a terminal, and copying an id by eye is the one
26
+ * irreducible failure mode this feature has (D-14 exists to remove it, and this
27
+ * alphabet removes most of the rest).
28
+ */
29
+ export const SESSION_ID_ALPHABET = '23456789abcdefghjkmnpqrstuvwxyz';
30
+ /** A generated id: 6 characters, optionally with the 4-character branch suffix. */
31
+ const GENERATED_ID_RE = /^([a-z0-9]{6})(?:-([a-z0-9]{4}))?$/;
32
+ /** One character from the alphabet. `random` is injected for deterministic tests. */
33
+ function pick(random) {
34
+ // `Math.min` because a stubbed generator may legitimately return 1.
35
+ const index = Math.min(SESSION_ID_ALPHABET.length - 1, Math.floor(Math.abs(random()) * SESSION_ID_ALPHABET.length));
36
+ return SESSION_ID_ALPHABET[index];
37
+ }
38
+ function randomChars(count, random) {
39
+ let out = '';
40
+ for (let i = 0; i < count; i += 1)
41
+ out += pick(random);
42
+ return out;
43
+ }
44
+ /** A fresh 6-character id. Satisfies `SESSION_ID_RE` by construction (D-1). */
45
+ export function createSessionId(random = Math.random) {
46
+ return randomChars(SESSION_ID_LIMITS.idLength, random);
47
+ }
48
+ /**
49
+ * The child id for `parentId`: 6-character root plus `-` plus 4 characters.
50
+ *
51
+ * THE ROOT DOES NOT GROW. `a4k2m9` and `a4k2m9-x7f2` both branch to
52
+ * `a4k2m9-<new>`, so a chain of branches keeps a name that still fits the
53
+ * status bar and still points at the conversation it came from.
54
+ */
55
+ export function branchSessionId(parentId, random = Math.random) {
56
+ const root = GENERATED_ID_RE.exec(parentId)?.[1];
57
+ if (!root)
58
+ return createSessionId(random);
59
+ const suffix = randomChars(SESSION_ID_LIMITS.suffixLength, random);
60
+ return `${root}-${suffix}`;
61
+ }
62
+ /** Whether `id` is a shape this CLI mints (as opposed to a hand-chosen name). */
63
+ export function isGeneratedSessionId(id) {
64
+ return GENERATED_ID_RE.test(id);
65
+ }
66
+ /**
67
+ * The next id for a session LOADED FROM `loadedPath`, or `null` when the file
68
+ * name is not a generated id.
69
+ *
70
+ * THE INPUT IS A PATH, NOT AN ID, AND THE PARENT'S IDENTITY COMES FROM THE FILE
71
+ * NAME (D-5 / R-8). `meta.id` lives inside the file, and a file is input - a
72
+ * hand-written `meta.id` must not be able to name where this process writes.
73
+ * `null` means "this was a `/save` export or an archive": the caller mints a
74
+ * fresh id and the new file has no `branchOf`.
75
+ */
76
+ export function derivedChildId(loadedPath, random = Math.random) {
77
+ const base = basenameId(loadedPath);
78
+ return isGeneratedSessionId(base) ? branchSessionId(base, random) : null;
79
+ }
80
+ /**
81
+ * Claim `id` with an atomic `wx` create, writing a READABLE EMPTY SESSION into
82
+ * it (D-23 / R-3).
83
+ *
84
+ * `wx` IS THE MUTUAL EXCLUSION and stays that way: `existsSync` followed by a
85
+ * write has a window in which two processes both see "absent". What changed in
86
+ * v2 is the CONTENT. A 0-byte placeholder is unparseable, so `loadSession()`
87
+ * throws a raw `SyntaxError` on it, `listSessions` silently skips it, and
88
+ * `--save` refuses the name forever - a file the user can neither see nor
89
+ * resume. A legal empty `SavedSession` fixes all three at once: it loads, it
90
+ * lists, and `sessions rm` can delete it.
91
+ *
92
+ * NO `meta` IS WRITTEN, deliberately: "has `meta`" stays equivalent to "at least
93
+ * one checkpoint has been written", which is the predicate `sessions list` /
94
+ * `prune` are built on. `model` is written only when the caller has one - it is
95
+ * optional in the format, and the startup path resolves an identity BEFORE the
96
+ * controller exists, so a fabricated model would be a lie on disk for a field
97
+ * nothing reads back.
98
+ */
99
+ export function reserveSessionId(id, opts = {}) {
100
+ const path = (opts.pathFor ?? sessionPathFor)(id);
101
+ const payload = {
102
+ version: 1,
103
+ savedAt: Date.now(),
104
+ ...(opts.model ? { model: opts.model } : {}),
105
+ messages: [],
106
+ entries: [],
107
+ };
108
+ let fd;
109
+ try {
110
+ // THE DIRECTORY FIRST, and inside the same `try`: on a fresh machine
111
+ // `sessions/` does not exist yet, and a reservation that silently answered
112
+ // "name taken" because of a missing parent would send the caller into a
113
+ // pointless retry loop. A parent that is a FILE fails here, which is the
114
+ // honest answer.
115
+ mkdirSync(dirname(path), { recursive: true });
116
+ fd = openSync(path, 'wx');
117
+ }
118
+ catch {
119
+ // EEXIST is the ordinary answer to "someone already holds this name", and a
120
+ // missing directory is the ordinary answer on a fresh machine. Both mean
121
+ // "this id is not available", which is all the caller can act on.
122
+ return false;
123
+ }
124
+ try {
125
+ writeSync(fd, `${JSON.stringify(payload, null, 2)}\n`);
126
+ }
127
+ catch {
128
+ // A half-written placeholder is exactly the unreadable file D-23 exists to
129
+ // rule out, so the failure is undone rather than left behind.
130
+ closeSync(fd);
131
+ unlinkQuietly(path);
132
+ return false;
133
+ }
134
+ // Closed IMMEDIATELY: an open handle keeps Windows from replacing the file
135
+ // when the first checkpoint lands (the same reason `store.ts` closes its lock
136
+ // handle at once).
137
+ closeSync(fd);
138
+ return true;
139
+ }
140
+ /** Best effort: a cleanup failure must not turn "id unavailable" into a throw. */
141
+ function unlinkQuietly(path) {
142
+ try {
143
+ unlinkSync(path);
144
+ }
145
+ catch {
146
+ // Nothing to clean up, or someone else already did.
147
+ }
148
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Structural bounds for session identity and the session checkpoint
3
+ * (tui-session-id-resume §3.13).
4
+ *
5
+ * ASCII ONLY - `src/session/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * STRUCTURE, NOT POLICY: these are the shapes an id and a checkpoint can
8
+ * physically take, so they live in the subsystem's own `limits.ts` like every
9
+ * other subsystem's do (CLAUDE.md: "`limits.ts` is structural,
10
+ * `config/schema.ts` is policy"). Nothing here is user-tunable (D-19): a
11
+ * "show an id but never write it" switch would manufacture the one state this
12
+ * feature must not have - an id a user can copy and cannot use.
13
+ */
14
+ export declare const SESSION_ID_LIMITS: {
15
+ /** 6 characters of `SESSION_ID_ALPHABET`: 31^6 is about 8.9e8 ids. */
16
+ readonly idLength: 6;
17
+ /** The branch suffix, `-` plus this many characters. */
18
+ readonly suffixLength: 4;
19
+ /** The upper bound `SESSION_ID_RE` already imposes (`store.ts:50`). */
20
+ readonly maxIdLength: 64;
21
+ /** `maxIdLength - 1 - suffixLength` = 64 - 1 - 4. */
22
+ readonly maxRootLength: 59;
23
+ /** `wx` reservation attempts before a fresh id is minted instead. */
24
+ readonly reserveAttempts: 5;
25
+ };
26
+ export declare const SESSION_CHECKPOINT_LIMITS: {
27
+ /** The window several `agent_end` events are merged into (T-11b). */
28
+ readonly debounceMs: 750;
29
+ /** Floor between two writes, so a burst cannot land twice in one turn. */
30
+ readonly minWriteIntervalMs: 250;
31
+ /** `renameSync` retries when Windows refuses to replace an open file (D-4). */
32
+ readonly renameAttempts: 3;
33
+ /** Gap between those retries, waited synchronously (`Atomics.wait`). */
34
+ readonly renameRetryMs: 20;
35
+ /** Consecutive failures before the log record is raised from warn to error. */
36
+ readonly loudFailureStreak: 3;
37
+ };
38
+ /**
39
+ * Structural bounds for branching a session into a NEW terminal window
40
+ * (tui-branch-terminal-shortcut 5.3).
41
+ *
42
+ * WHY THESE LIVE HERE (R-12): this file's header says STRUCTURE, NOT POLICY, and
43
+ * `cooldownMs` is a timing policy. The precedent is already in this file -
44
+ * `SESSION_CHECKPOINT_LIMITS.debounceMs` / `minWriteIntervalMs` are timing
45
+ * policies of exactly the same kind. D-17 keeps every one of these out of
46
+ * `config/schema.ts`, so none of them is user-tunable.
47
+ *
48
+ * EVERY ENTRY NEEDS AN ENFORCEMENT POINT (R-7): a declared limit nobody checks is
49
+ * worse than no limit, because it makes a review believe the case was handled.
50
+ * `cooldownMs` is read in `branch-launch.ts` steps 1-2, `titleMaxCells` by
51
+ * `truncateTitle()` in step 7, `spawnProbeMs` by the `trySpawn` timer,
52
+ * `maxCommandLineCells` in step 4.5 BEFORE any spawn, and `maxRungs` as the
53
+ * bound of the rung loop in step 8.
54
+ */
55
+ export declare const BRANCH_TERMINAL_LIMITS: {
56
+ /** Minimum gap between two successful opens (D-10). FAILURES DO NOT CONSUME IT. */
57
+ readonly cooldownMs: 2000;
58
+ /** Max cells of the new window title (`aragon ` + id; an id can be 64 long). */
59
+ readonly titleMaxCells: 60;
60
+ /** How long to wait for the `'spawn'` event; a timeout counts as success. */
61
+ readonly spawnProbeMs: 1500;
62
+ /** Hard cap on one command line (base64 included); over it we refuse (step 4.5). */
63
+ readonly maxCommandLineCells: 8000;
64
+ /** How many rungs to try (the POSIX candidate table can be long; 3 covers reality). */
65
+ readonly maxRungs: 3;
66
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Structural bounds for session identity and the session checkpoint
3
+ * (tui-session-id-resume §3.13).
4
+ *
5
+ * ASCII ONLY - `src/session/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * STRUCTURE, NOT POLICY: these are the shapes an id and a checkpoint can
8
+ * physically take, so they live in the subsystem's own `limits.ts` like every
9
+ * other subsystem's do (CLAUDE.md: "`limits.ts` is structural,
10
+ * `config/schema.ts` is policy"). Nothing here is user-tunable (D-19): a
11
+ * "show an id but never write it" switch would manufacture the one state this
12
+ * feature must not have - an id a user can copy and cannot use.
13
+ */
14
+ export const SESSION_ID_LIMITS = {
15
+ /** 6 characters of `SESSION_ID_ALPHABET`: 31^6 is about 8.9e8 ids. */
16
+ idLength: 6,
17
+ /** The branch suffix, `-` plus this many characters. */
18
+ suffixLength: 4,
19
+ /** The upper bound `SESSION_ID_RE` already imposes (`store.ts:50`). */
20
+ maxIdLength: 64,
21
+ /** `maxIdLength - 1 - suffixLength` = 64 - 1 - 4. */
22
+ maxRootLength: 59,
23
+ /** `wx` reservation attempts before a fresh id is minted instead. */
24
+ reserveAttempts: 5,
25
+ };
26
+ export const SESSION_CHECKPOINT_LIMITS = {
27
+ /** The window several `agent_end` events are merged into (T-11b). */
28
+ debounceMs: 750,
29
+ /** Floor between two writes, so a burst cannot land twice in one turn. */
30
+ minWriteIntervalMs: 250,
31
+ /** `renameSync` retries when Windows refuses to replace an open file (D-4). */
32
+ renameAttempts: 3,
33
+ /** Gap between those retries, waited synchronously (`Atomics.wait`). */
34
+ renameRetryMs: 20,
35
+ /** Consecutive failures before the log record is raised from warn to error. */
36
+ loudFailureStreak: 3,
37
+ };
38
+ /**
39
+ * Structural bounds for branching a session into a NEW terminal window
40
+ * (tui-branch-terminal-shortcut 5.3).
41
+ *
42
+ * WHY THESE LIVE HERE (R-12): this file's header says STRUCTURE, NOT POLICY, and
43
+ * `cooldownMs` is a timing policy. The precedent is already in this file -
44
+ * `SESSION_CHECKPOINT_LIMITS.debounceMs` / `minWriteIntervalMs` are timing
45
+ * policies of exactly the same kind. D-17 keeps every one of these out of
46
+ * `config/schema.ts`, so none of them is user-tunable.
47
+ *
48
+ * EVERY ENTRY NEEDS AN ENFORCEMENT POINT (R-7): a declared limit nobody checks is
49
+ * worse than no limit, because it makes a review believe the case was handled.
50
+ * `cooldownMs` is read in `branch-launch.ts` steps 1-2, `titleMaxCells` by
51
+ * `truncateTitle()` in step 7, `spawnProbeMs` by the `trySpawn` timer,
52
+ * `maxCommandLineCells` in step 4.5 BEFORE any spawn, and `maxRungs` as the
53
+ * bound of the rung loop in step 8.
54
+ */
55
+ export const BRANCH_TERMINAL_LIMITS = {
56
+ /** Minimum gap between two successful opens (D-10). FAILURES DO NOT CONSUME IT. */
57
+ cooldownMs: 2_000,
58
+ /** Max cells of the new window title (`aragon ` + id; an id can be 64 long). */
59
+ titleMaxCells: 60,
60
+ /** How long to wait for the `'spawn'` event; a timeout counts as success. */
61
+ spawnProbeMs: 1_500,
62
+ /** Hard cap on one command line (base64 included); over it we refuse (step 4.5). */
63
+ maxCommandLineCells: 8_000,
64
+ /** How many rungs to try (the POSIX candidate table can be long; 3 covers reality). */
65
+ maxRungs: 3,
66
+ };