@aragon-agent/cli 0.5.11 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +306 -9
  3. package/dist/agent/controller.d.ts +37 -1
  4. package/dist/agent/controller.js +34 -4
  5. package/dist/agent/reducer.js +14 -4
  6. package/dist/agent/system-prompt.d.ts +16 -0
  7. package/dist/agent/system-prompt.js +5 -0
  8. package/dist/cli.js +71 -2
  9. package/dist/commands/builtins.js +21 -2
  10. package/dist/config/ui-state.d.ts +18 -0
  11. package/dist/config/ui-state.js +10 -0
  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 +167 -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 +435 -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 +162 -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 +129 -0
  41. package/dist/session/store.js +385 -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/todo/store.d.ts +12 -6
  47. package/dist/todo/store.js +12 -6
  48. package/dist/todo/types.d.ts +2 -1
  49. package/dist/tools/index.d.ts +20 -0
  50. package/dist/tools/index.js +26 -0
  51. package/dist/ui/ActivityLine.d.ts +32 -0
  52. package/dist/ui/ActivityLine.js +23 -1
  53. package/dist/ui/App.d.ts +14 -1
  54. package/dist/ui/App.js +70 -6
  55. package/dist/ui/BottomStatusRow.d.ts +33 -1
  56. package/dist/ui/BottomStatusRow.js +20 -6
  57. package/dist/ui/Transcript.js +15 -0
  58. package/dist/ui/entries/FastCard.js +20 -2
  59. package/dist/ui/overlays/HelpOverlay.js +1 -1
  60. package/dist/ui/use-startup-notices.d.ts +67 -4
  61. package/dist/ui/use-startup-notices.js +76 -2
  62. package/dist/ui/win-vt-input.d.ts +57 -0
  63. package/dist/ui/win-vt-input.js +62 -0
  64. 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
+ }
@@ -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,12 +10,18 @@
10
10
  * `TeamRuntime`.
11
11
  *
12
12
  * INVARIANT I-2 — the list is a PROJECTION OF WHAT THE MODEL BELIEVES, and
13
- * nothing that does not tell the model may change it. `/clear` wipes the
14
- * transcript but not the conversation, so it must NOT reach this class;
15
- * `/reset` clears `messages`, so it must. Every write is a FULL REPLACEMENT and
16
- * every clear is a FULL REMOVAL - there is no partial update anywhere, which is
17
- * what makes I-9 ("the panel is never a contradiction of the model") provable
18
- * rather than argued.
13
+ * nothing that does not tell the model may change it — EXCEPT AN EXPLICIT USER
14
+ * INSTRUCTION, which is the rule's standing exception and is marked
15
+ * `TodoClearReason = 'user'`. Two commands come through that door: `/todo clear`
16
+ * and `/clear`. Neither tells the model anything, and that is fine, because what
17
+ * they leave behind is "panel absent, model still remembers" - the direction I-9
18
+ * below calls safe, and one the model's next full-replacement write heals on its
19
+ * own. `/reset` (reason `'reset'`) remains the only clear caused by the belief
20
+ * itself being destroyed, because it clears `messages`.
21
+ *
22
+ * Every write is a FULL REPLACEMENT and every clear is a FULL REMOVAL - there is
23
+ * no partial update anywhere, which is what makes I-9 ("the panel is never a
24
+ * contradiction of the model") provable rather than argued.
19
25
  */
20
26
  import { type NormalizeResult } from './normalize.js';
21
27
  import type { TodoClearReason, TodoEventListener, TodoSnapshot } from './types.js';
@@ -10,12 +10,18 @@
10
10
  * `TeamRuntime`.
11
11
  *
12
12
  * INVARIANT I-2 — the list is a PROJECTION OF WHAT THE MODEL BELIEVES, and
13
- * nothing that does not tell the model may change it. `/clear` wipes the
14
- * transcript but not the conversation, so it must NOT reach this class;
15
- * `/reset` clears `messages`, so it must. Every write is a FULL REPLACEMENT and
16
- * every clear is a FULL REMOVAL - there is no partial update anywhere, which is
17
- * what makes I-9 ("the panel is never a contradiction of the model") provable
18
- * rather than argued.
13
+ * nothing that does not tell the model may change it — EXCEPT AN EXPLICIT USER
14
+ * INSTRUCTION, which is the rule's standing exception and is marked
15
+ * `TodoClearReason = 'user'`. Two commands come through that door: `/todo clear`
16
+ * and `/clear`. Neither tells the model anything, and that is fine, because what
17
+ * they leave behind is "panel absent, model still remembers" - the direction I-9
18
+ * below calls safe, and one the model's next full-replacement write heals on its
19
+ * own. `/reset` (reason `'reset'`) remains the only clear caused by the belief
20
+ * itself being destroyed, because it clears `messages`.
21
+ *
22
+ * Every write is a FULL REPLACEMENT and every clear is a FULL REMOVAL - there is
23
+ * no partial update anywhere, which is what makes I-9 ("the panel is never a
24
+ * contradiction of the model") provable rather than argued.
19
25
  */
20
26
  import { getLogger } from '../logging/logger.js';
21
27
  import { TODO_LIMITS } from './limits.js';
@@ -42,7 +42,8 @@ export interface TodoSnapshot {
42
42
  /**
43
43
  * Why a list went away.
44
44
  *
45
- * `user` - `/todo clear`.
45
+ * `user` - `/todo clear`, or `/clear`. Both are an explicit user instruction,
46
+ * which is I-2's standing exception; neither touches `messages`.
46
47
  * `reset` - `/reset`, or a `/resume` whose file carried no list (§3.13).
47
48
  * `turn` - every item was completed when the next user turn started.
48
49
  * `stale` - unfinished, and unwritten for more than `TODO_LIMITS.staleTurns`
@@ -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
  //
@@ -21,10 +21,42 @@
21
21
  *
22
22
  * What is left is the one thing the status bar cannot say: THAT THE SILENCE IS
23
23
  * WORK. The bar shows state; this row shows life.
24
+ *
25
+ * THIS ROW OWNS THE ONLY ANIMATED SPINNER WHILE IT IS MOUNTED
26
+ * (single-spinner-while-running D-1). Seven other sites can animate — the
27
+ * streaming assistant marker, the tool badge, the team/fast/retry cards, and the
28
+ * two panels' rows — and each was independently right to. Emergently they put up
29
+ * to five braille animations on adjacent rows, each on its own 80 ms timer: one
30
+ * piece of information rendered five times and out of phase, which is the same
31
+ * duplication the paragraph above legislates against for text. So `App` widens
32
+ * the `reducedMotion` it hands those seven to `cfg.reducedMotion || (running &&
33
+ * !overlay)`, and they all fall back to the static glyphs they already had.
34
+ *
35
+ * THIS COMPONENT KEEPS THE RAW CONFIG FLAG, and that asymmetry is deliberate
36
+ * (D-5). It reads the flag TWICE: once for the spinner below, and once for
37
+ * `pickActivityPhrase`'s rotation. Handing it the widened value would freeze the
38
+ * phrase at the first word of every run — a second, unrelated regression, and
39
+ * one nobody would connect to a change about icons.
24
40
  */
25
41
  import React from 'react';
26
42
  import type { Theme } from './theme.js';
27
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;
28
60
  export interface ActivityLineProps {
29
61
  /** Epoch ms the current run began — the phrase sequence's seed. */
30
62
  startedAt: number;