Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions .specs/features/agents-pane-timer/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# 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>m <ss>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>h <mm>m <ss>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. 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.

## 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.

## 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.
- 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.
45 changes: 45 additions & 0 deletions .specs/features/agents-pane-timer/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# 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

- Status: complete

- Requirement: spec "Ground", unknown item. Blocks T2 tick work.
- In a live PTY session (tmux + `claude --plugin-dir <built plugin>` 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

- 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.
24 changes: 24 additions & 0 deletions docs/mods.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
102 changes: 102 additions & 0 deletions plugin/hooks/pane-ticker.ts
Original file line number Diff line number Diff line change
@@ -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.
}
},
};
}
41 changes: 33 additions & 8 deletions plugin/hooks/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -51,14 +52,17 @@ const toggleAgentsPane = async ($: Engine$, isOpen: boolean): Promise<boolean> =

// 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.
Expand All @@ -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<void> => {
// 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
Expand Down Expand Up @@ -103,6 +114,7 @@ const refresh = async ($: Engine$, state: PaneState): Promise<void> => {
} finally {
state.inFlight = false;
state.lastRefreshEndedAt = Date.now();
syncTicker($, state, true);
}
};

Expand All @@ -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<typeof setInterval>),
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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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);
});
Expand Down
Loading
Loading