The qsm loop at a glance: a band above the prompt with the stage the main chat said and every running agent on a chip coloured by its model tier, a stage tool…

The loop at a glance for one developer who runs several agents at once: which step the main chat is in, which agents run on which model, and what was decided.
A session that runs a loop of steps and several agents at once hides both in a scrolling transcript, so the person cannot tell at a glance where the work stands or which model each agent spends. This plug-in makes the step something the agent says out loud and the person sees, instead of something inferred from its tool calls and guessed wrong. It refuses to judge the work or to move the loop by itself; it only shows what the agent and its agents report. It would have no reason to exist if the harness showed the step and each agent's model in its own interface.
| Kind | Name | What it does |
|---|---|---|
| Tool | stage | The agent says its step (LOAD, PLAN, EXECUTE, VERIFY, LEARN, CLOSE) and a few words; the answer is what the step asks and the next one |
| Band | above the prompt | The six steps with the current one on a blue chip, what the chat does there, and every running agent on a chip in its tier's colour (haiku green, sonnet cyan, opus magenta, fable yellow) with the command it runs now |
| Pane | /cockpit | Cards for the chat, the agents (finished ones behind a dropdown), the claimed tasks, and the decisions grouped under the prompt they answered |
| Context | every prompt | One line: the step the chat said, how long ago, and the next step |
claude plugin install qsm-cockpit@qsm-marketplace
Install brings qsm-tasks with it, because the Tasks card reads the claimed tasks from the task tool.
stage each time it enters a step, back to EXECUTE after a failed VERIFY included. The loop in the instructions says so./cockpit to open the pane. It shows the whole session since the plug-in loaded, whether the pane was open or not.stage leaves the band on "not said".qsm-cockpit ERROR: tasks: .... A task an agent closes shows only after the main chat runs a task-tool command.stage call moves its own row, never the chat's step.hooks/register.tsx 470 lines1// qsm-cockpit's function-hooks module: the stage tool, the hooks that record the session into its
2// state, a band above the prompt, and the /cockpit pane. The rules are lib/cockpit.ts; the hooks
3// only record and draw.
4import type { EngineInterface, On, Register, RenderChildren } from "claude-code";
5import { atom, read, update } from "claude-code";
6import {
7 askTitle,
8 closedIds,
9 decisionsOf,
10 doingOf,
11 guideText,
12 isStage,
13 OUTCOME_WORD,
14 offsetOf,
15 reminderText,
16 STAGES,
17 seconds,
18 TIER_COLOR,
19 tasksOf,
20 tierOf,
21} from "../lib/cockpit.ts";
22import type { AgentRun, Stage, StageStep } from "../types/state.d.ts";
23import { failure, registerNoun } from "./noun.generated.ts";
24
25const PANE = "qsm-cockpit";
26const STAGE_TOOL = "mcp__qsm-cockpit__stage";
27const steps = atom({ plugin: "qsm-cockpit", key: "steps" } as const, []);
28const agents = atom({ plugin: "qsm-cockpit", key: "runs" } as const, []);
29const decisions = atom({ plugin: "qsm-cockpit", key: "answers" } as const, []);
30const currentAsk = atom({ plugin: "qsm-cockpit", key: "currentAsk" } as const, "");
31const openAsks = atom({ plugin: "qsm-cockpit", key: "openAsks" } as const, []);
32const tasks = atom({ plugin: "qsm-cockpit", key: "tasks" } as const, []);
33const closed = atom({ plugin: "qsm-cockpit", key: "closed" } as const, []);
34const isMainWorking = atom({ plugin: "qsm-cockpit", key: "isMainWorking" } as const, false);
35const isShowingDone = atom({ plugin: "qsm-cockpit", key: "isShowingDone" } as const, false);
36
37type Elements = ReturnType<EngineInterface["ui"]["resolve"]>;
38
39export const register: Register = (on) => {
40 registerNoun(on);
41 registerStage(on);
42 registerRecords(on);
43 registerAgents(on);
44 registerBand(on);
45 registerPane(on);
46};
47
48/** The main chat enters a stage; saying the same stage again only renames what it does there. */
49async function enter($: EngineInterface, stage: Stage, why: string): Promise<void> {
50 const at = await $.clock.now();
51 await update($, steps, (list) => {
52 const last = list.at(-1);
53 if (last?.stage === stage) {
54 return [...list.slice(0, -1), { ...last, why }];
55 }
56 const step: StageStep = { stage, why, at };
57 return [...list, step].slice(-50);
58 });
59}
60
61async function refreshTasks($: EngineInterface): Promise<void> {
62 const list = tasksOf(await $.cockpit.tasks());
63 await update($, tasks, () => list);
64}
65
66function refreshLater($: EngineInterface): void {
67 refreshTasks($).catch((error) => {
68 failure($, "tasks", {
69 kind: "throw",
70 budget: 0,
71 message: error instanceof Error ? error.message : String(error),
72 });
73 });
74}
75
76/** The stage tool, and the line every prompt carries about it. */
77function registerStage(on: On): void {
78 on("session.start", async ($, e, next) => {
79 await $.command.register({ name: "cockpit", description: "Open the qsm cockpit pane" });
80 await $.tool.register({
81 name: "stage",
82 isDeferred: false,
83 description:
84 "Say which stage of the qsm loop you are in, each time you enter one: LOAD (notes, ready tasks, the governing spec), PLAN (six questions, size, grilling, split, spec-propose), EXECUTE (apply, claim, delegate), VERIFY (the real path ran), LEARN (a rule, a feedback task, a decision reason), CLOSE (archive, push, a note). Call it again when you go back, as VERIFY failing to EXECUTE. It answers what the stage asks, when it is done, and the next stage; the person sees it in the cockpit.",
85 inputSchema: {
86 type: "object",
87 properties: {
88 stage: { type: "string", enum: STAGES },
89 doing: {
90 type: "string",
91 description: 'What you do in it, a few words: "grilling the cockpit epic".',
92 },
93 },
94 required: ["stage"],
95 },
96 });
97 refreshLater($);
98 return next(e);
99 });
100
101 // The caller says its stage: the main chat's moves the cockpit, an agent's only its own row.
102 on("tool.call", { tool: STAGE_TOOL }, async ($, e) => {
103 const stage = "stage" in e ? e.stage : undefined;
104 const doing = "doing" in e ? e.doing : undefined;
105 if (!isStage(stage)) {
106 return {
107 result: `stage must be one of ${STAGES.join(", ")}; got ${String(stage)}`,
108 isError: true,
109 };
110 }
111 const id = e.agentId;
112 if (id === undefined) {
113 await enter($, stage, typeof doing === "string" ? doing.slice(0, 60) : "");
114 } else {
115 await update($, agents, (list) =>
116 list.map((run) => (run.id === id ? { ...run, stage } : run)),
117 );
118 }
119 return { result: guideText(stage) };
120 });
121
122 on("prompt.submit", async ($, e, next) => {
123 const line = reminderText((await read($, steps)).at(-1), await $.clock.now());
124 // A typed prompt is the ask the next decisions answer to.
125 if (e.origin.kind === "composer" && e.text.trim() !== "") {
126 await update($, currentAsk, () => askTitle(e.text));
127 }
128 return next({ ...e, context: [...(e.context ?? []), line] });
129 })
130 // a reminder that fails never holds the prompt
131 .catch(($, e, next) => {
132 failure($, "prompt.submit", next.error);
133 return next(e);
134 });
135}
136
137/** What an agent runs now: set before its call, back to thinking after it. */
138async function agentCall<T>(
139 $: EngineInterface,
140 id: string,
141 doing: string,
142 run: () => Promise<T>,
143): Promise<T> {
144 await update($, agents, (list) => list.map((one) => (one.id === id ? { ...one, doing } : one)));
145 try {
146 return await run();
147 } finally {
148 await update($, agents, (list) =>
149 list.map((one) =>
150 one.id === id && one.doing === doing ? { ...one, doing: "thinking" } : one,
151 ),
152 );
153 }
154}
155
156/** Everything the band and the pane read, recorded as it happens whether the pane is open or not. */
157function registerRecords(on: On): void {
158 on("tool.call", async ($, e, next) => {
159 const args: Record<string, unknown> = e;
160 if (e.agentId !== undefined) {
161 return agentCall($, e.agentId, doingOf(e.tool, args), () => next(e));
162 }
163 const result = await next(e);
164 if (e.tool === "AskUserQuestion" && result.result !== undefined) {
165 const [ask, at] = await Promise.all([read($, currentAsk), $.clock.now()]);
166 const picked = decisionsOf(args, result.result, ask, at);
167 await update($, decisions, (list) => [...list, ...picked].slice(-60));
168 }
169 if (e.tool === "Bash" && typeof args.command === "string" && /\bbr\b/.test(args.command)) {
170 const ids = closedIds(args.command);
171 await update($, closed, (list) => [...list, ...ids]);
172 refreshLater($);
173 }
174 return result;
175 })
176 // an observer never blocks a call
177 .catch(($, e, next) => {
178 failure($, "tool.call", next.error);
179 return next(e);
180 });
181}
182
183/** Every agent the session starts, and how and when its turn ends; the main chat's turn too. */
184function registerAgents(on: On): void {
185 on("agent.spawn", async ($, e, next) => {
186 const started = await next(e);
187 if (started.agentId === undefined) {
188 return started;
189 }
190 const model = e.fork ? e.parentModel : started.model;
191 const run: AgentRun = {
192 id: started.agentId,
193 tier: tierOf(model),
194 model,
195 description: e.description,
196 kind: e.fork ? "fork" : e.subagentType,
197 startedAt: await $.clock.now(),
198 tookMs: null,
199 outcome: "running",
200 doing: "starting",
201 };
202 await update($, agents, (list) => [...list, run].slice(-100));
203 return started;
204 }).catch(($, e, next) => {
205 failure($, "agent.spawn", next.error);
206 return next(e);
207 });
208
209 on("turn.start", async ($, e, next) => {
210 // Only the main chat's turn starts from a prompt.
211 await update($, isMainWorking, () => true);
212 return next(e);
213 });
214
215 on("turn.complete", async ($, e, next) => {
216 const id = e.agentId;
217 if (id === undefined) {
218 await update($, isMainWorking, () => false);
219 } else {
220 await update($, agents, (list) =>
221 list.map((run) =>
222 run.id === id && run.tookMs === null
223 ? { ...run, tookMs: e.durationMs, outcome: e.reason }
224 : run,
225 ),
226 );
227 }
228 return next(e);
229 });
230}
231
232/** The six stages in one line: the current one on a blue chip, the reached ones plain, the rest dim. */
233function stageLine(Text: Elements["Text"], current: Stage | undefined, reached: Set<Stage>) {
234 return STAGES.flatMap((stage, i) => [
235 ...(i === 0
236 ? []
237 : [
238 <Text key={`s${i}`} dimColor>
239 ›
240 </Text>,
241 ]),
242 stage === current ? (
243 <Text key={stage} backgroundColor="blue" color="white" bold>{` ${stage} `}</Text>
244 ) : (
245 <Text key={stage} dimColor={!reached.has(stage)}>{` ${stage} `}</Text>
246 ),
247 ]);
248}
249
250function tierChip(Text: Elements["Text"], run: AgentRun) {
251 return (
252 <Text backgroundColor={TIER_COLOR[run.tier] ?? "gray"} color="black" bold>
253 {` ${run.tier} `}
254 </Text>
255 );
256}
257
258/** What a running agent does: its stage when it said one, its command, and how long it has run. */
259function agentState(run: AgentRun, now: number): string {
260 const stage = run.stage === undefined ? "" : `${run.stage} · `;
261 return `${stage}${run.doing} · ${seconds(now - run.startedAt)}`;
262}
263
264function registerBand(on: On): void {
265 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
266 const [history, all, claimed] = await Promise.all([
267 read($, steps),
268 read($, agents),
269 read($, tasks),
270 ]);
271 if (e.props.hasSurvey || (history.length === 0 && all.length === 0)) {
272 return next(e);
273 }
274 const { Box, Text } = $.ui.resolve(e);
275 const now = await $.clock.now();
276 const last = history.at(-1);
277 const running = all.filter((run) => run.outcome === "running");
278 const room = Math.max(1, e.props.maxRows - 2);
279 return (
280 <Box flexDirection="column">
281 <Box flexDirection="row">
282 <Text color={e.props.isWorking ? "blue" : undefined} dimColor={!e.props.isWorking} bold>
283 {e.props.isWorking ? "● " : "○ "}
284 </Text>
285 {stageLine(Text, last?.stage, new Set(history.map((step) => step.stage)))}
286 {claimed[0] !== undefined && <Text dimColor>{` ${claimed[0].id}`}</Text>}
287 </Box>
288 {last !== undefined && last.why !== "" && (
289 <Text wrap="truncate-end">{`${" ".repeat(2 + offsetOf(last.stage))}└ ${last.why}`}</Text>
290 )}
291 {running.slice(0, room).map((run) => (
292 // One Text per row: a chip beside a Text in a row Box takes the row's height and spills.
293 <Text key={run.id} wrap="truncate-end">
294 {tierChip(Text, run)}
295 {` ${run.description} `}
296 <Text dimColor>{agentState(run, now)}</Text>
297 </Text>
298 ))}
299 {running.length > room && <Text dimColor>{`+${running.length - room} more running`}</Text>}
300 </Box>
301 );
302 });
303}
304
305function card(els: Elements, title: string, note: string, body: RenderChildren) {
306 const { Box, Text } = els;
307 return (
308 <Box
309 flexDirection="column"
310 borderStyle="round"
311 borderColor="gray"
312 borderDimColor
313 paddingX={1}
314 marginBottom={1}
315 >
316 <Text>
317 <Text bold>{title}</Text>
318 <Text dimColor>{note === "" ? "" : ` ${note}`}</Text>
319 </Text>
320 {body}
321 </Box>
322 );
323}
324
325async function chatCard($: EngineInterface, els: Elements) {
326 const { Box, Text } = els;
327 const [history, isWorking] = await Promise.all([read($, steps), read($, isMainWorking)]);
328 const last = history.at(-1);
329 return card(
330 els,
331 "Chat",
332 isWorking ? "● working" : "○ idle",
333 <Box flexDirection="column" marginTop={1}>
334 <Box flexDirection="row">
335 {stageLine(Text, last?.stage, new Set(history.map((step) => step.stage)))}
336 </Box>
337 {last === undefined && <Text dimColor>not said yet: the agent calls the stage tool</Text>}
338 {last !== undefined && last.why !== "" && (
339 <Text wrap="truncate-end">
340 <Text dimColor>{`${" ".repeat(offsetOf(last.stage))}└ `}</Text>
341 <Text color="blue">{last.why}</Text>
342 </Text>
343 )}
344 </Box>,
345 );
346}
347
348async function agentsCard($: EngineInterface, els: Elements) {
349 const { Box, Button, Text } = els;
350 const [all, isOpen, now] = await Promise.all([
351 read($, agents),
352 read($, isShowingDone),
353 $.clock.now(),
354 ]);
355 const running = all.filter((run) => run.outcome === "running");
356 const finished = all
357 .filter((run) => run.outcome !== "running")
358 .toReversed()
359 .slice(0, 12);
360 return card(
361 els,
362 "Agents",
363 `${running.length} running`,
364 <Box flexDirection="column">
365 {running.length === 0 && <Text dimColor>none running</Text>}
366 {running.map((run) => (
367 <Box key={run.id} flexDirection="column">
368 <Text wrap="truncate-end">
369 {tierChip(Text, run)}
370 {` ${run.description}`}
371 </Text>
372 <Text
373 dimColor
374 wrap="truncate-end"
375 >{`${" ".repeat(run.tier.length + 2)}└ ${agentState(run, now)}`}</Text>
376 </Box>
377 ))}
378 {finished.length > 0 && (
379 <Button
380 key="done"
381 label={`${isOpen ? "▾" : "▸"} ${all.length - running.length} finished`}
382 onPress={() => update($, isShowingDone, (shown) => !shown)}
383 />
384 )}
385 {isOpen &&
386 finished.map((run) => (
387 <Text key={run.id} dimColor wrap="truncate-end">
388 {`${run.tier.padEnd(6)} ${(OUTCOME_WORD[run.outcome] ?? run.outcome).padEnd(7)} ${seconds(run.tookMs ?? 0).padStart(6)} ${run.description}`}
389 </Text>
390 ))}
391 </Box>,
392 );
393}
394
395async function tasksCard($: EngineInterface, els: Elements) {
396 const { Box, Text } = els;
397 const [claimed, done] = await Promise.all([read($, tasks), read($, closed)]);
398 return card(
399 els,
400 "Tasks",
401 done.length === 0 ? "" : `${done.length} closed this session`,
402 <Box flexDirection="column">
403 {claimed.length === 0 && <Text dimColor>none claimed</Text>}
404 {claimed.map((task) => (
405 <Text key={task.id} wrap="truncate-end">
406 <Text color="blue">{task.id}</Text>
407 {` ${task.title}`}
408 </Text>
409 ))}
410 </Box>,
411 );
412}
413
414/** Decisions grouped under the typed ask they answered, in the order the asks came, each a dropdown. */
415async function decisionsCard($: EngineInterface, els: Elements) {
416 const { Box, Button, Text } = els;
417 const [picked, open] = await Promise.all([read($, decisions), read($, openAsks)]);
418 const asks = [...new Set(picked.map((decision) => decision.ask))];
419 const toggle = (ask: string) =>
420 update($, openAsks, (list) =>
421 list.includes(ask) ? list.filter((one) => one !== ask) : [...list, ask],
422 );
423 return card(
424 els,
425 "Decisions",
426 "",
427 <Box flexDirection="column">
428 {asks.length === 0 && <Text dimColor>no question answered yet</Text>}
429 {asks.map((ask, i) => {
430 const answers = picked.filter((decision) => decision.ask === ask);
431 const isOpen = open.includes(ask);
432 return (
433 <Box key={`ask${i}`} flexDirection="column">
434 <Button
435 key={`ask:${i}`}
436 label={`${isOpen ? "▾" : "▸"} ${ask === "" ? "before the first prompt" : ask} (${answers.length})`}
437 onPress={() => toggle(ask)}
438 />
439 {isOpen &&
440 answers.map((decision) => (
441 <Text key={`${decision.at}-${decision.question}`} wrap="truncate-end">
442 <Text dimColor>{` ${decision.question} `}</Text>
443 {decision.answer}
444 </Text>
445 ))}
446 </Box>
447 );
448 })}
449 </Box>,
450 );
451}
452
453function registerPane(on: On): void {
454 on("command.run", { command: "cockpit" }, async ($) => {
455 await $.ui.open({ id: PANE, title: "qsm cockpit" });
456 return { text: "qsm cockpit opened." };
457 });
458
459 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
460 const els = $.ui.resolve(e);
461 const cards = await Promise.all([
462 chatCard($, els),
463 agentsCard($, els),
464 tasksCard($, els),
465 decisionsCard($, els),
466 ]);
467 return <els.Box flexDirection="column">{cards}</els.Box>;
468 });
469}
470lib/cockpit.ts 178 lines1// The cockpit's rules, apart from its wiring: the six stages and what each asks, how a model maps to
2// its tier and colour, how a tool call is said short, and what an answered question records.
3import type { Decision, Stage, StageStep, Task } from "../types/state.d.ts";
4
5export const STAGES: Stage[] = ["LOAD", "PLAN", "EXECUTE", "VERIFY", "LEARN", "CLOSE"];
6
7/** The chip colour of each model tier; its keys are the tiers. */
8export const TIER_COLOR: Readonly<Record<string, string>> = Object.fromEntries([
9 ["haiku", "green"],
10 ["sonnet", "cyan"],
11 ["opus", "magenta"],
12 ["fable", "yellow"],
13]);
14
15/** How an agent's turn ended, said as the pane says it. */
16export const OUTCOME_WORD: Readonly<Record<string, string>> = {
17 answer: "done",
18 aborted: "stopped",
19 refusal: "refused",
20 error: "failed",
21};
22
23/** What each stage asks, when it is done, and where the loop goes next: the loop of the instructions. */
24const GUIDE: Record<Stage, { asks: string; done: string; next: Stage }> = {
25 LOAD: {
26 asks: "read the notes for this tree, the ready tasks and the spec that governs the change; search past sessions before asking twice",
27 done: "you know what was decided and what is open",
28 next: "PLAN",
29 },
30 PLAN: {
31 asks: "the six questions, a probe for what exists, the size: a fix goes to EXECUTE; an epic goes through grilling (intent, criteria), the split, and a spec per piece",
32 done: "the person said yes to the epic, the split and the spec",
33 next: "EXECUTE",
34 },
35 EXECUTE: {
36 asks: "apply the spec change, claim the task, delegate and take the work back",
37 done: "the change is in and the run that proves it is ready",
38 next: "VERIFY",
39 },
40 VERIFY: {
41 asks: "run the real path end to end; lint and typecheck; a skill passes its eval",
42 done: "you read the output and it holds; a failure goes back to EXECUTE",
43 next: "LEARN",
44 },
45 LEARN: {
46 asks: "a rule found into its instruction file or skill, a tool defect into a task, a decision's reason into the spec design or the commit",
47 done: "nothing learned is left only in the chat",
48 next: "CLOSE",
49 },
50 CLOSE: {
51 asks: "spec-archive closes the change's task; the epic closes when every task is done, its criteria hold, test-it passed and the work is pushed after review; a note when work stays open",
52 done: "the task is closed or the note is written",
53 next: "LOAD",
54 },
55};
56
57// differs: hooks sandbox (the hooks module imports this file, and root lib/ is out of its reach)
58function isRecord(value: unknown): value is Record<string, unknown> {
59 return typeof value === "object" && value !== null;
60}
61
62/** The tasks in a list of issues: each entry that has an id and a title. */
63export function tasksOf(issues: unknown): Task[] {
64 return Array.isArray(issues)
65 ? issues.flatMap((issue: unknown) =>
66 isRecord(issue) && typeof issue.id === "string" && typeof issue.title === "string"
67 ? [{ id: issue.id, title: issue.title }]
68 : [],
69 )
70 : [];
71}
72
73export function isStage(value: unknown): value is Stage {
74 return typeof value === "string" && (STAGES as string[]).includes(value);
75}
76
77/** The tier word of a model alias or id (`claude-sonnet-5-5` is sonnet), else the model itself. */
78export function tierOf(model: string): string {
79 return Object.keys(TIER_COLOR).find((tier) => model.includes(tier)) ?? model;
80}
81
82/** What the stage tool answers: the stage's ask, its done test, and the next stage. */
83export function guideText(stage: Stage): string {
84 const { asks, done, next } = GUIDE[stage];
85 return `${stage}. Asks: ${asks}. Done when: ${done}. Next: ${next}; call stage again when you move, back to EXECUTE included.`;
86}
87
88/** The one line every prompt carries, so the main chat always knows where it stands. */
89export function reminderText(step: StageStep | undefined, now: number): string {
90 if (step === undefined) {
91 return "qsm stage: not said yet. Call the stage tool with the stage you start in.";
92 }
93 const note = step.why === "" ? "" : `, ${step.why}`;
94 return `qsm stage: ${step.stage} (${seconds(now - step.at)}${note}); next ${GUIDE[step.stage].next}. Call the stage tool when you move.`;
95}
96
97/** A duration said short: `42s`, `3m05s`. */
98export function seconds(ms: number): string {
99 const s = Math.round(ms / 1000);
100 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
101}
102
103/** The column the current stage starts at in the stage line (` LOAD ›` is seven), so its note sits under it. */
104export function offsetOf(current: Stage | undefined): number {
105 const at = current === undefined ? 0 : STAGES.indexOf(current);
106 return STAGES.slice(0, at).reduce((sum, stage) => sum + stage.length + 3, 0) + 1;
107}
108
109/** A path said by its last part, anything else as it is. */
110function shortArg(value: string): string {
111 return value.includes("/") && !value.includes(" ") ? (value.split("/").pop() ?? value) : value;
112}
113
114/** A tool call said short, as the state of whoever made it: `Bash sleep 30`, `Edit register.tsx`. */
115export function doingOf(tool: string, args: Record<string, unknown>): string {
116 const pick = ["command", "file_path", "pattern", "skill", "description", "url", "query"]
117 .map((key) => args[key])
118 .find((value): value is string => typeof value === "string");
119 const name = tool.includes("__") ? (tool.split("__").pop() ?? tool) : tool;
120 if (pick === undefined) {
121 return name;
122 }
123 const short = tool === "Bash" ? bare(pick) : shortArg(pick);
124 return `${name} ${short.replaceAll(/\s+/g, " ").slice(0, 48)}`;
125}
126
127/**
128 * A shell command without what only frames it: a leading `cd <dir> &&`, `set -o …;`, a wrapping
129 * subshell, and trailing `2>&1`, `| tee …` or `> file`. `cd /tmp && (sleep 30 && echo done) 2>&1 |
130 * tee x.log` is `sleep 30 && echo done`.
131 */
132function bare(command: string): string {
133 let rest = command.trim();
134 for (let before = ""; before !== rest;) {
135 before = rest;
136 rest = rest
137 .replace(/^cd\s+\S+\s*(&&|;)\s*/, "")
138 .replace(/^set\s+[-+]\S+(\s+\S+)?\s*(&&|;)\s*/, "")
139 .replace(/\s*\|\s*tee\s+\S+\s*$/, "")
140 .replace(/\s*\d?>&\d\s*$/, "")
141 .replace(/\s*\d?>>?\s*\S+\s*$/, "")
142 .replace(/^\((.*)\)$/, "$1")
143 .trim();
144 }
145 return rest;
146}
147
148/** The decisions one AskUserQuestion call made: each question it asked that has an answer. */
149export function decisionsOf(
150 args: Record<string, unknown>,
151 result: unknown,
152 ask: string,
153 at: number,
154): Decision[] {
155 const answers = isRecord(result) && isRecord(result.answers) ? result.answers : {};
156 const asked: unknown[] = Array.isArray(args.questions) ? args.questions : [];
157 return asked.flatMap((entry) => {
158 if (!isRecord(entry) || typeof entry.question !== "string") {
159 return [];
160 }
161 const { question } = entry;
162 const answer = answers[question];
163 return typeof answer === "string" ? [{ ask, question, answer, at }] : [];
164 });
165}
166
167/** The task ids a shell command closes with `br close`. */
168export function closedIds(command: string): string[] {
169 return [...command.matchAll(/\bbr close\s+([\w.-]+(?:\s+[\w.-]+)*)/g)]
170 .flatMap((match) => (match[1] ?? "").split(/\s+/))
171 .filter((id) => /^[a-z]+-\w/.test(id));
172}
173
174/** A typed prompt said as the title of its decisions. */
175export function askTitle(text: string): string {
176 return text.trim().replaceAll(/\s+/g, " ").slice(0, 200);
177}
178types/state.d.ts 49 lines1export type Stage = "LOAD" | "PLAN" | "EXECUTE" | "VERIFY" | "LEARN" | "CLOSE";
2
3/** One step of the loop the main agent entered, and what put it there. */
4export type StageStep = { stage: Stage; why: string; at: number };
5
6/** How an agent's run stands: running until its turn completes, then why it ended. */
7export type Outcome = "running" | "answer" | "aborted" | "refusal" | "error";
8
9export type AgentRun = {
10 id: string;
11 tier: string;
12 model: string;
13 description: string;
14 kind: string;
15 startedAt: number;
16 /** Milliseconds it ran; null while it runs. */
17 tookMs: number | null;
18 outcome: Outcome;
19 /** The stage it said with the stage tool, when it said one. */
20 stage?: Stage;
21 /** What it does now: its last tool call, said short (`Bash sleep 30`). */
22 doing: string;
23};
24
25/** One answered question, under the typed ask it was asked for. */
26export type Decision = { ask: string; question: string; answer: string; at: number };
27
28export type Task = { id: string; title: string };
29
30declare module "claude-code" {
31 interface PluginState {
32 "qsm-cockpit": {
33 steps: StageStep[];
34 runs: AgentRun[];
35 answers: Decision[];
36 /** The person's last typed prompt: the ask the next decisions answer to. */
37 currentAsk: string;
38 /** The asks whose decisions the pane shows open. */
39 openAsks: string[];
40 tasks: Task[];
41 closed: string[];
42 /** Whether the main chat's turn runs now. */
43 isMainWorking: boolean;
44 /** Whether the pane lists the agents that finished. */
45 isShowingDone: boolean;
46 };
47 }
48}
49hooks/noun.generated.ts 126 lines1// Generated by lib/generate-noun.ts from qsm.json. Do not hand-edit; run
2// `bun lib/generate-noun.ts plugins/<name>` again after changing qsm.json.
3import type { Caught, EngineInterface, On } from "claude-code";
4import type { Cockpit } from "../types/index.d.ts";
5
6/** How one option becomes argv: a positional value (`flag` null), a bare
7 * boolean flag, or a flag and its value; a list repeats it per element. */
8export interface VerbArg {
9 readonly name: string;
10 readonly flag: string | null;
11 readonly boolean?: true;
12}
13
14export interface VerbRow {
15 readonly words: readonly string[];
16 readonly args: readonly VerbArg[];
17 readonly passthrough?: true;
18}
19
20export const verbs: Readonly<Record<string, VerbRow>> = {
21 doctor: { words: ["qsm-cockpit","doctor"], args: [] },
22 tasks: { words: ["qsm-cockpit","tasks"], args: [] },
23};
24
25/** One verb's argv: its words, each option in declaration order, the
26 * passthrough rest after `--`, and `--json` last. */
27export function argvOf(row: VerbRow, options: Readonly<Record<string, unknown>> = {}): string[] {
28 const argv = [...row.words];
29 for (const arg of row.args) {
30 const value = options[arg.name];
31 const values = Array.isArray(value) ? value : [value];
32 for (const one of values) {
33 if (one === undefined || one === false) {
34 continue;
35 }
36 argv.push(...(arg.flag === null ? [] : [arg.flag]), ...(arg.boolean ? [] : [String(one)]));
37 }
38 }
39 const rest = options.rest;
40 if (row.passthrough && Array.isArray(rest)) {
41 argv.push("--", ...rest.map(String));
42 }
43 argv.push("--json");
44 return argv;
45}
46
47/** The binary prints one outcome envelope on stdout under --json:
48 * { code, exit, reason, message, warnings, data }. Data on ok; otherwise the
49 * code and message become the error. A stale or misbehaving binary that
50 * prints something else (not JSON, or JSON with no numeric `exit`) throws
51 * loud here instead of a raw JSON.parse SyntaxError or a silent `undefined`. */
52export function unwrap(
53 argv: readonly string[],
54 result: { readonly exitCode: number; readonly stdout: string; readonly stderr: string },
55 label = argv.slice(0, 2).join(" "),
56): unknown {
57 const text = result.stdout.trim();
58 const stderr = result.stderr.trim().slice(0, 200);
59 const cause = `(exit ${result.exitCode}): ${stderr.length > 0 ? stderr : "no stderr"}`;
60 if (text.length === 0) {
61 throw new Error(`${argv.join(" ")}: printed no envelope ${cause}`);
62 }
63 let parsed: unknown;
64 try {
65 parsed = JSON.parse(text);
66 } catch {
67 parsed = undefined;
68 }
69 if (typeof parsed !== "object" || parsed === null || typeof (parsed as { exit?: unknown }).exit !== "number") {
70 throw new Error(`${argv.join(" ")}: stdout is not an outcome envelope ${cause}: ${text.slice(0, 200)}`);
71 }
72 const envelope = parsed as { code: string; exit: number; message: string; data: unknown };
73 if (envelope.exit !== 0) {
74 throw new Error(`${label}: ${envelope.code}: ${envelope.message}`);
75 }
76 return envelope.data;
77}
78
79/** What every `.catch` does first: a toast and a log for the person. The toast says `fixedReason`
80 * when the cause may carry something the toast must not show, else the cause; the cause goes to
81 * the log and comes back for the caller's own policy. */
82export function failure(
83 $: EngineInterface,
84 event: string,
85 error: Caught["error"],
86 fixedReason?: string,
87): string {
88 const said = error.message ?? error.kind;
89 $.ui.toast(`qsm-cockpit ERROR: ${event}: ${(fixedReason ?? said).slice(0, 120)}`);
90 $.ui.log(`${event} failed (${error.kind}): ${said}`);
91 return said;
92}
93
94/** Adds $.cockpit to the engine. Call once from the plugin's own
95 * `register(on, options)` — never call the engine.create hook's own
96 * `next(e)` result from anywhere but here. */
97export function registerNoun(on: On): void {
98 on("engine.create", async (_$, e, next) => {
99 const beneath = await next(e);
100 const run = async (row: VerbRow, options?: Readonly<Record<string, unknown>>): Promise<unknown> => {
101 const argv = argvOf(row, options);
102 let result: Awaited<ReturnType<typeof beneath.process.run>>;
103 try {
104 result = await beneath.process.run(argv);
105 } catch (error) {
106 // A spawn in a deleted directory fails as ENOENT naming the binary; name the directory. A
107 // check that cannot run leaves the spawn's own error as the cause.
108 const gone = await beneath.session
109 .cwd()
110 .then(async (cwd) => ((await beneath.fs.exists(cwd)) ? null : cwd))
111 .catch(() => null);
112 if (gone !== null) {
113 throw new Error(`${row.words.join(" ")}: the session's working directory ${gone} no longer exists; cd to one that does`);
114 }
115 throw error;
116 }
117 return unwrap(argv, result, row.words.join(" "));
118 };
119 const methods = Object.entries(verbs).map(([method, row]) => [
120 method,
121 (options?: Readonly<Record<string, unknown>>) => run(row, options),
122 ]);
123 return { ...beneath, cockpit: Object.fromEntries(methods) as Cockpit };
124 });
125}
126types/index.d.ts 63 lines1// Generated by lib/generate-noun.ts from qsm.json. Do not hand-edit; run
2// `bun lib/generate-noun.ts plugins/<name>` again after changing qsm.json.
3
4export type Cockpit = {
5 doctor(): Promise<unknown>;
6 tasks(): Promise<unknown>;
7};
8
9declare module 'claude-code' {
10 interface EngineInterface {
11 cockpit: Cockpit;
12 }
13}
14
15export type Stage = "LOAD" | "PLAN" | "EXECUTE" | "VERIFY" | "LEARN" | "CLOSE";
16
17/** One step of the loop the main agent entered, and what put it there. */
18export type StageStep = { stage: Stage; why: string; at: number };
19
20/** How an agent's run stands: running until its turn completes, then why it ended. */
21export type Outcome = "running" | "answer" | "aborted" | "refusal" | "error";
22
23export type AgentRun = {
24 id: string;
25 tier: string;
26 model: string;
27 description: string;
28 kind: string;
29 startedAt: number;
30 /** Milliseconds it ran; null while it runs. */
31 tookMs: number | null;
32 outcome: Outcome;
33 /** The stage it said with the stage tool, when it said one. */
34 stage?: Stage;
35 /** What it does now: its last tool call, said short (`Bash sleep 30`). */
36 doing: string;
37};
38
39/** One answered question, under the typed ask it was asked for. */
40export type Decision = { ask: string; question: string; answer: string; at: number };
41
42export type Task = { id: string; title: string };
43
44declare module "claude-code" {
45 interface PluginState {
46 "qsm-cockpit": {
47 steps: StageStep[];
48 runs: AgentRun[];
49 answers: Decision[];
50 /** The person's last typed prompt: the ask the next decisions answer to. */
51 currentAsk: string;
52 /** The asks whose decisions the pane shows open. */
53 openAsks: string[];
54 tasks: Task[];
55 closed: string[];
56 /** Whether the main chat's turn runs now. */
57 isMainWorking: boolean;
58 /** Whether the pane lists the agents that finished. */
59 isShowingDone: boolean;
60 };
61 }
62}
63