@aragon-agent/cli 0.6.22 → 0.6.23

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.
@@ -634,7 +634,15 @@ export class AgentController {
634
634
  // `dispatchTimeoutMs: 0` (the default) reaches the executor as an
635
635
  // override of `0` = NO TIMER AT ALL: the dispatch lasts as long as its
636
636
  // slowest child, and only an Esc ends it early (main-agent parity).
637
- ...(this.teamRegistered ? { task: config.team.dispatchTimeoutMs } : {}),
637
+ // D-4, THE SECOND ARMING SITE: the runtime arms its own dispatch
638
+ // timer under the same gate, because the executor timeout being
639
+ // cooperative means this override must never be the ONLY ceiling.
640
+ // With the supervisor gate open the ceiling disarms HERE too, so
641
+ // team.overseer can never leave two kill paths alive behind one
642
+ // config. Read once at construction, exactly as before.
643
+ ...(this.teamRegistered
644
+ ? { task: config.team.overseer ? 0 : config.team.dispatchTimeoutMs }
645
+ : {}),
638
646
  },
639
647
  },
640
648
  });
@@ -183,18 +183,20 @@ export function reduceEvent(event, cost, patches) {
183
183
  }
184
184
  /**
185
185
  * Tools that render their own transcript entry, so the generic tool card would
186
- * be a duplicate. EXACTLY ONE MEMBER; read todo-plan-execution §3.8 before
187
- * adding a second.
186
+ * be a duplicate. `task` is the second member (F-8 A'3): every dispatch opens
187
+ * a TeamCard immediately after, so the pair read as TWO dispatches - the first
188
+ * failed, the second succeeded - when there was only ever one. `todo_write`'s
189
+ * reasons (todo-plan-execution §3.8 / D-10) carry over unchanged.
188
190
  *
189
191
  * SUPPRESSING ONLY `tool_call_start` IS SUFFICIENT, AND THAT IS THE WHOLE REASON
190
192
  * THIS IS SAFE (D-10). Every later handler — `toolCallDelta` / `toolCallEnd` /
191
193
  * `toolExecStart` / `toolExecEnd` — resolves its target through
192
194
  * `findToolEntryId`, which returns `undefined` when no entry was created, and
193
- * every one of them already early-returns on that. There is no fifth handler. A
194
- * second suppression rule would be four more places to get the id matching
195
- * wrong, for nothing.
195
+ * every one of them already early-returns on that. There is no fifth handler.
196
+ * Blast radius verified: headless and `exec` writers key on EVENTS, not tool
197
+ * names, and `transcript-text` renders `kind:'team'` on its own.
196
198
  */
197
- const SELF_RENDERING_TOOLS = new Set(['todo_write']);
199
+ const SELF_RENDERING_TOOLS = new Set(['todo_write', 'task']);
198
200
  function reduceStreamEvent(se) {
199
201
  switch (se.type) {
200
202
  case 'thinking_start':
@@ -6,10 +6,10 @@
6
6
  * legitimately long single tool run can never be watchdog-killed.
7
7
  */
8
8
  import process from 'node:process';
9
- import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampCompactionConfig, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampBashConfig, clampUpdateConfig, clampTheme, clampThinkingLevel, clampContextWindow, clampMaxTokens, clampMaxRenderInterval, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, parseThresholdInput, } from './schema.js';
9
+ import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampCompactionConfig, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampBashConfig, clampUpdateConfig, clampTheme, clampThinkingLevel, clampContextWindow, clampMaxTokens, clampMaxRenderInterval, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, legacyTeamCeilingPatch, parseThresholdInput, } from './schema.js';
10
10
  import { loadDotenv, readEnvConfig, readEnvDisabledSkills } from './env.js';
11
- import { readConfigFile } from './store.js';
12
- import { projectModelProfiles, resolveModelProfileState, resolveModelRoleKey } from './model-profile-resolution.js';
11
+ import { readConfigFile, updatePersistedConfig } from './store.js';
12
+ import { projectModelProfiles, resolveModelProfileState, resolveModelRoleKey, } from './model-profile-resolution.js';
13
13
  import { getConfigPath } from './app-paths.js';
