@aragon-agent/cli 0.5.12 → 0.6.2

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 (63) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +322 -6
  3. package/dist/agent/agent-mode.d.ts +39 -0
  4. package/dist/agent/agent-mode.js +36 -0
  5. package/dist/agent/controller.d.ts +32 -0
  6. package/dist/agent/controller.js +25 -0
  7. package/dist/agent/system-prompt.d.ts +16 -0
  8. package/dist/agent/system-prompt.js +5 -0
  9. package/dist/cli.js +143 -2
  10. package/dist/config/ui-state.d.ts +29 -0
  11. package/dist/config/ui-state.js +14 -4
  12. package/dist/diagnostics/cli-commands.d.ts +10 -0
  13. package/dist/diagnostics/cli-commands.js +61 -0
  14. package/dist/diagnostics/doctor.d.ts +29 -0
  15. package/dist/diagnostics/doctor.js +207 -0
  16. package/dist/diagnostics/info.d.ts +30 -0
  17. package/dist/diagnostics/info.js +99 -0
  18. package/dist/exec/cli-commands.d.ts +31 -0
  19. package/dist/exec/cli-commands.js +63 -0
  20. package/dist/exec/emitter.d.ts +80 -0
  21. package/dist/exec/emitter.js +127 -0
  22. package/dist/exec/events.d.ts +226 -0
  23. package/dist/exec/events.js +43 -0
  24. package/dist/exec/index.d.ts +56 -0
  25. package/dist/exec/index.js +439 -0
  26. package/dist/exec/options.d.ts +76 -0
  27. package/dist/exec/options.js +221 -0
  28. package/dist/exec/permission.d.ts +66 -0
  29. package/dist/exec/permission.js +96 -0
  30. package/dist/exec/runner.d.ts +204 -0
  31. package/dist/exec/runner.js +559 -0
  32. package/dist/exec/stdin-stream.d.ts +46 -0
  33. package/dist/exec/stdin-stream.js +144 -0
  34. package/dist/logging/install.d.ts +22 -0
  35. package/dist/logging/install.js +22 -0
  36. package/dist/session/cli-commands.d.ts +30 -0
  37. package/dist/session/cli-commands.js +184 -0
  38. package/dist/session/persist.d.ts +46 -0
  39. package/dist/session/persist.js +8 -0
  40. package/dist/session/store.d.ts +147 -0
  41. package/dist/session/store.js +442 -0
  42. package/dist/team/runtime.d.ts +12 -0
  43. package/dist/team/runtime.js +6 -0
  44. package/dist/team/subagent.d.ts +12 -0
  45. package/dist/team/subagent.js +4 -0
  46. package/dist/tools/index.d.ts +20 -0
  47. package/dist/tools/index.js +26 -0
  48. package/dist/ui/ActivityLine.d.ts +16 -0
  49. package/dist/ui/ActivityLine.js +23 -1
  50. package/dist/ui/App.d.ts +14 -1
  51. package/dist/ui/App.js +61 -7
  52. package/dist/ui/BottomStatusRow.d.ts +33 -1
  53. package/dist/ui/BottomStatusRow.js +20 -6
  54. package/dist/ui/Composer.d.ts +14 -1
  55. package/dist/ui/Composer.js +5 -5
  56. package/dist/ui/overlays/HelpOverlay.js +12 -1
  57. package/dist/ui/use-startup-notices.d.ts +98 -4
  58. package/dist/ui/use-startup-notices.js +108 -2
  59. package/dist/ui/win-vt-force.d.ts +136 -0
  60. package/dist/ui/win-vt-force.js +237 -0
  61. package/dist/ui/win-vt-input.d.ts +57 -0
  62. package/dist/ui/win-vt-input.js +62 -0
  63. package/package.json +7 -3
