@aragon-agent/cli 0.5.12 → 0.6.1

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 (56) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +301 -6
  3. package/dist/agent/controller.d.ts +32 -0
  4. package/dist/agent/controller.js +25 -0
  5. package/dist/agent/system-prompt.d.ts +16 -0
  6. package/dist/agent/system-prompt.js +5 -0
  7. package/dist/cli.js +71 -2
  8. package/dist/config/ui-state.d.ts +18 -0
  9. package/dist/config/ui-state.js +10 -0
  10. package/dist/diagnostics/cli-commands.d.ts +10 -0
  11. package/dist/diagnostics/cli-commands.js +61 -0
  12. package/dist/diagnostics/doctor.d.ts +29 -0
  13. package/dist/diagnostics/doctor.js +167 -0
  14. package/dist/diagnostics/info.d.ts +30 -0
  15. package/dist/diagnostics/info.js +99 -0
  16. package/dist/exec/cli-commands.d.ts +31 -0
  17. package/dist/exec/cli-commands.js +63 -0
  18. package/dist/exec/emitter.d.ts +80 -0
  19. package/dist/exec/emitter.js +127 -0
  20. package/dist/exec/events.d.ts +226 -0
  21. package/dist/exec/events.js +43 -0
  22. package/dist/exec/index.d.ts +56 -0
  23. package/dist/exec/index.js +435 -0
  24. package/dist/exec/options.d.ts +76 -0
  25. package/dist/exec/options.js +221 -0
  26. package/dist/exec/permission.d.ts +66 -0
  27. package/dist/exec/permission.js +96 -0
  28. package/dist/exec/runner.d.ts +204 -0
  29. package/dist/exec/runner.js +559 -0
  30. package/dist/exec/stdin-stream.d.ts +46 -0
  31. package/dist/exec/stdin-stream.js +144 -0
  32. package/dist/logging/install.d.ts +22 -0
  33. package/dist/logging/install.js +22 -0
  34. package/dist/session/cli-commands.d.ts +30 -0
  35. package/dist/session/cli-commands.js +162 -0
  36. package/dist/session/persist.d.ts +46 -0
  37. package/dist/session/persist.js +8 -0
  38. package/dist/session/store.d.ts +129 -0
  39. package/dist/session/store.js +385 -0
  40. package/dist/team/runtime.d.ts +12 -0
  41. package/dist/team/runtime.js +6 -0
  42. package/dist/team/subagent.d.ts +12 -0
  43. package/dist/team/subagent.js +4 -0
  44. package/dist/tools/index.d.ts +20 -0
  45. package/dist/tools/index.js +26 -0
  46. package/dist/ui/ActivityLine.d.ts +16 -0
  47. package/dist/ui/ActivityLine.js +23 -1
  48. package/dist/ui/App.d.ts +14 -1
  49. package/dist/ui/App.js +38 -3
  50. package/dist/ui/BottomStatusRow.d.ts +33 -1
  51. package/dist/ui/BottomStatusRow.js +20 -6
  52. package/dist/ui/use-startup-notices.d.ts +67 -4
  53. package/dist/ui/use-startup-notices.js +76 -2
  54. package/dist/ui/win-vt-input.d.ts +57 -0
  55. package/dist/ui/win-vt-input.js +62 -0
  56. package/package.json +7 -3
