SLOPSHOPPER

wfc-liveness

Names a stalled subagent in the status line as a fact, and lists every subagent in a pane on /liveness (#863)

newpanecommandtoaststatustimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wfc-liveness
│ ┃ Subagents ✕ › fix the failing auth test and add an audit log call │ ┃ No subagents listed. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /liveness │ ⎿ wfc-liveness: Subagents pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Subagents
No subagents listed.
README

wfc-liveness

A mod that shows, under the prompt, how many subagents are running and names any that are stalled (#480, #863).

A subagent is stalled when its status is running and no model step has started in it for 5 minutes, clocked from the moment the mod first saw it listed where no step was seen, and from its return to running after any other status. Each stalled agent is named with its task description, how long it has been quiet, and what it was doing: model answering, running <tools>, or between steps. The longest stall comes first. pending, waiting and idle agents are never judged. The line clears when no subagent is pending, running or waiting. One toast marks each stall's onset; the onset and the end go to the debug log.

Every 5 s it makes one $.agent.list() call; it forks no process, and reads no file but the repo's .claude/workflow-core.json, once at its start. It hooks session.start, turn.step, turn.complete, ui.render on its own pane and command.run on /liveness alone, passes every step's stream and result through untouched, and never tells the model, wakes the session, or stops or messages an agent. The Artefact: line and scripts/watch-artefact.js are untouched and still give the parent session its abandon rule.

Tested on Claude Code 2.1.292. Floor: 2.1.289. Below it the mod starts nothing and says so once in the debug log.

The pane

Type /liveness to open a pane with one row per subagent the session lists, live or finished: its task description, type, status, steps so far, what it is doing and for how long, and stalled, <time> quiet while it is stalled. Its times move every 5 s. Type /liveness again to close it, or close it from its mark. The command runs at once, even mid-turn, starts no model turn, and the pane never opens unasked. A finished, failed or killed agent keeps its row with its final status until the engine stops listing it, about 30 s after it ends (#864).

How it loads

  • In workflow-core, as a stand-alone mod, wfc-liveness@skills-dir, in any session started at the repo's root.
  • In an adopter, through the workflow-core plugin's one entry module (#933), in any session started at the adopter's root that loads the plugin. Where the session's folder also carries .claude/skills/wfc-liveness/, this load starts nothing and says so in the debug log, so the mod never runs twice.

At session start it checks, in order, the Claude Code floor, the repo's off switch, then that double-load guard. Each one that stops it writes one debug-log line naming why.

Switching it off

It keeps a repo-wide switch, where the delivery mod has none (#1467): it only displays, and never tells the model or acts, so a repo that turns it off loses no rule. For every seat and session in a repo, add this key to the repo's .claude/workflow-core.json and commit it:

"mods": { "wfc-liveness": false }

A missing file, a missing mods key or a mod missing from it leaves the mod on. A file that cannot be read or parsed also leaves it on, with one debug-log line, and a mods value that is no object of booleans leaves it on with a readConfig warning as well.

In workflow-core alone, this line in .claude/settings.local.json also turns the stand-alone load off, for that seat only. In a repo that also loads the workflow-core plugin, the guard above still sees the folder, so that line leaves neither load running:

{ "enabledPlugins": { "wfc-liveness@skills-dir": false } }

Checking it

  • claude plugin test .claude/skills/wfc-liveness: the mod's own tests. The ordinary vitest suite never collects them; pre-push runs them when a push touches this folder, and every scheduled sweep that tests runs them on the newest Claude Code (#1854).
  • claude plugin validate .claude/skills/wfc-liveness: must list exactly those five hooks. scripts/wfc-mods-validate.test.ts asserts it.
  • npm run typecheck: one of its passes type-checks the module and tests.

The engine's types are an untracked copy (#1960): Claude Code's declarations carry no grant to redistribute them. The engine writes no .claude-plugin/types/ for a @skills-dir mod (#851, check (e)), so npm run typecheck copies the newest unpacked plugin-authoring skill's types/claude-code.d.ts to the gitignored .claude-plugin/types/claude-code/index.d.ts, and skips the mod's pass where no session has unpacked that skill. types/index.d.ts is the mod's own $.state contract.

Source 2 files
hooks/register.tsx 523 lines
1/*
2 * register.tsx, the wfc-liveness mod — while any subagent is live, a status line under the prompt
3 * says how many are running and names each stalled one as a fact (#480,
4 * #863, #865).
5 *
6 * A stall: a `running` subagent with no model step started for 5 minutes,
7 * clocked from the tick that first listed it where no step was seen, and
8 * from its return to `running` after any other status. Every other status is
9 * shown by count alone and never judged (#863 §The stall rule, user story
10 * 17).
11 *
12 * `/liveness` opens a pane with one row per listed agent, live or finished,
13 * and closes it when run again; it never opens unasked (#866). The pane
14 * draws the rows each tick writes to `$.state`, so its times move.
15 *
16 * Hooks `session.start`, `turn.step`, `turn.complete`, `ui.render` on its own
17 * pane and `command.run` on its own command alone. It passes every step's
18 * stream and result through untouched, and never tells the model, wakes the
19 * session, or stops or messages an agent (#863 §Guard safety and failure).
20 *
21 * EVERYTHING LIVES INSIDE `register`. The tests stand for the load through
22 * the workflow-core plugin by loading `register` again as an inline plugin,
23 * which carries the function's own text and nothing around it (#944).
24 */
25
26import type { AgentInfo, Register } from "claude-code";
27import type { LivenessRecord, LivenessRow } from "../types";
28
29export const register: Register = (on) => {
30	/* The build #851's go checks passed on; below it the mod is unproven and
31	 * starts nothing (#844 Q2). */
32	const versionFloor = [2, 1, 289];
33	// #863 §Tick and §The stall rule.
34	const tickMs = 5000;
35	const stallMs = 5 * 60_000;
36	/* The engine prefixes the line with a glyph and the plugin name (#864's
37	 * drawing check), so a description is cut short; a guess, never measured. */
38	const descriptionMaxChars = 32;
39	// The engine's own last tool of a subagent run, not a tool it runs (#864).
40	const handbackTool = "SubagentHandback";
41	const liveStatuses: ReadonlySet<string> = new Set([
42		"pending",
43		"running",
44		"waiting",
45	]);
46	/* Owners spelled out, not modName: the release render renames these literals
47	 * to the host plugin (#945). */
48	const recordsRef = { plugin: "wfc-liveness", key: "records" } as const;
49	const rowsRef = { plugin: "wfc-liveness", key: "rows" } as const;
50	const paneId = "liveness";
51	const modName = "wfc-liveness";
52	const configRelPath = ".claude/workflow-core.json";
53
54	/* Module memory, which a reload starts over: `session.start` restores the
55	 * records from `$.state`, where every change is saved. */
56	let isLive = false;
57	let records: Record<string, LivenessRecord> = {};
58	let cancelTick: (() => void) | undefined;
59	let saving: Promise<unknown> = Promise.resolve();
60
61	/* The helpers COPIED names in scripts/wfc-mods-copies.test.ts are copied in
62	 * the delivery mod, not shared: the tests' inline load, standing for the
63	 * plugin's, carries `register`'s own text alone (#944), and the engine
64	 * follows `$` across no import (#903 F3). That test fails where the copies
65	 * differ; a helper copied later is held only once it joins COPIED. */
66
67	/** True when `version` is at or above versionFloor; an unparseable one is below. */
68	const isAtFloor = (version: string): boolean => {
69		const parts = /^(\d+)\.(\d+)\.(\d+)/.exec(version)?.slice(1).map(Number);
70		if (!parts) return false;
71		for (let i = 0; i < versionFloor.length; i++) {
72			const have = parts[i] ?? 0;
73			const need = versionFloor[i] ?? 0;
74			if (have !== need) return have > need;
75		}
76		return true;
77	};
78
79	const messageOf = (err: unknown) =>
80		err instanceof Error ? err.message : String(err);
81
82	/** This mod's switch in the repo's config text, on where `mods` is absent;
83	 * throws where the text is not an object or `mods` not one of booleans. */
84	const isOnInConfig = (text: string): boolean => {
85		const parsed: unknown = JSON.parse(text);
86		if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
87			throw new Error("not a JSON object");
88		const mods = (parsed as Record<string, unknown>).mods;
89		if (mods === undefined) return true;
90		const value =
91			typeof mods === "object" && mods !== null && !Array.isArray(mods)
92				? (mods as Record<string, unknown>)[modName]
93				: null;
94		if (value === undefined || typeof value === "boolean")
95			return value !== false;
96		throw new Error(`"mods" is not an object of booleans`);
97	};
98
99	const blank = (): LivenessRecord => ({
100		firstListedAt: null,
101		description: "",
102		status: null,
103		quietSince: null,
104		steps: 0,
105		phaseSince: null,
106		isStepping: false,
107		tools: [],
108		isStalled: false,
109	});
110
111	/* A step's records are kept whether or not the list names its agent yet:
112	 * a subagent's first step starts about 2 s before it is listed (#864). */
113	const recordOf = (id: string): LivenessRecord => {
114		records[id] ??= blank();
115		return records[id];
116	};
117
118	/* Chained, so writes land in the order made, each carrying the records as
119	 * they stand when it runs. */
120	const save = (
121		write: () => Promise<unknown>,
122		debug: (text: string) => void,
123	) => {
124		saving = saving
125			.then(write)
126			.catch((err: unknown) =>
127				debug(`records not saved, kept in memory: ${messageOf(err)}`),
128			);
129	};
130
131	const phaseOf = (rec: LivenessRecord) => {
132		if (rec.isStepping) return "model answering";
133		if (rec.tools.length > 0) return `running ${rec.tools.join(", ")}`;
134		return "between steps";
135	};
136
137	const durationOf = (ms: number) => {
138		const minutes = Math.floor(ms / 60_000);
139		return minutes < 60
140			? `${minutes}m`
141			: `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
142	};
143
144	/** To the tick's 5 s. */
145	const elapsedOf = (ms: number) => {
146		const seconds = Math.floor(ms / 1000);
147		if (seconds < 60) return `${seconds}s`;
148		const minutes = Math.floor(seconds / 60);
149		if (minutes >= 60) return durationOf(ms);
150		return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
151	};
152
153	const truncate = (text: string) =>
154		text.length > descriptionMaxChars
155			? `${text.slice(0, descriptionMaxChars - 1)}…`
156			: text;
157
158	interface Stall {
159		agent: AgentInfo;
160		quietMs: number;
161		phase: string;
162	}
163
164	const stallText = ({ agent, quietMs, phase }: Stall) =>
165		`${truncate(agent.description)} (${durationOf(quietMs)} quiet, ${phase})`;
166
167	const statusText = (running: number, stalls: Stall[]) => {
168		const count = `${running} subagent${running === 1 ? "" : "s"} running`;
169		if (stalls.length === 0) return count;
170		const longest = [...stalls].sort((a, b) => b.quietMs - a.quietMs);
171		return `${count}; stalled: ${longest.map(stallText).join(", ")}`;
172	};
173
174	/** Moves `rec`'s clocks and listing on to `agent` as listed at `now`; true
175	 * where its first listing, status or description changed. */
176	const refreshRecord = (
177		rec: LivenessRecord,
178		agent: AgentInfo,
179		now: number,
180	): boolean => {
181		const isBackToRunning =
182			agent.status === "running" &&
183			rec.status !== null &&
184			rec.status !== "running";
185		if (isBackToRunning) {
186			rec.quietSince = now;
187			rec.phaseSince = now;
188		} else {
189			// A step seen before the first listing keeps its own time.
190			rec.quietSince ??= now;
191			rec.phaseSince ??= now;
192		}
193		if (
194			rec.firstListedAt === null ||
195			rec.status !== agent.status ||
196			rec.description !== agent.description
197		) {
198			rec.firstListedAt ??= now;
199			rec.status = agent.status;
200			rec.description = agent.description;
201			return true;
202		}
203		return false;
204	};
205
206	const rowOf = (
207		stall: Stall,
208		rec: LivenessRecord,
209		now: number,
210		isStalled: boolean,
211	): LivenessRow => {
212		const { agent, quietMs } = stall;
213		const isRunning = agent.status === "running";
214		return {
215			id: agent.id,
216			description: agent.description,
217			type: agent.type,
218			status: agent.status,
219			steps: rec.steps,
220			phase: isRunning
221				? `${stall.phase} ${elapsedOf(now - (rec.phaseSince ?? now))}`
222				: null,
223			stall: isStalled ? `${elapsedOf(quietMs)} quiet` : null,
224		};
225	};
226
227	// Matches every cwd, so one module may hook `session.start` twice (#903 F2).
228	on("session.start", { cwd: /./ }, async ($, e, next) => {
229		isLive = false;
230		/* Closures over `$`, never helpers taking it: the engine follows `$`
231		 * into a function declared at the top of the file alone. */
232		const debug = (text: string) =>
233			$.ui.log(`${modName}: ${text}`, { to: "debug" });
234
235		/** Off only where the repo's `mods` key sets this mod false (#944). */
236		const isSwitchedOn = async () => {
237			const file = `${e.cwd}/${configRelPath}`;
238			try {
239				if (!(await $.fs.exists(file))) return true;
240				return isOnInConfig(await $.fs.read(file));
241			} catch (err) {
242				debug(
243					`${configRelPath} unreadable or malformed, read as on: ${messageOf(err)}`,
244				);
245				return true;
246			}
247		};
248
249		/** Why this load starts nothing — the floor, then the repo's switch, then
250		 * the double-load guard (#944) — or undefined where it starts. The guard
251		 * reads the folder alone, so a repo that also loads the plugin and turns
252		 * the stand-alone copy off in its settings runs neither load. */
253		const whyNotStarted = async () => {
254			const { version } = await $.session.version();
255			if (!isAtFloor(version))
256				return `Claude Code ${version} is below ${versionFloor.join(".")}; not started`;
257			if (!(await isSwitchedOn()))
258				return `switched off by "mods" in ${configRelPath}; not started`;
259			if (
260				$.plugin.name !== modName &&
261				(await $.fs.exists(`${e.cwd}/.claude/skills/${modName}`))
262			)
263				return `loaded through ${$.plugin.name}, but .claude/skills/${modName} here loads it stand-alone; not started`;
264		};
265
266		try {
267			const why = await whyNotStarted();
268			if (why !== undefined) {
269				debug(why);
270				return next(e);
271			}
272			const { value } = await $.state.get(recordsRef);
273			/* Copied, as what the host answers may be frozen, and filled out, as
274			 * a record an older load saved may lack a field (#866's `steps`). */
275			records = {};
276			for (const [id, rec] of Object.entries(value ?? {}))
277				records[id] = { ...blank(), ...JSON.parse(JSON.stringify(rec)) };
278			// null until this load first writes the line.
279			let shown: string | undefined | null = null;
280			let shownRows: string | null = null;
281			let isBusy = false;
282
283			/** Drops each agent listed once and gone since: the engine dropped
284			 * it. True where any was dropped. */
285			const dropUnlisted = (ids: Set<string>) => {
286				let isChanged = false;
287				for (const [id, rec] of Object.entries(records)) {
288					if (rec.firstListedAt === null || ids.has(id)) continue;
289					if (rec.isStalled)
290						debug(
291							`stall ended: ${id} ${truncate(rec.description)} (no longer listed)`,
292						);
293					delete records[id];
294					isChanged = true;
295				}
296				return isChanged;
297			};
298
299			const announceStall = (stall: Stall, isStalled: boolean) => {
300				const { agent } = stall;
301				if (isStalled) {
302					debug(`stall began: ${agent.id} ${stallText(stall)}`);
303					$.ui.toast(`Subagent stalled: ${stallText(stall)}`);
304				} else {
305					const endReason =
306						agent.status === "running"
307							? "stepped again"
308							: `now ${agent.status}`;
309					debug(
310						`stall ended: ${agent.id} ${truncate(agent.description)} (${endReason})`,
311					);
312				}
313			};
314
315			/** One listed agent's record brought up to `now`, its stall announced
316			 * where one began or ended; true where the record changed. */
317			const track = (
318				agent: AgentInfo,
319				now: number,
320				stalls: Stall[],
321				rows: LivenessRow[],
322			): boolean => {
323				const rec = recordOf(agent.id);
324				const isChanged = refreshRecord(rec, agent, now);
325				const quietMs = now - (rec.quietSince ?? now);
326				const isStalled = agent.status === "running" && quietMs >= stallMs;
327				const stall = { agent, quietMs, phase: phaseOf(rec) };
328				if (isStalled) stalls.push(stall);
329				rows.push(rowOf(stall, rec, now, isStalled));
330				if (isStalled === rec.isStalled) return isChanged;
331				rec.isStalled = isStalled;
332				announceStall(stall, isStalled);
333				return true;
334			};
335
336			/** The status line and the pane's rows, each written only where it
337			 * changed. */
338			const publish = async (
339				listed: AgentInfo[],
340				stalls: Stall[],
341				rows: LivenessRow[],
342			) => {
343				const text = listed.some((agent) => liveStatuses.has(agent.status))
344					? statusText(
345							listed.filter((agent) => agent.status === "running").length,
346							stalls,
347						)
348					: undefined;
349				if (text !== shown) {
350					$.ui.status(text);
351					shown = text;
352				}
353				const drawn = JSON.stringify(rows);
354				if (drawn !== shownRows) {
355					await $.state.set(rowsRef, rows);
356					shownRows = drawn;
357				}
358			};
359
360			const tick = async () => {
361				if (isBusy) return;
362				isBusy = true;
363				try {
364					// A teammate is no subagent, and one in a pane runs no loop here.
365					const listed = (await $.agent.list()).filter(
366						(agent) => agent.teammateId === undefined,
367					);
368					const now = await $.clock.now();
369					const ids = new Set(listed.map((agent) => agent.id));
370					let isChanged = dropUnlisted(ids);
371					const stalls: Stall[] = [];
372					const rows: LivenessRow[] = [];
373					for (const agent of listed)
374						isChanged = track(agent, now, stalls, rows) || isChanged;
375					if (isChanged) save(() => $.state.set(recordsRef, records), debug);
376					await publish(listed, stalls, rows);
377				} catch (err) {
378					debug(`tick failed, retried next tick: ${messageOf(err)}`);
379				} finally {
380					isBusy = false;
381				}
382			};
383
384			/* Only a test re-raises `session.start` within one load; a reload
385			 * cancels the old load's timers itself. */
386			cancelTick?.();
387			const timer = $.clock.every(tickMs, tick);
388			cancelTick = () => timer.cancel();
389			isLive = true;
390			try {
391				await $.command.register({
392					name: "liveness",
393					description: "Open or close a pane listing this session's subagents",
394					// Mid-turn is when it is wanted: a foreground agent holds the turn.
395					immediate: true,
396				});
397			} catch (err) {
398				debug(`/liveness not registered: ${messageOf(err)}`);
399			}
400		} catch (err) {
401			debug(`not started: ${messageOf(err)}`);
402		}
403		return next(e);
404	});
405
406	on("turn.step", async function* ($, e, next) {
407		const id = e.agentId;
408		// The main loop's steps, and every step until this load has started.
409		if (!isLive || id === undefined) return yield* next(e);
410		const debug = (text: string) =>
411			$.ui.log(`${modName}: ${text}`, { to: "debug" });
412		try {
413			const rec = recordOf(id);
414			rec.quietSince = await $.clock.now();
415			rec.phaseSince = rec.quietSince;
416			rec.steps++;
417			rec.isStepping = true;
418			rec.tools = [];
419			save(() => $.state.set(recordsRef, records), debug);
420		} catch (err) {
421			debug(`step start not recorded for ${id}: ${messageOf(err)}`);
422		}
423		let tools: string[] = [];
424		try {
425			const result = yield* next(e);
426			// A failure here costs the record, never the step (#863 §Guard safety).
427			try {
428				tools = result.toolUses
429					.map((use) => use.name)
430					.filter((name) => name !== handbackTool);
431			} catch (err) {
432				debug(`step tools not recorded for ${id}: ${messageOf(err)}`);
433			}
434			return result;
435		} finally {
436			// A failed stream still ends the step; its failure goes on up.
437			const rec = records[id];
438			if (rec) {
439				rec.isStepping = false;
440				rec.tools = tools;
441				try {
442					rec.phaseSince = await $.clock.now();
443				} catch (err) {
444					debug(`step end not timed for ${id}: ${messageOf(err)}`);
445				}
446				save(() => $.state.set(recordsRef, records), debug);
447			}
448		}
449	});
450
451	on("turn.complete", ($, e, next) => {
452		const debug = (text: string) =>
453			$.ui.log(`${modName}: ${text}`, { to: "debug" });
454		const rec = isLive && e.agentId !== undefined && records[e.agentId];
455		if (rec) {
456			rec.isStepping = false;
457			rec.tools = [];
458			save(() => $.state.set(recordsRef, records), debug);
459		}
460		return next(e);
461	});
462
463	// paneId spelled out, and kept equal to it: `claude plugin validate` reads a
464	// matcher's literal alone, and no test catches the two drifting apart.
465	on(
466		"ui.render",
467		{ component: "Pane", requestId: "liveness" },
468		async ($, e) => {
469			const { Box, Text } = $.ui.resolve(e);
470			const { value: rows = [] } = await $.state.get(rowsRef);
471			if (rows.length === 0)
472				return <Text dimColor={true}>No subagents listed.</Text>;
473			return (
474				<Box flexDirection="column">
475					{rows.map((row) => (
476						<Box
477							key={`agent:${row.id}`}
478							flexDirection="column"
479							marginBottom={1}
480						>
481							<Text bold={true} wrap="truncate-end">
482								{row.description}
483							</Text>
484							<Text dimColor={true}>
485								{[
486									row.type,
487									row.status,
488									`${row.steps} step${row.steps === 1 ? "" : "s"}`,
489									...(row.phase === null ? [] : [row.phase]),
490								].join(" · ")}
491							</Text>
492							{row.stall !== null && (
493								<Text color="warning">{`stalled, ${row.stall}`}</Text>
494							)}
495						</Box>
496					))}
497				</Box>
498			);
499		},
500	);
501
502	// Answers its own command, so no model turn starts (#863 §Where it draws).
503	on("command.run", { command: "liveness" }, async ($) => {
504		const debug = (text: string) =>
505			$.ui.log(`${modName}: ${text}`, { to: "debug" });
506		try {
507			if ((await $.ui.panes()).some((pane) => pane.id === paneId)) {
508				await $.ui.close({ id: paneId });
509				return { text: "Subagents pane closed." };
510			}
511			const opened = await $.ui.open({ id: paneId, title: "Subagents" });
512			return {
513				text: opened.isPlaced
514					? "Subagents pane opened."
515					: `Subagents pane not drawn: ${opened.reason}`,
516			};
517		} catch (err) {
518			debug(`/liveness failed: ${messageOf(err)}`);
519			return { text: `Subagents pane: ${messageOf(err)}` };
520		}
521	});
522};
523
types/index.d.ts 58 lines
1/*
2 * The liveness mod's `$.state` contract: one record per subagent loop it has
3 * seen a step from or listed, keyed by `agentId`. Held by the host, so a hot
4 * reload of the module keeps it (#863 §What it watches).
5 */
6
7export interface LivenessRecord {
8	/** When a tick first listed the agent; null while the list never has. */
9	firstListedAt: number | null;
10	/** Its task description and status as last listed. */
11	description: string;
12	/* The engine's `AgentStatus`, spelled out: a contract imports nothing. */
13	status:
14		| "pending"
15		| "running"
16		| "waiting"
17		| "idle"
18		| "completed"
19		| "failed"
20		| "killed"
21		| null;
22	/** Where its quiet clock runs from: its latest step's start, its first
23	 * listing where none was seen, or its latest return to `running`. */
24	quietSince: number | null;
25	/** Model steps started in its loop that this session has seen. */
26	steps: number;
27	/** When its current phase began: a step's start or end, or its listing. */
28	phaseSince: number | null;
29	/** True from a step's start until its result or failure. */
30	isStepping: boolean;
31	/** The tools its latest step's result called, the engine's hand-back left out. */
32	tools: string[];
33	/** True from a stall's onset until it ends, so a reload never toasts it twice. */
34	isStalled: boolean;
35}
36
37/** One pane row as the latest tick wrote it, in the list's order (#866). */
38export interface LivenessRow {
39	id: string;
40	description: string;
41	type: string;
42	status: NonNullable<LivenessRecord["status"]>;
43	steps: number;
44	/** What a `running` agent is doing and for how long; null otherwise. */
45	phase: string | null;
46	/** How long a stalled agent has been quiet; null while it is not. */
47	stall: string | null;
48}
49
50declare module "claude-code" {
51	interface PluginState {
52		"wfc-liveness": {
53			records: Record<string, LivenessRecord>;
54			rows: LivenessRow[];
55		};
56	}
57}
58