@yagni-app/code-staging 1.1.3-staging.1376.1 → 1.1.3-staging.1378.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.
@@ -199,7 +199,7 @@ export type { ComparisonReport, LaneFit, LaneOutcome } from "./pipeline/eval.js"
199
199
  export { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
200
200
  export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkdown, buildSubagentStage, formatAgentList, mapModelTier, SUBAGENT_TOOL_NAME, GENERAL_AGENT_NAME, MAX_PARALLEL_SUBAGENTS, MAX_PARALLEL_SUBAGENTS_ULTRA, DEFAULT_SUBAGENT_TOOLS, } from "./subagents.js";
201
201
  export type { SubagentDef, SubagentSource } from "./subagents.js";
202
- export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, MAX_TODOS, } from "./todos.js";
202
+ export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, clampSingleInProgress, formatAgeMs, oldestInProgress, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, TODO_STALE_MS, MAX_TODOS, } from "./todos.js";
203
203
  export type { TodoItem, TodoStatus, TodoTheme } from "./todos.js";
204
204
  export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission/gate.js";
205
205
  export { classifyCommand, DEFAULT_EXEC_POLICY, } from "./permission/execPolicy.js";
@@ -285,15 +285,23 @@ export async function registerYagni(pi, deps = {}) {
285
285
  }
286
286
  // Lock the interactive session to the `advanced` tier only. The backend
287
287
  // catalog returns all tiers, but only `advanced` is registered with the
288
- // `yagni` provider, so /model and Ctrl+P show a single entry. Child
289
- // processes (/go, subagents, advisor) fetch their own catalog and register
290
- // their own provider, so they are unaffected by this filter.
288
+ // `yagni` provider, so /model and Ctrl+P show a single entry.
291
289
  //
292
- // Eval mode is exempt: a headless harness (scoping sessions, code evals)
293
- // names its tier explicitly the backend's scoping sessions run `--model
294
- // standard` — and there is no /model picker to keep tidy. Filtering there
295
- // turns a valid tier request into "no models match".
296
- const catalog = evalMode ? fullCatalog : fullCatalog.filter((m) => m.id === "advanced");
290
+ // Two exemptions:
291
+ // - CHILD processes: the runner stamps YAGNI_CALLER on every /go, subagent,
292
+ // and advisor spawn (`isDriverCaller` false). A child names its tier on
293
+ // the command line (`--model standard` for a general subagent, `--model
294
+ // efficient` for a searcher) and must resolve it EXACTLY against its own
295
+ // catalog — the advanced-only filter used to leave those tier names
296
+ // unresolved, so pi fell back to its custom-model-id path (a stderr
297
+ // warning on every non-advanced child and default-shaped capability
298
+ // metadata instead of the real context-window/token caps).
299
+ // - Eval mode: a headless harness (scoping sessions, code evals) names its
300
+ // tier explicitly — the backend's scoping sessions run `--model standard`
301
+ // — and there is no /model picker to keep tidy. Filtering there turns a
302
+ // valid tier request into "no models match".
303
+ const driver = isDriverCaller(env);
304
+ const catalog = evalMode || !driver ? fullCatalog : fullCatalog.filter((m) => m.id === "advanced");
297
305
  // YAG-471: the driver's own completions carry attribution headers read from
298
306
  // this process's env (YAGNI_SESSION_ID minted by the launcher; YAGNI_CALLER
299
307
  // defaults to "driver" when unset, i.e. every session that is not a /go
@@ -1836,7 +1844,7 @@ export { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
1836
1844
  // The general subagent tool: Claude Code-format agent discovery + fan-out.
1837
1845
  export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkdown, buildSubagentStage, formatAgentList, mapModelTier, SUBAGENT_TOOL_NAME, GENERAL_AGENT_NAME, MAX_PARALLEL_SUBAGENTS, MAX_PARALLEL_SUBAGENTS_ULTRA, DEFAULT_SUBAGENT_TOOLS, } from "./subagents.js";
1838
1846
  // The session todo checklist: TodoWrite tool, widget renderer, /todos.
1839
- export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, MAX_TODOS, } from "./todos.js";
1847
+ export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, clampSingleInProgress, formatAgeMs, oldestInProgress, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, TODO_STALE_MS, MAX_TODOS, } from "./todos.js";
1840
1848
  // P3 + W4: the permission gate seam (decideGate is pure; policy injectable) plus
1841
1849
  // the session bless-with-remember capture hook.
1842
1850
  export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission/gate.js";
