@yagni-app/code-staging 1.1.3-staging.1377.1 → 1.1.3-staging.1380.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/crashReport.d.ts +18 -0
- package/dist/extension/crashReport.js +66 -2
- package/dist/extension/index.d.ts +2 -2
- package/dist/extension/index.js +9 -4
- package/dist/extension/surface.d.ts +1 -1
- package/dist/extension/surface.js +2 -2
- package/dist/extension/todos.d.ts +62 -3
- package/dist/extension/todos.js +249 -21
- package/package.json +2 -2
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* `backend/src/yagniCode/crashReports.ts`. Spec:
|
|
25
25
|
* docs/superpowers/specs/2026-08-08-crash-reporting-design.md
|
|
26
26
|
*/
|
|
27
|
+
import { type SinkEvent } from "./errorSink.js";
|
|
27
28
|
export declare const CRASH_REPORT_DISABLE_ENV = "YAGNI_DISABLE_CRASH_REPORTS";
|
|
28
29
|
export declare const CRASH_REPORT_TIMEOUT_MS = 1500;
|
|
29
30
|
export declare const MAX_CRASH_MESSAGE = 512;
|
|
@@ -97,13 +98,30 @@ export type SpawnLike = (command: string, args: string[], options: {
|
|
|
97
98
|
export interface FatalCrashOpts extends CrashReporterOpts {
|
|
98
99
|
/** Spawn seam for tests (defaults to node:child_process spawn). */
|
|
99
100
|
spawnImpl?: SpawnLike;
|
|
101
|
+
/** Local error-trail seam for tests (defaults to errorSink's logEvent). */
|
|
102
|
+
trail?: (event: SinkEvent) => void;
|
|
100
103
|
}
|
|
104
|
+
/** Test seam: forget that a fatal report went out, and that a reporter is installed. */
|
|
105
|
+
export declare function resetFatalReportedForTests(): void;
|
|
101
106
|
/**
|
|
102
107
|
* Deliver a crash report from a process that is about to die: build the
|
|
103
108
|
* sanitized payload in-process (cheap, synchronous), then spawn a detached
|
|
104
109
|
* one-shot sender that outlives the crash. Never throws.
|
|
105
110
|
*/
|
|
106
111
|
export declare function reportFatalCrash(error: unknown, opts: FatalCrashOpts, context?: string): void;
|
|
112
|
+
/**
|
|
113
|
+
* Report a pi process that ends with a non-zero code WITHOUT an uncaught
|
|
114
|
+
* exception. pi's own fatal paths print to stderr and `process.exit(1)`, which
|
|
115
|
+
* the exception monitor never sees, so until now those deaths were invisible
|
|
116
|
+
* unless the user pasted their terminal. An `exit` listener observes only: it
|
|
117
|
+
* cannot keep the process alive or change the code, and the detached sender
|
|
118
|
+
* is spawned synchronously, which is all an `exit` handler is allowed to do.
|
|
119
|
+
*
|
|
120
|
+
* Skipped on the desktop surface, where the shell files a richer report built
|
|
121
|
+
* from the driver's stderr tail (one incident, one report), and for the two
|
|
122
|
+
* "stopped on purpose" codes.
|
|
123
|
+
*/
|
|
124
|
+
export declare function installNonZeroExitReporter(opts: FatalCrashOpts, proc?: Pick<NodeJS.Process, "on">): void;
|
|
107
125
|
/**
|
|
108
126
|
* Observe (never alter) a fatal crash in pi's process. Uses
|
|
109
127
|
* `uncaughtExceptionMonitor`, which fires before the process dies without
|
|
@@ -170,7 +170,7 @@ export function makeCrashReporter(opts) {
|
|
|
170
170
|
const token = opts.getToken();
|
|
171
171
|
const sanitized = sanitizeCrashError(error, { env, repoRoot });
|
|
172
172
|
const payload = {
|
|
173
|
-
client: isDesktopSurface() ? "desktop" : "cli",
|
|
173
|
+
client: isDesktopSurface(env) ? "desktop" : "cli",
|
|
174
174
|
clientVersion: env.YAGNI_CODE_VERSION?.trim() || "unknown",
|
|
175
175
|
platform: platformLabel(),
|
|
176
176
|
...sanitized,
|
|
@@ -226,6 +226,19 @@ const DETACHED_SENDER_SRC = [
|
|
|
226
226
|
" signal: AbortSignal.timeout(4000),",
|
|
227
227
|
"}).catch(() => {}).finally(done);",
|
|
228
228
|
].join("\n");
|
|
229
|
+
/**
|
|
230
|
+
* Set once a fatal report has been handed to a sender, so the exit reporter
|
|
231
|
+
* never files a second report for the same death (an uncaught exception is
|
|
232
|
+
* followed by an exit-1, and both hooks would otherwise fire).
|
|
233
|
+
*/
|
|
234
|
+
let fatalReported = false;
|
|
235
|
+
/** The exit reporter is per process, not per extension load (a reload re-runs the factory). */
|
|
236
|
+
let exitReporterInstalled = false;
|
|
237
|
+
/** Test seam: forget that a fatal report went out, and that a reporter is installed. */
|
|
238
|
+
export function resetFatalReportedForTests() {
|
|
239
|
+
fatalReported = false;
|
|
240
|
+
exitReporterInstalled = false;
|
|
241
|
+
}
|
|
229
242
|
/**
|
|
230
243
|
* Deliver a crash report from a process that is about to die: build the
|
|
231
244
|
* sanitized payload in-process (cheap, synchronous), then spawn a detached
|
|
@@ -241,7 +254,7 @@ export function reportFatalCrash(error, opts, context) {
|
|
|
241
254
|
return;
|
|
242
255
|
const sanitized = sanitizeCrashError(error, { env });
|
|
243
256
|
const payload = {
|
|
244
|
-
client: isDesktopSurface() ? "desktop" : "cli",
|
|
257
|
+
client: isDesktopSurface(env) ? "desktop" : "cli",
|
|
245
258
|
clientVersion: env.YAGNI_CODE_VERSION?.trim() || "unknown",
|
|
246
259
|
platform: platformLabel(),
|
|
247
260
|
...sanitized,
|
|
@@ -263,11 +276,62 @@ export function reportFatalCrash(error, opts, context) {
|
|
|
263
276
|
},
|
|
264
277
|
});
|
|
265
278
|
child.unref();
|
|
279
|
+
fatalReported = true;
|
|
266
280
|
}
|
|
267
281
|
catch {
|
|
268
282
|
// a crash reporter must never add its own crash
|
|
269
283
|
}
|
|
270
284
|
}
|
|
285
|
+
/** Exit codes that mean "someone stopped it" (Ctrl+C, SIGTERM), not "it broke". */
|
|
286
|
+
const STOPPED_EXIT_CODES = new Set([130, 143]);
|
|
287
|
+
/**
|
|
288
|
+
* Report a pi process that ends with a non-zero code WITHOUT an uncaught
|
|
289
|
+
* exception. pi's own fatal paths print to stderr and `process.exit(1)`, which
|
|
290
|
+
* the exception monitor never sees, so until now those deaths were invisible
|
|
291
|
+
* unless the user pasted their terminal. An `exit` listener observes only: it
|
|
292
|
+
* cannot keep the process alive or change the code, and the detached sender
|
|
293
|
+
* is spawned synchronously, which is all an `exit` handler is allowed to do.
|
|
294
|
+
*
|
|
295
|
+
* Skipped on the desktop surface, where the shell files a richer report built
|
|
296
|
+
* from the driver's stderr tail (one incident, one report), and for the two
|
|
297
|
+
* "stopped on purpose" codes.
|
|
298
|
+
*/
|
|
299
|
+
export function installNonZeroExitReporter(opts, proc = process) {
|
|
300
|
+
// Once per process: pi re-runs the extension factory on every reload, and
|
|
301
|
+
// a listener per reload would both pile up and race each other on exit.
|
|
302
|
+
if (exitReporterInstalled)
|
|
303
|
+
return;
|
|
304
|
+
exitReporterInstalled = true;
|
|
305
|
+
proc.on("exit", (code) => {
|
|
306
|
+
try {
|
|
307
|
+
if (code === 0 || STOPPED_EXIT_CODES.has(code) || fatalReported)
|
|
308
|
+
return;
|
|
309
|
+
const env = opts.env ?? process.env;
|
|
310
|
+
// The same source the payload's client label reads, so the skip and the
|
|
311
|
+
// label can never disagree about which surface this is.
|
|
312
|
+
if (isDesktopSurface(env))
|
|
313
|
+
return;
|
|
314
|
+
const error = new Error(`pi exited with code ${code}`);
|
|
315
|
+
error.name = "ProcessExit";
|
|
316
|
+
// The listener's own frames say nothing about why pi exited.
|
|
317
|
+
error.stack = undefined;
|
|
318
|
+
reportFatalCrash(error, opts, `process-exit:${code}`);
|
|
319
|
+
// The local trail too, so a logged-out user (no token, nothing sent)
|
|
320
|
+
// still has a record /feedback can bind next session.
|
|
321
|
+
(opts.trail ?? logEvent)({
|
|
322
|
+
source: "tool",
|
|
323
|
+
level: "error",
|
|
324
|
+
event: "process_exit",
|
|
325
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
326
|
+
flush: "sync",
|
|
327
|
+
fields: { code },
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
// an exit hook must never throw
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
}
|
|
271
335
|
/**
|
|
272
336
|
* Observe (never alter) a fatal crash in pi's process. Uses
|
|
273
337
|
* `uncaughtExceptionMonitor`, which fires before the process dies without
|
|
@@ -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";
|
|
@@ -225,6 +225,6 @@ export { ToolRunTracker, summarizeRun, isQuiet, kindForTool } from "./toolRuns.j
|
|
|
225
225
|
export type { RowKind, ToolRow, ToolRowPatch } from "./toolRuns.js";
|
|
226
226
|
export { registerWorkingLine, composeWorkingMessage, NULL_WORKING_LINE, WORKING_INDICATOR_FRAMES, WORKING_VERBS, } from "./workingLine.js";
|
|
227
227
|
export type { WorkingLineHandle, RegisterWorkingLineDeps } from "./workingLine.js";
|
|
228
|
-
export { crashReportsDisabled, installUncaughtExceptionMonitor, makeCrashReporter, reportFatalCrash, sanitizeCrashError, sanitizeCrashText, } from "./crashReport.js";
|
|
228
|
+
export { crashReportsDisabled, installNonZeroExitReporter, installUncaughtExceptionMonitor, makeCrashReporter, reportFatalCrash, sanitizeCrashError, sanitizeCrashText, } from "./crashReport.js";
|
|
229
229
|
export type { CrashReporter, CrashReporterOpts, FatalCrashOpts, SanitizedCrash } from "./crashReport.js";
|
|
230
230
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/extension/index.js
CHANGED
|
@@ -53,7 +53,7 @@ import { loadGroundingEnabled } from "./grounding.js";
|
|
|
53
53
|
import { attributionPromptSection, loadAttributionSettings, } from "./attribution.js";
|
|
54
54
|
import { registerAmbientRecall } from "./recall.js";
|
|
55
55
|
import { resilientFetch } from "./resilientFetch.js";
|
|
56
|
-
import { installUncaughtExceptionMonitor, makeCrashReporter, runningUnderTest } from "./crashReport.js";
|
|
56
|
+
import { installNonZeroExitReporter, installUncaughtExceptionMonitor, makeCrashReporter, runningUnderTest, } from "./crashReport.js";
|
|
57
57
|
import { createToolOutcomeBatcher } from "./toolOutcomes.js";
|
|
58
58
|
import { flushSpool as defaultFlushSpool } from "./spool.js";
|
|
59
59
|
import { makeAuthedFetch, makeTokenProvider } from "./tokenProvider.js";
|
|
@@ -252,7 +252,12 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
252
252
|
// soft, opt-out via YAGNI_DISABLE_CRASH_REPORTS=1; off in eval mode like
|
|
253
253
|
// every other external side effect.
|
|
254
254
|
if (!evalMode) {
|
|
255
|
-
|
|
255
|
+
const fatalOpts = { baseUrl, getToken: getTokenFn, env: deps.env };
|
|
256
|
+
installUncaughtExceptionMonitor(fatalOpts);
|
|
257
|
+
// A pi that exits non-zero without throwing (its own fatal paths) is a
|
|
258
|
+
// death the monitor never sees; report it too, so a terminal user's
|
|
259
|
+
// "it just quit" reaches Sentry without a pasted transcript.
|
|
260
|
+
installNonZeroExitReporter(fatalOpts);
|
|
256
261
|
}
|
|
257
262
|
// YAG-500 Fix E: non-fatal auth-event reporter for 401s on the model path.
|
|
258
263
|
// Reuses the crash endpoint (/api/yagni-code/crash) with a distinct context
|
|
@@ -1844,7 +1849,7 @@ export { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
|
|
|
1844
1849
|
// The general subagent tool: Claude Code-format agent discovery + fan-out.
|
|
1845
1850
|
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";
|
|
1846
1851
|
// The session todo checklist: TodoWrite tool, widget renderer, /todos.
|
|
1847
|
-
export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, MAX_TODOS, } from "./todos.js";
|
|
1852
|
+
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";
|
|
1848
1853
|
// P3 + W4: the permission gate seam (decideGate is pure; policy injectable) plus
|
|
1849
1854
|
// the session bless-with-remember capture hook.
|
|
1850
1855
|
export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission/gate.js";
|
|
@@ -1870,5 +1875,5 @@ export { appendToSpool, loadSpool, flushSpool, sendOrSpool, spoolFile, MAX_SPOOL
|
|
|
1870
1875
|
export { registerCondensedTools, displayPath, isScratchpadPath, primaryArg, formatRowTitle, formatWriteBody, formatEditBody, formatBashErrorBody, formatBashPartialBody, formatExpandedOutput, splitBashError, countPatchAdditions, WRITE_PREVIEW_LINES, DIFF_PREVIEW_LINES, } from "./condensedTools.js";
|
|
1871
1876
|
export { ToolRunTracker, summarizeRun, isQuiet, kindForTool } from "./toolRuns.js";
|
|
1872
1877
|
export { registerWorkingLine, composeWorkingMessage, NULL_WORKING_LINE, WORKING_INDICATOR_FRAMES, WORKING_VERBS, } from "./workingLine.js";
|
|
1873
|
-
export { crashReportsDisabled, installUncaughtExceptionMonitor, makeCrashReporter, reportFatalCrash, sanitizeCrashError, sanitizeCrashText, } from "./crashReport.js";
|
|
1878
|
+
export { crashReportsDisabled, installNonZeroExitReporter, installUncaughtExceptionMonitor, makeCrashReporter, reportFatalCrash, sanitizeCrashError, sanitizeCrashText, } from "./crashReport.js";
|
|
1874
1879
|
//# sourceMappingURL=index.js.map
|
|
@@ -6,5 +6,5 @@
|
|
|
6
6
|
* Desktop-facing widgets are structured single-line JSON records the app
|
|
7
7
|
* parses, not themed terminal lines.
|
|
8
8
|
*/
|
|
9
|
-
export declare function isDesktopSurface(): boolean;
|
|
9
|
+
export declare function isDesktopSurface(env?: NodeJS.ProcessEnv): boolean;
|
|
10
10
|
//# sourceMappingURL=surface.d.ts.map
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Desktop-facing widgets are structured single-line JSON records the app
|
|
7
7
|
* parses, not themed terminal lines.
|
|
8
8
|
*/
|
|
9
|
-
export function isDesktopSurface() {
|
|
10
|
-
return
|
|
9
|
+
export function isDesktopSurface(env = process.env) {
|
|
10
|
+
return env.YAGNI_SURFACE === "desktop";
|
|
11
11
|
}
|
|
12
12
|
//# sourceMappingURL=surface.js.map
|
|
@@ -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 = {
|
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
|
});
|
|
@@ -373,10 +510,24 @@ export function makeTodoTool(get, set, completedAt) {
|
|
|
373
510
|
// error.
|
|
374
511
|
throw new Error(`Error: ${normalized.error}`);
|
|
375
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
|
+
});
|
|
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.1380.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": "2924db07168a415885dd5ac2ae552e9b121f4736"
|
|
62
62
|
}
|