The shared agent workflow: guards, the session arbiter, and the pick-next, wayfinder and implement skills.

hooks/register.ts 24 lines1/*
2 * register.ts, the workflow-core plugin's hooks module: loads every mod the release ships,
3 * so an adopter runs them from the install cache (#933). The root
4 * hooks/hooks.json names it under `modules`; in the core itself the plugin is
5 * not enabled, and each mod loads stand-alone from .claude/skills/ instead.
6 *
7 * Only `on` and `options` cross the import, never a hook body or `$`: the
8 * engine refuses `$` followed into another file (#903 F3). Each mod's own
9 * `session.start` carries a matcher, without which two in one module fail to
10 * load (#903 F2).
11 *
12 * A new mod is also named in the tests that list each mod
13 * (wfc-mods-validate.test.ts, lib/plugin-tree.test.ts).
14 */
15
16import type { Register } from "claude-code";
17import { register as delivery } from "../.claude/skills/wfc-delivery/hooks/register.ts";
18import { register as liveness } from "../.claude/skills/wfc-liveness/hooks/register.tsx";
19
20export const register: Register = (on, options) => {
21 delivery(on, options);
22 liveness(on, options);
23};
24.claude/skills/wfc-delivery/hooks/register.ts 237 lines1/*
2 * register.ts, the wfc-delivery mod — brings a report this session's own start produced into the
3 * session while it runs, rather than at the next session start (#849, #854),
4 * a release request against a file it holds at its next turn rather than
5 * its next write to that file (#855), and a sweep verdict that changes while
6 * it runs within about ten minutes (#856).
7 *
8 * A thin loop over scripts/deliver-to-session.js, which owns every rule of
9 * what is due: owner, allow-list, once per session, the arbiter's wording.
10 *
11 * Hooks `session.start` alone and never calls `$.prompt.submit`: it can
12 * neither skip a guard nor wake an idle session (#849 §Guard safety).
13 *
14 * EVERYTHING LIVES INSIDE `register`. The tests stand for the load through
15 * the workflow-core plugin by loading `register` again as an inline plugin,
16 * which carries the function's own text and nothing around it (#944).
17 */
18
19import type { Register } from "claude-code";
20
21export const register: Register = (on) => {
22 /* The build #851's go checks passed on; below it the mod is unproven and
23 * starts nothing (#849 user story 19). */
24 const versionFloor = [2, 1, 289];
25 // #849 §Guard safety.
26 const tickMs = 30_000;
27 /* "A few seconds" (#849 §Guard safety), a guess never measured: `--reports`
28 * reads files, and `--requests` bounds its arbiter run at 4 s. */
29 const runTimeoutMs = 5000;
30 /* #849 §CI re-check mid-session. The first run is 10 minutes in: the
31 * session-start check covers the minutes before. */
32 const ciIntervalMs = 10 * 60_000;
33 /* Above the script's own bounds, 8 s on its gate and 15 s on the check, with
34 * room for two node starts; a guess. */
35 const ciRunTimeoutMs = 30_000;
36 // A guess: enough to ride out a transient rejection, never measured.
37 const maxOffers = 3;
38 const modName = "wfc-delivery";
39 const scriptRelPath = "scripts/deliver-to-session.js";
40 const deliveryHeader =
41 "Delivered by the wfc-delivery mod: a report this session's own start produced, a release request against a file it holds, or a sweep verdict that changed since (#849).";
42
43 /* The claim store's directory changes whenever any session takes or gives up
44 * a claim, this one's among them; its claims are read only then (#855). */
45 const sources = [
46 { flag: "--reports", watch: ["notifyReports"] },
47 { flag: "--requests", watch: ["requests", "locks"] },
48 ] as const;
49 type Paths = Record<(typeof sources)[number]["watch"][number], string>;
50
51 /* The helpers COPIED names in scripts/wfc-mods-copies.test.ts are copied in
52 * the liveness mod, not shared: the tests' inline load, standing for the
53 * plugin's, carries `register`'s own text alone (#944), and the engine
54 * follows `$` across no import (#903 F3). That test fails where the copies
55 * differ; a helper copied later is held only once it joins COPIED. */
56
57 /** True when `version` is at or above versionFloor; an unparseable one is below. */
58 const isAtFloor = (version: string): boolean => {
59 const parts = /^(\d+)\.(\d+)\.(\d+)/.exec(version)?.slice(1).map(Number);
60 if (!parts) return false;
61 for (let i = 0; i < versionFloor.length; i++) {
62 const have = parts[i] ?? 0;
63 const need = versionFloor[i] ?? 0;
64 if (have !== need) return have > need;
65 }
66 return true;
67 };
68
69 const messageOf = (err: unknown) =>
70 err instanceof Error ? err.message : String(err);
71
72 // Matches every cwd, so one module may hook `session.start` twice (#903 F2).
73 on("session.start", { cwd: /./ }, async ($, e, next) => {
74 /* Closures over `$`, never helpers taking it: the engine follows `$`
75 * into a function declared at the top of the file alone. */
76 const debug = (text: string) =>
77 $.ui.log(`${modName}: ${text}`, { to: "debug" });
78
79 /** Why this load starts nothing — the floor, then the double-load guard
80 * (#944) — or undefined where it starts. The guard reads the folder
81 * alone, so a repo that also loads the plugin and turns the stand-alone
82 * copy off in its settings runs neither load. No repo-wide switch, since
83 * the mod tells the model what it must act on (#1467); an adopter's seat
84 * stops the hosted load only with an empty .claude/skills/wfc-delivery/. */
85 const whyNotStarted = async () => {
86 const { version } = await $.session.version();
87 if (!isAtFloor(version))
88 return `Claude Code ${version} is below ${versionFloor.join(".")}; not started`;
89 if (
90 $.plugin.name !== modName &&
91 (await $.fs.exists(`${e.cwd}/.claude/skills/${modName}`))
92 )
93 return `loaded through ${$.plugin.name}, but .claude/skills/${modName} here loads it stand-alone; not started`;
94 };
95
96 try {
97 const why = await whyNotStarted();
98 if (why !== undefined) {
99 debug(why);
100 return next(e);
101 }
102 // Hosted, the script is the one the host plugin ships (#933).
103 const root = $.plugin.name === modName ? e.cwd : $.plugin.root;
104 const script = `${root}/${scriptRelPath}`;
105 let paths: Paths | undefined;
106 // Per source, absent until its first stat, so the first tick forks each.
107 const stamps = new Map<string, string>();
108 /* Printed text not yet appended. The script's ledger already holds it,
109 * so it is offered from here, never printed again; dropped after
110 * maxOffers rejections, or lost to a reload or the session's end. A
111 * report is shown again by the next session start's replay (#854); a
112 * release request only once its said-once marker is swept, about an
113 * hour on; a CI re-check never this session (#891). */
114 let pending = "";
115 let offers = 0;
116 let isBusy = false;
117 /* Set by the CI interval, run by the next tick, so every fork and the
118 * pending row stay with one loop. */
119 let isCiDue = false;
120
121 const run = async (args: string[], timeoutMs = runTimeoutMs) => {
122 const ran = await $.process.run(["node", script, ...args], {
123 cwd: e.cwd,
124 env: { CLAUDE_CODE_SESSION_ID: await $.session.id() },
125 timeoutMs,
126 });
127 if (ran.exitCode !== 0)
128 throw new Error(
129 `${scriptRelPath} ${args.join(" ")} exited ${ran.exitCode}: ${ran.stderr.trim()}`,
130 );
131 return ran.stdout;
132 };
133
134 const storePaths = async (): Promise<Paths> => {
135 const printed = JSON.parse(await run(["--paths"]));
136 for (const { watch } of sources)
137 for (const key of watch)
138 if (typeof printed[key] !== "string")
139 throw new Error(`${scriptRelPath} --paths named no ${key}`);
140 return printed;
141 };
142
143 const deliver = async () => {
144 const names = (
145 pending.match(/^▸ \S+/gm)?.map((n) => n.slice(2)) ?? ["a report"]
146 ).join(", ");
147 offers++;
148 const appended = await $.session
149 .append({
150 message: {
151 type: "user",
152 content: [
153 { type: "text", text: `${deliveryHeader}\n\n${pending}` },
154 ],
155 },
156 })
157 .catch((err: unknown) => {
158 if (offers >= maxOffers) pending = "";
159 throw new Error(
160 `append of ${names} rejected (offer ${offers} of ${maxOffers}): ${messageOf(err)}`,
161 );
162 });
163 pending = "";
164 /* A plugin above refused the row: a ruling, so it is dropped rather
165 * than offered again (#854). */
166 if (appended.deny !== undefined) {
167 debug(`append of ${names} refused, dropped: ${appended.deny}`);
168 return;
169 }
170 debug(`delivered ${names}`);
171 if ((await $.session.surfaces()).length > 0)
172 $.ui.toast(`${names} delivered to this session`);
173 };
174
175 // A held row never stops a newer report being fetched.
176 const fetch = async ({ flag, watch }: (typeof sources)[number]) => {
177 paths ??= await storePaths();
178 const known = paths;
179 // -1 for a missing directory: nothing has written there yet.
180 const stamp = (
181 await Promise.all(
182 watch.map((key) =>
183 $.fs.stat(known[key]).then(
184 (s) => s.mtimeMs,
185 () => -1,
186 ),
187 ),
188 )
189 ).join();
190 if (stamp === stamps.get(flag)) return;
191 const out = (await run([flag])).trim();
192 stamps.set(flag, stamp);
193 hold(out);
194 };
195
196 const hold = (out: string) => {
197 if (!out) return;
198 pending = pending ? `${pending}\n\n${out}` : out;
199 offers = 0;
200 };
201
202 const failed = (err: unknown) =>
203 debug(`tick failed, retried next tick: ${messageOf(err)}`);
204
205 const tick = async () => {
206 if (isBusy) return;
207 isBusy = true;
208 // One source failing never starves the other.
209 for (const source of sources) await fetch(source).catch(failed);
210 /* Cleared first: a failed or timed-out run delivers nothing and waits
211 * for the next interval (#856). */
212 if (isCiDue) {
213 isCiDue = false;
214 await run(["--ci"], ciRunTimeoutMs)
215 .then((out) => hold(out.trim()))
216 .catch((err: unknown) =>
217 debug(
218 `CI re-check failed, retried next interval: ${messageOf(err)}`,
219 ),
220 );
221 }
222 if (pending) await deliver().catch(failed);
223 isBusy = false;
224 };
225
226 // `tick` catches its own failures, so nothing it returns is dropped.
227 $.clock.every(tickMs, tick);
228 $.clock.every(ciIntervalMs, () => {
229 isCiDue = true;
230 });
231 } catch (err) {
232 debug(`not started: ${messageOf(err)}`);
233 }
234 return next(e);
235 });
236};
237.claude/skills/wfc-liveness/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};
523.claude/skills/wfc-liveness/types/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