@@ -41,9 +41,19 @@ export type RunStageFn = (stage: PipelineStage, ctx: {
41
41
  export interface ResilienceAttemptRecord {
42
42
  stageId: string;
43
43
  lens?: ReviewLens;
44
+ /** The stage's agent name (a subagent child's agent, or the /go stage id). */
45
+ agent?: string;
46
+ /**
47
+ * Which task of a multi-task subagent call this attempt belongs to
48
+ * (0-based). Content-free — an index, never task text — so parallel
49
+ * same-agent failures are attributable without leaking content.
50
+ */
51
+ taskIndex?: number;
44
52
  /** 1-based attempt number. */
45
53
  attempt: number;
46
54
  outcome: "ok" | "transient" | "fatal" | "timeout" | "aborted";
55
+ /** Which timer fired, when the attempt timed out. */
56
+ timeoutKind?: "idle" | "wall";
47
57
  exitCode: number;
48
58
  stopReason?: string;
49
59
  elapsedMs: number;
@@ -31,6 +31,8 @@
31
31
  const WRITE_STAGE_IDS = ["implement", "fix"];
32
32
  /** Honest message stamped on a stage we aborted for exceeding its time budget. */
33
33
  const TIMEOUT_MESSAGE = "stage exceeded its idle or wall-clock timeout";
34
+ const WALL_TIMEOUT_MESSAGE = "stage exceeded its wall-clock timeout";
35
+ const IDLE_TIMEOUT_MESSAGE = "stage exceeded its idle timeout";
34
36
  /** stderr fingerprints of a transient transport/provider blip worth retrying. */
35
37
  const TRANSIENT_STDERR = /\b429\b|\b5\d\d\b|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up/i;
36
38
  /**
@@ -86,7 +88,12 @@ export function withResilience(base, policy, opts = {}) {
86
88
  const telemetry = opts.telemetry;
87
89
  return async (stage, ctx, deps) => {
88
90
  const callerSignal = deps.signal;
89
- const isWriteStage = WRITE_STAGE_IDS.includes(stage.id);
91
+ // The write-gate keys on what the child can DO, not on the stage id: a
92
+ // subagent stage carries the synthetic id "implement" for /go-compat, so
93
+ // the subagent tool passes an explicit per-agent flag (a read-only
94
+ // verifier is retried freely; an edit-capable child keeps the no-re-run
95
+ // discipline). Absent, the /go stage-id vocabulary decides as before.
96
+ const isWriteStage = deps.stageWriteCapable ?? WRITE_STAGE_IDS.includes(stage.id);
90
97
  let last;
91
98
  for (let attempt = 1; attempt <= policy.maxAttempts; attempt++) {
92
99
  // A caller abort during a backoff window: stop before spending another attempt.
@@ -95,9 +102,11 @@ export function withResilience(base, policy, opts = {}) {
95
102
  const timeoutController = new AbortController();
96
103
  const composed = composeAbortSignal(callerSignal, timeoutController.signal);
97
104
  let timedOut = false;
105
+ let timeoutKind;
98
106
  let sawAnyEvent = false;
99
- const fireTimeout = () => {
107
+ const fireTimeout = (kind) => {
100
108
  timedOut = true;
109
+ timeoutKind = kind;
101
110
  if (!timeoutController.signal.aborted)
102
111
  timeoutController.abort();
103
112
  };
@@ -120,9 +129,9 @@ export function withResilience(base, policy, opts = {}) {
120
129
  armIdle();
121
130
  return;
122
131
  }
123
- fireTimeout();
132
+ fireTimeout("idle");
124
133
  };
125
- const wallTimer = setTimeout(fireTimeout, policy.wallTimeoutMs);
134
+ const wallTimer = setTimeout(() => fireTimeout("wall"), policy.wallTimeoutMs);
126
135
  wallTimer.unref?.();
127
136
  armIdle();
128
137
  const originalOnEvent = deps.onEvent;
@@ -150,8 +159,11 @@ export function withResilience(base, policy, opts = {}) {
150
159
  telemetry?.({
151
160
  stageId: stage.id,
152
161
  ...(ctx.lens ? { lens: ctx.lens } : {}),
162
+ ...(stage.agent ? { agent: stage.agent } : {}),
163
+ ...(deps.taskIndex !== undefined ? { taskIndex: deps.taskIndex } : {}),
153
164
  attempt,
154
165
  outcome,
166
+ ...(timedOut && timeoutKind ? { timeoutKind } : {}),
155
167
  exitCode: last.exitCode,
156
168
  ...(last.stopReason ? { stopReason: last.stopReason } : {}),
157
169
  elapsedMs,
@@ -167,12 +179,17 @@ export function withResilience(base, policy, opts = {}) {
167
179
  record("ok", false);
168
180
  return last;
169
181
  }
170
- if (timedOut)
171
- last.errorMessage = TIMEOUT_MESSAGE;
182
+ if (timedOut) {
183
+ last.errorMessage = timeoutKind === "wall" ? WALL_TIMEOUT_MESSAGE : timeoutKind === "idle" ? IDLE_TIMEOUT_MESSAGE : TIMEOUT_MESSAGE;
184
+ }
172
185
  const transient = classifyTransient(last, timedOut);
186
+ // A wall-clock exhaustion is a "task too big" signal, not a stall: a
187
+ // lane that opts out (subagents) fails honestly instead of re-burning
188
+ // the same 20 minutes per attempt. Idle stalls stay retryable.
189
+ const wallNotRetryable = timedOut && timeoutKind === "wall" && policy.retryWallTimeout === false;
173
190
  // No double-apply: a write stage that already started working is never re-run.
174
191
  const blockedByWriteGate = isWriteStage && sawAnyEvent;
175
- const willRetry = transient && !blockedByWriteGate && attempt < policy.maxAttempts;
192
+ const willRetry = transient && !wallNotRetryable && !blockedByWriteGate && attempt < policy.maxAttempts;
176
193
  record(timedOut ? "timeout" : transient ? "transient" : "fatal", willRetry);
177
194
  if (!willRetry)
178
195
  return last;
@@ -54,6 +54,20 @@ export interface RunStageDeps {
54
54
  * subagent tool and the advisor pass their own label through this seam.
55
55
  */
56
56
  callerLabel?: string;
57
+ /**
58
+ * Write-gate override for the resilience wrapper: whether THIS child's tool
59
+ * list can mutate the workspace (edit/write). The subagent tool sets it per
60
+ * agent (read-only verifiers retry; edit-capable children do not); the /go
61
+ * pipeline leaves it absent so the stage-id vocabulary decides as before.
62
+ */
63
+ stageWriteCapable?: boolean;
64
+ /**
65
+ * Which task of a multi-task subagent call this child runs (0-based). Rides
66
+ * the resilience attempt record only — content-free attribution so parallel
67
+ * same-agent failures stay distinguishable in the telemetry. Absent for
68
+ * single-task /go stages.
69
+ */
70
+ taskIndex?: number;
57
71
  /**
58
72
  * Additive live tap: invoked once per parsed NDJSON event, in stream order,
59
73
  * right after it is buffered for the reducers. PURE side-channel for the
@@ -524,6 +524,14 @@ export interface ResiliencePolicy {
524
524
  backoffMaxMs: number;
525
525
  /** Jitter as a fraction of the computed delay, applied as +/- (0 = none). */
526
526
  jitterRatio: number;
527
+ /**
528
+ * Whether a WALL-clock exhaustion is retryable. Default true (the /go
529
+ * doctrine: a wedged stage is worth another attempt). A lane whose children
530
+ * run read-only, fast work (subagents) sets false: a 20-minute burn means
531
+ * the task is too big, and re-running it just multiplies the spend before
532
+ * the same honest fail. Idle-stall timeouts stay retryable either way.
533
+ */
534
+ retryWallTimeout?: boolean;
527
535
  }
528
536
  /**
529
537
  * Default resilience policy. Generous timeouts so a legitimately long but live
@@ -29,6 +29,7 @@ export const DEFAULT_RESILIENCE_POLICY = {
29
29
  backoffBaseMs: 1_000,
30
30
  backoffMaxMs: 30_000,
31
31
  jitterRatio: 0.25,
32
+ retryWallTimeout: true,
32
33
  };
33
34
  /**
34
35
  * R3-a tool-failure health gate. A WRITE stage (implement / fix) that exits 0 is
@@ -53,6 +53,8 @@ export interface SubagentTaskProgress {
53
53
  }
54
54
  export interface SubagentDetails {
55
55
  tasks: SubagentTaskProgress[];
56
+ /** True when every task in the call failed — keys the tool_result handler's isError override. */
57
+ allFailed?: boolean;
56
58
  }
57
59
  /** Bound on the retained action log so a chatty child cannot grow details unbounded. */
58
60
  export declare const ACTION_LOG_MAX = 120;
@@ -21,9 +21,22 @@ import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-cod
21
21
  import { Type } from "typebox";
22
22
  import type { ChildUsageHandle } from "./childUsage.js";
23
23
  import type { WorkingLineHandle } from "./workingLine.js";
24
+ import { type ResilienceAttemptRecord } from "./pipeline/resilience.js";
24
25
  import { runStage } from "./pipeline/runner.js";
25
- import { type ModelTier, type PipelineStage } from "./pipeline/types.js";
26
+ import { type ModelTier, type PipelineStage, type ResiliencePolicy } from "./pipeline/types.js";
26
27
  import { renderSubagentCall, renderSubagentResult } from "./subagentRender.js";
28
+ /**
29
+ * pi's tool contract signals an error by THROWING, and a normally-returned
30
+ * result is always isError=false — the `isError` field on an AgentToolResult
31
+ * is never read by the agent loop. But the `tool_result` EXTENSION event CAN
32
+ * override the flag (`{ isError: true }` from a handler is merged by
33
+ * agent-session's afterToolCall). A completed subagent call that failed ALL
34
+ * its tasks must keep the partial child output in the content (so throwing
35
+ * is wrong there), and this handler is the one seam that marks it as an
36
+ * error in session history. Registered by registerSubagents; fail-soft —
37
+ * a throw inside is swallowed by the runner anyway, but never rely on it.
38
+ */
39
+ export declare function registerSubagentFailureFlag(pi: ExtensionAPI): void;
27
40
  export declare const SUBAGENT_TOOL_NAME = "subagent";
28
41
  export declare const GENERAL_AGENT_NAME = "general";
29
42
  export declare const MAX_PARALLEL_SUBAGENTS = 4;
@@ -93,6 +106,8 @@ export interface MakeSubagentToolDeps {
93
106
  runStageImpl?: typeof runStage;
94
107
  discover?: (deps: DiscoverDeps) => SubagentDef[];
95
108
  homeDir?: string;
109
+ /** Env seam for the telemetry sessionId read (defaults to process.env). */
110
+ env?: NodeJS.ProcessEnv;
96
111
  /** Live ultra-mode probe (/ultra): widens the per-call fan-out ceiling. */
97
112
  isUltra?: () => boolean;
98
113
  /**
@@ -116,6 +131,22 @@ export interface MakeSubagentToolDeps {
116
131
  */
117
132
  childUsage?: ChildUsageHandle;
118
133
  }
134
+ /**
135
+ * The subagent lane's resilience policy: the /go defaults, except a wall-
136
+ * clock exhaustion is NOT retried (a 20-minute burn means the task is too
137
+ * big — fail honestly instead of re-burning it per attempt; idle stalls stay
138
+ * retryable). Exported so a test can pin the composed policy the production
139
+ * path actually runs (every unit test injects runStageImpl instead).
140
+ */
141
+ export declare function subagentRunPolicy(): ResiliencePolicy;
142
+ /**
143
+ * The per-attempt telemetry adapter: one content-free error-sink row per
144
+ * resilience attempt (agent, taskIndex, outcome, timeoutKind, exit code,
145
+ * elapsed, willRetry — never task text or stderr). Exported for the same
146
+ * reason as subagentRunPolicy: the default `run` composes it, and a test
147
+ * drives the exact production adapter rather than a parallel re-statement.
148
+ */
149
+ export declare function subagentTelemetry(env: NodeJS.ProcessEnv): (rec: ResilienceAttemptRecord) => void;
119
150
  export declare function makeSubagentTool(deps?: MakeSubagentToolDeps): {
120
151
  name: string;
121
152
  label: string;
@@ -139,20 +170,13 @@ export declare function makeSubagentTool(deps?: MakeSubagentToolDeps): {
139
170
  }>;
140
171
  details: unknown;
141
172
  }) => void, ctx?: ExtensionContext): Promise<{
142
- content: {
143
- type: "text";
144
- text: string;
145
- }[];
146
- details: {};
147
- isError: boolean;
148
- } | {
149
- isError?: boolean | undefined;
150
173
  content: {
151
174
  type: "text";
152
175
  text: string;
153
176
  }[];
154
177
  details: {
155
178
  tasks: import("./subagentRender.js").SubagentTaskProgress[];
179
+ allFailed: boolean;
156
180
  };
157
181
  }>;