14
14
  import { setHistoryEnabled } from './prompt-history.js';
15
15
  import { setEntryRetain } from '../agent/entry-limits.js';
@@ -439,6 +439,44 @@ function resolveContextWindow(env, file) {
439
439
  }
440
440
  return DEFAULT_CONFIG.contextWindow;
441
441
  }
442
+ /**
443
+ * Rewrite the cli<=0.6.16 persisted team ceiling defaults to 0 and persist the
444
+ * change (F-8 A'4). ONE-SHOT and IDEMPOTENT: after the first rewrite the file
445
+ * carries 0, which matches nothing, so nothing is written again.
446
+ *
447
+ * THE FILE LAYER ONLY, deliberately: env and flags are this-launch intent and
448
+ * survive untouched, and with the D-4 semantics a positive surviving value is
449
+ * a supervisor cadence hint, never a kill. The raw file object is patched IN
450
+ * PLACE (the same precedent `readConfigFile` sets with `fullscreen`) so the
451
+ * `file` layer this function's caller resolves from already reads 0; the
452
+ * write goes through `updatePersistedConfig`, the one atomic write path every
453
+ * other settings change uses.
454
+ *
455
+ * A FAILED WRITE MUST NOT BLOCK STARTUP: the values are already neutralized
456
+ * in memory, and the next launch retries — which is why the catch is empty
457
+ * and the migration is logged instead of surfaced.
458
+ */
459
+ function migrateLegacyTeamCeilings(raw) {
460
+ const patch = legacyTeamCeilingPatch(raw?.team);
461
+ if (patch === null)
462
+ return;
463
+ // THE FULL SECTION, not the bare patch: the write port types `team` as a
464
+ // whole `TeamConfig` (its merge then deep-merges over what is on disk), and
465
+ // the section the file already carries plus the zeroed keys IS the section
466
+ // we want on disk.
467
+ const migratedTeam = { ...raw.team, ...patch };
468
+ Object.assign(raw.team, patch);
469
+ try {
470
+ updatePersistedConfig({ team: migratedTeam });
471
+ }
472
+ catch {
473
+ // Unwritable config dir: in-memory values are already migrated; retry
474
+ // next launch (idempotent).
475
+ }
476
+ getLogger().warn('config', 'team_legacy_ceilings_migrated', {
477
+ keys: Object.keys(patch).join(','),
478
+ });
479
+ }
442
480
  /**
443
481
  * Resolve the effective config by merging all layers, then derive the runtime
444
482
  * (non-persisted) fields and enforce the timeout invariant.
@@ -448,6 +486,7 @@ export function loadConfig(flags = {}, options = {}) {
448
486
  // `.env` must be loaded before reading env overrides.
449
487
  loadDotenv(cwd);
450
488
  const read = readConfigFile();
489
+ migrateLegacyTeamCeilings(read.config);
451
490
  const projection = projectModelProfiles(read.config ?? {});
452
491
  const file = projection.file;
453
492
  const env = readEnvConfig();
@@ -380,6 +380,31 @@ export declare const DEFAULT_TEAM_CONFIG: TeamConfig;
380
380
  * the two disagree would make the panel's `n/m` readout confusing for no gain.
381
381
  */
382
382
  export declare function clampTeamConfig(raw: unknown): TeamConfig;
