@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.
- package/CHANGELOG.md +1924 -1786
- package/README.md +2730 -2562
- package/dist/agent/controller.d.ts +49 -3
- package/dist/agent/controller.js +78 -3
- package/dist/agent/conversation-reset.d.ts +91 -0
- package/dist/agent/conversation-reset.js +66 -0
- package/dist/agent/model-probe-limits.d.ts +39 -0
- package/dist/agent/model-probe-limits.js +25 -0
- package/dist/agent/model-probe.d.ts +104 -0
- package/dist/agent/model-probe.js +391 -0
- package/dist/agent/reducer.d.ts +22 -0
- package/dist/agent/reducer.js +49 -14
- 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 +62 -40
- package/dist/commands/clear-command.d.ts +27 -0
- package/dist/commands/clear-command.js +107 -0
- package/dist/commands/registry.d.ts +48 -1
- 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/config/model-profiles.d.ts +8 -0
- package/dist/config/model-profiles.js +10 -0
- package/dist/exec/index.js +10 -1
- package/dist/logging/logger.d.ts +1 -1
- package/dist/proc/terminal-plan.d.ts +155 -0
- package/dist/proc/terminal-plan.js +360 -0
- package/dist/session/branch-invocation.d.ts +83 -0
- package/dist/session/branch-invocation.js +109 -0
- package/dist/session/branch-launch.d.ts +70 -0
- package/dist/session/branch-launch.js +248 -0
- package/dist/session/checkpoint.d.ts +257 -0
- package/dist/session/checkpoint.js +512 -0
- package/dist/session/clear-archive.d.ts +51 -0
- package/dist/session/clear-archive.js +160 -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/todo/types.d.ts +5 -3
- package/dist/ui/App.d.ts +54 -2
- package/dist/ui/App.js +154 -28
- 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/entries/CompactionCard.d.ts +5 -4
- package/dist/ui/entries/CompactionCard.js +5 -4
- 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 +17 -5
- package/dist/ui/overlays/ModelDiscoveryList.d.ts +23 -0
- package/dist/ui/overlays/ModelDiscoveryList.js +161 -0
- package/dist/ui/overlays/ModelProfileEditor.d.ts +18 -1
- package/dist/ui/overlays/ModelProfileEditor.js +117 -68
- package/dist/ui/overlays/SettingsScreen.d.ts +8 -0
- package/dist/ui/overlays/SettingsScreen.js +34 -2
- package/dist/ui/overlays/model-probe-view.d.ts +128 -0
- package/dist/ui/overlays/model-probe-view.js +225 -0
- package/dist/ui/overlays/model-profile-state.d.ts +9 -1
- package/dist/ui/overlays/model-profile-state.js +10 -0
- package/dist/ui/overlays/use-model-probe.d.ts +30 -0
- package/dist/ui/overlays/use-model-probe.js +109 -0
- 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,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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
`
|
|
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}
|
|
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
|
+
};
|