158
182
  };
@@ -168,7 +192,7 @@ export interface RegisterSubagentsDeps {
168
192
  /** Session child-usage accumulator; see MakeSubagentToolDeps.childUsage. */
169
193
  childUsage?: ChildUsageHandle;
170
194
  }
171
- /** Wire the subagent tool and the /agents listing command. */
195
+ /** Wire the subagent tool, the failure flag handler, and the /agents listing command. */
172
196
  export declare function registerSubagents(pi: ExtensionAPI, deps?: RegisterSubagentsDeps): void;
173
197
  export {};
174
198
  //# sourceMappingURL=subagents.d.ts.map
@@ -23,10 +23,39 @@ import { delimiter, join } from "node:path";
23
23
  import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
24
24
  import { Type } from "typebox";
25
25
  import { sanitizeCallerSegment } from "./config.js";
26
+ import { logEvent } from "./errorSink.js";
26
27
  import { withResilience } from "./pipeline/resilience.js";
27
28
  import { runStage } from "./pipeline/runner.js";
28
29
  import { DEFAULT_RESILIENCE_POLICY } from "./pipeline/types.js";
29
30
  import { applyChildEvent, finalizeTask, formatWorkingMessage, newTaskProgress, progressSummaryText, renderSubagentCall, renderSubagentResult, } from "./subagentRender.js";