383
+ /**
384
+ * The team ceilings cli <= 0.6.16 (7a6bf5630) persisted as its OWN defaults:
385
+ * every user who touched team mode back then still carries this exact triple
386
+ * in config.json, because `resolveTeamConfig` merges file values verbatim and
387
+ * no migration ever rewrote them. They are the kill ceilings behind F-8's
388
+ * "first block all failed" dispatches, so loading rewrites them to 0 (= no
389
+ * ceiling, the shipped default since 2ff15a9c3).
390
+ *
391
+ * EXACT-EQUALITY ONLY: a value that differs was a deliberate user choice and
392
+ * survives untouched. The price of that test is that a user who hand-types
393
+ * 300000 today is migrated to 0 too - the accepted trade, and the safe
394
+ * direction (no ceiling, not a cliff).
395
+ */
396
+ export declare const LEGACY_TEAM_CEILING_DEFAULTS: Readonly<{
397
+ subagentTimeoutMs: number;
398
+ dispatchTimeoutMs: number;
399
+ maxTurnsPerSubagent: number;
400
+ }>;
401
+ /**
402
+ * The `{ key: 0 }` patch for legacy-default-equal ceilings, or `null` when
403
+ * nothing matches. Pure on purpose: `loadConfig` decides WHERE the patch is
404
+ * applied and persisted; this function only answers WHICH keys match, so the
405
+ * equality test lives beside the constants it reads.
406
+ */
407
+ export declare function legacyTeamCeilingPatch(raw: unknown): Partial<TeamConfig> | null;
383
408
  export interface TodoConfig {
384
409
  /** Register `todo_write` at all. Default TRUE. */
385
410
  enabled: boolean;
@@ -492,6 +492,44 @@ export function clampTeamConfig(raw) {
492
492
  overseerIntervalMs: clampIntAllowingZero(src.overseerIntervalMs, DEFAULT_TEAM_CONFIG.overseerIntervalMs, OVERSEER_INTERVAL_RANGE),
493
493
  };
494
494
  }
