SLOPSHOPPER

qsm-cockpit

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…

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · qsm-cockpit
│ ┃ qsm cockpit ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ╭─────────────────────────────────────────── │ qsm-cockpit │ │ ┃ │ Chat ○ idle ● qsm-cockpit: tasks f│ qsm-cockpit ERROR: tasks: undefined is not │ │ ┃ │ ⏺ Read(src/auth.ts) │ an object (evaluating '$.cockpit.tasks') │ │ ┃ │ LOAD › PLAN › EXECUTE › VERIFY › LEARN › ⎿ Read 6 lines ╰────────────────────────────────────────────╯ │ ┃ │ not said yet: the agent calls the stage ⏺ Update(src/auth.ts) │ ┃ │ tool ⎿ Added 2 lines, removed 1 line │ ┃ ╰─────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Agents 0 running │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ none running │ │ ┃ ╰──────────────────────────────────────────╯ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ╭──────────────────────────────────────────╮ › /cockpit │ ┃ │ Tasks │ ⎿ qsm-cockpit: qsm cockpit opened. │ ┃ │ none claimed │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Decisions │ │ ┃ │ no question answered yet │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · qsm cockpit
╭──────────────────────────────────────────────────────────╮ │ Chat ○ idle │ │ │ │ LOAD › PLAN › EXECUTE › VERIFY › LEARN › CLOSE │ │ not said yet: the agent calls the stage tool │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Agents 0 running │ │ none running │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Tasks │ │ none claimed │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Decisions │ │ no question answered yet │ ╰──────────────────────────────────────────────────────────╯
README

qsm-cockpit

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.

Why it exists

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.

What you get

KindNameWhat it does
ToolstageThe 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
Bandabove the promptThe 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/cockpitCards for the chat, the agents (finished ones behind a dropdown), the claimed tasks, and the decisions grouped under the prompt they answered
Contextevery promptOne line: the step the chat said, how long ago, and the next step

Install

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.

Use

  • The agent calls stage each time it enters a step, back to EXECUTE after a failed VERIFY included. The loop in the instructions says so.
  • Type /cockpit to open the pane. It shows the whole session since the plug-in loaded, whether the pane was open or not.

Caveats

  • The step is what the agent said. An agent that never calls stage leaves the band on "not said".
  • The Tasks card asks the task tool for the claimed tasks in the session's directory. A directory without a tracker leaves the card as it was and toasts qsm-cockpit ERROR: tasks: .... A task an agent closes shows only after the main chat runs a task-tool command.
  • A subagent's stage call moves its own row, never the chat's step.
Source 5 files
hooks/register.tsx 470 lines
1// 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}
470
lib/cockpit.ts 178 lines
1// 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}
178
types/state.d.ts 49 lines
1export 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}
49
hooks/noun.generated.ts 126 lines
1// 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}
126
types/index.d.ts 63 lines
1// 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