@@ -0,0 +1,144 @@
1
+ /**
2
+ * `--input-format stream-json`: an NDJSON reader on stdin
3
+ * (cli-integration-surface section 4.1).
4
+ *
5
+ * ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
6
+ *
7
+ * TOLERANT BY CONTRACT (AC-20). A malformed line yields ONE non-fatal `error`
8
+ * event and is skipped; an unknown `type` is ignored entirely. The forward
9
+ * compatibility the schema promises consumers has to run in both directions, or
10
+ * a caller written against a newer version could not talk to an older CLI at
11
+ * all - and a run that dies on one bad line is the worst possible failure for a
12
+ * long-lived worker feeding turns down a pipe.
13
+ *
14
+ * Three message types are understood:
15
+ *
16
+ * { "type": "user", "text": "..." } the next turn
17
+ * { "type": "interrupt" } abort the turn in flight
18
+ * { "type": "end" } no more turns; settle and exit
19
+ *
20
+ * Reaching end-of-stream means the same as `end`, so a caller that simply closes
21
+ * the pipe is not punished for it.
22
+ */
23
+ /**
24
+ * A prompt source backed by NDJSON on stdin.
25
+ *
26
+ * IT BUFFERS RATHER THAN PAUSING THE STREAM. A caller may legitimately push
27
+ * three turns in one write before the first has finished, and a reader that only
28
+ * accepted a line when someone was waiting for one would deadlock the second and
29
+ * third behind a turn that is minutes long.
30
+ */
31
+ export function createStdinPromptSource(opts) {
32
+ const queue = [];
33
+ const waiting = [];
34
+ let finished = false;
35
+ let buffer = '';
36
+ const deliver = (text) => {
37
+ const resolve = waiting.shift();
38
+ if (resolve)
39
+ resolve(text);
40
+ else
41
+ queue.push(text);
42
+ };
43
+ const finish = () => {
44
+ if (finished)
45
+ return;
46
+ finished = true;
47
+ // Everything still parked is answered with `null`, so a `next()` in flight
48
+ // when the pipe closes resolves instead of hanging the process forever.
49
+ while (waiting.length > 0)
50
+ waiting.shift()?.(null);
51
+ };
52
+ const handleLine = (line) => {
53
+ // AFTER `end`, NOTHING ELSE IS A TURN. Lines arrive in chunks, so a caller
54
+ // that wrote `end` and a stray `user` in one write would otherwise have the
55
+ // second QUEUED and run - the exact opposite of what `end` means, and
56
+ // invisible until someone looked at the transcript.
57
+ if (finished)
58
+ return;
59
+ const trimmed = line.trim();
60
+ if (trimmed.length === 0)
61
+ return;
62
+ let parsed;
63
+ try {
64
+ parsed = JSON.parse(trimmed);
65
+ }
66
+ catch {
67
+ opts.onBadLine(`Skipped a line that is not JSON: ${clip(trimmed)}`);
68
+ return;
69
+ }
70
+ if (!parsed || typeof parsed !== 'object') {
71
+ opts.onBadLine(`Skipped a line that is not a JSON object: ${clip(trimmed)}`);
72
+ return;
73
+ }
74
+ const message = parsed;
75
+ if (message.type === 'end') {
76
+ finish();
77
+ return;
78
+ }
79
+ if (message.type === 'interrupt') {
80
+ opts.onInterrupt();
81
+ return;
82
+ }
83
+ if (message.type !== 'user') {
84
+ // An unknown type is IGNORED, not reported: reporting it would make every
85
+ // additive change to the schema noisy for callers running an older CLI.
86
+ return;
87
+ }
88
+ const text = typeof message.text === 'string' ? message.text : message.content;
89
+ if (typeof text !== 'string' || text.trim().length === 0) {
90
+ opts.onBadLine('Skipped a "user" message with no non-empty "text".');
91
+ return;
92
+ }
93
+ deliver(text);
94
+ };
95
+ opts.input.setEncoding?.('utf-8');
96
+ opts.input.on('data', (chunk) => {
97
+ buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
98
+ let index = buffer.indexOf('\n');
99
+ while (index >= 0) {
100
+ handleLine(buffer.slice(0, index));
101
+ buffer = buffer.slice(index + 1);
102
+ index = buffer.indexOf('\n');
103
+ }
104
+ });
105
+ opts.input.on('end', () => {
106
+ // A final line with no trailing newline is still a line.
107
+ if (buffer.length > 0) {
108
+ handleLine(buffer);
109
+ buffer = '';
110
+ }
111
+ finish();
112
+ });
113
+ opts.input.on('error', () => finish());
114
+ return {
115
+ next: () => {
116
+ const queued = queue.shift();
117
+ if (queued !== undefined)
118
+ return Promise.resolve(queued);
119
+ if (finished)
120
+ return Promise.resolve(null);
121
+ return new Promise((resolve) => waiting.push(resolve));
122
+ },
123
+ };
124
+ }
125
+ /**
126
+ * Serve a fixed list of prompts, then delegate.
127
+ *
128
+ * Used for `aragon exec --input-format stream-json "first turn"`, where the
129
+ * positional argument is the opening message and stdin supplies the rest.
130
+ */
131
+ export function prefixSource(prefix, rest) {
132
+ const pending = [...prefix];
133
+ return {
134
+ next: async () => {
135
+ const first = pending.shift();
136
+ if (first !== undefined)
137
+ return first;
138
+ return rest.next();
139
+ },
140
+ };
141
+ }
142
+ function clip(text) {
143
+ return text.length <= 120 ? text : `${text.slice(0, 117)}...`;
144
+ }
@@ -47,6 +47,28 @@ export declare function setScreenRestore(fn: () => void): void;
47
47
  * Replace what a signal does after the flush. The default (a bare
48
48
  * `exit(128 + signo)`) is correct for headless, inline and every subcommand;
