@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,385 @@
|
|
|
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 { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync, writeSync, } from 'node:fs';
|
|
22
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
23
|
+
import { hostname } from 'node:os';
|
|
24
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
25
|
+
import process from 'node:process';
|
|
26
|
+
import { getSessionsDir } from '../config/app-paths.js';
|
|
27
|
+
import { loadSession, saveSession } from './persist.js';
|
|
28
|
+
/**
|
|
29
|
+
* The id is a FILENAME under the user's home directory, and the caller supplying
|
|
30
|
+
* it is frequently the least trusted input in the pipeline (a branch name, a
|
|
31
|
+
* ticket id, a webhook field). Rejecting `/`, `\`, `..` and a leading dot is a
|
|
32
|
+
* SECURITY REQUIREMENT, not tidiness (R-7 / AC-8).
|
|
33
|
+
*
|
|
34
|
+
* A leading dot is excluded by the first character class rather than by a
|
|
35
|
+
* separate check, which is also what keeps `.last.json` unreachable as an id.
|
|
36
|
+
*/
|
|
37
|
+
export const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
38
|
+
export function isValidSessionId(id) {
|
|
39
|
+
return SESSION_ID_RE.test(id);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 10 minutes, matching `UPDATE_LIMITS.lockTtlMs`.
|
|
43
|
+
*
|
|
44
|
+
* The TTL is the WORST CASE for a wedge, so a long one is not the safe choice:
|
|
45
|
+
* without the liveness probe below, a CI job killed mid-run would hold its
|
|
46
|
+
* `--session-id` for the whole window and every retry would return
|
|
47
|
+
* `session_busy` - a hang wearing an exit code, in the one environment this
|
|
48
|
+
* feature exists for.
|
|
49
|
+
*/
|
|
50
|
+
export const SESSION_LOCK_TTL_MS = 600_000;
|
|
51
|
+
/** The `--continue` pointer. A CACHE, with a documented scan fallback (R-9). */
|
|
52
|
+
const POINTER_FILE = '.last.json';
|
|
53
|
+
export function sessionsDir() {
|
|
54
|
+
return getSessionsDir();
|
|
55
|
+
}
|
|
56
|
+
export function sessionPathFor(id) {
|
|
57
|
+
return join(getSessionsDir(), `${id}.json`);
|
|
58
|
+
}
|
|
59
|
+
function ensureDir() {
|
|
60
|
+
const dir = getSessionsDir();
|
|
61
|
+
if (!existsSync(dir))
|
|
62
|
+
mkdirSync(dir, { recursive: true });
|
|
63
|
+
return dir;
|
|
64
|
+
}
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Read / write
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
/** Read one file, or `null` for anything unreadable. NEVER THROWS. */
|
|
69
|
+
export function readSessionFile(path) {
|
|
70
|
+
try {
|
|
71
|
+
return loadSession(path);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Resolve `--resume <id|path>` to a file that exists, or `null`.
|
|
79
|
+
*
|
|
80
|
+
* A value containing a separator, or an absolute one, is a PATH; anything else
|
|
81
|
+
* is an id. That split is the same one `resolveSessionPath` makes for `/save`,
|
|
82
|
+
* so the two spellings agree about what "a name" means.
|
|
83
|
+
*/
|
|
84
|
+
export function resolveSessionRef(idOrPath, cwd) {
|
|
85
|
+
const value = idOrPath.trim();
|
|
86
|
+
if (value.length === 0)
|
|
87
|
+
return null;
|
|
88
|
+
const looksLikePath = isAbsolute(value) || value.includes('/') || value.includes('\\') || value.endsWith('.json');
|
|
89
|
+
const path = looksLikePath
|
|
90
|
+
? resolve(cwd, value)
|
|
91
|
+
: isValidSessionId(value)
|
|
92
|
+
? sessionPathFor(value)
|
|
93
|
+
: null;
|
|
94
|
+
if (!path)
|
|
95
|
+
return null;
|
|
96
|
+
return existsSync(path) ? path : null;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Write a session and refresh the `--continue` pointer.
|
|
100
|
+
*
|
|
101
|
+
* THE POINTER IS UPDATED AFTER THE FILE, never before: a crash between the two
|
|
102
|
+
* leaves a pointer naming a file that is one run stale, which the fallback scan
|
|
103
|
+
* corrects. The other order would leave a pointer naming a file that does not
|
|
104
|
+
* exist yet.
|
|
105
|
+
*/
|
|
106
|
+
export function writeSession(input) {
|
|
107
|
+
ensureDir();
|
|
108
|
+
const path = sessionPathFor(input.id);
|
|
109
|
+
saveSession(path, {
|
|
110
|
+
model: input.session.model,
|
|
111
|
+
messages: input.session.messages,
|
|
112
|
+
entries: input.session.entries,
|
|
113
|
+
todos: input.session.todos ?? [],
|
|
114
|
+
meta: input.meta,
|
|
115
|
+
});
|
|
116
|
+
writePointer(input.meta.cwd, input.id, input.meta.updatedAt);
|
|
117
|
+
return path;
|
|
118
|
+
}
|
|
119
|
+
export function removeSession(id) {
|
|
120
|
+
const path = sessionPathFor(id);
|
|
121
|
+
try {
|
|
122
|
+
unlinkSync(path);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Listing
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
function describe(path, session) {
|
|
133
|
+
const meta = session.meta;
|
|
134
|
+
const id = meta?.id ?? basenameId(path);
|
|
135
|
+
return {
|
|
136
|
+
id,
|
|
137
|
+
path,
|
|
138
|
+
savedAt: session.savedAt ?? 0,
|
|
139
|
+
updatedAt: meta?.updatedAt ?? session.savedAt ?? 0,
|
|
140
|
+
cwd: meta?.cwd ?? '',
|
|
141
|
+
turns: meta?.turns ?? 0,
|
|
142
|
+
provider: meta?.provider ?? session.model?.providerId ?? '',
|
|
143
|
+
model: meta?.model ?? session.model?.modelId ?? '',
|
|
144
|
+
managed: typeof meta?.id === 'string' && meta.id.length > 0,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function basenameId(path) {
|
|
148
|
+
const name = path.replace(/\\/g, '/').split('/').pop() ?? path;
|
|
149
|
+
return name.endsWith('.json') ? name.slice(0, -'.json'.length) : name;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Every session in the directory, newest first.
|
|
153
|
+
*
|
|
154
|
+
* `all: false` (the default) keeps ONLY exec-managed files. `.last.json` and
|
|
155
|
+
* `*.lock` are excluded by name on both paths, because neither is a session in
|
|
156
|
+
* any sense and listing them would invite `sessions rm` on the pointer.
|
|
157
|
+
*/
|
|
158
|
+
export function listSessions(opts = {}) {
|
|
159
|
+
const dir = getSessionsDir();
|
|
160
|
+
if (!existsSync(dir))
|
|
161
|
+
return [];
|
|
162
|
+
const out = [];
|
|
163
|
+
for (const name of safeReaddir(dir)) {
|
|
164
|
+
if (name === POINTER_FILE || name.endsWith('.lock') || !name.endsWith('.json'))
|
|
165
|
+
continue;
|
|
166
|
+
const path = join(dir, name);
|
|
167
|
+
const session = readSessionFile(path);
|
|
168
|
+
if (!session)
|
|
169
|
+
continue;
|
|
170
|
+
const entry = describe(path, session);
|
|
171
|
+
if (!opts.all && !entry.managed)
|
|
172
|
+
continue;
|
|
173
|
+
out.push(entry);
|
|
174
|
+
}
|
|
175
|
+
return out.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
176
|
+
}
|
|
177
|
+
function safeReaddir(dir) {
|
|
178
|
+
try {
|
|
179
|
+
return readdirSync(dir);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** `--dry-run` reports and deletes nothing; nothing is ever pruned automatically. */
|
|
186
|
+
export function pruneSessions(opts = {}) {
|
|
187
|
+
const now = (opts.now ?? Date.now)();
|
|
188
|
+
const cutoff = opts.olderThanDays === undefined ? now : now - opts.olderThanDays * 24 * 60 * 60 * 1000;
|
|
189
|
+
const doomed = listSessions({ ...(opts.all ? { all: true } : {}) }).filter((entry) => entry.updatedAt <= cutoff);
|
|
190
|
+
if (opts.dryRun)
|
|
191
|
+
return doomed;
|
|
192
|
+
for (const entry of doomed) {
|
|
193
|
+
try {
|
|
194
|
+
unlinkSync(entry.path);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// A file that vanished under us, or one the OS has pinned. Neither is a
|
|
198
|
+
// reason to abandon the rest of the sweep.
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return doomed;
|
|
202
|
+
}
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// `--continue`: the pointer, and the scan that makes it safe to keep
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
function cwdKey(cwd) {
|
|
207
|
+
return createHash('sha256').update(resolve(cwd)).digest('hex').slice(0, 16);
|
|
208
|
+
}
|
|
209
|
+
function readPointer() {
|
|
210
|
+
try {
|
|
211
|
+
const parsed = JSON.parse(readFileSync(join(getSessionsDir(), POINTER_FILE), 'utf-8'));
|
|
212
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return {};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function writePointer(cwd, id, updatedAt) {
|
|
219
|
+
try {
|
|
220
|
+
const pointer = readPointer();
|
|
221
|
+
pointer[cwdKey(cwd)] = { id, updatedAt };
|
|
222
|
+
// Not atomic, and it does not need to be: a torn write yields unparseable
|
|
223
|
+
// JSON, `readPointer` returns `{}`, and `latestSessionForCwd` falls back to
|
|
224
|
+
// the directory scan. The failure mode is one slower `--continue`.
|
|
225
|
+
writeFileSync(join(getSessionsDir(), POINTER_FILE), `${JSON.stringify(pointer)}\n`, 'utf-8');
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// The pointer is a cache. Failing to write it must never fail a run whose
|
|
229
|
+
// session file is already safely on disk.
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The newest exec session whose `meta.cwd` matches, or `null`.
|
|
234
|
+
*
|
|
235
|
+
* THE POINTER IS A CACHE AND IS TREATED AS ONE (R-9 / AC-9). If it is missing,
|
|
236
|
+
* unparseable, or names a file that is gone, this falls through to reading the
|
|
237
|
+
* directory - so a desync SELF-HEALS instead of stranding the user, which is the
|
|
238
|
+
* property that makes it safe to keep a denormalised index at all.
|
|
239
|
+
*/
|
|
240
|
+
export function latestSessionForCwd(cwd) {
|
|
241
|
+
const hinted = readPointer()[cwdKey(cwd)];
|
|
242
|
+
if (hinted && isValidSessionId(hinted.id)) {
|
|
243
|
+
const path = sessionPathFor(hinted.id);
|
|
244
|
+
const session = existsSync(path) ? readSessionFile(path) : null;
|
|
245
|
+
if (session?.meta && sameCwd(session.meta.cwd, cwd))
|
|
246
|
+
return describe(path, session);
|
|
247
|
+
}
|
|
248
|
+
const target = resolve(cwd);
|
|
249
|
+
for (const entry of listSessions()) {
|
|
250
|
+
if (sameCwd(entry.cwd, target))
|
|
251
|
+
return entry;
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
function sameCwd(a, b) {
|
|
256
|
+
if (!a || !b)
|
|
257
|
+
return false;
|
|
258
|
+
const norm = (p) => resolve(p).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
259
|
+
const left = norm(a);
|
|
260
|
+
const right = norm(b);
|
|
261
|
+
// Case-insensitive on Windows only: two paths differing in case are the same
|
|
262
|
+
// directory there and two different directories on Linux.
|
|
263
|
+
return process.platform === 'win32'
|
|
264
|
+
? left.toLowerCase() === right.toLowerCase()
|
|
265
|
+
: left === right;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* `ESRCH` means gone. `EPERM` means the process EXISTS but belongs to another
|
|
269
|
+
* user - treating that as dead would let one user steal another's lock, so it
|
|
270
|
+
* reports ALIVE. Verbatim from `update/install-lock.ts`, which took it from
|
|
271
|
+
* `skills/lock.ts`.
|
|
272
|
+
*/
|
|
273
|
+
function defaultIsProcessAlive(pid) {
|
|
274
|
+
try {
|
|
275
|
+
process.kill(pid, 0);
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
return err.code !== 'ESRCH';
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function readLock(path) {
|
|
283
|
+
try {
|
|
284
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
285
|
+
if (!parsed || typeof parsed !== 'object')
|
|
286
|
+
return null;
|
|
287
|
+
if (typeof parsed.pid !== 'number' || typeof parsed.startedAt !== 'number')
|
|
288
|
+
return null;
|
|
289
|
+
if (typeof parsed.uuid !== 'string' || typeof parsed.host !== 'string')
|
|
290
|
+
return null;
|
|
291
|
+
return parsed;
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/** `wx` - atomic exclusive create. THIS SINGLE CALL IS THE MUTUAL EXCLUSION. */
|
|
298
|
+
function tryCreate(path, payload) {
|
|
299
|
+
let fd;
|
|
300
|
+
try {
|
|
301
|
+
fd = openSync(path, 'wx');
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
writeSync(fd, JSON.stringify(payload));
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
// Closed IMMEDIATELY: on Windows an open handle blocks `unlink`, so holding
|
|
311
|
+
// it would make our own release fail and turn every lock into a ten-minute
|
|
312
|
+
// stall for the next process.
|
|
313
|
+
closeSync(fd);
|
|
314
|
+
}
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Take the lock for one `--session-id`, or report that someone else has it.
|
|
319
|
+
* NEVER SLEEPS, NEVER THROWS (D-11).
|
|
320
|
+
*
|
|
321
|
+
* ONE ATTEMPT, NO RETRY. A second process exits 2 with `session_busy` rather
|
|
322
|
+
* than waiting, because a wrapper that wants a queue can build one and a CLI
|
|
323
|
+
* that blocks for an unbounded time on a lock it cannot show the user is a hang.
|
|
324
|
+
* `skills/lock.ts::acquireRootLock` busy-waits through `Atomics.wait` and
|
|
325
|
+
* throws, which is right for a human who typed `aragon skills install` and wrong
|
|
326
|
+
* for a background caller; `update/install-lock.ts` already made that split.
|
|
327
|
+
*/
|
|
328
|
+
export function tryAcquireSessionLock(id, opts = {}) {
|
|
329
|
+
const now = opts.now ?? Date.now;
|
|
330
|
+
const isAlive = opts.isProcessAlive ?? defaultIsProcessAlive;
|
|
331
|
+
ensureDir();
|
|
332
|
+
const path = join(getSessionsDir(), `${id}.lock`);
|
|
333
|
+
const payload = {
|
|
334
|
+
pid: process.pid,
|
|
335
|
+
host: hostname(),
|
|
336
|
+
startedAt: now(),
|
|
337
|
+
uuid: randomUUID(),
|
|
338
|
+
};
|
|
339
|
+
if (tryCreate(path, payload))
|
|
340
|
+
return makeHandle(path, payload);
|
|
341
|
+
const holder = readLock(path);
|
|
342
|
+
// An unreadable lock is indistinguishable from one mid-creation (create and
|
|
343
|
+
// write are two syscalls), so it is NOT evidence of abandonment and is left
|
|
344
|
+
// alone. The TTL still collects it eventually.
|
|
345
|
+
if (holder === null)
|
|
346
|
+
return null;
|
|
347
|
+
const stale = now() - holder.startedAt > SESSION_LOCK_TTL_MS ||
|
|
348
|
+
(holder.host === payload.host && !isAlive(holder.pid));
|
|
349
|
+
if (!stale)
|
|
350
|
+
return null;
|
|
351
|
+
try {
|
|
352
|
+
unlinkSync(path);
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
// Another process won the race to clean it up. Not ours this tick.
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
return tryCreate(path, payload) ? makeHandle(path, payload) : null;
|
|
359
|
+
}
|
|
360
|
+
function makeHandle(path, payload) {
|
|
361
|
+
let released = false;
|
|
362
|
+
return {
|
|
363
|
+
path,
|
|
364
|
+
release() {
|
|
365
|
+
if (released)
|
|
366
|
+
return;
|
|
367
|
+
released = true;
|
|
368
|
+
try {
|
|
369
|
+
const holder = readLock(path);
|
|
370
|
+
// THE UUID CHECK IS NOT DEFENSIVE PADDING. If this process was judged
|
|
371
|
+
// stale and preempted, an unconditional `unlink` here would delete the
|
|
372
|
+
// NEW holder's lock and open a second concurrency window - the classic
|
|
373
|
+
// file-lock bug, and one that only shows up under exactly the contention
|
|
374
|
+
// the lock exists to handle.
|
|
375
|
+
if (holder?.uuid !== payload.uuid)
|
|
376
|
+
return;
|
|
377
|
+
unlinkSync(path);
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
// A failed release costs at most one TTL of waiting for the next
|
|
381
|
+
// process. Letting it throw would end a SUCCESSFUL run in an error.
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
}
|
package/dist/team/runtime.d.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import type { ProviderRegistry, SkillRegistry, ToolPolicyDecision, ToolPolicyVerdict } from '@aragon-agent/core';
|
|
21
21
|
import type { AgentMode } from '../agent/agent-mode.js';
|
|
22
22
|
import type { CliConfig } from '../config/schema.js';
|
|
23
|
+
import type { ToolPermission } from '../exec/permission.js';
|
|
23
24
|
import type { TeamHumanQueue } from './human-queue.js';
|
|
24
25
|
import { type SubagentAgentFactory, type SubagentDeps } from './subagent.js';
|
|
25
26
|
import type { DispatchOutcome, SubagentSpec, TeamEventListener, TeamSnapshot } from './types.js';
|
|
@@ -33,6 +34,17 @@ export interface TeamRuntimeDeps {
|
|
|
33
34
|
skillRegistry?: SkillRegistry;
|
|
34
35
|
alwaysBlock?: () => string;
|
|
35
36
|
toolPolicy?: () => ToolPolicyDecision;
|
|
37
|
+
/**
|
|
38
|
+
* The lead's `aragon exec` tool policy, carried BESIDE `toolPolicy` and never
|
|
39
|
+
* folded into it (cli-integration-surface §4.2 invariant 3 / R-4).
|
|
40
|
+
*
|
|
41
|
+
* They answer different questions: `toolPolicy` is the skills CEILING, which
|
|
42
|
+
* refuses at call time and changes per turn; this REMOVES the tool from the
|
|
43
|
+
* child's array. A child that inherits one and not the other is a policy that
|
|
44
|
+
* holds for the lead and not for the fan-out, which is the security control
|
|
45
|
+
* silently not holding (AC-12).
|
|
46
|
+
*/
|
|
47
|
+
permission?: ToolPermission;
|
|
36
48
|
onChildToolPolicyEvent?: (e: {
|
|
37
49
|
label: string;
|
|
38
50
|
tool: string;
|
package/dist/team/runtime.js
CHANGED
|
@@ -119,6 +119,12 @@ export class TeamRuntime {
|
|
|
119
119
|
...(this.deps.skillRegistry ? { skillRegistry: this.deps.skillRegistry } : {}),
|
|
120
120
|
...(this.deps.alwaysBlock ? { alwaysBlock: this.deps.alwaysBlock() } : {}),
|
|
121
121
|
...(this.deps.toolPolicy ? { toolPolicy: this.deps.toolPolicy } : {}),
|
|
122
|
+
// Site two of two for the policy. Declaring it on `TeamRuntimeDeps` and
|
|
123
|
+
// forgetting this line type-checks and forwards nothing — the
|
|
124
|
+
// `recordChange` / `recordOutput` failure mode `tools/index.ts` records in
|
|
125
|
+
// its own words, here with a security consequence rather than a cosmetic
|
|
126
|
+
// one (R-5).
|
|
127
|
+
...(this.deps.permission ? { permission: this.deps.permission } : {}),
|
|
122
128
|
...(this.deps.onChildToolPolicyEvent
|
|
123
129
|
? { onChildToolPolicyEvent: this.deps.onChildToolPolicyEvent }
|
|
124
130
|
: {}),
|
package/dist/team/subagent.d.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { type AgentConfig, type AgentEvent, type AgentTool, type ModelRef, type ProviderRegistry, type SkillRegistry, type ThinkingLevel, type TokenUsage, type ToolPolicyDecision, type ToolPolicyVerdict } from '@aragon-agent/core';
|
|
14
14
|
import type { AgentMode } from '../agent/agent-mode.js';
|
|
15
15
|
import type { CliConfig } from '../config/schema.js';
|
|
16
|
+
import type { ToolPermission } from '../exec/permission.js';
|
|
16
17
|
import type { FastTierName } from '../fast/types.js';
|
|
17
18
|
import { TeamBus } from './bus.js';
|
|
18
19
|
import type { TeamHumanQueue, WatchdogPausable } from './human-queue.js';
|
|
@@ -51,6 +52,17 @@ export interface SubagentDeps {
|
|
|
51
52
|
/** The lead's always-on skill bodies, spliced into every child's prompt. */
|
|
52
53
|
alwaysBlock?: string;
|
|
53
54
|
toolPolicy?: () => ToolPolicyDecision;
|
|
55
|
+
/**
|
|
56
|
+
* The lead's `aragon exec` tool policy (cli-integration-surface §4.2
|
|
57
|
+
* invariant 3). TIGHTENING ONLY, exactly as `spec.readOnly` is: there is no
|
|
58
|
+
* field anywhere that gives a child more permission than the session it was
|
|
59
|
+
* spawned from.
|
|
60
|
+
*
|
|
61
|
+
* `team_send` / `team_wait` survive it unconditionally — the exemption lives
|
|
62
|
+
* inside `ToolPermission.isAllowed`, not here, so every caller gets it and no
|
|
63
|
+
* second list of the same two names exists (P1-3).
|
|
64
|
+
*/
|
|
65
|
+
permission?: ToolPermission;
|
|
54
66
|
/** Already label-decorating; see `TeamRuntime` (§3.11 / P1-3). */
|
|
55
67
|
onChildToolPolicyEvent?: (e: {
|
|
56
68
|
label: string;
|
package/dist/team/subagent.js
CHANGED
|
@@ -107,6 +107,10 @@ export function buildSubagentTools(spec, deps, getAgent) {
|
|
|
107
107
|
// a mid-dispatch Shift+Tab to PLAN tightens every running child at its next
|
|
108
108
|
// tool call.
|
|
109
109
|
agentMode: () => (spec.readOnly ? 'plan' : deps.getMode()),
|
|
110
|
+
// Site three of three for the policy (`ControllerDeps` -> `TeamRuntimeDeps`
|
|
111
|
+
// -> here). Spread, so a child built without one gets today's options bag
|
|
112
|
+
// byte for byte and `buildSubagentTools`'s existing tests are untouched.
|
|
113
|
+
...(deps.permission ? { permission: deps.permission } : {}),
|
|
110
114
|
...(deps.toolPolicy
|
|
111
115
|
? {
|
|
112
116
|
toolPolicy: deps.toolPolicy,
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { type AgentTool, type ToolPolicyDecision, type ToolPolicyVerdict } from '@aragon-agent/core';
|
|
12
12
|
import type { AgentMode } from '../agent/agent-mode.js';
|
|
13
|
+
import type { ToolPermission } from '../exec/permission.js';
|
|
13
14
|
import type { FilePatch } from './patch.js';
|
|
14
15
|
export interface ConfirmRequest {
|
|
15
16
|
tool: string;
|
|
@@ -163,6 +164,25 @@ export interface BuiltinToolsOptions {
|
|
|
163
164
|
* re-applies the lead's policy to children to avoid.
|
|
164
165
|
*/
|
|
165
166
|
policyExempt?: ReadonlySet<string>;
|
|
167
|
+
/**
|
|
168
|
+
* The non-interactive tool policy of `aragon exec`
|
|
169
|
+
* (cli-integration-surface §4.2). A denied tool is NOT REGISTERED AT ALL, so
|
|
170
|
+
* the model never sees it and never plans around it (D-4).
|
|
171
|
+
*
|
|
172
|
+
* OMITTING IT MUST LEAVE THE ARRAY UNTOUCHED, PROVEN BY IDENTITY (P1-2 /
|
|
173
|
+
* AC-28). Every optional in this bag follows that house rule and `AC-G17`
|
|
174
|
+
* already enforces it for `--no-skills` in those words. An unconditional
|
|
175
|
+
* `tools.filter(...)` would type-check, behave identically, and quietly
|
|
176
|
+
* allocate a new array on every existing path — forfeiting the ability to
|
|
177
|
+
* PROVE that `-p`, the TUI and every subagent are unchanged.
|
|
178
|
+
*
|
|
179
|
+
* IT IS A THIRD MECHANISM, NOT A RENAME OF THE OTHER TWO. `toolPolicy` is the
|
|
180
|
+
* skills ceiling (refuses at call time, per turn); `agentMode` is the plan
|
|
181
|
+
* gate (refuses at call time, per mode); this REMOVES the tool. `policyExempt`
|
|
182
|
+
* belongs to the first of those and is unrelated to the comm-tool exemption
|
|
183
|
+
* `ToolPermission.isAllowed` applies for itself.
|
|
184
|
+
*/
|
|
185
|
+
permission?: ToolPermission;
|
|
166
186
|
}
|
|
167
187
|
/**
|
|
168
188
|
* Tools the ceiling always permits (D-G6). The ONLY place that knows which of
|
package/dist/tools/index.js
CHANGED
|
@@ -252,6 +252,32 @@ export function createBuiltinTools(options) {
|
|
|
252
252
|
if (options.teamTools && options.teamTools.length > 0) {
|
|
253
253
|
tools = [...tools, ...options.teamTools];
|
|
254
254
|
}
|
|
255
|
+
// --- The `aragon exec` permission filter (cli-integration-surface §4.2). ---
|
|
256
|
+
//
|
|
257
|
+
// ITS OWN `if`, AFTER THE LAST APPEND AND BEFORE THE `toolPolicy` WRAPPER, and
|
|
258
|
+
// both tail returns below are left byte-identical (P1-1 / R-6).
|
|
259
|
+
//
|
|
260
|
+
// AFTER THE APPENDS so `skillTools` / `planTools` / `todoTools` / `teamTools`
|
|
261
|
+
// are all subject to it. BEFORE THE WRAPPERS so neither of the two independent
|
|
262
|
+
// guards at the end of this function has to move: the v1 instruction "applied
|
|
263
|
+
// last, after the plan gate" was unimplementable (the function ends in TWO
|
|
264
|
+
// returns, so there is no position after both that is not after the whole
|
|
265
|
+
// function) and reaching for one pushes an implementer into restructuring the
|
|
266
|
+
// exact tail whose comment records the `--no-skills --plan` bug.
|
|
267
|
+
//
|
|
268
|
+
// ORDERING IS A PROPERTY OF WRAPPERS, NOT OF FILTERS. The rule below is about
|
|
269
|
+
// NESTING at call time - which gate runs before which, and therefore who pays
|
|
270
|
+
// for a human wait. A filter removes array elements and has no call-time
|
|
271
|
+
// position at all; every wrapper preserves `.name` through `{...tool}`, so
|
|
272
|
+
// `filter . map === map . filter` here and this placement is chosen for the
|
|
273
|
+
// two structural reasons above rather than for a semantic one.
|
|
274
|
+
//
|
|
275
|
+
// THE GUARD IS WHAT MAKES AC-28 CHECKABLE. Omitting `permission` must leave
|
|
276
|
+
// the tool OBJECTS untouched, provable by identity - the `AC-G17` discipline.
|
|
277
|
+
if (options.permission) {
|
|
278
|
+
const permission = options.permission;
|
|
279
|
+
tools = tools.filter((t) => permission.isAllowed(t.name));
|
|
280
|
+
}
|
|
255
281
|
// No decision provider (`--no-skills`, or a caller that never enabled the
|
|
256
282
|
// policy) means the ceiling adds nothing.
|
|
257
283
|
//
|
|
@@ -41,6 +41,22 @@
|
|
|
41
41
|
import React from 'react';
|
|
42
42
|
import type { Theme } from './theme.js';
|
|
43
43
|
import type { TermCapabilities } from './capabilities.js';
|
|
44
|
+
/**
|
|
45
|
+
* The animated spinner this row draws while it is live, or `null` when it would
|
|
46
|
+
* be STATIC — an ASCII terminal, or a user who asked for stillness.
|
|
47
|
+
*
|
|
48
|
+
* A FACTORY, NOT A COMPONENT, and the distinction is load-bearing. A
|
|
49
|
+
* `<LiveSpinner/>` element whose own render returned `null` would still be
|
|
50
|
+
* truthy at the call site — the exact trap `BottomStatusRow`'s `update` prop
|
|
51
|
+
* documents at length. Callers test the RETURN VALUE and get a real answer.
|
|
52
|
+
*
|
|
53
|
+
* It is exported so `BottomStatusRow` can put the bare glyph beside a toast
|
|
54
|
+
* without importing `ink-spinner` itself: `spinner-census.test.ts` enumerates
|
|
55
|
+
* every file under `src/ui/**` that imports it, and that list is the checklist
|
|
56
|
+
* for "who is allowed to animate". One more importer is one more site a future
|
|
57
|
+
* reader has to reason about; one more caller of this function is not.
|
|
58
|
+
*/
|
|
59
|
+
export declare function liveSpinner(reducedMotion: boolean, caps: TermCapabilities): React.ReactElement | null;
|
|
44
60
|
export interface ActivityLineProps {
|
|
45
61
|
/** Epoch ms the current run began — the phrase sequence's seed. */
|
|
46
62
|
startedAt: number;
|
package/dist/ui/ActivityLine.js
CHANGED
|
@@ -3,6 +3,24 @@ import { Box, Text } from 'ink';
|
|
|
3
3
|
import Spinner from 'ink-spinner';
|
|
4
4
|
import { pickGlyphs } from './glyphs.js';
|
|
5
5
|
import { pickActivityPhrase } from './activity-phrases.js';
|
|
6
|
+
/**
|
|
7
|
+
* The animated spinner this row draws while it is live, or `null` when it would
|
|
8
|
+
* be STATIC — an ASCII terminal, or a user who asked for stillness.
|
|
9
|
+
*
|
|
10
|
+
* A FACTORY, NOT A COMPONENT, and the distinction is load-bearing. A
|
|
11
|
+
* `<LiveSpinner/>` element whose own render returned `null` would still be
|
|
12
|
+
* truthy at the call site — the exact trap `BottomStatusRow`'s `update` prop
|
|
13
|
+
* documents at length. Callers test the RETURN VALUE and get a real answer.
|
|
14
|
+
*
|
|
15
|
+
* It is exported so `BottomStatusRow` can put the bare glyph beside a toast
|
|
16
|
+
* without importing `ink-spinner` itself: `spinner-census.test.ts` enumerates
|
|
17
|
+
* every file under `src/ui/**` that imports it, and that list is the checklist
|
|
18
|
+
* for "who is allowed to animate". One more importer is one more site a future
|
|
19
|
+
* reader has to reason about; one more caller of this function is not.
|
|
20
|
+
*/
|
|
21
|
+
export function liveSpinner(reducedMotion, caps) {
|
|
22
|
+
return caps.unicode && !reducedMotion ? _jsx(Spinner, { type: "dots" }) : null;
|
|
23
|
+
}
|
|
6
24
|
export function ActivityLine({ startedAt, elapsedMs, reducedMotion, runningTool, theme, caps, }) {
|
|
7
25
|
const glyphs = pickGlyphs(caps);
|
|
8
26
|
// PURE AND DERIVED FROM `(startedAt, now)`, so the sequence does not need to
|
|
@@ -17,6 +35,10 @@ export function ActivityLine({ startedAt, elapsedMs, reducedMotion, runningTool,
|
|
|
17
35
|
// The IDENTICAL branch `Transcript.tsx` uses for the assistant role marker, and
|
|
18
36
|
// it must stay identical: braille dots are both an animation AND a Unicode-only
|
|
19
37
|
// glyph, so reduced motion and an ASCII terminal fall back to the same marker.
|
|
20
|
-
|
|
38
|
+
//
|
|
39
|
+
// Through `liveSpinner` rather than inline, so this row and the glyph
|
|
40
|
+
// `BottomStatusRow` composes beside a toast can never disagree about WHEN the
|
|
41
|
+
// spinner is live — the same reason `App` names its mount condition once.
|
|
42
|
+
const spinner = liveSpinner(reducedMotion, caps) ?? glyphs.spinnerStill;
|
|
21
43
|
return (_jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate", color: theme.thinking, children: [' ', spinner, " ", label] }) }));
|
|
22
44
|
}
|
package/dist/ui/App.d.ts
CHANGED
|
@@ -50,6 +50,19 @@ export interface AppProps {
|
|
|
50
50
|
* is off: inline mode, `--no-mouse`, a non-TTY, or a filter that failed to
|
|
51
51
|
* build (mouse-wheel-region-routing §4.2). */
|
|
52
52
|
mouseSource?: MouseSource;
|
|
53
|
+
/**
|
|
54
|
+
* Present ONLY on a console that can deliver neither `CSI Z` nor a mouse
|
|
55
|
+
* report — a Windows console whose Node predates `UV_TTY_MODE_RAW_VT`
|
|
56
|
+
* (`ui/win-vt-input.ts`). Absent everywhere else, which is what makes every
|
|
57
|
+
* unaffected machine byte-identical to a build without this prop.
|
|
58
|
+
*
|
|
59
|
+
* DECIDED BY `cli.tsx`, not here, and by the SAME expression that gates
|
|
60
|
+
* `mouseSource`: the two must never disagree about whether this terminal
|
|
61
|
+
* reports anything.
|
|
62
|
+
*/
|
|
63
|
+
vtInputWarning?: {
|
|
64
|
+
nodeVersion: string;
|
|
65
|
+
};
|
|
53
66
|
/**
|
|
54
67
|
* The auto-updater's late-arrival channel (cli-auto-update §3.8).
|
|
55
68
|
*
|
|
@@ -60,4 +73,4 @@ export interface AppProps {
|
|
|
60
73
|
*/
|
|
61
74
|
updateBridge?: UpdateBridge;
|
|
62
75
|
}
|
|
63
|
-
export declare function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, updateBridge, }: AppProps): React.ReactElement;
|
|
76
|
+
export declare function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, }: AppProps): React.ReactElement;
|