@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.
- package/dist/extension/index.d.ts +1 -1
- package/dist/extension/index.js +17 -9
- package/dist/extension/pipeline/resilience.d.ts +10 -0
- package/dist/extension/pipeline/resilience.js +24 -7
- package/dist/extension/pipeline/runner.d.ts +14 -0
- package/dist/extension/pipeline/types.d.ts +8 -0
- package/dist/extension/pipeline/types.js +1 -0
- package/dist/extension/subagentRender.d.ts +2 -0
- package/dist/extension/subagents.d.ts +34 -10
- package/dist/extension/subagents.js +164 -20
- package/dist/extension/todos.d.ts +62 -13
- package/dist/extension/todos.js +254 -26
- package/package.json +2 -2
|
@@ -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";
|
package/dist/extension/index.js
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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).
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
565
|
-
|
|
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
|
|
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[]
|
|
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
|
/**
|
package/dist/extension/todos.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
208
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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.
|
|
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": "
|
|
61
|
+
"yagniSourceSha": "cc4bcf38093fc70fc51ed4b712777566307a1e72"
|
|
62
62
|
}
|