49
49
  * full-screen substitutes one that restores the terminal first.
50
+ *
51
+ * A TERMINATOR MAY NOW RETURN WITHOUT EXITING, AND THAT IS A CONTRACT CHANGE
52
+ * WORTH WRITING DOWN (cli-integration-surface section 3.6 / D-15). The module
53
+ * header above says of the default "it must exit itself" - true of THAT
54
+ * terminator, and true because nothing before now had anything to settle.
55
+ * `aragon exec` does: it aborts the run, persists the session and emits its one
56
+ * `result` line, so a wrapper reading NDJSON learns the run ended instead of
57
+ * timing out. Returning is safe here for one specific reason - the logger has
58
+ * ALREADY FLUSHED by the time this is called, so a terminator that returns
59
+ * cannot lose log records.
60
+ *
61
+ * THE PRICE IS THAT SUCH A TERMINATOR MUST OWN AN EXIT PATH OF ITS OWN. Merely
62
+ * having a signal listener stops Node terminating by default, so one that
63
+ * returns and then never exits turns Ctrl-C into "nothing happens". exec's pays
64
+ * for it with a second-signal escape (a second SIGINT within 2s exits
65
+ * immediately) and with `process.exitCode` on the settle path. Any future
66
+ * terminator that returns needs the same two.
67
+ *
68
+ * REPLACING, NOT ADDING, IS ALSO NOT OPTIONAL for a caller that wants to be
69
+ * heard: the listeners registered by `installProcessHooks` run FIRST
70
+ * (EventEmitter order) and the default terminator exits, so a
71
+ * `process.on('SIGINT')` added later never runs at all (P0-1).
50
72
  */
51
73
  export declare function setSignalTerminator(fn: (signo: number) => void): void;
52
74
  /** Tests only: undo the hook swaps so cases cannot leak into each other. */