31
+ /**
32
+ * pi's tool contract signals an error by THROWING, and a normally-returned
33
+ * result is always isError=false — the `isError` field on an AgentToolResult
34
+ * is never read by the agent loop. But the `tool_result` EXTENSION event CAN
35
+ * override the flag (`{ isError: true }` from a handler is merged by
36
+ * agent-session's afterToolCall). A completed subagent call that failed ALL
37
+ * its tasks must keep the partial child output in the content (so throwing
38
+ * is wrong there), and this handler is the one seam that marks it as an
39
+ * error in session history. Registered by registerSubagents; fail-soft —
40
+ * a throw inside is swallowed by the runner anyway, but never rely on it.
41
+ */
42
+ export function registerSubagentFailureFlag(pi) {
43
+ pi.on("tool_result", async (event) => {
44
+ try {
45
+ if (event.toolName !== SUBAGENT_TOOL_NAME || event.isError)
46
+ return undefined;
47
+ const details = event.details;
48
+ if (!details || typeof details !== "object" || !Array.isArray(details.tasks))
49
+ return undefined;
50
+ if (!details.allFailed)
51
+ return undefined;
52
+ return { isError: true };
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ });
58
+ }
30
59
  /**
31
60
  * YAG-471 attribution: the `x-yagni-caller` prefix for a subagent invocation.
32
61
  * The sanitized agent name is capped so the WHOLE label (prefix + name) stays
@@ -274,6 +303,15 @@ function loadAgentsFromDir(dir, source) {
274
303
  agents.push(def);
275
304
  }
276
305
  catch {
306
+ // A malformed file used to vanish silently — now it leaves a warn row in
307
+ // the sink so a broken definition is diagnosable. Content-free: dir +
308
+ // file name only, never the parse error text (unknown shape).
309
+ logEvent({
310
+ source: "subagent",
311
+ level: "warn",
312
+ event: "agent_file_unparseable",
313
+ fields: { dir, file: entry.name },
314
+ });
277
315
  continue;
278
316
  }
279
317
  }
@@ -419,19 +457,61 @@ const parameters = Type.Object({
419
457
  agent: Type.Optional(Type.String({ description: "Agent name (see /agents). Defaults to general." })),
420
458
  tasks: Type.Optional(Type.Array(Type.Object({
421
459
  task: Type.String(),
422
- agent: Type.Optional(Type.String()),
460
+ agent: Type.Optional(Type.String({ description: "Agent name; defaults to the top-level `agent`, then general." })),
423
461
  }), {
424
- description: `Run several independent tasks in parallel (max ${MAX_PARALLEL_SUBAGENTS}; ${MAX_PARALLEL_SUBAGENTS_ULTRA} in ultra mode). Use INSTEAD of task.`,
462
+ description: `Run several independent tasks in parallel (max ${MAX_PARALLEL_SUBAGENTS}; ${MAX_PARALLEL_SUBAGENTS_ULTRA} in ultra mode). Use INSTEAD of task. Entries without their own agent run as the top-level agent.`,
425
463
  })),
426
464
  });
465
+ /**
466
+ * The subagent lane's resilience policy: the /go defaults, except a wall-
467
+ * clock exhaustion is NOT retried (a 20-minute burn means the task is too
468
+ * big — fail honestly instead of re-burning it per attempt; idle stalls stay
469
+ * retryable). Exported so a test can pin the composed policy the production
470
+ * path actually runs (every unit test injects runStageImpl instead).
471
+ */
472
+ export function subagentRunPolicy() {
473
+ return { ...DEFAULT_RESILIENCE_POLICY, retryWallTimeout: false };
474
+ }
475
+ /**
476
+ * The per-attempt telemetry adapter: one content-free error-sink row per
477
+ * resilience attempt (agent, taskIndex, outcome, timeoutKind, exit code,
478
+ * elapsed, willRetry — never task text or stderr). Exported for the same
479
+ * reason as subagentRunPolicy: the default `run` composes it, and a test
480
+ * drives the exact production adapter rather than a parallel re-statement.
481
+ */
482
+ export function subagentTelemetry(env) {
483
+ return (rec) => logEvent({
484
+ source: "subagent",
485
+ level: rec.outcome === "ok" ? "info" : rec.outcome === "timeout" || rec.outcome === "fatal" ? "error" : "warn",
486
+ event: `attempt_${rec.outcome}`,
487
+ sessionId: env.YAGNI_SESSION_ID,
488
+ fields: {
489
+ ...(rec.agent ? { agent: rec.agent } : {}),
490
+ ...(rec.taskIndex !== undefined ? { taskIndex: rec.taskIndex } : {}),
491
+ attempt: rec.attempt,
492
+ ...(rec.timeoutKind ? { timeoutKind: rec.timeoutKind } : {}),
493
+ exitCode: rec.exitCode,
494
+ ...(rec.stopReason ? { stopReason: rec.stopReason } : {}),
495
+ elapsedMs: rec.elapsedMs,
496
+ willRetry: rec.willRetry,
497
+ },
498
+ });
499
+ }
427
500
  export function makeSubagentTool(deps = {}) {
428
501
  // The default runner rides the /go pipeline's resilience wrapper, so a chat
429
502
  // subagent gets the same idle + wall-clock ceilings and transient-only retry
430
503
  // as a /go stage child (previously a hung subagent hung the tool call until
431
- // the user pressed Esc). The synthetic stage id is "implement", so the
432
- // wrapper's write-gate already refuses to re-run a child that may have
433
- // landed a partial edit.
434
- const run = deps.runStageImpl ?? withResilience(runStage, DEFAULT_RESILIENCE_POLICY);
504
+ // the user pressed Esc). Two subagent-specific knobs ride the wrapper:
505
+ // retryWallTimeout=false (a 20-minute burn means the task is too big fail
506
+ // honestly instead of re-burning it per attempt) and per-attempt telemetry
507
+ // into the error sink (a subagent failure previously left NO trace outside
508
+ // the tool result text). The write-gate keys on the resolved agent's tools,
509
+ // passed per task below — the synthetic stage id is "implement" and would
510
+ // otherwise block retry of a read-only verifier forever.
511
+ const run = deps.runStageImpl ??
512
+ withResilience(runStage, subagentRunPolicy(), {
513
+ telemetry: subagentTelemetry(deps.env ?? process.env),
514
+ });
435
515
  const discover = deps.discover ?? discoverSubagents;
436
516
  const grounded = deps.grounded !== false;
437
517
  // Resolve the effective body for a spawned subagent: the grounding-free
@@ -442,6 +522,29 @@ export function makeSubagentTool(deps = {}) {
442
522
  return def.body;
443
523
  return BUILTIN_BLIND_BODIES[def.name] ?? def.body;
444
524
  };
525
+ // One-line-per-agent listing so the model never guesses a name (a
526
+ // hallucinated agent name is a hard error). Built at registration time —
527
+ // the list is current as of session launch; /agents shows the live set.
528
+ // Fails soft: discovery problems never block the tool.
529
+ let agentListBlock = "";
530
+ try {
531
+ const discovered = discover({ cwd: process.cwd(), homeDir: deps.homeDir });
532
+ if (discovered.length > 0) {
533
+ agentListBlock =
534
+ " Available agents (first sentence only; /agents lists them in full):\n" +
535
+ discovered
536
+ .map((a) => `- ${a.name}: ${a.description.split(/[.!?]/)[0]?.trim() || a.description}`)
537
+ .join("\n");
538
+ }
539
+ }
540
+ catch {
541
+ // A discovery failure here silently ships the tool without the name
542
+ // enumeration — the model is back to guessing names (the failure class
543
+ // this listing exists to prevent) — so it must leave a trace. Content-
544
+ // free: no dir list, no error text (unknown shape).
545
+ logEvent({ source: "subagent", level: "warn", event: "agent_list_discovery_failed" });
546
+ agentListBlock = "";
547
+ }
445
548
  return {
446
549
  name: SUBAGENT_TOOL_NAME,
447
550
  label: "Subagent",
@@ -449,7 +552,9 @@ export function makeSubagentTool(deps = {}) {
449
552
  "a compressed report. Use it for context-heavy exploration you don't need blow-by-blow, or to " +
450
553
  "run independent tasks in parallel via `tasks`. The subagent shares NONE of your conversation: " +
451
554
  "spell out the task completely. Agents come from this repo's .claude/agents and .pi/agents " +
452
- "(list them with /agents); omit `agent` for the general-purpose one.",
555
+ "(list them with /agents); omit `agent` for the general-purpose one. The list below was " +
556
+ "captured at session launch — if the session's cwd differs from the launch cwd it may be " +
557
+ "stale; an unknown name is a hard error, so trust /agents over this list when they differ." + agentListBlock,
453
558
  promptSnippet: "subagent: delegate a self-contained task (or parallel tasks) to a fresh-context agent; returns its report.",
454
559
  parameters,
455
560
  // Self-framed: the condensed transcript look has no tinted tool boxes.
@@ -457,11 +562,15 @@ export function makeSubagentTool(deps = {}) {
457
562
  renderCall: renderSubagentCall,
458
563
  renderResult: renderSubagentResult,
459
564
  async execute(toolCallId, params, signal, onUpdate, ctx) {
460
- const fail = (text) => ({
461
- content: [{ type: "text", text }],
462
- details: {},
463
- isError: true,
464
- });
565
+ // pi's tool contract signals an error by THROWING: a normally-returned
566
+ // result is always isError=false (agent-loop.js stamps it), and the
567
+ // `isError` field on a returned AgentToolResult was never read — the
568
+ // old `fail()` helper's flag was inert, so the driver model saw these
569
+ // as normal text. Thrown Error messages become the tool result content
570
+ // verbatim with isError=true, which is exactly the old intent.
571
+ const fail = (text) => {
572
+ throw new Error(text);
573
+ };
465
574
  const requested = params.tasks && params.tasks.length > 0
466
575
  ? params.tasks
467
576
  : params.task
@@ -478,7 +587,10 @@ export function makeSubagentTool(deps = {}) {
478
587
  const agents = discover({ cwd, homeDir: deps.homeDir });
479
588
  const resolved = [];
480
589
  for (const req of requested) {
481
- const name = (req.agent ?? GENERAL_AGENT_NAME).toLowerCase();
590
+ // A tasks[] entry names its own agent first; absent that, it inherits
591
+ // the call's top-level `agent` (previously silently dropped here, so
592
+ // `{agent: "verification", tasks: [{task}]}` ran as general).
593
+ const name = (req.agent ?? params.agent ?? GENERAL_AGENT_NAME).toLowerCase();
482
594
  const def = agents.find((a) => a.name === name);
483
595
  if (!def) {
484
596
  return fail(`Error: unknown agent "${name}". Available agents:\n${formatAgentList(agents)}`);
@@ -518,6 +630,21 @@ export function makeSubagentTool(deps = {}) {
518
630
  const result = await run(stage, stageCtx, {
519
631
  cwd,
520
632
  signal,
633
+ // Write-gate input: whether the agent's DECLARED tool list has
634
+ // edit/write. This is a retry-discipline heuristic, not a
635
+ // capability claim — bash can still mutate, but it runs behind
636
+ // the same sandbox + permission stack as the driver's, and the
637
+ // gate's purpose is only to refuse re-running a child that may
638
+ // have landed a partial edit. Read-only-by-declaration verifiers
639
+ // retry freely; edit-capable children keep the discipline.
640
+ // Computed from the tools the runner will actually pass the
641
+ // child (`--tools`), so a custom definition cannot opt out by
642
+ // name.
643
+ stageWriteCapable: (stage.tools).some((t) => t === "edit" || t === "write"),
644
+ // Content-free task discriminator: which entry of THIS call's
645
+ // task list the attempt belongs to (parallel same-agent
646
+ // failures stay attributable without leaking task text).
647
+ taskIndex: index,
521
648
  personaBody: () => bodyFor(def),
522
649
  // YAG-471: attribute this child's completions to the specific
523
650
  // subagent, not the generic /go stage label the runner would
@@ -535,7 +662,7 @@ export function makeSubagentTool(deps = {}) {
535
662
  // identity — per invocation, so parallel tasks never collide).
536
663
  deps.childUsage?.record("subagent", progressKey(toolCallId, progress, index), result.usage);
537
664
  emit();
538
- return { agent: def.name, task, result };
665
+ return { agent: def.name, task, result, def };
539
666
  }));
540
667
  }
541
668
  finally {
@@ -548,29 +675,46 @@ export function makeSubagentTool(deps = {}) {
548
675
  const allFailed = outcomes.every((o) => o.result.exitCode !== 0);
549
676
  const sections = outcomes.map((o) => {
550
677
  const output = o.result.finalOutput.trim();
678
+ // Honest failure framing: the child's partial text must never read as
679
+ // a completed report. The prefix says what happened; the failed note
680
+ // keeps the exit code + stderr tail for diagnosis. Plain text only
681
+ // (no emojis — this reaches the driver model's context).
682
+ const failureBanner = o.result.exitCode !== 0
683
+ ? `SUBAGENT FAILED (exit ${o.result.exitCode}${o.result.errorMessage ? `, ${o.result.errorMessage}` : ""}) — the text below is PARTIAL output, not a completed report.`
684
+ : "";
551
685
  const failedNote = o.result.exitCode !== 0
552
686
  ? `\n\n(subagent failed, exit ${o.result.exitCode}${o.result.stderr.trim() ? `: ${o.result.stderr.trim().slice(-500)}` : ""})`
553
687
  : "";
554
688
  const bodyText = output || (o.result.exitCode === 0 ? "(no output)" : "");
689
+ // The verification agent's contract is a `## Verdict` header; an
690
+ // output without it is not a verdict, and the driver must not treat
691
+ // a truncated probe log as "HOLDS".
692
+ const missingVerdict = o.def.source === "builtin" && o.agent === "verification" && o.result.exitCode === 0 && !output.includes("## Verdict")
693
+ ? "\n\n(no verdict — the verification run did not produce its verdict section)"
694
+ : "";
555
695
  return outcomes.length === 1
556
- ? `${bodyText}${failedNote}`
557
- : `## ${o.agent}: ${o.task}\n\n${bodyText}${failedNote}`;
696
+ ? `${failureBanner ? failureBanner + "\n\n" : ""}${bodyText}${missingVerdict}${failedNote}`
697
+ : `## ${o.agent}: ${o.task}\n\n${failureBanner ? failureBanner + "\n\n" : ""}${bodyText}${missingVerdict}${failedNote}`;
558
698
  });
559
699
  return {
560
700
  content: [{ type: "text", text: sections.join("\n\n") }],
561
701
  // The folded progress records ARE the final details: a superset of the
562
702
  // old {agent, task, exitCode, usage, toolCalls} shape, plus the action
563
- // log and report that renderSubagentResult paints.
564
- details: { tasks: progresses },
565
- ...(allFailed ? { isError: true } : {}),
703
+ // log and report that renderSubagentResult paints. `allFailed` keys
704
+ // the tool_result handler's isError override (see
705
+ // registerSubagentFailureFlag) the returned field itself is inert
706
+ // in pi's contract (errors are signaled by throwing, and here we keep
707
+ // the partial child output in the result instead).
708
+ details: { tasks: progresses, allFailed },
566
709
  };
567
710
  },
568
711
  };
569
712
  }
570
- /** Wire the subagent tool and the /agents listing command. */
713
+ /** Wire the subagent tool, the failure flag handler, and the /agents listing command. */
571
714
  export function registerSubagents(pi, deps = {}) {
572
715
  const discover = deps.discover ?? discoverSubagents;
573
716
  pi.registerTool(makeSubagentTool(deps));
717
+ registerSubagentFailureFlag(pi);
574
718
  pi.registerCommand("agents", {
575
719
  description: "List the subagents available in this repo (.claude/agents, .pi/agents).",
576
720
  handler: async (_args, ctx) => {
@@ -55,6 +55,19 @@ export declare const TODO_REMINDER_TURNS = 10;
55
55
  * dropping behind open work — Claude Code's TaskList RECENT_COMPLETED_TTL.
56
56
  */
57
57
  export declare const TODO_COMPLETED_LINGER_MS = 30000;
58
+ /**
59
+ * Age at which an in-progress step is treated as possibly stale: the aged
60
+ * reminder names the item and its age instead of the generic nudge, and the
61
+ * widget row picks up a dim `(23m)` suffix. Below this the board reads as
62
+ * normal active work.
63
+ */
64
+ export declare const TODO_STALE_MS: number;
65
+ /**
66
+ * The board repaints at this cadence while an active step can age, so the
67
+ * `(23m)` suffix stays live. Under the staleness threshold the suffix is
68
+ * hidden entirely, so a slow tick costs nothing.
69
+ */
70
+ export declare const TODO_REPAINT_MS = 30000;
58
71
  /**
59
72
  * The desktop's structured state record rides its own widget key, like the
60
73
  * `/go` run state: one JSON line the app parses and renders itself, never
@@ -75,15 +88,31 @@ export interface TodoItem {
75
88
  * Validate a full replacement list. Strict: this is model input rendered
76
89
  * straight into the terminal. An empty list is valid (it clears the board).
77
90
  * Accepts both the current shape ({content, activeForm}) and the legacy
78
- * {text} shape so old sessions replay cleanly.
91
+ * {text} shape so old sessions replay cleanly. The single-active clamp runs
92
+ * here so every path (tool writes, branch replay, legacy sessions) enforces
93
+ * it; `demoted` reports what the clamp changed so callers can tell the model.
79
94
  */
80
95
  export declare function normalizeTodos(raw: unknown): {
81
96
  ok: true;
82
97
  todos: TodoItem[];
98
+ demoted: string[];
83
99
  } | {
84
100
  ok: false;
85
101
  error: string;
86
102
  };
103
+ /**
104
+ * PURE: enforce the single-active invariant on an already-valid list. The
105
+ * model occasionally marks several steps in_progress at once (parallel
106
+ * sub-parts of one block, recorded 4-at-a-time in real sessions); tools
107
+ * execute sequentially so only one can be truthfully "being worked on".
108
+ * Keep the FIRST in list order, demote the rest to pending — quieter than
109
+ * rejecting the write, and the model self-corrects on the next pass since
110
+ * the result echoes the normalized list.
111
+ */
112
+ export declare function clampSingleInProgress(todos: TodoItem[]): {
113
+ todos: TodoItem[];
114
+ demoted: string[];
115
+ };
87
116
  export declare function todoSummary(todos: TodoItem[]): {
88
117
  done: number;
89
118
  total: number;
@@ -130,9 +159,26 @@ export declare function formatTodoOverflow(hidden: TodoItem[]): string | null;
130
159
  * The in-progress row shows the active form in bold (the live "what am I
131
160
  * doing" signal); pending and completed rows show the imperative content.
132
161
  */
133
- export declare function renderTodoWidget(todos: TodoItem[], theme: TodoTheme, completedAtCache?: Map<string, number>, nowMs?: number): string[];
162
+ export declare function renderTodoWidget(todos: TodoItem[], theme: TodoTheme, completedAtCache?: Map<string, number>, nowMs?: number, opts?: {
163
+ idle?: boolean;
164
+ startedAt?: (content: string) => number | undefined;
165
+ }): string[];
134
166
  /** The desktop state record: exactly one JSON line under TODO_STATE_KEY. */
135
167
  export declare function todoStateLine(todos: TodoItem[]): string;
168
+ /**
169
+ * PURE: render an in-progress step's age as a dim suffix, empty while the
170
+ * step is younger than {@link TODO_STALE_MS}. `47m` under an hour, `1h 47m`
171
+ * after — the "is it stuck?" signal the board owes the user at a glance.
172
+ */
173
+ export declare function formatAgeMs(ms: number): string;
174
+ /**
175
+ * PURE: the oldest in-progress step past the staleness threshold, if any —
176
+ * the concrete anchor the aged reminder names instead of the generic nudge.
177
+ */
178
+ export declare function oldestInProgress(todos: TodoItem[], startedAt: ((content: string) => number | undefined) | undefined, nowMs: number): {
179
+ todo: TodoItem;
180
+ age: string;
181
+ } | null;
136
182
  /**
137
183
  * PURE: is a staleness reminder due? Only when the board has open work (an
138
184
  * empty or fully-completed list never nags) and BOTH throttle counters have
@@ -149,7 +195,20 @@ export declare function shouldRemindTodos(input: {
149
195
  * read, and explicitly licenses ignoring it, so an accurate board costs one
150
196
  * glance rather than a spurious TodoWrite.
151
197
  */
152
- export declare function formatTodoReminder(todos: TodoItem[]): string;
198
+ export declare function formatTodoReminder(todos: TodoItem[], opts?: {
199
+ startedAt?: (content: string) => number | undefined;
200
+ nowMs?: number;
201
+ }): string;
202
+ /**
203
+ * PURE: reconcile the two timestamp caches against the next board. Both
204
+ * share the eviction contract — a stamp leaves when its content leaves the
205
+ * board — but startedAt is stricter: a step that stops being in_progress
206
+ * (completed, or demoted to pending by the single-active clamp) drops its
207
+ * stamp, so a later re-activation counts as a NEW active span. Otherwise an
208
+ * in_progress → pending → in_progress gap would bill the idle time between
209
+ * spans to the second one's age.
210
+ */
211
+ export declare function observeTimestamps(next: TodoItem[], completedAt: Map<string, number>, startedAt: Map<string, number>, now: number): void;
153
212
  /** Replay the branch: the last todo-tool result is the canonical list. */
154
213
  export declare function reconstructTodos(entries: unknown[]): TodoItem[];
155
214
  type TodoParams = {
@@ -194,16 +253,6 @@ export declare function makeTodoTool(get: () => TodoItem[], set: (todos: TodoIte
194
253
  details: {
195
254
  todos: TodoItem[];
196
255
  };
197
- isError: boolean;
198
- } | {
199
- content: {
200
- type: "text";
201
- text: string;
202
- }[];
203
- details: {
204
- todos: TodoItem[];
205
- };
206
- isError?: undefined;
207
256
  }>;
208
257
  };
209
258
  /**
@@ -56,6 +56,19 @@ export const TODO_REMINDER_TURNS = 10;
56
56
  * dropping behind open work — Claude Code's TaskList RECENT_COMPLETED_TTL.
57
57
  */
58
58
  export const TODO_COMPLETED_LINGER_MS = 30_000;
59
+ /**
60
+ * Age at which an in-progress step is treated as possibly stale: the aged
61
+ * reminder names the item and its age instead of the generic nudge, and the
62
+ * widget row picks up a dim `(23m)` suffix. Below this the board reads as
63
+ * normal active work.
64
+ */
65
+ export const TODO_STALE_MS = 10 * 60 * 1000;
66
+ /**
67
+ * The board repaints at this cadence while an active step can age, so the
68
+ * `(23m)` suffix stays live. Under the staleness threshold the suffix is
69
+ * hidden entirely, so a slow tick costs nothing.
70
+ */
71
+ export const TODO_REPAINT_MS = 30_000;
59
72
  const WIDGET_KEY = "yagni-todos";
60
73
  /**
61
74
  * The desktop's structured state record rides its own widget key, like the
@@ -92,7 +105,9 @@ function coerceItem(raw) {
92
105
  * Validate a full replacement list. Strict: this is model input rendered
93
106
  * straight into the terminal. An empty list is valid (it clears the board).
94
107
  * Accepts both the current shape ({content, activeForm}) and the legacy
95
- * {text} shape so old sessions replay cleanly.
108
+ * {text} shape so old sessions replay cleanly. The single-active clamp runs
109
+ * here so every path (tool writes, branch replay, legacy sessions) enforces
110
+ * it; `demoted` reports what the clamp changed so callers can tell the model.
96
111
  */
97
112
  export function normalizeTodos(raw) {
98
113
  if (!Array.isArray(raw))
@@ -108,7 +123,32 @@ export function normalizeTodos(raw) {
108
123
  return { ok: false, error: coerced.error };
109
124
  todos.push(coerced);
110
125
  }
111
- return { ok: true, todos };
126
+ const clamped = clampSingleInProgress(todos);
127
+ return { ok: true, todos: clamped.todos, demoted: clamped.demoted };
128
+ }
129
+ /**
130
+ * PURE: enforce the single-active invariant on an already-valid list. The
131
+ * model occasionally marks several steps in_progress at once (parallel
132
+ * sub-parts of one block, recorded 4-at-a-time in real sessions); tools
133
+ * execute sequentially so only one can be truthfully "being worked on".
134
+ * Keep the FIRST in list order, demote the rest to pending — quieter than
135
+ * rejecting the write, and the model self-corrects on the next pass since
136
+ * the result echoes the normalized list.
137
+ */
138
+ export function clampSingleInProgress(todos) {
139
+ const demoted = [];
140
+ let keptActive = false;
141
+ const next = todos.map((t) => {
142
+ if (t.status !== "in_progress")
143
+ return t;
144
+ if (keptActive) {
145
+ demoted.push(t.content);
146
+ return { ...t, status: "pending" };
147
+ }
148
+ keptActive = true;
149
+ return t;
150
+ });
151
+ return demoted.length > 0 ? { todos: next, demoted } : { todos, demoted };
112
152
  }
113
153
  export function todoSummary(todos) {
114
154
  return {
@@ -191,7 +231,8 @@ export function formatTodoOverflow(hidden) {
191
231
  * The in-progress row shows the active form in bold (the live "what am I
192
232
  * doing" signal); pending and completed rows show the imperative content.
193
233
  */
194
- export function renderTodoWidget(todos, theme, completedAtCache = new Map(), nowMs = Date.now()) {
234
+ export function renderTodoWidget(todos, theme, completedAtCache = new Map(), nowMs = Date.now(), opts = {}) {
235
+ const { idle, startedAt } = opts;
195
236
  const { total, done } = todoCounts(todos);
196
237
  if (total === 0 || done === total)
197
238
  return [];
@@ -204,8 +245,16 @@ export function renderTodoWidget(todos, theme, completedAtCache = new Map(), now
204
245
  lines.push(`${theme.fg("success", "✔ ")}${theme.fg("dim", text)}`);
205
246
  }
206
247
  else if (todo.status === "in_progress") {
207
- const active = theme.bold ? theme.bold(`${todo.activeForm}…`) : `${todo.activeForm}…`;
208
- lines.push(`${theme.fg("accent", "◼ ")}${theme.fg("text", active)}`);
248
+ const age = staleAgeSuffix(todo, startedAt, nowMs);
249
+ if (idle) {
250
+ // An idle agent has no "actively doing" claim; keep the row but
251
+ // drop the live-work styling so the board stops pretending.
252
+ lines.push(theme.fg("dim", `◼ ${todo.activeForm}…${age}`));
253
+ }
254
+ else {
255
+ const active = theme.bold ? theme.bold(`${todo.activeForm}…`) : `${todo.activeForm}…`;
256
+ lines.push(`${theme.fg("accent", "◼ ")}${theme.fg("text", active)}${theme.fg("dim", age)}`);
257
+ }
209
258
  }
210
259
  else {
211
260
  lines.push(`${theme.fg("dim", "◻ ")}${theme.fg("muted", todo.content)}`);
@@ -220,6 +269,50 @@ export function renderTodoWidget(todos, theme, completedAtCache = new Map(), now
220
269
  export function todoStateLine(todos) {
221
270
  return JSON.stringify({ v: TODO_STATE_VERSION, todos });
222
271
  }
272
+ /**
273
+ * PURE: render an in-progress step's age as a dim suffix, empty while the
274
+ * step is younger than {@link TODO_STALE_MS}. `47m` under an hour, `1h 47m`
275
+ * after — the "is it stuck?" signal the board owes the user at a glance.
276
+ */
277
+ export function formatAgeMs(ms) {
278
+ const totalMinutes = Math.floor(ms / 60_000);
279
+ if (totalMinutes < 1)
280
+ return "";
281
+ const hours = Math.floor(totalMinutes / 60);
282
+ const minutes = totalMinutes % 60;
283
+ return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
284
+ }
285
+ function staleAgeSuffix(todo, startedAt, nowMs) {
286
+ const at = startedAt?.(todo.content);
287
+ if (at === undefined)
288
+ return "";
289
+ const age = nowMs - at;
290
+ if (age < TODO_STALE_MS)
291
+ return "";
292
+ const text = formatAgeMs(age);
293
+ return text ? ` (${text})` : "";
294
+ }
295
+ /**
296
+ * PURE: the oldest in-progress step past the staleness threshold, if any —
297
+ * the concrete anchor the aged reminder names instead of the generic nudge.
298
+ */
299
+ export function oldestInProgress(todos, startedAt, nowMs) {
300
+ let worst = null;
301
+ for (const t of todos) {
302
+ if (t.status !== "in_progress")
303
+ continue;
304
+ const at = startedAt?.(t.content);
305
+ if (at === undefined)
306
+ continue;
307
+ const ageMs = nowMs - at;
308
+ if (ageMs >= TODO_STALE_MS && (!worst || ageMs > worst.ageMs))
309
+ worst = { todo: t, ageMs };
310
+ }
311
+ if (!worst)
312
+ return null;
313
+ const age = formatAgeMs(worst.ageMs);
314
+ return age ? { todo: worst.todo, age } : null;
315
+ }
223
316
  /**
224
317
  * PURE: is a staleness reminder due? Only when the board has open work (an
225
318
  * empty or fully-completed list never nags) and BOTH throttle counters have
@@ -240,13 +333,54 @@ export function shouldRemindTodos(input) {
240
333
  * read, and explicitly licenses ignoring it, so an accurate board costs one
241
334
  * glance rather than a spurious TodoWrite.
242
335
  */
243
- export function formatTodoReminder(todos) {
336
+ export function formatTodoReminder(todos, opts = {}) {
337
+ const stale = oldestInProgress(todos, opts.startedAt, opts.nowMs ?? Date.now());
338
+ if (stale) {
339
+ // A concrete, named accusation with a number on it — the generic nudge
340
+ // lost 20 times in a row to a busy context in the recorded session that
341
+ // motivated this.
342
+ return (`⟦YAGNI todos⟧ "${stale.todo.content}" has been in_progress for ${stale.age} — if it is ` +
343
+ "done or superseded, mark it completed or remove it; if you are still working on it, " +
344
+ "ignore this and keep the board current as you go.\n" +
345
+ formatTodoList(todos));
346
+ }
244
347
  return ("⟦YAGNI todos⟧ The TodoWrite checklist has not been updated for a while. " +
245
348
  "If the work has moved on, bring it current now: mark finished steps completed, " +
246
- "set the step you are on to in_progress, and add newly discovered steps. " +
349
+ "set the step you are on to in_progress, and add newly discovered follow-up steps. " +
247
350
  "If the list is already accurate, ignore this.\n" +
248
351
  formatTodoList(todos));
249
352
  }
353
+ /**
354
+ * PURE: reconcile the two timestamp caches against the next board. Both
355
+ * share the eviction contract — a stamp leaves when its content leaves the
356
+ * board — but startedAt is stricter: a step that stops being in_progress
357
+ * (completed, or demoted to pending by the single-active clamp) drops its
358
+ * stamp, so a later re-activation counts as a NEW active span. Otherwise an
359
+ * in_progress → pending → in_progress gap would bill the idle time between
360
+ * spans to the second one's age.
361
+ */
362
+ export function observeTimestamps(next, completedAt, startedAt, now) {
363
+ const seen = new Set(next.map((t) => t.content));
364
+ for (const [content] of completedAt) {
365
+ if (!seen.has(content))
366
+ completedAt.delete(content);
367
+ }
368
+ for (const [content] of startedAt) {
369
+ if (!seen.has(content))
370
+ startedAt.delete(content);
371
+ }
372
+ const activeNow = new Set(next.filter((t) => t.status === "in_progress").map((t) => t.content));
373
+ for (const [content] of startedAt) {
374
+ if (!activeNow.has(content))
375
+ startedAt.delete(content);
376
+ }
377
+ for (const t of next) {
378
+ if (t.status === "completed" && !completedAt.has(t.content))
379
+ completedAt.set(t.content, now);
380
+ if (t.status === "in_progress" && !startedAt.has(t.content))
381
+ startedAt.set(t.content, now);
382
+ }
383
+ }
250
384
  /** Replay the branch: the last todo-tool result is the canonical list. */
251
385
  export function reconstructTodos(entries) {
252
386
  let todos = [];
@@ -303,7 +437,7 @@ function todoRenderers() {
303
437
  },
304
438
  };
305
439
  }
306
- function paintWidget(ctx, todos, completedAt) {
440
+ function paintWidget(ctx, todos, completedAt, opts = {}) {
307
441
  if (!ctx?.hasUI)
308
442
  return;
309
443
  try {
@@ -318,7 +452,10 @@ function paintWidget(ctx, todos, completedAt) {
318
452
  return;
319
453
  }
320
454
  const theme = ctx.ui.theme;
321
- const lines = renderTodoWidget(todos, theme, completedAt);
455
+ const lines = renderTodoWidget(todos, theme, completedAt, Date.now(), {
456
+ idle: opts.idle,
457
+ startedAt: opts.startedAt ? (c) => opts.startedAt.get(c) : undefined,
458
+ });
322
459
  ctx.ui.setWidget?.(WIDGET_KEY, lines.length > 0 ? lines : undefined, {
323
460
  placement: "aboveEditor",
324
461
  });
@@ -367,16 +504,30 @@ export function makeTodoTool(get, set, completedAt) {
367
504
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
368
505
  const normalized = normalizeTodos(params.todos);
369
506
  if (!normalized.ok) {
370
- return {
371
- content: [{ type: "text", text: `Error: ${normalized.error}` }],
372
- details: { todos: get() },
373
- isError: true,
374
- };
507
+ // pi signals tool errors by THROWING (a returned result is always
508
+ // isError=false; the returned `isError` field was never read), so the
509
+ // error text must ride the thrown message to reach the model as an
510
+ // error.
511
+ throw new Error(`Error: ${normalized.error}`);
512
+ }
513
+ if (normalized.demoted.length > 0) {
514
+ logEvent({
515
+ source: "todos",
516
+ level: "info",
517
+ event: "multi_in_progress_clamped",
518
+ fields: { demoted: normalized.demoted, count: normalized.demoted.length },
519
+ });
375
520
  }
376
521
  set(normalized.todos);
377
522
  paintWidget(ctx, normalized.todos, completedAt);
523
+ const note = normalized.demoted.length > 0
524
+ ? `Normalized: kept "${normalized.todos.find((t) => t.status === "in_progress")?.content}" ` +
525
+ `as the single in_progress task; demoted ${normalized.demoted
526
+ .map((c) => `"${c}"`)
527
+ .join(", ")} to pending.\n\n`
528
+ : "";
378
529
  return {
379
- content: [{ type: "text", text: `${formatTodoList(normalized.todos)}\n\n${TODO_RESULT_ECHO}` }],
530
+ content: [{ type: "text", text: `${formatTodoList(normalized.todos)}\n\n${note}${TODO_RESULT_ECHO}` }],
380
531
  details: { todos: normalized.todos },
381
532
  };
382
533
  },
@@ -397,18 +548,49 @@ export function registerTodos(pi) {
397
548
  // passes), re-floating it every few minutes. prioritizeTodos already treats
398
549
  // an aged-out stamp as "older"; the cache never needs a sweeper.
399
550
  const completedAt = new Map();
400
- const observe = (next, now = Date.now()) => {
401
- const seen = new Set(next.map((t) => t.content));
402
- for (const [content] of completedAt) {
403
- if (!seen.has(content))
404
- completedAt.delete(content);
405
- }
406
- for (const t of next) {
407
- if (t.status === "completed" && !completedAt.has(t.content))
408
- completedAt.set(t.content, now);
551
+ // When each open step entered in_progress the aged reminder and the
552
+ // widget's `(23m)` suffix key off this. Same eviction contract as
553
+ // completedAt: entries leave when the content leaves the board; a
554
+ // re-entering item gets a fresh stamp (a restart of the same work is
555
+ // genuinely a new active span).
556
+ const startedAt = new Map();
557
+ // True while the agent is NOT running (between turns, awaiting input)
558
+ // an idle board must not style its active row as live work.
559
+ let idle = true;
560
+ let repaintTimer;
561
+ const hasActive = () => todos.some((t) => t.status === "in_progress");
562
+ // Repaint while an active step can age: the (23m) suffix and idle styling
563
+ // would otherwise freeze at the last write. TUI only (desktop gets a
564
+ // paint on every state change and renders its own board); unref'd so a
565
+ // batched test process never lingers on it, and torn down whenever the
566
+ // board empties or the session restarts.
567
+ const ensureRepaintTimer = (ctx) => {
568
+ if (!ctx.hasUI || isDesktopSurface() || repaintTimer || !hasActive())
569
+ return;
570
+ repaintTimer = setInterval(() => {
571
+ // hasUI is captured at registration; ctx here is the tool/refresh ctx.
572
+ paintWidget(latestCtx ?? undefined, todos, completedAt, { idle, startedAt });
573
+ }, TODO_REPAINT_MS);
574
+ repaintTimer.unref?.();
575
+ };
576
+ const clearRepaintTimer = () => {
577
+ if (repaintTimer) {
578
+ clearInterval(repaintTimer);
579
+ repaintTimer = undefined;
409
580
  }
410
581
  };
582
+ let latestCtx;
583
+ const rememberCtx = (ctx) => {
584
+ latestCtx = ctx;
585
+ };
586
+ const observe = (next, now = Date.now()) => {
587
+ observeTimestamps(next, completedAt, startedAt, now);
588
+ };
411
589
  const reconstruct = (ctx) => {
590
+ // Clear-then-ensure: a session switch/fork must never inherit the
591
+ // previous session's repaint interval — an empty replayed board would
592
+ // otherwise leave the old 30s timer firing at a dead pane forever.
593
+ clearRepaintTimer();
412
594
  try {
413
595
  todos = reconstructTodos(ctx.sessionManager.getBranch());
414
596
  }
@@ -427,17 +609,48 @@ export function registerTodos(pi) {
427
609
  // completed items from history rank as "older" (outside the linger
428
610
  // window), exactly like a live item whose tick has aged out.
429
611
  completedAt.clear();
612
+ startedAt.clear();
430
613
  for (const t of todos) {
431
614
  if (t.status === "completed") {
432
615
  completedAt.set(t.content, Date.now() - TODO_COMPLETED_LINGER_MS - 1);
433
616
  }
617
+ if (t.status === "in_progress") {
618
+ // Age counts from resume — the true start time is unknowable
619
+ // post-hoc, and stamping fresh keeps the suffix from instantly
620
+ // showing a fabricated age.
621
+ startedAt.set(t.content, Date.now());
622
+ }
434
623
  }
435
624
  turnsSinceWrite = 0;
436
625
  turnsSinceReminder = 0;
437
- paintWidget(ctx, todos);
626
+ rememberCtx(ctx);
627
+ idle = true;
628
+ paintWidget(ctx, todos, completedAt, { idle, startedAt });
629
+ if (hasActive())
630
+ ensureRepaintTimer(ctx);
438
631
  };
439
632
  pi.on("session_start", async (_event, ctx) => reconstruct(ctx));
440
633
  pi.on("session_tree", async (_event, ctx) => reconstruct(ctx));
634
+ // The live-work signal: an idle agent's active row dims. Fires on every
635
+ // agent loop, cheap on both sides, and repaints immediately so the dim
636
+ // lands without waiting for the next paint-on-write.
637
+ pi.on("agent_start", async (_event, ctx) => {
638
+ idle = false;
639
+ rememberCtx(ctx);
640
+ if (hasActive())
641
+ ensureRepaintTimer(ctx);
642
+ paintWidget(ctx, todos, completedAt, { idle, startedAt });
643
+ });
644
+ // Idle keeps the timer alive on purpose: the aging suffix on a dimmed row
645
+ // is exactly the "is it stuck?" signal the user watches while the agent
646
+ // waits for input. The timer dies when the board empties, not here.
647
+ pi.on("agent_end", async (_event, ctx) => {
648
+ idle = true;
649
+ rememberCtx(ctx);
650
+ if (hasActive())
651
+ ensureRepaintTimer(ctx);
652
+ paintWidget(ctx, todos, completedAt, { idle, startedAt });
653
+ });
441
654
  // Turn counting: one tick per finalized assistant message, the same "turn"
442
655
  // the model experiences between opportunities to call TodoWrite.
443
656
  pi.on("message_end", async (event) => {
@@ -460,7 +673,10 @@ export function registerTodos(pi) {
460
673
  return {
461
674
  content: [
462
675
  ...event.content,
463
- { type: "text", text: `\n\n${formatTodoReminder(todos)}` },
676
+ {
677
+ type: "text",
678
+ text: `\n\n${formatTodoReminder(todos, { startedAt: (c) => startedAt.get(c) })}`,
679
+ },
464
680
  ],
465
681
  };
466
682
  }
@@ -472,6 +688,18 @@ export function registerTodos(pi) {
472
688
  todos = next;
473
689
  observe(next);
474
690
  turnsSinceWrite = 0;
691
+ if (!hasActive())
692
+ clearRepaintTimer();
693
+ // Repaint here with the full live opts (idle state, startedAt ages)
694
+ // — the tool's own paint is ctx-bound but opts-less, so the age
695
+ // suffix and idle dimming land through this pass. Also (re)arm the
696
+ // repaint timer: a write that introduces the first active step
697
+ // shouldn't wait for the next agent event to start ticking.
698
+ if (latestCtx) {
699
+ paintWidget(latestCtx, todos, completedAt, { idle, startedAt });
700
+ if (hasActive())
701
+ ensureRepaintTimer(latestCtx);
702
+ }
475
703
  }, completedAt));
476
704
  pi.registerCommand("todos", {
477
705
  description: "Show the agent's current task list for this session.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.3-staging.1376.1",
3
+ "version": "1.1.3-staging.1378.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "ad5363e506fdbfaa52dc79d778d3488661659426"
61
+ "yagniSourceSha": "cc4bcf38093fc70fc51ed4b712777566307a1e72"
62
62
  }