@@ -0,0 +1,442 @@
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
+ /**
57
+ * THE CHOKE POINT. Every string that becomes a session filename passes here.
58
+ *
59
+ * `isValidSessionId` is applied HERE rather than only at `--session-id`, because
60
+ * that flag is not the only way a caller-supplied string reaches this function:
61
+ * `--resume <id|path>` derives an id from the resumed FILE, `sessions rm` /
62
+ * `sessions show` take one straight from argv, and a session file can carry any
63
+ * `meta.id` at all. Guarding each caller instead would mean the boundary holds
64
+ * only for the callers someone remembered - and `join()` silently normalises
65
+ * `../..` away, so an escape leaves no trace to notice later. Validating at the
66
+ * single point that builds the path is what makes R-7 a property of the module
67
+ * rather than a habit of its callers.
68
+ *
69
+ * THROWS rather than coercing to a "safe" name: a caller who asked for one file
70
+ * and silently got another is a worse failure than a loud one, and every caller
71
+ * that can be handed untrusted input validates first and reports exit 2 itself.
72
+ */
73
+ function assertSessionId(id) {
74
+ if (!isValidSessionId(id)) {
75
+ throw new Error(`Invalid session id "${id}": expected 1-64 characters matching ` +
76
+ '[A-Za-z0-9][A-Za-z0-9._-]* (no "/", "\\", ".." or a leading dot).');
77
+ }
78
+ return id;
79
+ }
80
+ export function sessionPathFor(id) {
81
+ return join(getSessionsDir(), `${assertSessionId(id)}.json`);
82
+ }
83
+ /**
84
+ * The id a RESUMED session writes back under.
85
+ *
86
+ * `--resume` accepts `<id|path>`, so the value the caller typed is NOT usable as
87
+ * an id: `--resume ./sessions/hand-saved.json` would otherwise make the path
88
+ * itself the id, and every later `sessionPathFor` / lock call would build a
89
+ * filename out of it - outside the sessions directory whenever the path was
90
+ * relative. That is the same write primitive `--session-id` is validated to
91
+ * prevent, arriving through the flag next to it. `meta.id` is no safer: it comes
92
+ * out of a file, and files are inputs.
93
+ *
94
+ * The order is the one that keeps every legitimate resume landing where the user
95
+ * expects: an exec-written session names itself, a TUI `/save` is named by its
96
+ * file, and anything else gets a fresh id rather than a guess - reported back in
97
+ * `system.init.sessionId` and `result.sessionId`, so the caller is never left
98
+ * wondering which session it just wrote.
99
+ */
100
+ export function resumedSessionId(path, saved) {
101
+ const declared = saved.meta?.id;
102
+ if (typeof declared === 'string' && isValidSessionId(declared))
103
+ return declared;
104
+ const base = basenameId(path);
105
+ if (isValidSessionId(base))
106
+ return base;
107
+ return randomUUID();
108
+ }
109
+ function ensureDir() {
110
+ const dir = getSessionsDir();
111
+ if (!existsSync(dir))
112
+ mkdirSync(dir, { recursive: true });
113
+ return dir;
114
+ }
115
+ // ---------------------------------------------------------------------------
116
+ // Read / write
117
+ // ---------------------------------------------------------------------------
118
+ /** Read one file, or `null` for anything unreadable. NEVER THROWS. */
119
+ export function readSessionFile(path) {
120
+ try {
121
+ return loadSession(path);
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ /**
128
+ * Resolve `--resume <id|path>` to a file that exists, or `null`.
129
+ *
130
+ * A value containing a separator, or an absolute one, is a PATH; anything else
131
+ * is an id. That split is the same one `resolveSessionPath` makes for `/save`,
132
+ * so the two spellings agree about what "a name" means.
133
+ */
134
+ export function resolveSessionRef(idOrPath, cwd) {
135
+ const value = idOrPath.trim();
136
+ if (value.length === 0)
137
+ return null;
138
+ const looksLikePath = isAbsolute(value) || value.includes('/') || value.includes('\\') || value.endsWith('.json');
139
+ const path = looksLikePath
140
+ ? resolve(cwd, value)
141
+ : isValidSessionId(value)
142
+ ? sessionPathFor(value)
143
+ : null;
144
+ if (!path)
145
+ return null;
146
+ return existsSync(path) ? path : null;
147
+ }
148
+ /**
149
+ * Write a session and refresh the `--continue` pointer.
150
+ *
151
+ * THE POINTER IS UPDATED AFTER THE FILE, never before: a crash between the two
152
+ * leaves a pointer naming a file that is one run stale, which the fallback scan
153
+ * corrects. The other order would leave a pointer naming a file that does not
154
+ * exist yet.
155
+ */
156
+ export function writeSession(input) {
157
+ ensureDir();
158
+ const path = sessionPathFor(input.id);
159
+ saveSession(path, {
160
+ model: input.session.model,
161
+ messages: input.session.messages,
162
+ entries: input.session.entries,
163
+ todos: input.session.todos ?? [],
164
+ meta: input.meta,
165
+ });
166
+ writePointer(input.meta.cwd, input.id, input.meta.updatedAt);
167
+ return path;
168
+ }
169
+ export function removeSession(id) {
170
+ // An id that cannot name a session in this directory has not "failed to be
171
+ // deleted" - there was never a file of that name to delete. `sessions rm`
172
+ // validates first and reports the reason, so this stays a boolean.
173
+ if (!isValidSessionId(id))
174
+ return false;
175
+ const path = sessionPathFor(id);
176
+ try {
177
+ unlinkSync(path);
178
+ return true;
179
+ }
180
+ catch {
181
+ return false;
182
+ }
183
+ }
184
+ // ---------------------------------------------------------------------------
185
+ // Listing
186
+ // ---------------------------------------------------------------------------
187
+ function describe(path, session) {
188
+ const meta = session.meta;
189
+ const id = meta?.id ?? basenameId(path);
190
+ return {
191
+ id,
192
+ path,
193
+ savedAt: session.savedAt ?? 0,
194
+ updatedAt: meta?.updatedAt ?? session.savedAt ?? 0,
195
+ cwd: meta?.cwd ?? '',
196
+ turns: meta?.turns ?? 0,
197
+ provider: meta?.provider ?? session.model?.providerId ?? '',
198
+ model: meta?.model ?? session.model?.modelId ?? '',
199
+ managed: typeof meta?.id === 'string' && meta.id.length > 0,
200
+ };
201
+ }
202
+ function basenameId(path) {
203
+ const name = path.replace(/\\/g, '/').split('/').pop() ?? path;
204
+ return name.endsWith('.json') ? name.slice(0, -'.json'.length) : name;
205
+ }
206
+ /**
207
+ * Every session in the directory, newest first.
208
+ *
209
+ * `all: false` (the default) keeps ONLY exec-managed files. `.last.json` and
210
+ * `*.lock` are excluded by name on both paths, because neither is a session in
211
+ * any sense and listing them would invite `sessions rm` on the pointer.
212
+ */
213
+ export function listSessions(opts = {}) {
214
+ const dir = getSessionsDir();
215
+ if (!existsSync(dir))
216
+ return [];
217
+ const out = [];
218
+ for (const name of safeReaddir(dir)) {
219
+ if (name === POINTER_FILE || name.endsWith('.lock') || !name.endsWith('.json'))
220
+ continue;
221
+ const path = join(dir, name);
222
+ const session = readSessionFile(path);
223
+ if (!session)
224
+ continue;
225
+ const entry = describe(path, session);
226
+ if (!opts.all && !entry.managed)
227
+ continue;
228
+ out.push(entry);
229
+ }
230
+ return out.sort((a, b) => b.updatedAt - a.updatedAt);
231
+ }
232
+ function safeReaddir(dir) {
233
+ try {
234
+ return readdirSync(dir);
235
+ }
236
+ catch {
237
+ return [];
238
+ }
239
+ }
240
+ /** `--dry-run` reports and deletes nothing; nothing is ever pruned automatically. */
241
+ export function pruneSessions(opts = {}) {
242
+ const now = (opts.now ?? Date.now)();
243
+ const cutoff = opts.olderThanDays === undefined ? now : now - opts.olderThanDays * 24 * 60 * 60 * 1000;
244
+ const doomed = listSessions({ ...(opts.all ? { all: true } : {}) }).filter((entry) => entry.updatedAt <= cutoff);
245
+ if (opts.dryRun)
246
+ return doomed;
247
+ for (const entry of doomed) {
248
+ try {
249
+ unlinkSync(entry.path);
250
+ }
251
+ catch {
252
+ // A file that vanished under us, or one the OS has pinned. Neither is a
253
+ // reason to abandon the rest of the sweep.
254
+ }
255
+ }
256
+ return doomed;
257
+ }
258
+ // ---------------------------------------------------------------------------
259
+ // `--continue`: the pointer, and the scan that makes it safe to keep
260
+ // ---------------------------------------------------------------------------
261
+ function cwdKey(cwd) {
262
+ return createHash('sha256').update(resolve(cwd)).digest('hex').slice(0, 16);
263
+ }
264
+ function readPointer() {
265
+ try {
266
+ const parsed = JSON.parse(readFileSync(join(getSessionsDir(), POINTER_FILE), 'utf-8'));
267
+ return parsed && typeof parsed === 'object' ? parsed : {};
268
+ }
269
+ catch {
270
+ return {};
271
+ }
272
+ }
273
+ function writePointer(cwd, id, updatedAt) {
274
+ try {
275
+ const pointer = readPointer();
276
+ pointer[cwdKey(cwd)] = { id, updatedAt };
277
+ // Not atomic, and it does not need to be: a torn write yields unparseable
278
+ // JSON, `readPointer` returns `{}`, and `latestSessionForCwd` falls back to
279
+ // the directory scan. The failure mode is one slower `--continue`.
280
+ writeFileSync(join(getSessionsDir(), POINTER_FILE), `${JSON.stringify(pointer)}\n`, 'utf-8');
281
+ }
282
+ catch {
283
+ // The pointer is a cache. Failing to write it must never fail a run whose
284
+ // session file is already safely on disk.
285
+ }
286
+ }
287
+ /**
288
+ * The newest exec session whose `meta.cwd` matches, or `null`.
289
+ *
290
+ * THE POINTER IS A CACHE AND IS TREATED AS ONE (R-9 / AC-9). If it is missing,
291
+ * unparseable, or names a file that is gone, this falls through to reading the
292
+ * directory - so a desync SELF-HEALS instead of stranding the user, which is the
293
+ * property that makes it safe to keep a denormalised index at all.
294
+ */
295
+ export function latestSessionForCwd(cwd) {
296
+ const hinted = readPointer()[cwdKey(cwd)];
297
+ if (hinted && isValidSessionId(hinted.id)) {
298
+ const path = sessionPathFor(hinted.id);
299
+ const session = existsSync(path) ? readSessionFile(path) : null;
300
+ if (session?.meta && sameCwd(session.meta.cwd, cwd))
301
+ return describe(path, session);
302
+ }
303
+ const target = resolve(cwd);
304
+ for (const entry of listSessions()) {
305
+ if (sameCwd(entry.cwd, target))
306
+ return entry;
307
+ }
308
+ return null;
309
+ }
310
+ function sameCwd(a, b) {
311
+ if (!a || !b)
312
+ return false;
313
+ const norm = (p) => resolve(p).replace(/\\/g, '/').replace(/\/+$/, '');
314
+ const left = norm(a);
315
+ const right = norm(b);
316
+ // Case-insensitive on Windows only: two paths differing in case are the same
317
+ // directory there and two different directories on Linux.
318
+ return process.platform === 'win32'
319
+ ? left.toLowerCase() === right.toLowerCase()
320
+ : left === right;
321
+ }
322
+ /**
323
+ * `ESRCH` means gone. `EPERM` means the process EXISTS but belongs to another
324
+ * user - treating that as dead would let one user steal another's lock, so it
325
+ * reports ALIVE. Verbatim from `update/install-lock.ts`, which took it from
326
+ * `skills/lock.ts`.
327
+ */
328
+ function defaultIsProcessAlive(pid) {
329
+ try {
330
+ process.kill(pid, 0);
331
+ return true;
332
+ }
333
+ catch (err) {
334
+ return err.code !== 'ESRCH';
335
+ }
336
+ }
337
+ function readLock(path) {
338
+ try {
339
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
340
+ if (!parsed || typeof parsed !== 'object')
341
+ return null;
342
+ if (typeof parsed.pid !== 'number' || typeof parsed.startedAt !== 'number')
343
+ return null;
344
+ if (typeof parsed.uuid !== 'string' || typeof parsed.host !== 'string')
345
+ return null;
346
+ return parsed;
347
+ }
348
+ catch {
349
+ return null;
350
+ }
351
+ }
352
+ /** `wx` - atomic exclusive create. THIS SINGLE CALL IS THE MUTUAL EXCLUSION. */
353
+ function tryCreate(path, payload) {
354
+ let fd;
355
+ try {
356
+ fd = openSync(path, 'wx');
357
+ }
358
+ catch {
359
+ return false;
360
+ }
361
+ try {
362
+ writeSync(fd, JSON.stringify(payload));
363
+ }
364
+ finally {
365
+ // Closed IMMEDIATELY: on Windows an open handle blocks `unlink`, so holding
366
+ // it would make our own release fail and turn every lock into a ten-minute
367
+ // stall for the next process.
368
+ closeSync(fd);
369
+ }
370
+ return true;
371
+ }
372
+ /**
373
+ * Take the lock for one `--session-id`, or report that someone else has it.
374
+ * NEVER SLEEPS, NEVER THROWS (D-11).
375
+ *
376
+ * ONE ATTEMPT, NO RETRY. A second process exits 2 with `session_busy` rather
377
+ * than waiting, because a wrapper that wants a queue can build one and a CLI
378
+ * that blocks for an unbounded time on a lock it cannot show the user is a hang.
379
+ * `skills/lock.ts::acquireRootLock` busy-waits through `Atomics.wait` and
380
+ * throws, which is right for a human who typed `aragon skills install` and wrong
381
+ * for a background caller; `update/install-lock.ts` already made that split.
382
+ */
383
+ export function tryAcquireSessionLock(id, opts = {}) {
384
+ const now = opts.now ?? Date.now;
385
+ const isAlive = opts.isProcessAlive ?? defaultIsProcessAlive;
386
+ ensureDir();
387
+ // Same boundary as `sessionPathFor` - a lock is a file in this directory too,
388
+ // and an id that may not name a session may not name its lock either.
389
+ const path = join(getSessionsDir(), `${assertSessionId(id)}.lock`);
390
+ const payload = {
391
+ pid: process.pid,
392
+ host: hostname(),
393
+ startedAt: now(),
394
+ uuid: randomUUID(),
395
+ };
396
+ if (tryCreate(path, payload))
397
+ return makeHandle(path, payload);
398
+ const holder = readLock(path);
399
+ // An unreadable lock is indistinguishable from one mid-creation (create and
400
+ // write are two syscalls), so it is NOT evidence of abandonment and is left
401
+ // alone. The TTL still collects it eventually.
402
+ if (holder === null)
403
+ return null;
404
+ const stale = now() - holder.startedAt > SESSION_LOCK_TTL_MS ||
405
+ (holder.host === payload.host && !isAlive(holder.pid));
406
+ if (!stale)
407
+ return null;
408
+ try {
409
+ unlinkSync(path);
410
+ }
411
+ catch {
412
+ // Another process won the race to clean it up. Not ours this tick.
413
+ return null;
414
+ }
415
+ return tryCreate(path, payload) ? makeHandle(path, payload) : null;
416
+ }
417
+ function makeHandle(path, payload) {
418
+ let released = false;
419
+ return {
420
+ path,
421
+ release() {
422
+ if (released)
423
+ return;
424
+ released = true;
425
+ try {
426
+ const holder = readLock(path);
427
+ // THE UUID CHECK IS NOT DEFENSIVE PADDING. If this process was judged
428
+ // stale and preempted, an unconditional `unlink` here would delete the
429
+ // NEW holder's lock and open a second concurrency window - the classic
430
+ // file-lock bug, and one that only shows up under exactly the contention
431
+ // the lock exists to handle.
432
+ if (holder?.uuid !== payload.uuid)
433
+ return;
434
+ unlinkSync(path);
435
+ }
436
+ catch {
437
+ // A failed release costs at most one TTL of waiting for the next
438
+ // process. Letting it throw would end a SUCCESSFUL run in an error.
439
+ }
440
+ },
441
+ };
442
+ }
@@ -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;
@@ -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
  : {}),
@@ -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;
@@ -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,
@@ -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
@@ -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;
@@ -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
- const spinner = caps.unicode && !reducedMotion ? _jsx(Spinner, { type: "dots" }) : glyphs.spinnerStill;
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;