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

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.
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).
wfc-liveness@skills-dir, in any session started at the repo's root..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.
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 } }
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.
hooks/register.tsx 523 lines1/*
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};
523types/index.d.ts 58 lines1/*
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