@@ -65,6 +65,28 @@ export function setScreenRestore(fn) {
65
65
  * Replace what a signal does after the flush. The default (a bare
66
66
  * `exit(128 + signo)`) is correct for headless, inline and every subcommand;
67
67
  * full-screen substitutes one that restores the terminal first.
68
+ *
69
+ * A TERMINATOR MAY NOW RETURN WITHOUT EXITING, AND THAT IS A CONTRACT CHANGE
70
+ * WORTH WRITING DOWN (cli-integration-surface section 3.6 / D-15). The module
71
+ * header above says of the default "it must exit itself" - true of THAT
72
+ * terminator, and true because nothing before now had anything to settle.
73
+ * `aragon exec` does: it aborts the run, persists the session and emits its one
74
+ * `result` line, so a wrapper reading NDJSON learns the run ended instead of
75
+ * timing out. Returning is safe here for one specific reason - the logger has
76
+ * ALREADY FLUSHED by the time this is called, so a terminator that returns
77
+ * cannot lose log records.
78
+ *
79
+ * THE PRICE IS THAT SUCH A TERMINATOR MUST OWN AN EXIT PATH OF ITS OWN. Merely
80
+ * having a signal listener stops Node terminating by default, so one that
81
+ * returns and then never exits turns Ctrl-C into "nothing happens". exec's pays
82
+ * for it with a second-signal escape (a second SIGINT within 2s exits
83
+ * immediately) and with `process.exitCode` on the settle path. Any future
84
+ * terminator that returns needs the same two.
85
+ *
86
+ * REPLACING, NOT ADDING, IS ALSO NOT OPTIONAL for a caller that wants to be
87
+ * heard: the listeners registered by `installProcessHooks` run FIRST
88
+ * (EventEmitter order) and the default terminator exits, so a
89
+ * `process.on('SIGINT')` added later never runs at all (P0-1).
68
90
  */
69
91
  export function setSignalTerminator(fn) {
70
92
  signalTerminator = fn;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `aragon sessions` (cli-integration-surface section 4.3).
3
+ *
4
+ * ASCII ONLY - `src/session/**` is inside the glyph scanner's scope from this
5
+ * feature onward.
6
+ *
7
+ * `list` AND `prune` SEE ONLY EXEC-MANAGED SESSIONS (P1-5 / R-16 / AC-23). The
8
+ * sessions directory is SHARED with the TUI's `/save`, so a sweep that treated
9
+ * every `*.json` as its own would destroy conversations a human saved by hand -
10
+ * unrecoverable loss, triggered by a maintenance command the docs recommend.
11
+ * `--all` opts in and says how many foreign files it will touch; `show` and `rm`
12
+ * take any id, because the user named it.
13
+ *
14
+ * NOTHING IS EVER DELETED AUTOMATICALLY. `sessions list` makes the growth
15
+ * visible; deciding to delete a user's conversations is not a call this CLI
16
+ * should make on its own.
17
+ */
18
+ import type { Command } from 'commander';
19
+ import { type SessionListEntry } from './store.js';
20
+ export interface SessionsCliOptions {
21
+ json?: boolean;
22
+ lines?: string;
23
+ all?: boolean;
24
+ olderThan?: string;
25
+ dryRun?: boolean;
26
+ yes?: boolean;
27
+ }
28
+ export declare function registerSessionsCommand(program: Command): void;
29
+ export declare function runSessionsCommand(subcommand: string, argument: string | undefined, opts: SessionsCliOptions): number;
30
+ export type { SessionListEntry };
@@ -0,0 +1,162 @@
1
+ /**
2
+ * `aragon sessions` (cli-integration-surface section 4.3).
3
+ *
4
+ * ASCII ONLY - `src/session/**` is inside the glyph scanner's scope from this
5
+ * feature onward.
6
+ *
7
+ * `list` AND `prune` SEE ONLY EXEC-MANAGED SESSIONS (P1-5 / R-16 / AC-23). The
8
+ * sessions directory is SHARED with the TUI's `/save`, so a sweep that treated
9
+ * every `*.json` as its own would destroy conversations a human saved by hand -
10
+ * unrecoverable loss, triggered by a maintenance command the docs recommend.
11
+ * `--all` opts in and says how many foreign files it will touch; `show` and `rm`
12
+ * take any id, because the user named it.
13
+ *
14
+ * NOTHING IS EVER DELETED AUTOMATICALLY. `sessions list` makes the growth
15
+ * visible; deciding to delete a user's conversations is not a call this CLI
16
+ * should make on its own.
17
+ */
18
+ import process from 'node:process';
19
+ import { listSessions, pruneSessions, readSessionFile, removeSession, sessionPathFor, sessionsDir, } from './store.js';
20
+ const SUBCOMMANDS = ['list', 'show', 'rm', 'prune', 'path'];
21
+ export function registerSessionsCommand(program) {
22
+ program
23
+ .command('sessions [subcommand] [argument]')
24
+ .description('Manage `aragon exec` sessions: list | show | rm | prune | path')
25
+ .option('--json', 'Machine-readable output')
26
+ .option('-n, --lines <n>', 'Entries for `sessions list` (default 20)')
27
+ .option('--all', 'Include sessions this CLI did not create (TUI `/save` files)')
28
+ .option('--older-than <days>', 'Only prune sessions untouched for this many days')
29
+ .option('--dry-run', 'Print what `sessions prune` would delete, and delete nothing')
30
+ .option('--yes', 'Confirm destructive actions (required by `rm` and `prune`)')
31
+ .action((subcommand, argument, opts) => {
32
+ process.exitCode = runSessionsCommand(subcommand ?? 'list', argument, opts);
33
+ });
34
+ }
35
+ export function runSessionsCommand(subcommand, argument, opts) {
36
+ switch (subcommand) {
37
+ case 'path':
38
+ process.stdout.write(`${sessionsDir()}\n`);
39
+ return 0;
40
+ case 'list':
41
+ return runList(opts);
42
+ case 'show':
43
+ return runShow(argument, opts);
44
+ case 'rm':
45
+ return runRemove(argument, opts);
46
+ case 'prune':
47
+ return runPrune(opts);
48
+ default:
49
+ process.stderr.write(`Unknown sessions subcommand "${subcommand}". Choose: ${SUBCOMMANDS.join(' | ')}.\n`);
50
+ return 2;
51
+ }
52
+ }
53
+ function runList(opts) {
54
+ const limit = parseCount(opts.lines) ?? 20;
55
+ const entries = listSessions({ ...(opts.all ? { all: true } : {}) }).slice(0, limit);
56
+ if (opts.json) {
57
+ process.stdout.write(`${JSON.stringify(entries, null, 2)}\n`);
58
+ return 0;
59
+ }
60
+ if (entries.length === 0) {
61
+ process.stdout.write(opts.all
62
+ ? 'No sessions found.\n'
63
+ : 'No `aragon exec` sessions found. Pass --all to include TUI /save files.\n');
64
+ return 0;
65
+ }
66
+ for (const entry of entries) {
67
+ process.stdout.write(`${entry.id} ${stamp(entry.updatedAt)} ${entry.turns} turns ` +
68
+ `${entry.provider}/${entry.model} ${entry.cwd}\n`);
69
+ }
70
+ return 0;
71
+ }
72
+ function runShow(argument, opts) {
73
+ if (!argument) {
74
+ process.stderr.write('Usage: aragon sessions show <id> [--json]\n');
75
+ return 2;
76
+ }
77
+ const path = sessionPathFor(argument);
78
+ const session = readSessionFile(path);
79
+ if (!session) {
80
+ process.stderr.write(`session_not_found: no readable session "${argument}".\n`);
81
+ return 2;
82
+ }
83
+ if (opts.json) {
84
+ process.stdout.write(`${JSON.stringify(session, null, 2)}\n`);
85
+ return 0;
86
+ }
87
+ // THE BODIES ARE NOT PRINTED WITHOUT `--json`. A session transcript can be
88
+ // megabytes and is frequently the most sensitive thing on the machine; a
89
+ // command whose job is "tell me about this session" should not paste it into a
90
+ // shared terminal because someone typed an id.
91
+ const meta = session.meta;
92
+ process.stdout.write([
93
+ `id ${meta?.id ?? argument}`,
94
+ `path ${path}`,
95
+ `saved ${stamp(session.savedAt)}`,
96
+ `cwd ${meta?.cwd ?? '(unknown)'}`,
97
+ `turns ${meta?.turns ?? 0}`,
98
+ `model ${meta?.provider ?? session.model?.providerId ?? '?'}/${meta?.model ?? session.model?.modelId ?? '?'}`,
99
+ `messages ${session.messages.length}`,
100
+ `todos ${session.todos?.length ?? 0}`,
101
+ `managed ${meta?.id ? 'yes (aragon exec)' : 'no (TUI /save)'}`,
102
+ '',
103
+ ].join('\n'));
104
+ return 0;
105
+ }
106
+ function runRemove(argument, opts) {
107
+ if (!argument) {
108
+ process.stderr.write('Usage: aragon sessions rm <id> --yes\n');
109
+ return 2;
110
+ }
111
+ if (!opts.yes) {
112
+ process.stderr.write('Refusing to delete without --yes.\n');
113
+ return 2;
114
+ }
115
+ if (!removeSession(argument)) {
116
+ process.stderr.write(`session_not_found: no session "${argument}".\n`);
117
+ return 2;
118
+ }
119
+ process.stdout.write(`Removed ${argument}.\n`);
120
+ return 0;
121
+ }
122
+ function runPrune(opts) {
123
+ const days = parseCount(opts.olderThan);
124
+ if (opts.olderThan !== undefined && days === null) {
125
+ process.stderr.write('--older-than takes a non-negative number of days.\n');
126
+ return 2;
127
+ }
128
+ if (!opts.dryRun && !opts.yes) {
129
+ process.stderr.write('Refusing to prune without --yes. Use --dry-run to preview.\n');
130
+ return 2;
131
+ }
132
+ const doomed = pruneSessions({
133
+ ...(days !== null ? { olderThanDays: days } : {}),
134
+ ...(opts.dryRun ? { dryRun: true } : {}),
135
+ ...(opts.all ? { all: true } : {}),
136
+ });
137
+ if (opts.json) {
138
+ process.stdout.write(`${JSON.stringify(doomed, null, 2)}\n`);
139
+ return 0;
140
+ }
141
+ const verb = opts.dryRun ? 'Would remove' : 'Removed';
142
+ for (const entry of doomed)
143
+ process.stdout.write(`${verb} ${entry.id} ${stamp(entry.updatedAt)}\n`);
144
+ const foreign = doomed.filter((e) => !e.managed).length;
145
+ process.stdout.write(`${verb.toLowerCase()} ${doomed.length} session(s)` +
146
+ (foreign > 0 ? `, ${foreign} of them saved from the TUI (--all was given).` : '.') +
147
+ '\n');
148
+ return 0;
149
+ }
150
+ function parseCount(raw) {
151
+ if (raw === undefined)
152
+ return null;
153
+ if (!/^\d+$/.test(raw.trim()))
154
+ return null;
155
+ return Number.parseInt(raw.trim(), 10);
156
+ }
157
+ /** ISO-8601 without the fractional seconds. ASCII, sortable, unambiguous. */
158
+ function stamp(ms) {
159
+ if (!ms)
160
+ return '-';
161
+ return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
162
+ }
@@ -8,6 +8,34 @@
8
8
  import type { Message, ModelRef } from '@aragon-agent/core';
9
9
  import type { Entry } from '../agent/reducer.js';
10
10
  import type { TodoItem } from '../todo/types.js';
11
+ /**
12
+ * Bookkeeping `aragon exec` attaches to the sessions it owns
13
+ * (cli-integration-surface §5.2).
14
+ *
15
+ * ITS PRESENCE IS ALSO A MARKER, AND THAT IS LOAD-BEARING (P1-5 / R-16). This
16
+ * directory is SHARED with the TUI's `/save`: `resolveSessionPath` above puts
17
+ * every bare-name and default `/save` target in `getSessionsDir()`. So
18
+ * `aragon sessions list` / `prune` operate ONLY on files carrying `meta.id`,
19
+ * which is what stops a maintenance command destroying conversations a human
20
+ * saved by hand. Anything that starts writing `meta` from the TUI path must
21
+ * revisit that scoping first.
22
+ */
23
+ export interface SessionMeta {
24
+ id: string;
25
+ cwd: string;
26
+ createdAt: number;
27
+ updatedAt: number;
28
+ /** Cumulative across invocations, not per run. */
29
+ turns: number;
30
+ /** The CLI version that last wrote this file. */
31
+ cli: string;
32
+ provider: string;
33
+ model: string;
34
+ usage: {
35
+ inputTokens: number;
36
+ outputTokens: number;
37
+ };
38
+ }
11
39
  export interface SavedSession {
12
40
  version: number;
13
41
  savedAt: number;
@@ -23,6 +51,16 @@ export interface SavedSession {
23
51
  * right answer for a session that predates the feature (D-20 / D-22).
24
52
  */
25
53
  todos?: TodoItem[];
54
+ /**
55
+ * Written only by `aragon exec` (cli-integration-surface §5.2 / D-10).
56
+ *
57
+ * `SESSION_VERSION` STAYS 1 for the reason `todos?` records above, and this is
58
+ * the precedent that file's own comments already set: the field is additive
59
+ * and optional, and `loadSession` validates only array-ness of `messages` /
60
+ * `entries`. A TUI `/save` file simply has no `meta`, which is exactly how
61
+ * `sessions list` tells the two apart.
62
+ */
63
+ meta?: SessionMeta;
26
64
  }
27
65
  /** Resolve a `/save` target: absolute/relative path, or a name in the data dir. */
28
66
  export declare function resolveSessionPath(nameOrPath: string | undefined, cwd: string): string;
@@ -31,12 +69,20 @@ export declare function resolveSessionPath(nameOrPath: string | undefined, cwd:
31
69
  * the writer below builds its payload from THIS parameter, not from the type, so
32
70
  * a `todos?` field on the interface with no field here would ship a key nothing
33
71
  * ever writes.
72
+ *
73
+ * `meta` JOINS ON EXACTLY THOSE TERMS (cli-integration-surface P2-6). Adding it
74
+ * to `SavedSession` alone would type-check, `aragon exec` would appear to write
75
+ * sessions, and `sessions list` would report none of them — because the marker
76
+ * that identifies an exec-managed file would never be on disk. It is SPREAD
77
+ * conditionally so a `/save` from the TUI, which passes no `meta`, produces a
78
+ * payload with no such key and a file byte-identical to today's.
34
79
  */
35
80
  export declare function saveSession(filePath: string, data: {
36
81
  model: ModelRef;
37
82
  messages: Message[];
38
83
  entries: Entry[];
39
84
  todos: TodoItem[];
85
+ meta?: SessionMeta;
40
86
  }): void;
41
87
  /**
42
88
  * Settle any team entry that was saved mid-dispatch (team-subagents §5.3 / P1-5),
@@ -64,6 +64,13 @@ function stripLiveToolOutput(entries) {
64
64
  * the writer below builds its payload from THIS parameter, not from the type, so
65
65
  * a `todos?` field on the interface with no field here would ship a key nothing
66
66
  * ever writes.
67
+ *
68
+ * `meta` JOINS ON EXACTLY THOSE TERMS (cli-integration-surface P2-6). Adding it
69
+ * to `SavedSession` alone would type-check, `aragon exec` would appear to write
70
+ * sessions, and `sessions list` would report none of them — because the marker
71
+ * that identifies an exec-managed file would never be on disk. It is SPREAD
72
+ * conditionally so a `/save` from the TUI, which passes no `meta`, produces a
73
+ * payload with no such key and a file byte-identical to today's.
67
74
  */
68
75
  export function saveSession(filePath, data) {
69
76
  const dir = dirname(filePath);
@@ -76,6 +83,7 @@ export function saveSession(filePath, data) {
76
83
  messages: data.messages,
77
84
  entries: stripLiveToolOutput(data.entries),
78
85
  todos: data.todos,
86
+ ...(data.meta ? { meta: data.meta } : {}),
79
87
  };
80
88
  writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8');
81
89
  }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The id-keyed session store behind `aragon exec` (cli-integration-surface
3
+ * section 3.4 / 4.3).
4
+ *
5
+ * ASCII ONLY - `src/session/**` is inside the glyph scanner's scope from this
6
+ * feature onward (R-3 / AC-25). `session/` was outside the predicate BEFORE this
7
+ * change too, so adding it is closing a pre-existing hole rather than extending
8
+ * a guard to new code.
9
+ *
10
+ * `persist.ts` KEEPS THE FILE FORMAT; this module owns identity, listing,
11
+ * retention and mutual exclusion. The two exist separately because the format is
12
+ * shared with the TUI's `/save` and must not acquire exec-shaped opinions.
13
+ *
14
+ * THE DIRECTORY IS SHARED WITH `/save`, AND EVERY LISTING RULE HERE FOLLOWS FROM
15
+ * THAT (P1-5 / R-16). `resolveSessionPath` puts every bare-name and default
16
+ * `/save` target in `getSessionsDir()`, so a naive `prune` would delete
17
+ * conversations a human saved by hand - unrecoverable loss, from a maintenance
18
+ * command the docs recommend. `list` and `prune` therefore see only files
19
+ * carrying `meta.id`; `show` and `rm` accept any id, because the user named it.
20
+ */
21
+ import { type SavedSession, type SessionMeta } from './persist.js';
22
+ /**
23
+ * The id is a FILENAME under the user's home directory, and the caller supplying
24
+ * it is frequently the least trusted input in the pipeline (a branch name, a
25
+ * ticket id, a webhook field). Rejecting `/`, `\`, `..` and a leading dot is a
26
+ * SECURITY REQUIREMENT, not tidiness (R-7 / AC-8).
27
+ *
28
+ * A leading dot is excluded by the first character class rather than by a
29
+ * separate check, which is also what keeps `.last.json` unreachable as an id.
30
+ */
31
+ export declare const SESSION_ID_RE: RegExp;
32
+ export declare function isValidSessionId(id: string): boolean;
33
+ /**
34
+ * 10 minutes, matching `UPDATE_LIMITS.lockTtlMs`.
35
+ *
36
+ * The TTL is the WORST CASE for a wedge, so a long one is not the safe choice:
37
+ * without the liveness probe below, a CI job killed mid-run would hold its
38
+ * `--session-id` for the whole window and every retry would return
39
+ * `session_busy` - a hang wearing an exit code, in the one environment this
40
+ * feature exists for.
41
+ */
42
+ export declare const SESSION_LOCK_TTL_MS = 600000;
43
+ export interface SessionListEntry {
44
+ id: string;
45
+ path: string;
46
+ savedAt: number;
47
+ updatedAt: number;
48
+ cwd: string;
49
+ turns: number;
50
+ provider: string;
51
+ model: string;
52
+ /** `true` when the file carries `meta.id`, i.e. `aragon exec` wrote it. */
53
+ managed: boolean;
54
+ }
55
+ export declare function sessionsDir(): string;
56
+ export declare function sessionPathFor(id: string): string;
57
+ /** Read one file, or `null` for anything unreadable. NEVER THROWS. */
58
+ export declare function readSessionFile(path: string): SavedSession | null;
59
+ /**
60
+ * Resolve `--resume <id|path>` to a file that exists, or `null`.
61
+ *
62
+ * A value containing a separator, or an absolute one, is a PATH; anything else
63
+ * is an id. That split is the same one `resolveSessionPath` makes for `/save`,
64
+ * so the two spellings agree about what "a name" means.
65
+ */
66
+ export declare function resolveSessionRef(idOrPath: string, cwd: string): string | null;
67
+ export interface WriteSessionInput {
68
+ id: string;
69
+ session: Omit<SavedSession, 'version' | 'savedAt' | 'meta'>;
70
+ meta: SessionMeta;
71
+ }
72
+ /**
73
+ * Write a session and refresh the `--continue` pointer.
74
+ *
75
+ * THE POINTER IS UPDATED AFTER THE FILE, never before: a crash between the two
76
+ * leaves a pointer naming a file that is one run stale, which the fallback scan
77
+ * corrects. The other order would leave a pointer naming a file that does not
78
+ * exist yet.
79
+ */
80
+ export declare function writeSession(input: WriteSessionInput): string;
81
+ export declare function removeSession(id: string): boolean;
82
+ /**
83
+ * Every session in the directory, newest first.
84
+ *
85
+ * `all: false` (the default) keeps ONLY exec-managed files. `.last.json` and
86
+ * `*.lock` are excluded by name on both paths, because neither is a session in
87
+ * any sense and listing them would invite `sessions rm` on the pointer.
88
+ */
89
+ export declare function listSessions(opts?: {
90
+ all?: boolean;
91
+ }): SessionListEntry[];
92
+ export interface PruneOptions {
93
+ olderThanDays?: number;
94
+ dryRun?: boolean;
95
+ all?: boolean;
96
+ now?: () => number;
97
+ }
98
+ /** `--dry-run` reports and deletes nothing; nothing is ever pruned automatically. */
99
+ export declare function pruneSessions(opts?: PruneOptions): SessionListEntry[];
100
+ /**
101
+ * The newest exec session whose `meta.cwd` matches, or `null`.
102
+ *
103
+ * THE POINTER IS A CACHE AND IS TREATED AS ONE (R-9 / AC-9). If it is missing,
104
+ * unparseable, or names a file that is gone, this falls through to reading the
105
+ * directory - so a desync SELF-HEALS instead of stranding the user, which is the
106
+ * property that makes it safe to keep a denormalised index at all.
107
+ */
108
+ export declare function latestSessionForCwd(cwd: string): SessionListEntry | null;
109
+ export interface SessionLockHandle {
110
+ path: string;
111
+ release(): void;
112
+ }
113
+ export interface SessionLockOptions {
114
+ now?: () => number;
115
+ /** Injected for tests; defaults to `process.kill(pid, 0)` liveness probing. */
116
+ isProcessAlive?: (pid: number) => boolean;
117
+ }
118
+ /**
119
+ * Take the lock for one `--session-id`, or report that someone else has it.
120
+ * NEVER SLEEPS, NEVER THROWS (D-11).
121
+ *
122
+ * ONE ATTEMPT, NO RETRY. A second process exits 2 with `session_busy` rather
123
+ * than waiting, because a wrapper that wants a queue can build one and a CLI
124
+ * that blocks for an unbounded time on a lock it cannot show the user is a hang.
125
+ * `skills/lock.ts::acquireRootLock` busy-waits through `Atomics.wait` and
126
+ * throws, which is right for a human who typed `aragon skills install` and wrong
127
+ * for a background caller; `update/install-lock.ts` already made that split.
128
+ */
129
+ export declare function tryAcquireSessionLock(id: string, opts?: SessionLockOptions): SessionLockHandle | null;