@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.
- package/CHANGELOG.md +100 -0
- package/README.md +301 -6
- package/dist/agent/controller.d.ts +32 -0
- package/dist/agent/controller.js +25 -0
- package/dist/agent/system-prompt.d.ts +16 -0
- package/dist/agent/system-prompt.js +5 -0
- package/dist/cli.js +71 -2
- package/dist/config/ui-state.d.ts +18 -0
- package/dist/config/ui-state.js +10 -0
- package/dist/diagnostics/cli-commands.d.ts +10 -0
- package/dist/diagnostics/cli-commands.js +61 -0
- package/dist/diagnostics/doctor.d.ts +29 -0
- package/dist/diagnostics/doctor.js +167 -0
- package/dist/diagnostics/info.d.ts +30 -0
- package/dist/diagnostics/info.js +99 -0
- package/dist/exec/cli-commands.d.ts +31 -0
- package/dist/exec/cli-commands.js +63 -0
- package/dist/exec/emitter.d.ts +80 -0
- package/dist/exec/emitter.js +127 -0
- package/dist/exec/events.d.ts +226 -0
- package/dist/exec/events.js +43 -0
- package/dist/exec/index.d.ts +56 -0
- package/dist/exec/index.js +435 -0
- package/dist/exec/options.d.ts +76 -0
- package/dist/exec/options.js +221 -0
- package/dist/exec/permission.d.ts +66 -0
- package/dist/exec/permission.js +96 -0
- package/dist/exec/runner.d.ts +204 -0
- package/dist/exec/runner.js +559 -0
- package/dist/exec/stdin-stream.d.ts +46 -0
- package/dist/exec/stdin-stream.js +144 -0
- package/dist/logging/install.d.ts +22 -0
- package/dist/logging/install.js +22 -0
- package/dist/session/cli-commands.d.ts +30 -0
- package/dist/session/cli-commands.js +162 -0
- package/dist/session/persist.d.ts +46 -0
- package/dist/session/persist.js +8 -0
- package/dist/session/store.d.ts +129 -0
- package/dist/session/store.js +385 -0
- package/dist/team/runtime.d.ts +12 -0
- package/dist/team/runtime.js +6 -0
- package/dist/team/subagent.d.ts +12 -0
- package/dist/team/subagent.js +4 -0
- package/dist/tools/index.d.ts +20 -0
- package/dist/tools/index.js +26 -0
- package/dist/ui/ActivityLine.d.ts +16 -0
- package/dist/ui/ActivityLine.js +23 -1
- package/dist/ui/App.d.ts +14 -1
- package/dist/ui/App.js +38 -3
- package/dist/ui/BottomStatusRow.d.ts +33 -1
- package/dist/ui/BottomStatusRow.js +20 -6
- package/dist/ui/use-startup-notices.d.ts +67 -4
- package/dist/ui/use-startup-notices.js +76 -2
- package/dist/ui/win-vt-input.d.ts +57 -0
- package/dist/ui/win-vt-input.js +62 -0
- 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. */
|
package/dist/logging/install.js
CHANGED
|
@@ -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),
|
package/dist/session/persist.js
CHANGED
|
@@ -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;
|