@yagni-app/code-staging 1.1.3-staging.1377.1 → 1.1.3-staging.1378.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -199,7 +199,7 @@ export type { ComparisonReport, LaneFit, LaneOutcome } from "./pipeline/eval.js"
199
199
  export { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
200
200
  export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkdown, buildSubagentStage, formatAgentList, mapModelTier, SUBAGENT_TOOL_NAME, GENERAL_AGENT_NAME, MAX_PARALLEL_SUBAGENTS, MAX_PARALLEL_SUBAGENTS_ULTRA, DEFAULT_SUBAGENT_TOOLS, } from "./subagents.js";
201
201
  export type { SubagentDef, SubagentSource } from "./subagents.js";
202
- export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, MAX_TODOS, } from "./todos.js";
202
+ export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, clampSingleInProgress, formatAgeMs, oldestInProgress, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, TODO_STALE_MS, MAX_TODOS, } from "./todos.js";
203
203
  export type { TodoItem, TodoStatus, TodoTheme } from "./todos.js";
204
204
  export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission/gate.js";
205
205
  export { classifyCommand, DEFAULT_EXEC_POLICY, } from "./permission/execPolicy.js";
@@ -1844,7 +1844,7 @@ export { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
1844
1844
  // The general subagent tool: Claude Code-format agent discovery + fan-out.
1845
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";
1846
1846
  // 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";
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";
1848
1848
  // P3 + W4: the permission gate seam (decideGate is pure; policy injectable) plus
1849
1849
  // the session bless-with-remember capture hook.
1850
1850
  export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission/gate.js";
@@ -55,6 +55,19 @@ export declare const TODO_REMINDER_TURNS = 10;
55
55
  * dropping behind open work — Claude Code's TaskList RECENT_COMPLETED_TTL.
56
56
  */
57
57
  export declare const TODO_COMPLETED_LINGER_MS = 30000;
58
+ /**
59
+ * Age at which an in-progress step is treated as possibly stale: the aged
60
+ * reminder names the item and its age instead of the generic nudge, and the
61
+ * widget row picks up a dim `(23m)` suffix. Below this the board reads as
62
+ * normal active work.
63
+ */
64
+ export declare const TODO_STALE_MS: number;
65
+ /**
66
+ * The board repaints at this cadence while an active step can age, so the
67
+ * `(23m)` suffix stays live. Under the staleness threshold the suffix is
68
+ * hidden entirely, so a slow tick costs nothing.
69
+ */
70
+ export declare const TODO_REPAINT_MS = 30000;
58
71
  /**
59
72
  * The desktop's structured state record rides its own widget key, like the
60
73
  * `/go` run state: one JSON line the app parses and renders itself, never
@@ -75,15 +88,31 @@ export interface TodoItem {
75
88
  * Validate a full replacement list. Strict: this is model input rendered
76
89
  * straight into the terminal. An empty list is valid (it clears the board).
77
90
  * Accepts both the current shape ({content, activeForm}) and the legacy
78
- * {text} shape so old sessions replay cleanly.
91
+ * {text} shape so old sessions replay cleanly. The single-active clamp runs
92
+ * here so every path (tool writes, branch replay, legacy sessions) enforces
93
+ * it; `demoted` reports what the clamp changed so callers can tell the model.
79
94
  */
80
95
  export declare function normalizeTodos(raw: unknown): {
81
96
  ok: true;
82
97
  todos: TodoItem[];
98
+ demoted: string[];
83
99
  } | {
84
100
  ok: false;
85
101
  error: string;
86
102
  };
103
+ /**
104
+ * PURE: enforce the single-active invariant on an already-valid list. The
105
+ * model occasionally marks several steps in_progress at once (parallel
106
+ * sub-parts of one block, recorded 4-at-a-time in real sessions); tools
107
+ * execute sequentially so only one can be truthfully "being worked on".
108
+ * Keep the FIRST in list order, demote the rest to pending — quieter than
109
+ * rejecting the write, and the model self-corrects on the next pass since
110
+ * the result echoes the normalized list.
111
+ */
112
+ export declare function clampSingleInProgress(todos: TodoItem[]): {
113
+ todos: TodoItem[];
114
+ demoted: string[];
115
+ };
87
116
  export declare function todoSummary(todos: TodoItem[]): {
88
117
  done: number;
89
118
  total: number;
@@ -130,9 +159,26 @@ export declare function formatTodoOverflow(hidden: TodoItem[]): string | null;
130
159
  * The in-progress row shows the active form in bold (the live "what am I
131
160
  * doing" signal); pending and completed rows show the imperative content.
132
161
  */
133
- export declare function renderTodoWidget(todos: TodoItem[], theme: TodoTheme, completedAtCache?: Map<string, number>, nowMs?: number): string[];
162
+ export declare function renderTodoWidget(todos: TodoItem[], theme: TodoTheme, completedAtCache?: Map<string, number>, nowMs?: number, opts?: {
163
+ idle?: boolean;
164
+ startedAt?: (content: string) => number | undefined;
165
+ }): string[];
134
166
  /** The desktop state record: exactly one JSON line under TODO_STATE_KEY. */
135
167
  export declare function todoStateLine(todos: TodoItem[]): string;
168
+ /**
169
+ * PURE: render an in-progress step's age as a dim suffix, empty while the
170
+ * step is younger than {@link TODO_STALE_MS}. `47m` under an hour, `1h 47m`
171
+ * after — the "is it stuck?" signal the board owes the user at a glance.
172
+ */
173
+ export declare function formatAgeMs(ms: number): string;
174
+ /**
175
+ * PURE: the oldest in-progress step past the staleness threshold, if any —
176
+ * the concrete anchor the aged reminder names instead of the generic nudge.
177
+ */
178
+ export declare function oldestInProgress(todos: TodoItem[], startedAt: ((content: string) => number | undefined) | undefined, nowMs: number): {
179
+ todo: TodoItem;
180
+ age: string;
181
+ } | null;
136
182
  /**
137
183
  * PURE: is a staleness reminder due? Only when the board has open work (an
138
184
  * empty or fully-completed list never nags) and BOTH throttle counters have
@@ -149,7 +195,20 @@ export declare function shouldRemindTodos(input: {
149
195
  * read, and explicitly licenses ignoring it, so an accurate board costs one
150
196
  * glance rather than a spurious TodoWrite.
151
197
  */
152
- export declare function formatTodoReminder(todos: TodoItem[]): string;
198
+ export declare function formatTodoReminder(todos: TodoItem[], opts?: {
199
+ startedAt?: (content: string) => number | undefined;
200
+ nowMs?: number;
201
+ }): string;
202
+ /**
203
+ * PURE: reconcile the two timestamp caches against the next board. Both
204
+ * share the eviction contract — a stamp leaves when its content leaves the
205
+ * board — but startedAt is stricter: a step that stops being in_progress
206
+ * (completed, or demoted to pending by the single-active clamp) drops its
207
+ * stamp, so a later re-activation counts as a NEW active span. Otherwise an
208
+ * in_progress → pending → in_progress gap would bill the idle time between
209
+ * spans to the second one's age.
210
+ */
211
+ export declare function observeTimestamps(next: TodoItem[], completedAt: Map<string, number>, startedAt: Map<string, number>, now: number): void;
153
212
  /** Replay the branch: the last todo-tool result is the canonical list. */
154
213
  export declare function reconstructTodos(entries: unknown[]): TodoItem[];
155
214
  type TodoParams = {
@@ -56,6 +56,19 @@ export const TODO_REMINDER_TURNS = 10;
56
56
  * dropping behind open work — Claude Code's TaskList RECENT_COMPLETED_TTL.
57
57
  */
58
58
  export const TODO_COMPLETED_LINGER_MS = 30_000;
59
+ /**
60
+ * Age at which an in-progress step is treated as possibly stale: the aged
61
+ * reminder names the item and its age instead of the generic nudge, and the
62
+ * widget row picks up a dim `(23m)` suffix. Below this the board reads as
63
+ * normal active work.
64
+ */
65
+ export const TODO_STALE_MS = 10 * 60 * 1000;
66
+ /**
67
+ * The board repaints at this cadence while an active step can age, so the
68
+ * `(23m)` suffix stays live. Under the staleness threshold the suffix is
69
+ * hidden entirely, so a slow tick costs nothing.
70
+ */
71
+ export const TODO_REPAINT_MS = 30_000;
59
72
  const WIDGET_KEY = "yagni-todos";
60
73
  /**
61
74
  * The desktop's structured state record rides its own widget key, like the
@@ -92,7 +105,9 @@ function coerceItem(raw) {
92
105
  * Validate a full replacement list. Strict: this is model input rendered
93
106
  * straight into the terminal. An empty list is valid (it clears the board).
94
107
  * Accepts both the current shape ({content, activeForm}) and the legacy
95
- * {text} shape so old sessions replay cleanly.
108
+ * {text} shape so old sessions replay cleanly. The single-active clamp runs
109
+ * here so every path (tool writes, branch replay, legacy sessions) enforces
110
+ * it; `demoted` reports what the clamp changed so callers can tell the model.
96
111
  */
97
112
  export function normalizeTodos(raw) {
98
113
  if (!Array.isArray(raw))
@@ -108,7 +123,32 @@ export function normalizeTodos(raw) {
108
123
  return { ok: false, error: coerced.error };
109
124
  todos.push(coerced);
110
125
  }
111
- return { ok: true, todos };
126
+ const clamped = clampSingleInProgress(todos);
127
+ return { ok: true, todos: clamped.todos, demoted: clamped.demoted };
128
+ }
129
+ /**
130
+ * PURE: enforce the single-active invariant on an already-valid list. The
131
+ * model occasionally marks several steps in_progress at once (parallel
132
+ * sub-parts of one block, recorded 4-at-a-time in real sessions); tools
133
+ * execute sequentially so only one can be truthfully "being worked on".
134
+ * Keep the FIRST in list order, demote the rest to pending — quieter than
135
+ * rejecting the write, and the model self-corrects on the next pass since
136
+ * the result echoes the normalized list.
137
+ */
138
+ export function clampSingleInProgress(todos) {
139
+ const demoted = [];
140
+ let keptActive = false;
141
+ const next = todos.map((t) => {
142
+ if (t.status !== "in_progress")
143
+ return t;
144
+ if (keptActive) {
145
+ demoted.push(t.content);
146
+ return { ...t, status: "pending" };
147
+ }
148
+ keptActive = true;
149
+ return t;
150
+ });
151
+ return demoted.length > 0 ? { todos: next, demoted } : { todos, demoted };
112
152
  }
113
153
  export function todoSummary(todos) {
114
154
  return {
@@ -191,7 +231,8 @@ export function formatTodoOverflow(hidden) {
191
231
  * The in-progress row shows the active form in bold (the live "what am I
192
232
  * doing" signal); pending and completed rows show the imperative content.
193
233
  */
194
- export function renderTodoWidget(todos, theme, completedAtCache = new Map(), nowMs = Date.now()) {
234
+ export function renderTodoWidget(todos, theme, completedAtCache = new Map(), nowMs = Date.now(), opts = {}) {
235
+ const { idle, startedAt } = opts;
195
236
  const { total, done } = todoCounts(todos);
196
237
  if (total === 0 || done === total)
197
238
  return [];
@@ -204,8 +245,16 @@ export function renderTodoWidget(todos, theme, completedAtCache = new Map(), now
204
245
  lines.push(`${theme.fg("success", "✔ ")}${theme.fg("dim", text)}`);
205
246
  }
206
247
  else if (todo.status === "in_progress") {
207
- const active = theme.bold ? theme.bold(`${todo.activeForm}…`) : `${todo.activeForm}…`;
208
- lines.push(`${theme.fg("accent", "◼ ")}${theme.fg("text", active)}`);
248
+ const age = staleAgeSuffix(todo, startedAt, nowMs);
249
+ if (idle) {
250
+ // An idle agent has no "actively doing" claim; keep the row but
251
+ // drop the live-work styling so the board stops pretending.
252
+ lines.push(theme.fg("dim", `◼ ${todo.activeForm}…${age}`));
253
+ }
254
+ else {
255
+ const active = theme.bold ? theme.bold(`${todo.activeForm}…`) : `${todo.activeForm}…`;
256
+ lines.push(`${theme.fg("accent", "◼ ")}${theme.fg("text", active)}${theme.fg("dim", age)}`);
257
+ }
209
258
  }
210
259
  else {
211
260
  lines.push(`${theme.fg("dim", "◻ ")}${theme.fg("muted", todo.content)}`);
@@ -220,6 +269,50 @@ export function renderTodoWidget(todos, theme, completedAtCache = new Map(), now
220
269
  export function todoStateLine(todos) {
221
270
  return JSON.stringify({ v: TODO_STATE_VERSION, todos });
222
271
  }
272
+ /**
273
+ * PURE: render an in-progress step's age as a dim suffix, empty while the
274
+ * step is younger than {@link TODO_STALE_MS}. `47m` under an hour, `1h 47m`
275
+ * after — the "is it stuck?" signal the board owes the user at a glance.
276
+ */
277
+ export function formatAgeMs(ms) {
278
+ const totalMinutes = Math.floor(ms / 60_000);
279
+ if (totalMinutes < 1)
280
+ return "";
281
+ const hours = Math.floor(totalMinutes / 60);
282
+ const minutes = totalMinutes % 60;
283
+ return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
284
+ }
285
+ function staleAgeSuffix(todo, startedAt, nowMs) {
286
+ const at = startedAt?.(todo.content);
287
+ if (at === undefined)
288
+ return "";
289
+ const age = nowMs - at;
290
+ if (age < TODO_STALE_MS)
291
+ return "";
292
+ const text = formatAgeMs(age);
293
+ return text ? ` (${text})` : "";
294
+ }
295
+ /**
296
+ * PURE: the oldest in-progress step past the staleness threshold, if any —
297
+ * the concrete anchor the aged reminder names instead of the generic nudge.
298
+ */
299
+ export function oldestInProgress(todos, startedAt, nowMs) {
300
+ let worst = null;
301
+ for (const t of todos) {
302
+ if (t.status !== "in_progress")
303
+ continue;
304
+ const at = startedAt?.(t.content);
305
+ if (at === undefined)
306
+ continue;
307
+ const ageMs = nowMs - at;
308
+ if (ageMs >= TODO_STALE_MS && (!worst || ageMs > worst.ageMs))
309
+ worst = { todo: t, ageMs };
310
+ }
311
+ if (!worst)
312
+ return null;
313
+ const age = formatAgeMs(worst.ageMs);
314
+ return age ? { todo: worst.todo, age } : null;
315
+ }
223
316
  /**
224
317
  * PURE: is a staleness reminder due? Only when the board has open work (an
225
318
  * empty or fully-completed list never nags) and BOTH throttle counters have
@@ -240,13 +333,54 @@ export function shouldRemindTodos(input) {
240
333
  * read, and explicitly licenses ignoring it, so an accurate board costs one
241
334
  * glance rather than a spurious TodoWrite.
242
335
  */
243
- export function formatTodoReminder(todos) {
336
+ export function formatTodoReminder(todos, opts = {}) {
337
+ const stale = oldestInProgress(todos, opts.startedAt, opts.nowMs ?? Date.now());
338
+ if (stale) {
339
+ // A concrete, named accusation with a number on it — the generic nudge
340
+ // lost 20 times in a row to a busy context in the recorded session that
341
+ // motivated this.
342
+ return (`⟦YAGNI todos⟧ "${stale.todo.content}" has been in_progress for ${stale.age} — if it is ` +
343
+ "done or superseded, mark it completed or remove it; if you are still working on it, " +
344
+ "ignore this and keep the board current as you go.\n" +
345
+ formatTodoList(todos));
346
+ }
244
347
  return ("⟦YAGNI todos⟧ The TodoWrite checklist has not been updated for a while. " +
245
348
  "If the work has moved on, bring it current now: mark finished steps completed, " +
246
- "set the step you are on to in_progress, and add newly discovered steps. " +
349
+ "set the step you are on to in_progress, and add newly discovered follow-up steps. " +
247
350
  "If the list is already accurate, ignore this.\n" +
248
351
  formatTodoList(todos));
249
352
  }
353
+ /**
354
+ * PURE: reconcile the two timestamp caches against the next board. Both
355
+ * share the eviction contract — a stamp leaves when its content leaves the
356
+ * board — but startedAt is stricter: a step that stops being in_progress
357
+ * (completed, or demoted to pending by the single-active clamp) drops its
358
+ * stamp, so a later re-activation counts as a NEW active span. Otherwise an
359
+ * in_progress → pending → in_progress gap would bill the idle time between
360
+ * spans to the second one's age.
361
+ */
362
+ export function observeTimestamps(next, completedAt, startedAt, now) {
363
+ const seen = new Set(next.map((t) => t.content));
364
+ for (const [content] of completedAt) {
365
+ if (!seen.has(content))
366
+ completedAt.delete(content);
367
+ }
368
+ for (const [content] of startedAt) {
369
+ if (!seen.has(content))
370
+ startedAt.delete(content);
371
+ }
372
+ const activeNow = new Set(next.filter((t) => t.status === "in_progress").map((t) => t.content));
373
+ for (const [content] of startedAt) {
374
+ if (!activeNow.has(content))
375
+ startedAt.delete(content);
376
+ }
377
+ for (const t of next) {
378
+ if (t.status === "completed" && !completedAt.has(t.content))
379
+ completedAt.set(t.content, now);
380
+ if (t.status === "in_progress" && !startedAt.has(t.content))
381
+ startedAt.set(t.content, now);
382
+ }
383
+ }
250
384
  /** Replay the branch: the last todo-tool result is the canonical list. */
251
385
  export function reconstructTodos(entries) {
252
386
  let todos = [];
@@ -303,7 +437,7 @@ function todoRenderers() {
303
437
  },
304
438
  };
305
439
  }
306
- function paintWidget(ctx, todos, completedAt) {
440
+ function paintWidget(ctx, todos, completedAt, opts = {}) {
307
441
  if (!ctx?.hasUI)
308
442
  return;
309
443
  try {
@@ -318,7 +452,10 @@ function paintWidget(ctx, todos, completedAt) {
318
452
  return;
319
453
  }
320
454
  const theme = ctx.ui.theme;
321
- const lines = renderTodoWidget(todos, theme, completedAt);
455
+ const lines = renderTodoWidget(todos, theme, completedAt, Date.now(), {
456
+ idle: opts.idle,
457
+ startedAt: opts.startedAt ? (c) => opts.startedAt.get(c) : undefined,
458
+ });
322
459
  ctx.ui.setWidget?.(WIDGET_KEY, lines.length > 0 ? lines : undefined, {
323
460
  placement: "aboveEditor",
324
461
  });
@@ -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
- const observe = (next, now = Date.now()) => {
401
- const seen = new Set(next.map((t) => t.content));
402
- for (const [content] of completedAt) {
403
- if (!seen.has(content))
404
- completedAt.delete(content);
405
- }
406
- for (const t of next) {
407
- if (t.status === "completed" && !completedAt.has(t.content))
408
- completedAt.set(t.content, now);
551
+ // When each open step entered in_progress the aged reminder and the
552
+ // widget's `(23m)` suffix key off this. Same eviction contract as
553
+ // completedAt: entries leave when the content leaves the board; a
554
+ // re-entering item gets a fresh stamp (a restart of the same work is
555
+ // genuinely a new active span).
556
+ const startedAt = new Map();
557
+ // True while the agent is NOT running (between turns, awaiting input)
558
+ // an idle board must not style its active row as live work.
559
+ let idle = true;
560
+ let repaintTimer;
561
+ const hasActive = () => todos.some((t) => t.status === "in_progress");
562
+ // Repaint while an active step can age: the (23m) suffix and idle styling
563
+ // would otherwise freeze at the last write. TUI only (desktop gets a
564
+ // paint on every state change and renders its own board); unref'd so a
565
+ // batched test process never lingers on it, and torn down whenever the
566
+ // board empties or the session restarts.
567
+ const ensureRepaintTimer = (ctx) => {
568
+ if (!ctx.hasUI || isDesktopSurface() || repaintTimer || !hasActive())
569
+ return;
570
+ repaintTimer = setInterval(() => {
571
+ // hasUI is captured at registration; ctx here is the tool/refresh ctx.
572
+ paintWidget(latestCtx ?? undefined, todos, completedAt, { idle, startedAt });
573
+ }, TODO_REPAINT_MS);
574
+ repaintTimer.unref?.();
575
+ };
576
+ const clearRepaintTimer = () => {
577
+ if (repaintTimer) {
578
+ clearInterval(repaintTimer);
579
+ repaintTimer = undefined;
409
580
  }
410
581
  };
582
+ let latestCtx;
583
+ const rememberCtx = (ctx) => {
584
+ latestCtx = ctx;
585
+ };
586
+ const observe = (next, now = Date.now()) => {
587
+ observeTimestamps(next, completedAt, startedAt, now);
588
+ };
411
589
  const reconstruct = (ctx) => {
590
+ // Clear-then-ensure: a session switch/fork must never inherit the
591
+ // previous session's repaint interval — an empty replayed board would
592
+ // otherwise leave the old 30s timer firing at a dead pane forever.
593
+ clearRepaintTimer();
412
594
  try {
413
595
  todos = reconstructTodos(ctx.sessionManager.getBranch());
414
596
  }
@@ -427,17 +609,48 @@ export function registerTodos(pi) {
427
609
  // completed items from history rank as "older" (outside the linger
428
610
  // window), exactly like a live item whose tick has aged out.
429
611
  completedAt.clear();
612
+ startedAt.clear();
430
613
  for (const t of todos) {
431
614
  if (t.status === "completed") {
432
615
  completedAt.set(t.content, Date.now() - TODO_COMPLETED_LINGER_MS - 1);
433
616
  }
617
+ if (t.status === "in_progress") {
618
+ // Age counts from resume — the true start time is unknowable
619
+ // post-hoc, and stamping fresh keeps the suffix from instantly
620
+ // showing a fabricated age.
621
+ startedAt.set(t.content, Date.now());
622
+ }
434
623
  }
435
624
  turnsSinceWrite = 0;
436
625
  turnsSinceReminder = 0;
437
- paintWidget(ctx, todos);
626
+ rememberCtx(ctx);
627
+ idle = true;
628
+ paintWidget(ctx, todos, completedAt, { idle, startedAt });
629
+ if (hasActive())
630
+ ensureRepaintTimer(ctx);
438
631
  };
439
632
  pi.on("session_start", async (_event, ctx) => reconstruct(ctx));
440
633
  pi.on("session_tree", async (_event, ctx) => reconstruct(ctx));
634
+ // The live-work signal: an idle agent's active row dims. Fires on every
635
+ // agent loop, cheap on both sides, and repaints immediately so the dim
636
+ // lands without waiting for the next paint-on-write.
637
+ pi.on("agent_start", async (_event, ctx) => {
638
+ idle = false;
639
+ rememberCtx(ctx);
640
+ if (hasActive())
641
+ ensureRepaintTimer(ctx);
642
+ paintWidget(ctx, todos, completedAt, { idle, startedAt });
643
+ });
644
+ // Idle keeps the timer alive on purpose: the aging suffix on a dimmed row
645
+ // is exactly the "is it stuck?" signal the user watches while the agent
646
+ // waits for input. The timer dies when the board empties, not here.
647
+ pi.on("agent_end", async (_event, ctx) => {
648
+ idle = true;
649
+ rememberCtx(ctx);
650
+ if (hasActive())
651
+ ensureRepaintTimer(ctx);
652
+ paintWidget(ctx, todos, completedAt, { idle, startedAt });
653
+ });
441
654
  // Turn counting: one tick per finalized assistant message, the same "turn"
442
655
  // the model experiences between opportunities to call TodoWrite.
443
656
  pi.on("message_end", async (event) => {
@@ -460,7 +673,10 @@ export function registerTodos(pi) {
460
673
  return {
461
674
  content: [
462
675
  ...event.content,
463
- { type: "text", text: `\n\n${formatTodoReminder(todos)}` },
676
+ {
677
+ type: "text",
678
+ text: `\n\n${formatTodoReminder(todos, { startedAt: (c) => startedAt.get(c) })}`,
679
+ },
464
680
  ],
465
681
  };
466
682
  }
@@ -472,6 +688,18 @@ export function registerTodos(pi) {
472
688
  todos = next;
473
689
  observe(next);
474
690
  turnsSinceWrite = 0;
691
+ if (!hasActive())
692
+ clearRepaintTimer();
693
+ // Repaint here with the full live opts (idle state, startedAt ages)
694
+ // — the tool's own paint is ctx-bound but opts-less, so the age
695
+ // suffix and idle dimming land through this pass. Also (re)arm the
696
+ // repaint timer: a write that introduces the first active step
697
+ // shouldn't wait for the next agent event to start ticking.
698
+ if (latestCtx) {
699
+ paintWidget(latestCtx, todos, completedAt, { idle, startedAt });
700
+ if (hasActive())
701
+ ensureRepaintTimer(latestCtx);
702
+ }
475
703
  }, completedAt));
476
704
  pi.registerCommand("todos", {
477
705
  description: "Show the agent's current task list for this session.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.3-staging.1377.1",
3
+ "version": "1.1.3-staging.1378.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "2d36b38964c6959ed433421c6eb2502d5d351423"
61
+ "yagniSourceSha": "cc4bcf38093fc70fc51ed4b712777566307a1e72"
62
62
  }