495
+ /**
496
+ * The team ceilings cli <= 0.6.16 (7a6bf5630) persisted as its OWN defaults:
497
+ * every user who touched team mode back then still carries this exact triple
498
+ * in config.json, because `resolveTeamConfig` merges file values verbatim and
499
+ * no migration ever rewrote them. They are the kill ceilings behind F-8's
500
+ * "first block all failed" dispatches, so loading rewrites them to 0 (= no
501
+ * ceiling, the shipped default since 2ff15a9c3).
502
+ *
503
+ * EXACT-EQUALITY ONLY: a value that differs was a deliberate user choice and
504
+ * survives untouched. The price of that test is that a user who hand-types
505
+ * 300000 today is migrated to 0 too - the accepted trade, and the safe
506
+ * direction (no ceiling, not a cliff).
507
+ */
508
+ export const LEGACY_TEAM_CEILING_DEFAULTS = {
509
+ subagentTimeoutMs: 300_000,
510
+ dispatchTimeoutMs: 900_000,
511
+ maxTurnsPerSubagent: 24,
512
+ };
513
+ /**
514
+ * The `{ key: 0 }` patch for legacy-default-equal ceilings, or `null` when
515
+ * nothing matches. Pure on purpose: `loadConfig` decides WHERE the patch is
516
+ * applied and persisted; this function only answers WHICH keys match, so the
517
+ * equality test lives beside the constants it reads.
518
+ */
519
+ export function legacyTeamCeilingPatch(raw) {
520
+ if (!raw || typeof raw !== 'object')
521
+ return null;
522
+ const src = raw;
523
+ const patch = {};
524
+ let matched = false;
525
+ for (const key of Object.keys(LEGACY_TEAM_CEILING_DEFAULTS)) {
526
+ if (src[key] === LEGACY_TEAM_CEILING_DEFAULTS[key]) {
527
+ patch[key] = 0;
528
+ matched = true;
529
+ }
530
+ }
531
+ return matched ? patch : null;
532
+ }
495
533
  export const DEFAULT_TODO_CONFIG = {
496
534
  enabled: true,
497
535
  panel: true,
@@ -49,8 +49,10 @@ import type { OverseerAction, OverseerDecision, SubagentRun, SubagentSpec } from
49
49
  * Bumped whenever either supervisor protocol prompt changes (D-9), so a
50
50
  * behaviour report can be tied to a prompt revision with one grep.
51
51
  * `TEAM_BLOCK_VERSION` covers only the LEAD's `<team>` block, not these.
52
+ *
53
+ * v3 adds the turn-cap trigger to the single-inspection prompt (F-8 A'2).
52
54
  */
53
- export declare const OVERSEER_PROMPT_VERSION = "v2-2026-10";
55
+ export declare const OVERSEER_PROMPT_VERSION = "v3-2026-10";
54
56
  /** One remembered decision, so the second look knows the first happened. */
55
57
  export interface OverseerMemoryEntry {
56
58
  /** Seconds into the dispatch. */
@@ -63,7 +65,7 @@ export interface ChildDigestInput {
63
65
  run: SubagentRun;
64
66
  /** False only in the post-mortem inspection, after the child settled. */
65
67
  childAlive: boolean;
66
- trigger: 'silence' | 'clock' | 'postmortem';
68
+ trigger: 'silence' | 'clock' | 'turncap' | 'postmortem';
67
69
  /** The child's LIVE history (`SubagentAgentLike.state.messages`). */
68
70
  messages: readonly Message[];
69
71
  memory: readonly OverseerMemoryEntry[];
@@ -203,7 +205,7 @@ export declare function createFastOverseerProvider(deps: FastOverseerDeps): Over
203
205
  export interface OverseerInspectRequest {
204
206
  run: SubagentRun;
205
207
  messages: readonly Message[];
206
- trigger: 'silence' | 'clock' | 'postmortem';
208
+ trigger: 'silence' | 'clock' | 'turncap' | 'postmortem';
207
209
  childAlive: boolean;
208
210
  }
209
211
  export interface TeamOverseerOptions {
@@ -50,8 +50,10 @@ import { TEAM_LIMITS } from './limits.js';
50
50
  * Bumped whenever either supervisor protocol prompt changes (D-9), so a
51
51
  * behaviour report can be tied to a prompt revision with one grep.
52
52
  * `TEAM_BLOCK_VERSION` covers only the LEAD's `<team>` block, not these.
53
+ *
54
+ * v3 adds the turn-cap trigger to the single-inspection prompt (F-8 A'2).
53
55
  */
54
- export const OVERSEER_PROMPT_VERSION = 'v2-2026-10';
56
+ export const OVERSEER_PROMPT_VERSION = 'v3-2026-10';
55
57
  /** How many trailing history messages the digest carries. */
56
58
  const DIGEST_TAIL_MESSAGES = 8;
57
59
  /** Per-message text clamp inside the digest. */
@@ -78,6 +80,13 @@ function messageText(message) {
78
80
  function clampChars(text, max) {
79
81
  return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 3))}...`;
80
82
  }
83
+ /** One line per trigger, as the digest names it (a union VALUE, never prose). */
84
+ const TRIGGER_LABELS = {
85
+ silence: 'event silence (suspected stall)',
86
+ clock: 'wall-clock check',
87
+ turncap: 'turn cap reached (inspection trigger, not a stop)',
88
+ postmortem: 'post-mortem (child already ended)',
89
+ };
81
90
  /**
82
91
  * Build the bounded text one inspection reads.
83
92
  *
@@ -91,7 +100,7 @@ export function buildChildDigest(input) {
91
100
  const { run, childAlive, trigger, messages, memory, elapsedSec } = input;
92
101
  const lines = [];
93
102
  lines.push(`child: ${run.label} "${run.description}"`);
94
- lines.push(`trigger: ${trigger === 'silence' ? 'event silence (suspected stall)' : trigger === 'clock' ? 'wall-clock check' : 'post-mortem (child already ended)'}`);
103
+ lines.push(`trigger: ${TRIGGER_LABELS[trigger]}`);
95
104
  lines.push(`elapsed: ${Math.round(elapsedSec)}s, alive: ${childAlive ? 'yes' : 'no'}`);
96
105
  lines.push(`phase: ${run.phase}, turns: ${run.turns}, toolCalls: ${run.toolCalls}` +
97
106
  (run.lastTool ? `, lastTool: ${run.lastTool}` : ''));
@@ -270,8 +279,10 @@ export function parseOverseerBatchText(text) {
270
279
  }
271
280
  const OVERSEER_SYSTEM_PROMPT = [
272
281
  'You are the supervisor of a team of subagents. You are shown ONE subagent',
273
- 'that crossed a time trigger (event silence or a wall-clock check) and must',
274
- 'decide what to do with it. Answer with ONE JSON object and nothing else:',
282
+ 'that crossed a supervision trigger (event silence, a wall-clock check, or',
283
+ 'the configured turn cap) and must decide what to do with it. Crossing a',
284
+ 'ceiling does NOT stop the child - your decision does. Answer with ONE JSON',
285
+ 'object and nothing else:',
275
286
  '{"action": "wait" | "nudge" | "replace" | "abandon", "reason": "<= 200 chars",',
276
287
  ' "guidance": "<= 1200 chars, nudge only", "nextCheckMs": <ms, wait only>}.',
277
288
  'wait: the child looks healthy or is legitimately blocked (API backoff,',
@@ -488,6 +488,18 @@ export class TeamRuntime {
488
488
  // ONE KICK PER REAL AGENT EVENT, unthrottled (I-OV3): the silence
489
489
  // window resets on evidence, not on a coalesced UI frame.
490
490
  onActivity: () => this.supervision?.kick(spec.label),
491
+ // A'2: under supervision the turn cap is ONE inspection, fired from the
492
+ // child's own `turn_end` at the crossing turn. Async-and-forgotten
493
+ // exactly like the silence timer's `onFire`: nothing in the child's
494
+ // event path may block or throw. ABSENT in the legacy regime, which
495
+ // keeps the hard stop - the hook's presence IS the gate.
496
+ ...(this.overseer !== null
497
+ ? {
498
+ onTurnCapReached: (label) => {
499
+ void this.inspectAndApply(dispatchId, label, 'turncap');
500
+ },
501
+ }
502
+ : {}),
491
503
  });
492
504
  // ASSIGNMENT, not a declaration: a `const` here would shadow the `let` above
493
505
  // the bus, `canSend` would see the empty array forever, and every peer would
@@ -520,10 +532,15 @@ export class TeamRuntime {
520
532
  // its slowest child and only an Esc ends it early. The lead's idle
521
533
  // watchdog is paused for the whole dispatch either way, exactly as it is
522
534
  // for the lead's own long turns.
535
+ //
536
+ // D-4, NOW FOR THE WHOLE DISPATCH TOO: with the supervisor gate open the
537
+ // ceiling NEVER arms a hard abort - the cadence ladder is what looks at a
538
+ // long dispatch, exactly as it already looks at a long child. The legacy
539
+ // hard `abortAll()` exists ONLY under `team.overseer: false`, the same
540
+ // regime line `runCtx.subagentTimeoutMs` below draws.
541
+ const dispatchCeilingMs = team.overseer ? 0 : team.dispatchTimeoutMs;
523
542
  this.dispatchTimer =
524
- team.dispatchTimeoutMs > 0
525
- ? setTimeout(() => this.abortAll(), team.dispatchTimeoutMs)
526
- : null;
543
+ dispatchCeilingMs > 0 ? setTimeout(() => this.abortAll(), dispatchCeilingMs) : null;
527
544
  const runCtx = {
528
545
  dispatchId,
529
546
  handles,
@@ -135,6 +135,16 @@ export interface SubagentHooks {
135
135
  * so the phase read at fire time is current.
136
136
  */
137
137
  onActivity?: () => void;
138
+ /**
139
+ * THE TURN CAP AS AN INSPECTION TRIGGER (F-8 A'2). Called ONCE, at the turn
140
+ * that crossed `maxTurnsPerSubagent`, ONLY when the dispatch supervisor is
141
+ * live - the runtime wires this hook under the same gate as
142
+ * `overseerActive`, so its presence IS the supervised/legacy branch. The
143
+ * child is NOT stopped: the supervisor decides wait / nudge / replace /
144
+ * abandon through the same `inspectAndApply` path every other trigger
145
+ * uses, and a child it lets run carries no `truncated` flag.
146
+ */
147
+ onTurnCapReached?: (label: string) => void;
138
148
  }
139
149
  export interface SubagentHandle {
140
150
  spec: SubagentSpec;
@@ -359,12 +359,26 @@ export function createSubagent(spec, deps, hooks) {
359
359
  // not runaway - it is working.
360
360
  const turnCap = deps.config.team.maxTurnsPerSubagent;
361
361
  if (turnCap > 0 && run.turns >= turnCap) {
362
- run.truncated = true;
363
- // THE FLAG FIRST, exactly like `handle.abort()`: the child's
364
- // reviewer reads `abortRequested` as its guard-1 fact, and a
365
- // turn-cap stop must be visible to it in the same tick.
366
- abortRequested = true;
367
- agent.abort();
362
+ if (hooks.onTurnCapReached !== undefined) {
363
+ // A'2, D-4 for the turn cap: SUPERVISED means the ceiling is
364
+ // an inspection trigger, not a death sentence. ONE look at the
365
+ // crossing turn (`===`), never a per-turn storm; the child
366
+ // keeps working unless the supervisor decides replace or
367
+ // abandon, and a child that keeps running carries NO
368
+ // `truncated` flag - the report must not call it partial.
369
+ if (run.turns === turnCap)
370
+ hooks.onTurnCapReached(spec.label);
371
+ }
372
+ else {
373
+ // LEGACY regime (no supervisor for this dispatch): the hard
374
+ // stop, byte for byte what it always was.
375
+ run.truncated = true;
376
+ // THE FLAG FIRST, exactly like `handle.abort()`: the child's
377
+ // reviewer reads `abortRequested` as its guard-1 fact, and a
378
+ // turn-cap stop must be visible to it in the same tick.
379
+ abortRequested = true;
380
+ agent.abort();
381
+ }
368
382
  }
369
383
  break;
370
384
  }
@@ -201,13 +201,14 @@ export interface OverseerIntervention {
201
201
  action: OverseerAction;
202
202
  reason: string;
203
203
  /**
204
- * What the trigger was: event stall, wall-clock check, post-mortem, or
205
- * the supervisor going QUIET (subagent-overseer-v2 D-10) - the look
204
+ * What the trigger was: event stall, wall-clock check, the child's turn
205
+ * cap (F-8 A'2 - an inspection the supervisor then ruled on), post-mortem,
206
+ * or the supervisor going QUIET (subagent-overseer-v2 D-10) - the look
206
207
  * budget ran out and supervision stopped waking this child. A VALUE OF
207
208
  * THE UNION, never a reason prefix: a human-readable sentence is not a
208
209
  * machine contract (the `run.error` rule).
209
210
  */
210
- trigger: 'silence' | 'clock' | 'postmortem' | 'quiet';
211
+ trigger: 'silence' | 'clock' | 'turncap' | 'postmortem' | 'quiet';
211
212
  }
212
213
  /**
213
214
  * A terminal supervisor decision handed to the WORKER LOOP, which is the only
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/cli",
3
- "version": "0.6.22",
3
+ "version": "0.6.23",
4
4
  "description": "AragonAgent CLI — a Claude-Code / Codex-style interactive terminal UI (TUI) on top of the @aragon-agent/core engine. Installable and runnable via npm / npx.",
5
5
  "license": "MIT",
6
6
  "author": "AragonAgent contributors",
@@ -56,7 +56,7 @@
56
56
  "prepublishOnly": "npm run build"
57
57
  },
58
58
  "dependencies": {
59
- "@aragon-agent/core": "^0.2.34",
59
+ "@aragon-agent/core": "^0.2.35",
60
60
  "chalk": "^5.6.2",
61
61
  "cli-highlight": "^2.0.0",
62
62
  "commander": "^12.1.0",