From 1bacfd85a65d3f0348d8ec7bbb2ac404d924637a Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:26:32 -0300 Subject: [PATCH 1/4] docs(specs): Specify the live elapsed timer for the agents pane --- .specs/features/agents-pane-timer/spec.md | 84 ++++++++++++++++++++++ .specs/features/agents-pane-timer/tasks.md | 39 ++++++++++ 2 files changed, 123 insertions(+) create mode 100644 .specs/features/agents-pane-timer/spec.md create mode 100644 .specs/features/agents-pane-timer/tasks.md diff --git a/.specs/features/agents-pane-timer/spec.md b/.specs/features/agents-pane-timer/spec.md new file mode 100644 index 0000000..314bf24 --- /dev/null +++ b/.specs/features/agents-pane-timer/spec.md @@ -0,0 +1,84 @@ +# Agents pane: live elapsed timer + +## Goal + +Every worker card in the codedeck agents pane (`/band`, `plugin/mods/agents/`) +shows how long that worker has been running, and the number counts up on +screen once per second while the worker is live, the way Claude Code shows the +elapsed time of a running turn. + +Today the detail line reads `Working · 3m`, and that `3m` is the age of the +last update (`updatedAt`), not the run time, and it only moves when a +`tool.call` or `turn.complete` happens to refresh the pane. + +## Ground + +- `codedeck ps --all --json` already returns `createdAt` (ISO string) on every + row. Verified 2026-09-25 against the installed CLI. No daemon or CLI change + is needed. +- `formatPane` already reads `Date.now()` at draw time, so a redraw alone + advances a clock computed from `createdAt`. A tick does not need a + subprocess. +- Unknown and load bearing: whether a hooks module can schedule work on a + clock (`setInterval` / `setTimeout` in the module realm, or an engine timer + API). The realm has no `process` global (docs/mods.md), so timers are not a + safe assumption. Resolved by the probe in T1 before any code is written. + +## Acceptance criteria + +Elapsed text format (`elapsed(ms)`): + +- AC1. WHEN the elapsed time is under 60 seconds THEN the text SHALL be whole + seconds with an `s` suffix, e.g. `0s`, `42s`. +- AC2. WHEN the elapsed time is at least 60 seconds and under 1 hour THEN the + text SHALL be `m s` with seconds zero padded, e.g. `3m 07s`. +- AC3. WHEN the elapsed time is at least 1 hour THEN the text SHALL be + `h m s` with minutes and seconds zero padded, e.g. `1h 02m 07s`. +- AC4. IF the elapsed time is negative (clock skew) THEN the text SHALL be + `0s`. +- AC5. IF the start timestamp is missing or unparseable THEN the card SHALL + draw no elapsed text, and the rest of the card SHALL draw unchanged. + +Card content: + +- AC6. WHILE a worker is live (`working`, `starting`, `needs_input`) its card + detail line SHALL show `elapsed(now - createdAt)` in place of the + last-update age. +- AC7. WHEN a worker kept as a card is finished (any other status) THEN its + detail line SHALL show the frozen duration `elapsed(updatedAt - createdAt)`. +- AC8. The history preview lines SHALL keep their current last-update age + text. Unchanged behavior. +- AC9. `selectPane` SHALL carry `createdAt` from the session row onto the pane + row, and a non-string `createdAt` SHALL become undefined. +- AC10. Every pane line SHALL stay exactly `columns` code units wide with the + timer present, at every width the existing width tests cover. + +Tick: + +- AC11. WHILE the pane is open and the snapshot holds at least one live row, + the module SHALL invalidate `ui.render` once per second. +- AC12. WHEN the pane closes or the snapshot holds no live row THEN the tick + SHALL stop, and no timer SHALL stay scheduled. +- AC13. A tick SHALL NOT spawn `codedeck ps`. Data refresh during a tick is + capped: at most one `refresh` per 5 seconds while ticking, so a worker that + finishes while the orchestrator is idle stops counting within 5 seconds. +- AC14. Starting the tick twice SHALL NOT schedule two timers. +- AC15. A throw inside a tick SHALL be caught; it SHALL NOT escape the module. + +## Decisions (made by the orchestrator, cheap to reverse) + +1. Format with zero padding (`3m 07s`) so the text width stays stable while it + counts, which keeps the card from jittering once a second. +2. The timer replaces the last-update age on cards only. History keeps age. +3. The 5 second refresh while ticking overrides decision 5 of + `orchestrator-agents-band/spec.md` ("no timer") for the ticking window + only. Reason: a live timer on a stale status counts past the worker's end, + which is a wrong number on screen. +4. The button above the prompt is unchanged. + +## Out of scope + +- Daemon, store, CLI and `codedeck web` changes. +- Timers in the statusline or the button label. +- Per-turn timing, token counts, or any new card field besides elapsed time. +- Fixing the native `✕` close desync documented in docs/mods.md. diff --git a/.specs/features/agents-pane-timer/tasks.md b/.specs/features/agents-pane-timer/tasks.md new file mode 100644 index 0000000..54962c4 --- /dev/null +++ b/.specs/features/agents-pane-timer/tasks.md @@ -0,0 +1,39 @@ +# Tasks: agents pane live timer + +Source of truth: `spec.md` in this folder. + +## Coverage matrix + +| Layer | Test type | Lives in | Command | +|---|---|---|---| +| `plugin/mods/agents/pane.ts` (elapsed, cards, select) | unit | `tests/mods-agents/pane.test.ts` | `npx vitest run tests/mods-agents` | +| `plugin/mods/agents/types.ts` | none (types only) | n/a | `npx tsc --noEmit -p .` if it covers plugin, else the vitest run | +| `plugin/hooks/pane-ticker.ts` (tick scheduler) | unit, fake timers | `tests/mods-agents/pane-ticker.test.ts` | `npx vitest run tests/mods-agents` | +| `plugin/hooks/register.tsx` | none, wiring only | n/a | live PTY probe (T3) | +| manifest | contract | n/a | `claude plugin validate plugin/` | + +`register.tsx` stays wiring only. Any decision (when to tick, when to stop, +refresh cadence) lives in `pane-ticker.ts` where it is unit tested. + +## T1. Probe: can a hooks module run a clock + +- Requirement: spec "Ground", unknown item. Blocks T2 tick work. +- In a live PTY session (tmux + `claude --plugin-dir ` with + `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`), check whether `setInterval` / + `setTimeout` exist and fire inside the hooks module realm, and whether a + timer callback may call `$.ui.invalidate("ui.render")` with a `$` captured + from an earlier hook. +- Tests: none, finding only. Record the result in `docs/mods.md`. +- Gate: a capture showing a value that changed across ticks with no input. + +## T2. Pure layer: elapsed + cards + ticker + +- Requirement: AC1 to AC15. +- Tests: in this task, same files as the matrix. +- Gate: `npx vitest run tests/mods-agents` green. + +## T3. Wiring + live proof + +- Requirement: AC6, AC11, AC12. +- Gate: two tmux captures of the open pane at least 2 seconds apart, the + elapsed text of a live card advanced between them, no `hook skipped` text. From 9f37d6d6035a81cbe63c31ff9443510581ac02b3 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:52:51 -0300 Subject: [PATCH 2/4] feat(plugin): Count up worker run time in the agents pane Use createdAt for live and frozen card durations while preserving history ages. Keep redraws at one second and data refresh to no more than one request per five seconds. Co-Authored-By: Codex --- .specs/features/agents-pane-timer/tasks.md | 6 + docs/mods.md | 24 ++++ plugin/hooks/pane-ticker.ts | 102 +++++++++++++++++ plugin/hooks/register.tsx | 41 +++++-- plugin/mods/agents/pane.ts | 29 ++++- plugin/mods/agents/types.ts | 2 + tests/mods-agents/pane-ticker.test.ts | 122 +++++++++++++++++++++ tests/mods-agents/pane.test.ts | 116 +++++++++++++++++++- 8 files changed, 432 insertions(+), 10 deletions(-) create mode 100644 plugin/hooks/pane-ticker.ts create mode 100644 tests/mods-agents/pane-ticker.test.ts diff --git a/.specs/features/agents-pane-timer/tasks.md b/.specs/features/agents-pane-timer/tasks.md index 54962c4..52205d5 100644 --- a/.specs/features/agents-pane-timer/tasks.md +++ b/.specs/features/agents-pane-timer/tasks.md @@ -17,6 +17,8 @@ refresh cadence) lives in `pane-ticker.ts` where it is unit tested. ## T1. Probe: can a hooks module run a clock +- Status: complete + - Requirement: spec "Ground", unknown item. Blocks T2 tick work. - In a live PTY session (tmux + `claude --plugin-dir ` with `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`), check whether `setInterval` / @@ -28,12 +30,16 @@ refresh cadence) lives in `pane-ticker.ts` where it is unit tested. ## T2. Pure layer: elapsed + cards + ticker +- Status: complete + - Requirement: AC1 to AC15. - Tests: in this task, same files as the matrix. - Gate: `npx vitest run tests/mods-agents` green. ## T3. Wiring + live proof +- Status: complete + - Requirement: AC6, AC11, AC12. - Gate: two tmux captures of the open pane at least 2 seconds apart, the elapsed text of a live card advanced between them, no `hook skipped` text. diff --git a/docs/mods.md b/docs/mods.md index 6912f23..645084d 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -230,6 +230,30 @@ PTY session, a `Client` surface that seeded its state during render did not keep state across renders. That was observed. Why is a grounded hypothesis, the render-time seeding, not a proven fact. +### Timer primitives in a hooks module + +Probed on 2026-09-25 in Claude Code 2.1.282, in a live tmux PTY with +`CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`. Both `setTimeout` and `setInterval` +were functions in the module realm. A one-shot timeout and a one-second +interval changed the rendered button without input. Their callbacks called +`$.ui.invalidate("ui.render")` through `$` captured earlier in `session.start`. +The invalidation count advanced from 12 to 15 over three seconds, with no +caught errors and no `hook skipped` or `refused` text. + +The button labels from the two captures, with terminal padding omitted, were: + +``` +[ 1 working  [probe function/function t1 i11 c12 e0] ] +[ 1 working  [probe function/function t1 i14 c15 e0] ] +``` + +Here `t` counts timeout callbacks, `i` interval callbacks, `c` successful +captured-handle invalidations, and `e` caught errors. This proves timer +callbacks can invalidate a render with a `$` captured by an earlier hook. +The one-second callback invalidates the UI; a separate refresh request runs no +more than once every five seconds and uses the existing refresh helper, which +may execute `codedeck ps`. + ### process.run `$.process.run(argv, init?)`. **Both arguments are positional**, and this is diff --git a/plugin/hooks/pane-ticker.ts b/plugin/hooks/pane-ticker.ts new file mode 100644 index 0000000..3c65289 --- /dev/null +++ b/plugin/hooks/pane-ticker.ts @@ -0,0 +1,102 @@ +import type { PaneSnapshot } from "../mods/agents/types.js"; + +const TICK_INTERVAL_MS = 1000; +const REFRESH_INTERVAL_MS = 5000; +const LIVE_STATUSES = new Set(["working", "starting", "needs_input"]); + +export interface PaneTickerOptions { + now: () => number; + setInterval: (callback: () => void, milliseconds: number) => unknown; + clearInterval: (handle: unknown) => void; + invalidate: () => unknown; + refresh: () => unknown; +} + +export interface PaneTicker { + update(paneOpen: boolean, snapshot: PaneSnapshot | undefined): void; + refreshed(): void; +} + +/** Schedules pane redraws and refresh requests while any worker card is live. */ +export function createPaneTicker(options: PaneTickerOptions): PaneTicker { + let running = false; + let handle: unknown; + let lastRefreshAt = 0; + + const invoke = (action: () => unknown) => { + try { + void Promise.resolve(action()).catch(() => {}); + } catch { + // Hook failures drop the drawing, so timer work must stay contained. + } + }; + + const tick = () => { + if (!running) return; + try { + invoke(options.invalidate); + const now = options.now(); + if (!Number.isFinite(now) || now - lastRefreshAt < REFRESH_INTERVAL_MS) return; + lastRefreshAt = now; + invoke(options.refresh); + } catch { + // A failed timer callback must not escape into the hooks runtime. + } + }; + + const stop = () => { + if (!running) return; + running = false; + const currentHandle = handle; + handle = undefined; + try { + options.clearInterval(currentHandle); + } catch { + // Keep the module alive even if the timer host refuses a clear. + } + }; + + const start = () => { + if (running) return; + try { + const now = options.now(); + if (!Number.isFinite(now)) return; + lastRefreshAt = now; + running = true; + handle = options.setInterval(tick, TICK_INTERVAL_MS); + } catch { + running = false; + handle = undefined; + } + }; + + const hasLiveRow = (snapshot: PaneSnapshot | undefined): boolean => { + try { + return ( + snapshot !== undefined && + Array.isArray(snapshot.rows) && + snapshot.rows.some((row) => row != null && LIVE_STATUSES.has(row.status)) + ); + } catch { + return false; + } + }; + + return { + update(paneOpen, snapshot) { + if (paneOpen && hasLiveRow(snapshot)) { + start(); + return; + } + stop(); + }, + refreshed() { + try { + const now = options.now(); + if (Number.isFinite(now)) lastRefreshAt = now; + } catch { + // Keep the prior cadence if the injected clock fails. + } + }, + }; +} diff --git a/plugin/hooks/register.tsx b/plugin/hooks/register.tsx index ec07da5..e8294c1 100644 --- a/plugin/hooks/register.tsx +++ b/plugin/hooks/register.tsx @@ -7,6 +7,7 @@ import type { Register } from "claude-code"; import { formatPane, paneButtonLabel, selectPane } from "../mods/agents/pane.js"; import { parseRows } from "../mods/agents/parse.js"; import type { PaneSnapshot } from "../mods/agents/types.js"; +import { createPaneTicker, type PaneTicker } from "./pane-ticker.js"; import { togglePane } from "./pane-toggle.js"; // Stable pane id: 1 to 64 letters, digits, "_" or "-". open carries it into @@ -51,14 +52,17 @@ const toggleAgentsPane = async ($: Engine$, isOpen: boolean): Promise = // Passing $ into a helper is allowed, verified. What the engine refuses is // pulling a namespace off it: `const P = $.process` fails to load the module. -// So refresh takes $ as a parameter and the state register owns travels beside -// it in this object. paneOpen never leaves register, so it stays a bare let. +// refresh takes $ as a parameter; its state and paneOpen stay in this register. type PaneState = { // Last good snapshot; undefined until one refresh has fully succeeded, which // is how the pane draws nothing rather than a guess. snapshot: PaneSnapshot | undefined; inFlight: boolean; lastRefreshEndedAt: number; + ticker: PaneTicker | undefined; + paneOpen: boolean; + tickInvalidate: () => unknown; + tickRefresh: () => unknown; // True once session.start saw a CODEDECK_RUN_ID. Cached because ui.render // fires on every drawing pass and must not await an env read to decide // whether to draw one button. @@ -67,6 +71,13 @@ type PaneState = { const TOOL_REFRESH_GAP_MS = 1500; +function syncTicker($: Engine$, state: PaneState, refreshCompleted = false): void { + state.tickInvalidate = () => $.ui.invalidate("ui.render"); + state.tickRefresh = () => refresh($, state); + if (refreshCompleted) state.ticker?.refreshed(); + state.ticker?.update(state.paneOpen, state.snapshot); +} + const refresh = async ($: Engine$, state: PaneState): Promise => { // Claim the slot before the first await. Guard and set must not straddle a // yield point: with the `$.env.get` read in between, two tool.call firings @@ -103,6 +114,7 @@ const refresh = async ($: Engine$, state: PaneState): Promise => { } finally { state.inFlight = false; state.lastRefreshEndedAt = Date.now(); + syncTicker($, state, true); } }; @@ -112,10 +124,21 @@ export const register: Register = (on) => { inFlight: false, lastRefreshEndedAt: 0, hasRun: false, + ticker: undefined, + paneOpen: false, + tickInvalidate: () => undefined, + tickRefresh: () => undefined, }; - let paneOpen = false; + state.ticker = createPaneTicker({ + now: () => Date.now(), + setInterval: (callback, milliseconds) => setInterval(callback, milliseconds), + clearInterval: (handle) => clearInterval(handle as ReturnType), + invalidate: () => state.tickInvalidate(), + refresh: () => state.tickRefresh(), + }); on("session.start", async ($, e, next) => { + syncTicker($, state); // Not "agents": the engine refuses it with `$.command.register: "/agents" // refused: it is the built-in /agents`. "band", "deck" and // "codedeck-agents" were each verified free. The description is load @@ -145,10 +168,11 @@ export const register: Register = (on) => { // is the one call here not yet verified in a PTY session, paneOpen stays // true and the next /band retries the close, instead of the flag and the // pane desyncing for the rest of the session. - paneOpen = await toggleAgentsPane($, paneOpen); + state.paneOpen = await toggleAgentsPane($, state.paneOpen); + syncTicker($, state); await $.ui.invalidate("ui.render"); - if (paneOpen) void refresh($, state); - return { text: paneOpen ? "agents pane open" : "agents pane closed" }; + if (state.paneOpen) void refresh($, state); + return { text: state.paneOpen ? "agents pane open" : "agents pane closed" }; }); on("turn.complete", async ($, e, next) => { @@ -172,9 +196,10 @@ export const register: Register = (on) => { // A $ captured from a past render does work, verified, but it outlives the // event it came from and nothing promises how long. if ((e as { element?: string }).element === BUTTON_KEY) { - paneOpen = await toggleAgentsPane($, paneOpen); + state.paneOpen = await toggleAgentsPane($, state.paneOpen); + syncTicker($, state); await $.ui.invalidate("ui.render"); - if (paneOpen) void refresh($, state); + if (state.paneOpen) void refresh($, state); } return await next(e); }); diff --git a/plugin/mods/agents/pane.ts b/plugin/mods/agents/pane.ts index e190ea9..f6b6664 100644 --- a/plugin/mods/agents/pane.ts +++ b/plugin/mods/agents/pane.ts @@ -114,6 +114,7 @@ function toPaneRow(row: SessionRow, workers: ReadonlySet): PaneRow { effort: text(row.effort) || undefined, name: row.name || EMPTY_CELL, updatedAt: iso, + createdAt: typeof row.createdAt === "string" ? row.createdAt : undefined, parentId: parent !== "" && parent !== row.id && workers.has(parent) ? parent : undefined, role: text(row.role) || undefined, }; @@ -221,6 +222,32 @@ function age(iso: unknown, now: number): string { return `${Math.floor(minutes / 60)}h`; } +/** Stable elapsed-time text for a card detail line. */ +export function elapsed(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) return "0s"; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const paddedSeconds = String(seconds % 60).padStart(2, "0"); + if (minutes < 60) return `${minutes}m ${paddedSeconds}s`; + return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m ${paddedSeconds}s`; +} + +function cardElapsed(row: Partial, now: number): string { + if (typeof row.createdAt !== "string") return ""; + const createdAt = Date.parse(row.createdAt); + if (!Number.isFinite(createdAt)) return ""; + + const live = LIVE.has(row.status ?? "") || WAIT.has(row.status ?? ""); + const endAt = live + ? now + : typeof row.updatedAt === "string" + ? Date.parse(row.updatedAt) + : Number.NaN; + if (!Number.isFinite(endAt)) return ""; + return elapsed(endAt - createdAt); +} + function count(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0; } @@ -348,7 +375,7 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined return [ ` ${glyph(row.agent)} ${cell(row.id)} ${role ? `${role} · ` : ""}${harnessLabel(row.agent)}`, ` ${cell(row.name)}`, - detailLine(row.model, row.effort, statusWord(status), age(row.updatedAt, now), inner), + detailLine(row.model, row.effort, statusWord(status), cardElapsed(row, now), inner), ]; }; diff --git a/plugin/mods/agents/types.ts b/plugin/mods/agents/types.ts index fc0a2d7..82fbe14 100644 --- a/plugin/mods/agents/types.ts +++ b/plugin/mods/agents/types.ts @@ -20,6 +20,7 @@ export interface SessionRow { effort?: string; status?: string; updatedAt?: string; + createdAt?: string; } /** @@ -35,6 +36,7 @@ export interface PaneRow { effort?: string; name: string; updatedAt?: string; + createdAt?: string; /** * Id of the card this one hangs from, or undefined when it hangs from the * run root. A parent outside the run (or a legacy row with none) is folded diff --git a/tests/mods-agents/pane-ticker.test.ts b/tests/mods-agents/pane-ticker.test.ts new file mode 100644 index 0000000..25244a7 --- /dev/null +++ b/tests/mods-agents/pane-ticker.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createPaneTicker } from "../../plugin/hooks/pane-ticker.js"; +import type { PaneSnapshot } from "../../plugin/mods/agents/types.js"; + +afterEach(() => vi.useRealTimers()); + +function snapshot(status: string): PaneSnapshot { + return { + runId: "f5fd", + orchestrator: undefined, + rows: [{ id: "worker", status, agent: "claude", name: "worker" }], + hidden: 0, + total: 1, + }; +} + +function harness(overrides: Partial[0]> = {}) { + const setInterval = vi.fn((callback: () => void, delay: number) => + globalThis.setInterval(callback, delay), + ); + const clearInterval = vi.fn((handle: ReturnType) => + globalThis.clearInterval(handle), + ); + const invalidate = vi.fn(); + const refresh = vi.fn(); + const ticker = createPaneTicker({ + now: () => Date.now(), + setInterval, + clearInterval, + invalidate, + refresh, + ...overrides, + }); + return { ticker, setInterval, clearInterval, invalidate, refresh }; +} + +describe("createPaneTicker", () => { + it("starts one timer only for an open pane with a live row and stops it on close or completion", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-19T12:00:00.000Z")); + const h = harness(); + + h.ticker.update(false, snapshot("working")); + h.ticker.update(true, snapshot("completed")); + expect(h.setInterval).not.toHaveBeenCalled(); + + h.ticker.update(true, snapshot("needs_input")); + h.ticker.update(true, snapshot("working")); + expect(h.setInterval).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(1); + + await vi.advanceTimersByTimeAsync(1000); + expect(h.invalidate).toHaveBeenCalledTimes(1); + + h.ticker.update(true, snapshot("completed")); + expect(h.clearInterval).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + + h.ticker.update(true, snapshot("working")); + expect(h.setInterval).toHaveBeenCalledTimes(2); + h.ticker.update(false, snapshot("working")); + expect(h.clearInterval).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); + + await vi.advanceTimersByTimeAsync(2000); + expect(h.invalidate).toHaveBeenCalledTimes(1); + }); + + it("invalidates every second and requests refresh no more than every five seconds", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-19T12:00:00.000Z")); + const h = harness(); + h.ticker.update(true, snapshot("starting")); + + await vi.advanceTimersByTimeAsync(4999); + expect(h.invalidate).toHaveBeenCalledTimes(4); + expect(h.refresh).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(h.invalidate).toHaveBeenCalledTimes(5); + expect(h.refresh).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(4999); + expect(h.invalidate).toHaveBeenCalledTimes(9); + expect(h.refresh).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + expect(h.invalidate).toHaveBeenCalledTimes(10); + expect(h.refresh).toHaveBeenCalledTimes(2); + }); + + it("waits five seconds after another refresh completes before requesting data again", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-19T12:00:00.000Z")); + const h = harness(); + h.ticker.update(true, snapshot("working")); + + await vi.advanceTimersByTimeAsync(4000); + h.ticker.refreshed(); + await vi.advanceTimersByTimeAsync(4999); + expect(h.refresh).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(h.refresh).toHaveBeenCalledTimes(1); + }); + + it("catches synchronous tick errors and rejected refreshes", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-19T12:00:00.000Z")); + const invalidate = vi.fn(() => { + throw new Error("invalidate failed"); + }); + const refresh = vi.fn(() => Promise.reject(new Error("refresh failed"))); + const h = harness({ invalidate, refresh }); + h.ticker.update(true, snapshot("working")); + + await vi.advanceTimersByTimeAsync(5000); + expect(invalidate).toHaveBeenCalledTimes(5); + expect(refresh).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/mods-agents/pane.test.ts b/tests/mods-agents/pane.test.ts index 487d28f..ba01261 100644 --- a/tests/mods-agents/pane.test.ts +++ b/tests/mods-agents/pane.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { MAX_CARD_COLUMNS, MIN_COLUMNS, + elapsed, formatPane, paneButtonLabel, selectPane, @@ -37,6 +38,8 @@ const snapshot = (overrides: Partial = {}): PaneSnapshot => ({ const ids = (snap: PaneSnapshot) => snap.rows.map((row) => row.id); +afterEach(() => vi.useRealTimers()); + // The stem connector line at 89 columns: the card box caps at 56, so the // stem sits at column 31 inside a frame row padded to the full width. Exact // literal, so a stray or duplicated stem is caught by string identity. @@ -53,6 +56,23 @@ function expectCleanWidth(lines: string[], columns: number): void { } } +describe("elapsed", () => { + it("formats seconds, minutes, and hours with stable zero padding", () => { + expect(elapsed(0)).toBe("0s"); + expect(elapsed(42_999)).toBe("42s"); + expect(elapsed(59_999)).toBe("59s"); + expect(elapsed(60_000)).toBe("1m 00s"); + expect(elapsed(3 * 60_000 + 7_000)).toBe("3m 07s"); + expect(elapsed(60 * 60_000 + 2 * 60_000 + 7_000)).toBe("1h 02m 07s"); + }); + + it("clamps negative and non-finite durations to zero", () => { + expect(elapsed(-1)).toBe("0s"); + expect(elapsed(Number.NaN)).toBe("0s"); + expect(elapsed(Number.POSITIVE_INFINITY)).toBe("0s"); + }); +}); + // The real-screen fixture from the bug report: a run of 19 workers, one // working and 18 finished, in a pane 89 columns wide and 44 rows tall. Built // through selectPane so the snapshot is what the daemon pipeline produces. @@ -143,6 +163,20 @@ describe("selectPane", () => { expect(none.hidden).toBe(2); }); + it("carries string createdAt values and drops non-strings", () => { + const snap = selectPane( + [ + session({ id: "valid", createdAt: "2026-09-19T12:00:00.000Z" }), + session({ id: "invalid", createdAt: 123 as unknown as string }), + ], + RUN, + ); + expect(snap.rows.map(({ id, createdAt }) => [id, createdAt])).toEqual([ + ["invalid", undefined], + ["valid", "2026-09-19T12:00:00.000Z"], + ]); + }); + it("resolves a missing agent, name or status to a placeholder (rule 13)", () => { const snap = selectPane( [session({ id: "bare", status: undefined, agent: "", name: undefined })], @@ -159,6 +193,86 @@ describe("selectPane", () => { }); describe("formatPane", () => { + it("uses elapsed time on cards and keeps last-update age in history", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-19T12:00:42.000Z")); + const lines = formatPane( + selectPane( + [ + session({ + id: "finished-parent", + status: "completed", + createdAt: "2026-09-19T11:59:40.000Z", + updatedAt: "2026-09-19T12:00:00.000Z", + name: "finished", + }), + session({ + id: "live", + parentId: "finished-parent", + status: "working", + createdAt: "2026-09-19T12:00:00.000Z", + updatedAt: "2026-09-19T12:00:41.000Z", + name: "worker", + }), + session({ + id: "history", + status: "completed", + createdAt: "2026-09-19T11:00:00.000Z", + updatedAt: "2026-09-19T11:59:42.000Z", + name: "history", + }), + ], + RUN, + ), + 89, + ); + const joined = lines.join("\n"); + expect(joined).toContain("Completed · 20s"); + expect(joined).toContain("Working · 42s"); + expect(joined).toContain("history · 1m"); + expect(joined).not.toContain("history · 1h"); + }); + + it("leaves cards without a valid createdAt timestamp otherwise unchanged", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-19T12:00:42.000Z")); + const lines = formatPane( + snapshot({ + rows: [ + { id: "missing", status: "working", agent: "claude", name: "missing", updatedAt: "2026-09-19T12:00:00.000Z" }, + { id: "unparseable", status: "needs_input", agent: "claude", name: "bad", createdAt: "yesterday-ish" }, + ], + }), + 89, + ); + const joined = lines.join("\n"); + expect(joined).toContain("missing"); + expect(joined).toContain("unparseable"); + expect(joined).toContain("Working"); + expect(joined).toContain("Waiting for you"); + expect(joined).not.toMatch(/(?:Working|Waiting for you) · (?:\d+s|\d+m)/); + }); + + it("keeps every line at the requested width when a card has elapsed text", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-19T12:00:42.000Z")); + const snap = snapshot({ + rows: [ + { + id: "timed", + status: "working", + agent: "claude", + name: "timer worker", + createdAt: "2026-09-15T08:00:42.000Z", + }, + ], + }); + expect(formatPane(snap, 120).join("\n")).toContain("Working · 100h 00m 00s"); + for (const columns of [MIN_COLUMNS, 40, 89, 120]) { + expectCleanWidth(formatPane(snap, columns), columns); + } + }); + it("never exceeds the given rows, at every width and every input (rule 1)", () => { const snaps = [snapshot(), workerFixture(), snapshot({ rows: [], hidden: 7, total: 7 })]; for (const snap of snaps) { From 61c13a7022312c2d85449404666c051b950081c0 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:54:20 -0300 Subject: [PATCH 3/4] docs(specs): Align the timer refresh criterion with the shipped cadence --- .specs/features/agents-pane-timer/spec.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.specs/features/agents-pane-timer/spec.md b/.specs/features/agents-pane-timer/spec.md index 314bf24..fc81f19 100644 --- a/.specs/features/agents-pane-timer/spec.md +++ b/.specs/features/agents-pane-timer/spec.md @@ -59,9 +59,10 @@ Tick: the module SHALL invalidate `ui.render` once per second. - AC12. WHEN the pane closes or the snapshot holds no live row THEN the tick SHALL stop, and no timer SHALL stay scheduled. -- AC13. A tick SHALL NOT spawn `codedeck ps`. Data refresh during a tick is - capped: at most one `refresh` per 5 seconds while ticking, so a worker that - finishes while the orchestrator is idle stops counting within 5 seconds. +- AC13. WHILE ticking, the module SHALL request at most one data `refresh` + (the existing helper, which runs `codedeck ps`) per 5 seconds; the 1 second + redraws in between SHALL NOT refresh. A worker that finishes while the + orchestrator is idle stops counting within about 5 seconds. - AC14. Starting the tick twice SHALL NOT schedule two timers. - AC15. A throw inside a tick SHALL be caught; it SHALL NOT escape the module. From bd343214740f77c390836378b1e36efea3cb4096 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:56:25 -0300 Subject: [PATCH 4/4] docs(specs): Record the timer's frozen duration limitation --- .specs/features/agents-pane-timer/spec.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.specs/features/agents-pane-timer/spec.md b/.specs/features/agents-pane-timer/spec.md index fc81f19..ab09a57 100644 --- a/.specs/features/agents-pane-timer/spec.md +++ b/.specs/features/agents-pane-timer/spec.md @@ -77,6 +77,16 @@ Tick: which is a wrong number on screen. 4. The button above the prompt is unchanged. +## Known limitations + +- A finished card's frozen duration is `updatedAt - createdAt`, and the store + also bumps `updatedAt` on non-terminal updates such as `session.rename`. A + worker renamed after it finished shows a duration that includes the idle + time. `ps` exposes no end timestamp; fixing this needs a daemon or CLI + field, which is out of scope here. +- If `codedeck ps` keeps failing while the pane is open, the last snapshot + stays live and the timer keeps counting, with one refresh attempt per 5 s. + ## Out of scope - Daemon, store, CLI and `codedeck web` changes.