SLOPSHOPPER

dispatch

Plan before you fly — interview-driven specs you can execute. preflight captures what you want as INTENT.md before any solution is chosen; hop interviews…

newpaneguardcommandtoastprocess
★ 1v5.7.1MITupdated 2026-10-03FunnyQ/cc-plugins/packages/dispatch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dispatch
│ ┃ flightdeck ✕ › fix the failing auth test and add an audit log call │ ┃ Loading… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /flightdeck │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · flightdeck
Loading…
README

cc-plugins

A local Claude Code, Codex, and OpenCode plugin marketplace for a personal coding workflow. It ships five plugins: monitor turns local traces into useful dashboards — the usage-dashboard skill is the rear-view mirror for usage history, and the cockpit skill is the windshield for the session currently in flight; dispatch is interview-driven planning you can then execute — spec the work, write a blueprint to disk, and fly it with a quality loop, or map a whole project into milestone legs and plan each one just-in-time; relay delegates a task out to another harness's CLI (codex, opencode, or claude) — delegate work, request a review, or generate an image — then captures the result and reports back; chronicle authors your git history — commits (auto simple/atomic), reviewer-legible PRs/MRs, and config-first releases that bump versions, write the changelog, and cut the tag; herdr is reference plus a typed wrapper for driving agents across panes in the Herdr terminal workspace manager.

Development Workflow

This repository uses GitHub Flow. Create feature and fix branches from main, then open pull requests back into main. The existing develop branch is historical and does not indicate that this repository uses git-flow. Chronicle records this machine-readable PR policy in .chronicle/pr.json.

Plugins

monitor bundles three skills:

SkillDescription
usage-dashboardLocal usage dashboard for Claude Code, Codex, and OpenCode: sessions, tokens, cost, model mix, and project activity, plus live sessions for Claude Code and Codex
cockpitPer-project work cockpit for Claude Code, Codex, and OpenCode: goal capture, decision log, live transcript, needs-your-call bridge, and a send box for live sessions
installOne-stop prerequisite check and permission wiring for the whole plugin, command-triggered

dispatch bundles five skills:

SkillDescription
preflightShort interview that captures what you want as docs/<slug>/INTENT.md, before anyone decides how to build it — writes one file and stops, never executes
hopLightweight interviewer that gathers requirements into a single in-conversation plan to approve and execute
flightplanHeavyweight interviewer that writes a multi-file blueprint to disk — PLAN.md + a tasks/ tree of self-contained task files for sub-agents
autopilotExecutes a flightplan tree in parallel waves — a dev→verify→judge→score loop gated on each task's Eval rubric, an atomic commit between waves, then the closing final-review gate, leaving an audit trail
waypointsRolling-wave milestone-roadmap tier above flightplan — writes only docs/<proj>/WAYPOINTS.md plus a waypoints.ts CLI (active / leg-scaffold / advance) so each leg's flightplan is planned just-in-time after the previous leg lands

relay is a single portable skill:

SkillDescription
relayDelegate a task to another harness's CLI (codex / opencode / claude): delegate (do work), review (analysis only), or image (codex only) — capture the result, smart-apply when safe, and report back

chronicle bundles four skills:

SkillDescription
commitCraft git commit(s) for the current changes — auto-decides between one simple commit and an atomic split
prOpen a reviewer-legible PR/MR for the current branch, enriched by the cockpit decision trail when present
releaseCut a release — bump version files, write the CHANGELOG entry, then commit, merge, tag, and push; local stops before the push, prepare after the entry
installSet up chronicle's prerequisites — the named agent roles on Codex (Claude Code needs none)

herdr is a single skill:

SkillDescription
herdrReference for the Herdr terminal workspace manager (config, CLI, plugin dev) plus a typed herd wrapper to spawn and drive agents in sibling panes when running inside herdr

Claude Code Installation

CLI

claude plugins marketplace add FunnyQ/cc-plugins
claude plugins install monitor@q-lab-marketplace
# Any of these plugin ids works: monitor, dispatch, relay, chronicle, herdr

TUI

  1. Open Claude Code
  2. Type /plugins to open the plugin manager
  3. Select Add Marketplace → enter FunnyQ/cc-plugins
  4. Select Install Plugin → choose the plugin you want

The usage-dashboard skill runs a prerequisite check automatically before launching the dashboard, so there's no manual setup step. If something is missing, the hint is surfaced in the terminal. The most common case is stats-cache.json not existing yet; run /stats once in Claude Code to seed it.

If you want to run the precheck yourself:

bun $CLAUDE_PLUGIN_ROOT/skills/install/scripts/install.ts

Codex Installation

Codex reads this marketplace from .agents/plugins/marketplace.json. The registry lists all five plugins, and each is installed by id.

codex plugin marketplace add FunnyQ/cc-plugins
codex plugin add monitor@q-lab-marketplace
# Any of these plugin ids works: monitor, dispatch, relay, chronicle, herdr

Check the install:

codex plugin list | rg 'q-lab-marketplace|monitor'

After installing a Codex plugin, start a new Codex session so the skill list is refreshed.

OpenCode Installation

There is no plugin registry step. The repo is the single source of truth: opencode/install.ts symlinks skills, agents, commands, and the plugin module straight into ~/.config/opencode/, so an update to the checkout is live immediately — no reinstall, no rebuild.

Requires OpenCode 1.x. The plugin module targets OpenCode's V1 plugin API. OpenCode 2.x replaced that API, so on 2.x the module fails to load and none of its hooks run. The cockpit send and relay's opencode backend also support 1.x only.

bun opencode/install.ts --check     # report what is and is not wired
bun opencode/install.ts --apply     # symlink everything, raise subagent_depth
bun opencode/install.ts --unlink    # remove only what --apply created

--dry-run also exists — it prints the same plan as --apply without writing anything.

--apply installs:

InstallsCountTarget
Skills15~/.config/opencode/skills/<name>/
Plugin module1~/.config/opencode/plugin/q-lab.ts
Chronicle agents6~/.config/opencode/agents/<name>.md
Commands2~/.config/opencode/commands/<name>.md
subagent_depth1 edit~/.config/opencode/opencode.json

The subagent_depth prerequisite. --apply raises subagent_depth to at least 2 in ~/.config/opencode/opencode.json, the one config file it touches outside symlinks. The edit is raise-only — every other key in that file is preserved, and a value already ≥ 2 is left alone. OpenCode ships defaulting subagent_depth to 1, which blocks nesting outright, so this isn't a hypothetical edge case — it's the shipped default. Chronicle no longer needs it — none of its agents spawns a child — but dispatch's autopilot does: below the required depth its driver simply stops mid-run with no error naming the config, and the failure reads exactly like a plugin bug. Anyone hand-editing opencode.json instead of running --apply needs to set this key to 2 themselves.

Which config file gets the edit. OpenCode merges three global config names in order — config.json, then opencode.json, then opencode.jsonc — so the last one present wins. The installer patches whichever of those already exists, highest precedence first, and creates opencode.json only when none does. Two consequences: an existing opencode.jsonc is the file that gets raised, and a config carrying comments is read but never rewritten, because re-serializing it would delete them — --check and --apply report manual and print the line to add by hand.

Works under OpenCode:

  • All 15 skills, discovered from ~/.config/opencode/skills/.
  • usage-dashboard reads OpenCode data natively — the shared reader already handles ~/.local/share/opencode/opencode.db.
  • cockpit reads and sends for OpenCode sessions. Precondition: the OpenCode TUI must have been started with a port (opencode --port <n>), or OPENCODE_TUI_SERVER_URL must be set before cockpit starts.
  • relay delegate and review.
  • The five ported hook behaviors: decision-log start, the scribe nudge, the chronicle branch guard, the dispatch flightplan lint, and the guard comment check.
  • Both monitor commands.
  • Chronicle's subagents, spawned through the task tool.

Does not work under OpenCode:

  • The statusline. It is a Claude-only concept with no OpenCode equivalent, and there is no plan for one.
  • relay image — codex-only, and it fails at the capability gate before any CLI runs.
  • Workflow-driven autopilot. There is no Workflow tool, so autopilot runs as a manual task-tool wave loop instead: behaviorally close, but with no automatic parallel-wave scheduling.
  • monitor's setup.ts --session-check. It is deliberately not ported because it is inert outside Claude Code — it returns immediately without CLAUDE_PLUGIN_DATA, and its actual work is statusline-path migration and reaping orphaned Claude processes.

usage-dashboard

A single-page dashboard that reads local ~/.claude/ and ~/.codex/ data and visualizes usage in a browser. No telemetry, no cloud; everything stays on your machine.

Token Atlas dashboard preview

Features

  • Live now (Claude + Codex) — a panel of your currently-active Claude and Codex sessions with live status; click one to open it in cockpit's live transcript view (token-atlas links out rather than rendering transcripts itself). When cockpit's daemon isn't running the panel says so and the rows stay inert, so a click never opens a dead tab
  • Cost + usage overview — sessions, interactions, tokens, estimated spend, daily burn, and monthly budget projection
  • Persistent history — Claude usage is rolled into a local SQLite store (~/.local/share/q-lab/token-atlas/), so your token/cost/model history survives Claude Code's automatic transcript cleanup (cleanupPeriodDays) instead of disappearing as old sessions age out
  • Model analysis — daily trend, model distribution, and per-model token/cost breakdown
  • Project insights — project rankings with drilldown details for model mix and cost
  • Session ledger — recent Claude and Codex sessions side by side
  • Anomaly detection — flags days that break from your recent baseline
  • Token composition — input, output, cache-read, cache-write, and reasoning token shares
  • Activity timeline — hourly and daily activity patterns from local session data
  • Data health diagnostics — non-fatal source-read failures and record counts
  • Filters + export — provider/range filters, persisted preferences, and JSON/CSV export

Prerequisites

  • At least one Claude Code session (the cockpit shim fetches its release binary on first run)
  • For Claude usage totals, run /stats once to seed stats-cache.json

Quick Start

packages/monitor/skills/cockpit/bin/cockpit atlas serve   # [--port N] [--no-open]

As JSON instead: cockpit atlas stats (dashboard data), cockpit atlas live (active sessions), cockpit atlas rollup-update [--rebuild] (rollup DB).

Opens http://localhost:5938 in your default browser.

Options

--port <n>    Use a different port (default: 5938)
--no-open     Don't auto-open browser

Pricing

Token costs are estimated using bundled defaults (references/pricing-defaults.json). On startup, live prices are fetched from OpenRouter (3s timeout, silent fail). You can override with a custom file:

~/.config/cc-dashboard/pricing.json
{
  "models": {
    "claude-opus-4-7": { "input": 5.00, "output": 25.00, "cacheRead": 0.50, "cacheWrite": 6.25 }
  }
}

cockpit

Cockpit is a per-project dashboard and skill for active work. Start with a session goal, keep a distilled decision log, stream the current Claude Code or Codex transcript, and park on needs_your_call so a button click in the dashboard wakes the session.

Cockpit dashboard preview

Quick Start

In Claude Code or Codex, invoke the cockpit skill and confirm the proposed goals. From a development checkout, the dashboard can also be started directly:

packages/monitor/skills/cockpit/bin/cockpit server

Opens http://localhost:5858 in your default browser.

Provider Support

  • Claude Code transcripts resolve from ~/.claude/projects/**/<session>.jsonl.
  • Codex transcripts resolve from ~/.codex/state_5.sqlite thread rows and rollout files under ~/.codex/sessions.
  • OpenCode transcripts resolve via the opencode provider in packages/monitor/cockpit-rs/src/server/transcript.rs, reading the same ~/.local/share/opencode/opencode.db the usage-dashboard uses.
  • Decision logs live per-project under .cockpit/; the registry and wait/send bridge are shared through ~/.local/share/q-lab/cockpit/.

Send box

The send box at the bottom of the Decision Log column can send text into a running session.

  • Claude Code uses the monitor mod's inbox poll, described below. The agent's answer comes back through the live transcript.
  • Codex uses the managed Codex remote-control daemon. Cockpit connects to the local app-server control socket, resumes the selected thread, and submits or steers a turn. Direct app-server is only a fallback when remote-control is unavailable.
  • OpenCode uses the TUI HTTP bridge, not the mod: it discovers the running TUI from OPENCODE_TUI_SERVER_URL (or a ps scan for opencode --port <n>) and delivers through /tui/append-prompt + /tui/submit-prompt. Same port precondition as the OpenCode Installation section above — the TUI must have been started with --port; a serve process is not discovered, and a successful send is not a delivery receipt.

A monitor mod (hooks/register.ts) delivers Claude Code sends. On session start it long-polls the cockpit daemon's inbox over HTTP and submits each message as a new turn once the session is idle. The same mod relays permission dialogs to the dashboard. It needs a Claude Code build with function-hook mods, and a plain claude session gets both:

claude

If an older version left a hand-wired cockpit-channel entry in ~/.claude.json, /monitor:install removes it. See the cockpit skill for the full setup.

For Codex send support, install and enable the managed standalone Codex remote-control daemon:

curl -fsSL https://chatgpt.com/codex/install.sh | sh
codex app-server daemon enable-remote-control

Cockpit checks /api/codex-control/status before enabling the Codex send box, so stale or non-resumable threads stay disabled instead of failing only after send.

dispatch

Interview-driven planning you can execute. Four skills form one arc — capture what you want, gather the spec, commit a blueprint to disk, then fly it with a multi-agent quality loop — and a fifth, waypoints, sits above it for whole-project rolling-wave planning.

Dispatch flow: preflight, hop, waypoints, flightplan, autopilot, final review, ship

  • preflight — a short interview that captures what you want as docs/<slug>/INTENT.md, before anyone decides how to build it. Writes one file and stops; never executes.
  • hop — a lightweight interview that produces a single in-conversation plan and executes it. Best when you'll execute now, in one session. Formerly named preflight.
  • flightplan — a thorough interview that writes docs/<slug>/PLAN.md plus a tasks/ tree of self-contained task files (each with its own ## Eval rubric). Best when the work spans sessions or hands off to sub-agents.
  • autopilot — executes that tree in waves. Each wave re-scouts the ready set (next-ready) and runs those tasks in parallel; for each task it runs Dev → an independent binary gate (re-runs the task's Verification) → a rubric judge → a deterministic score gate, retrying until the task passes its rubric. Between waves it makes an atomic commit of the completed work, so the run leaves a clean per-wave history rather than one giant diff. The Final review task depends transitively on every other task, so the wave loop naturally schedules it last as the whole-tree gate — a closing multi-lens review round (cross-vendor codex + four /simplify lenses → an Opus fixer), followed by a final commit of its fixes. Every verdict lands in a self-gitignored docs/<slug>/.flightlog/ audit trail (RUNLOG.md).
  • waypoints — the tier above flightplan for large builds. It writes only a milestone roadmap (docs/<proj>/WAYPOINTS.md, legs tracked with [x]/[~]/[ ]); each leg's detailed flightplan is generated just-in-time after the previous leg lands, so every plan starts from what actually shipped rather than one oversized up-front guess. A waypoints.ts CLI collapses the lifecycle into three verbs — active (rolling-wave digest), leg-scaffold (nest a leg's tree under docs/<proj>/legs/NN-slug/), and advance (land the active leg; writing requires --outcome as the confirmation gate). flightplan gains a narrow waypoint mode that plans one leg at a time. Human-in-loop by design — one leg lands before the next is planned.

Installation

# Claude Code
claude plugins install dispatch@q-lab-marketplace

# Codex
codex plugin add dispatch@q-lab-marketplace

# OpenCode
bun opencode/install.ts --apply

(Add the marketplace first if you haven't — see the monitor install steps above.)

relay

One portable skill that delegates a task out to another harness's CLI, then captures the output and reports back — a multi-backend generalization of the codex-only odin-codex skill.

/relay <codex|opencode|claude> delegate <task>
/relay <codex|opencode|claude> review [task]
/relay codex image <prompt> --out <path>
  • delegate — ask a backend to do something (implement, refactor, debug); smart-applied when safe.
  • review — analysis only, no edits. No task reviews uncommitted changes; a provided task is followed as written.
  • image — generate an image via codex (gpt-image-2). codex-only; opencode/claude fail fast at the capability gate.

A capability gate rejects unsupported (backend, mode) pairs before any CLI runs. Every run captures full output to /tmp/relay/<ts>/last.md and prints it. Models resolve by precedence: --model flag > config file (~/.config/q-lab/cc-plugins/relay/config.json) > built-in defaults. Per-CLI invocation details, headless output handling, and the OpenCode symlink install live in the backend reference.

Live-pane mode — inside herdr (HERDR_ENV=1), delegate/review automatically run the backend's interactive TUI in a visible, take-over-able pane opened in its own new tab (so your working pane keeps its full size), via the herdr plugin's herd.ts, dynamically imported — no hard dependency. The answer is captured through a result-file contract; stdout stays the clean answer, live metadata rides stderr. --dangerous makes it a YOLO / unattended run (auto-approves permissions: codex/claude bypass flags, opencode --auto); without it, approval prompts surface in the pane for a human to answer. A run that outlives --wait-timeout (default 10 min) exits 0 with a "still running" report and leaves the pane alive. --headless opts out; outside herdr the classic headless flow is unchanged.

Installation

# Claude Code
claude plugins install relay@q-lab-marketplace

# Codex
codex plugin add relay@q-lab-marketplace

# OpenCode
bun opencode/install.ts --apply

If you previously ran the old manual symlink (ln -s .../packages/relay/skills/relay ~/.claude/skills/relay), remove it. opencode/install.ts --apply installs relay under ~/.config/opencode/skills/, and OpenCode scans ~/.claude/skills/ as well — leaving the old symlink in place surfaces two skills both named relay. --check warns about it.

chronicle

Chronicle authors your git history — what a commit says, what a pull request argues, and what a release ships. A thin SKILL.md sends the bulky reading to an agent — a diff, a branch, a range of commits — and keeps the decisions where you can see them. Diff-reading and git output stay out of your conversation.

  • commit — one agent reads the changeset, cuts it into commits that each build on their own, and writes the messages; a script decides whether the split is worth keeping and does the staging. Pass simple to force one commit.
  • pr — reads the branch, harvests the cockpit decision trail when one exists, and opens a GitHub PR or GitLab MR with a title, a four-section body, and an optional Mermaid overview diagram. The trail is an enrichment, not a requirement; an unrecognized remote stops the flow rather than guessing.
  • release — config-first: detects whole-repo vs per-component layout, remembers it in a committed .chronicle/release.json, then bumps the version files and writes the CHANGELOG entry, commits, tags, and pushes. Pass local to stop before the push, or prepare to stop after the entry.
  • install — sets up each harness's prerequisite: on Claude Code the nested-subagent spawn-depth setting described in the Plugins section above, on Codex the named agent roles the orchestrators are addressed by.

Installation

# Claude Code
claude plugins install chronicle@q-lab-marketplace

# Codex
codex plugin add chronicle@q-lab-marketplace

# OpenCode
bun opencode/install.ts --apply

herdr

Reference and in-session agent orchestration for Herdr, a terminal workspace manager with workspaces, tabs, split panes, and agent detection. Two halves:

  • Reference — a knowledge skill that answers questions about Herdr's config.toml, CLI, keybindings, and plugin development. Detail lives in references/; the skill reads only the relevant file.
  • herd wrapper — a typed Bun wrapper (scripts/herd.ts) over the raw herdr CLI, for when you (an agent) are running inside a herdr pane (HERDR_ENV=1) and want to spawn and drive other agents in sibling panes or their own tabs (spawn --new-tab). It collapses herdr's multi-step recipes into seven verbs and handles the sharp edges: it addresses agents by a collision-resistant generated name (pane ids renumber), its send writes the prompt and presses Enter (raw agent send only writes literal text), its keys verb sends bare key chords (submit / clear the input box), and its read defaults to the visible screen (agent TUIs leave scrollback empty).
HERD="$CLAUDE_PLUGIN_ROOT/skills/herdr/scripts/herd.ts"   # or the skill's load-time base dir

bun "$HERD" spawn reviewer --agent codex --task "review the diff in src/api/"
bun "$HERD" send reviewer-a3f9 "now check error handling"
bun "$HERD" wait reviewer-a3f9 --status idle --time
Source 7 files
hooks/register.ts 44 lines
1/**
2 * flightplan-lint as a Claude Code mod: the matcher rejects every path outside
3 * a flightplan tasks tree before the hook runs, so an unrelated Edit or Write
4 * spawns nothing. Violations ride back as the result's `context`, which the
5 * model reads the way it read the command hook's exit-2 stderr. OpenCode keeps
6 * flightplan-lint.sh, since it has no function hooks.
7 */
8
9import type { Register } from "claude-code";
10import { flightdeck } from "./flightdeck/flightdeck.tsx";
11import { TASK_PATH } from "./task-path.ts";
12
13export const register: Register = (on) => {
14  on(
15    "tool.call",
16    { tool: ["Edit", "Write"], file_path: TASK_PATH },
17    async ($, e, next) => {
18      const ran = await next(e);
19      if (ran.deny !== undefined || ran.isError) return ran;
20      if ((ran.result as { staged?: boolean }).staged) return ran;
21
22      // --authoring adds the task-size check, which only the author's own write should face, and passes a file without the task header.
23      const lint = await $.process.run([
24        "bun",
25        `${$.plugin.root}/skills/flightplan/scripts/lint-task.ts`,
26        "--authoring",
27        e.file_path,
28      ]);
29      if (lint.exitCode === 0) return ran;
30
31      const output = `${lint.stdout}${lint.stderr}`.trimEnd();
32      return {
33        ...ran,
34        context: [
35          ...(ran.context ?? []),
36          `flightplan lint violations in ${e.file_path}:\n${output}`,
37        ],
38      };
39    },
40  );
41
42  flightdeck(on);
43};
44
hooks/flightdeck/flightdeck.tsx 347 lines
1import { atom, read, update } from "claude-code";
2import type { EngineInterface, On, Timer } from "claude-code";
3
4import type { FlightdeckDeck } from "../../types";
5import { FLIGHTDECK_COMMAND, planArg, planFromOutput } from "./deck-command.ts";
6import { type CardModel, clip, COLOR, docked, inline, type Line } from "./rows.ts";
7import type { DeckSnapshot, DeckTask } from "./types.ts";
8
9const PANE = "flightdeck";
10// the dock width the layouts are drawn for, and the inline seat's height: a summary line and the current wave
11const COLUMNS = 40;
12const INLINE_ROWS = 2;
13// the snapshot child re-reads the task tree and run log; 2 s keeps elapsed times live without a busy child
14const TICK_MS = 2000;
15
16const EMPTY: FlightdeckDeck = {
17  snapshot: null,
18  stale: false,
19  message: null,
20};
21const deck = atom({ plugin: "dispatch", key: "flightdeck" } as const, EMPTY);
22
23// the module keeps the truth and publishes it whole, so two writes racing on the atom both land the latest view
24let view: FlightdeckDeck = EMPTY;
25// bumped by every open and close; a snapshot or lookup that returns under an older value is dropped
26let generation = 0;
27let ticker: Timer | undefined;
28// the last snapshot stdout drawn; an identical tick skips the parse and the publish
29let lastOut = "";
30// the plan the open pane follows, so a bare /flightdeck can seat a pane that waits undrawn
31let current: string | null = null;
32
33const publish = ($: EngineInterface) => update($, deck, () => view);
34
35const firstLine = (text: string) => text.split("\n")[0]?.trim() ?? "";
36const runScript = ($: EngineInterface, name: string, ...args: string[]) =>
37  $.process.run([
38    "bun",
39    `${$.plugin.root}/skills/autopilot/scripts/${name}`,
40    ...args,
41  ]);
42const isOpen = async ($: EngineInterface) =>
43  (await $.ui.panes()).some((p) => p.id === PANE);
44const isPlaced = async ($: EngineInterface) =>
45  (await $.ui.panes()).some((p) => p.id === PANE && p.isPlaced);
46
47const toastText = (t: DeckTask) => {
48  const parts = [t.ref, t.title, t.state, `attempt ${t.attempts}`];
49  if (t.score)
50    parts.push(
51      `score ${t.score.weighted.toFixed(1)}/${t.score.threshold.toFixed(1)} ${t.score.passed ? "passed" : "failed"}`,
52    );
53  return parts.join(" · ");
54};
55
56// a done task's tokens never change, so each is read once and kept; null marks a read that found none
57const tokens = new Map<string, number | null>();
58// the run total from the last --usage read, carried onto every later snapshot
59let runTokens: number | null = null;
60const unread = (s: DeckSnapshot | null) =>
61  Object.values(s?.tasks ?? {}).some((t) => t.state === "done" && !tokens.has(t.ref));
62
63const refresh = async ($: EngineInterface, gen: number, plan: string) => {
64  const withUsage = unread(view.snapshot as DeckSnapshot | null);
65  const r = withUsage
66    ? await runScript($, "deck-snapshot.ts", plan, "--usage")
67    : await runScript($, "deck-snapshot.ts", plan);
68  if (gen !== generation) return;
69  if (r.exitCode === 0) {
70    if (r.stdout !== lastOut || view.stale || withUsage) {
71      lastOut = r.stdout;
72      const snapshot = JSON.parse(r.stdout) as DeckSnapshot;
73      for (const t of Object.values(snapshot.tasks)) {
74        if (t.state !== "done") continue;
75        if (withUsage && !tokens.has(t.ref)) tokens.set(t.ref, t.tokens);
76        t.tokens = tokens.get(t.ref) ?? null;
77      }
78      if (withUsage) runTokens = snapshot.tokens;
79      snapshot.tokens = runTokens;
80      view = {
81        snapshot,
82        stale: false,
83        message: null,
84      };
85      await publish($);
86    }
87  } else {
88    const message = firstLine(r.stderr);
89    if (!view.stale || view.message !== message) {
90      view = { ...view, stale: true, message };
91      await publish($);
92    }
93  }
94  // a tick whose snapshot did not change still moves the agents' and tasks' elapsed times
95  const live = view.snapshot as DeckSnapshot | null;
96  if (live?.agents.length || (live?.time && live.time.endedAt === null))
97    $.ui.invalidate("ui.render");
98};
99
100// only open and close touch the ticker, synchronously before their first await
101const openOn = async ($: EngineInterface, plan: string) => {
102  const gen = ++generation;
103  current = plan;
104  tokens.clear();
105  runTokens = null;
106  view = EMPTY;
107  lastOut = "";
108  void publish($);
109  ticker?.cancel();
110  let isRunning = false;
111  let isFirst = true;
112  const tick = async () => {
113    if (isRunning) return;
114    isRunning = true;
115    try {
116      if (!isFirst && !(await isOpen($))) {
117        // closed by hand: stop here, unless an open or close already moved on and owns the ticker
118        if (gen === generation) {
119          generation++;
120          current = null;
121          own.cancel();
122        }
123        return;
124      }
125      isFirst = false;
126      await refresh($, gen, plan);
127    } finally {
128      isRunning = false;
129    }
130  };
131  const own = $.clock.every(TICK_MS, () => void tick());
132  ticker = own;
133  void tick();
134  const title = `Flightdeck · ${plan.replace(/\/+$/, "").split("/").pop()}`;
135  return $.ui.open({
136    id: PANE,
137    title,
138    columns: COLUMNS,
139    rows: INLINE_ROWS,
140  });
141};
142
143const close = async ($: EngineInterface) => {
144  generation++;
145  current = null;
146  ticker?.cancel();
147  ticker = undefined;
148  await $.ui.close({ id: PANE });
149};
150
151export const flightdeck = (on: On) => {
152  // a matcher of its own, so no other unmatched session.start hook in this module collides with it
153  on("session.start", { isInteractive: true }, async ($, e, next) => {
154    await $.command.register({
155      name: "flightdeck",
156      description: "Open the flightdeck overview pane",
157      argumentHint: "[planDir|close]",
158    });
159    return next(e);
160  });
161
162  on("command.run", { command: "flightdeck" }, async ($, e) => {
163    const arg = e.args.trim();
164    if (arg === "close" || (arg === "" && (await isPlaced($)))) {
165      await close($);
166      return { text: "Flightdeck closed." };
167    }
168    // a bare command while an auto-open waits undrawn is the person asking, which seats it at any width
169    const target = arg || current;
170    if (target) {
171      await openOn($, target);
172      return { text: `Flightdeck opened on ${target}.` };
173    }
174    const gen = generation;
175    const top = await $.process.run(["git", "rev-parse", "--show-toplevel"]);
176    const root = top.exitCode === 0 ? top.stdout.trim() : await $.session.cwd();
177    const r = await runScript($, "deck-snapshot.ts", "--latest", root);
178    if (r.exitCode === 3)
179      return { text: `No flightplan run found under ${root}/docs` };
180    if (r.exitCode !== 0) return { text: firstLine(r.stderr) };
181    // a close or another open issued during the lookup wins
182    if (gen !== generation) return { text: "Flightdeck lookup superseded." };
183    const { plan } = JSON.parse(r.stdout) as { plan: string };
184    await openOn($, plan);
185    return { text: `Flightdeck opened on ${plan}.` };
186  });
187
188  // a distinct matcher from register.ts's Edit|Write lint hook; opens in the launching session only
189  on(
190    "tool.call",
191    { tool: "Bash", command: FLIGHTDECK_COMMAND },
192    async ($, e, next) => {
193      const ran = await next(e);
194      if (ran.deny !== undefined || ran.isError) return ran;
195      const plan =
196        planFromOutput((ran.result as { stdout?: string }).stdout ?? "") ??
197        planArg(e.command);
198      if (plan && !(await openOn($, plan)).isPlaced)
199        $.ui.toast("Flightdeck is waiting for a wider terminal. Type /flightdeck to show it.");
200      return ran;
201    },
202  );
203
204  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
205    const { Box, Text, Button } = $.ui.resolve(e);
206    const { snapshot, stale, message } = await read($, deck);
207    const s = snapshot as DeckSnapshot | null;
208    const width = e.props.bodyColumns ?? COLUMNS;
209
210    const row = (line: Line) => (
211      <Box flexDirection="row">
212        {line.length === 0 && <Text> </Text>}
213        {line.map((seg) => {
214          const task = seg.ref ? s?.tasks[seg.ref] : undefined;
215          if (!task)
216            return (
217              <Text color={seg.color} dimColor={seg.dim}>
218                {seg.text}
219              </Text>
220            );
221          // a Button takes no colour, so the ref is the pressable part and the coloured glyph rides beside it; a clip that cut into the ref leaves it all pressable
222          const head = seg.text.startsWith(task.ref) ? task.ref : seg.text;
223          const rest = seg.text.slice(head.length);
224          return (
225            <Box flexDirection="row">
226              <Button
227                key={`card:${task.ref}`}
228                plain
229                dimColor={seg.dim}
230                label={head}
231                onPress={() => $.ui.toast(toastText(task))}
232              />
233              {rest && (
234                <Text color={seg.color} dimColor={seg.dim}>
235                  {rest}
236                </Text>
237              )}
238            </Box>
239          );
240        })}
241      </Box>
242    );
243
244    if (!s)
245      return row(clip([{ text: message ?? "Loading…", dim: true }], width));
246    if (e.props.placement === "inline")
247      return (
248        <Box flexDirection="column">{inline(s, width, stale).map(row)}</Box>
249      );
250    const launch = async () => {
251      const r = await runScript($, "flightdeck.ts", "--plan", s.plan, "--open");
252      if (r.exitCode !== 0) $.ui.toast(firstLine(r.stderr));
253    };
254    const { title, totals, bars, wave, states, cards, crew } = docked(s, width, Date.now(), stale);
255    // a Button takes no colour, so the glyph is coloured Text and the ref beside it is the pressable part
256    const cardBox = (c: CardModel) => (
257      <Box
258        flexDirection="column"
259        width={cards.inner + 2}
260        borderStyle="round"
261        borderColor={c.color}
262        borderDimColor={c.dim}
263      >
264        <Box flexDirection="row">
265          <Text color={c.color} dimColor={c.dim}>
266            {c.head.slice(0, 2)}
267          </Text>
268          <Button
269            key={`card:${c.ref}`}
270            plain
271            dimColor={c.dim}
272            label={c.head.slice(2)}
273            onPress={() => $.ui.toast(toastText(s.tasks[c.ref]!))}
274          />
275          {c.time && (
276            <Box flexGrow={1} justifyContent="flex-end">
277              <Text dimColor>{c.time}</Text>
278            </Box>
279          )}
280        </Box>
281        <Text dimColor>{c.sub}</Text>
282        {c.agents.map((line) => (
283          <Text color={COLOR["in-progress"]}>{line}</Text>
284        ))}
285        {c.tokens && <Text dimColor>{c.tokens}</Text>}
286      </Box>
287    );
288    return (
289      // the top row clears the pane's close mark, which sits over the body's first line
290      <Box flexDirection="column" paddingTop={1}>
291        <Box key="title" justifyContent="center" borderStyle="round" borderDimColor>
292          <Text bold>{title}</Text>
293        </Box>
294        {totals && (
295          <Box
296            key="totals"
297            flexDirection="column"
298            borderStyle="round"
299            borderDimColor
300            paddingX={1}
301          >
302            <Text dimColor>Total Cost</Text>
303            <Box flexDirection="row" justifyContent="space-between">
304              <Text>{totals.time}</Text>
305              {totals.tokens && <Text>{totals.tokens}</Text>}
306            </Box>
307          </Box>
308        )}
309        {bars.map(row)}
310        {crew.length > 0 && (
311          <Box
312            key="loose-agents"
313            flexDirection="column"
314            alignItems="center"
315            borderStyle="round"
316            borderColor={COLOR["in-progress"]}
317          >
318            {crew.map(row)}
319          </Box>
320        )}
321        {row([])}
322        <Box flexDirection="row" justifyContent="center">
323          {row(wave)}
324        </Box>
325        <Box flexDirection="row" justifyContent="center">
326          {row(states)}
327        </Box>
328        {cards.groups.map((g) => (
329          <Box flexDirection="row">
330            <Text>{g.label}</Text>
331            <Box flexDirection="row" flexWrap="wrap" flexShrink={1} columnGap={1}>
332              {g.cards.map(cardBox)}
333            </Box>
334          </Box>
335        ))}
336        <Box flexDirection="row" justifyContent="center">
337          <Button
338            key="open"
339            label="Open flightdeck"
340            onPress={() => void launch()}
341          />
342        </Box>
343      </Box>
344    );
345  });
346};
347
hooks/task-path.ts 4 lines
1// Copied in flightplan-lint.sh and as FLIGHTPLAN_TASK in opencode/plugin.ts; task-path.test.ts holds the three equal.
2export const TASK_PATH =
3  /(^|\/)docs\/.+\/tasks\/[a-z][a-z0-9]*\/[0-9]{2}-.+\.md$/;
4
hooks/flightdeck/deck-command.ts 26 lines
1// tool.call matcher: a command that runs flightdeck.ts and passes --plan.
2// The lookarounds pin the file name, so flightdeck.test.ts and myflightdeck.ts never open the pane.
3export const FLIGHTDECK_COMMAND =
4  /(?<![\w.-])flightdeck\.ts(?![\w.-])[\s\S]*?\s--plan(?:=|\s)/;
5
6const LAUNCHER = /(?<![\w.-])flightdeck\.ts(?![\w.-])/;
7const PLAN = /\s--plan(?:=|\s+)(?:"([^"]*)"|'([^']*)'|([^\s"']+))/;
8
9// launch.ts's planLine, as printed into the Bash result's stdout
10const PLAN_LINE = /^flightdeck plan: (.+)$/m;
11
12export function planFromOutput(stdout: string): string | null {
13  return PLAN_LINE.exec(stdout)?.[1] ?? null;
14}
15
16// the --plan value: "--plan \"/a b\"", "--plan '/a'", "--plan=/a", "--plan /a"; null when absent
17// Read only within the flightdeck.ts call, and null for a $ value the shell expanded but the command text did not.
18export function planArg(command: string): string | null {
19  const at = LAUNCHER.exec(command);
20  if (!at) return null;
21  const call = command.slice(at.index).split(/[;&|]/)[0] ?? "";
22  const m = PLAN.exec(call);
23  const plan = m && (m[1] ?? m[2] ?? m[3]);
24  return plan && !plan.includes("$") ? plan : null;
25}
26
hooks/flightdeck/rows.ts 310 lines
1import type { DeckAgent, DeckCrew, DeckSnapshot, DeckState, DeckTask } from "./types.ts";
2
3// Width is text.length: every glyph used here (✓ ● ○ · ✗ █ ░ … —) is one BMP code unit drawn one column wide.
4
5// `ref` is set only on card segments; the mod makes those pressable
6export type Seg = { text: string; color?: string; dim?: boolean; ref?: string };
7export type Line = Seg[];
8
9export const GLYPH: Record<DeckState, string> = {
10  done: "✓",
11  "in-progress": "●",
12  ready: "○",
13  blocked: "·",
14  invalid: "✗",
15};
16
17export const COLOR: Record<DeckState, string | undefined> = {
18  done: "#3fb950",
19  "in-progress": "#d29922",
20  ready: undefined,
21  blocked: undefined,
22  invalid: "#f85149",
23};
24
25const SEP = " · ";
26
27const width = (line: Line) => line.reduce((n, seg) => n + seg.text.length, 0);
28
29const styled = (state: DeckState, text: string): Seg => ({
30  text,
31  ...(COLOR[state] && { color: COLOR[state] }),
32  ...(state === "blocked" && { dim: true }),
33});
34
35export function card(task: DeckTask): Seg {
36  const attempts = task.attempts > 1 ? String(task.attempts) : "";
37  return {
38    ...styled(task.state, `${task.ref} ${GLYPH[task.state]}${attempts}`),
39    ref: task.ref,
40  };
41}
42
43export function endState(s: DeckSnapshot): "wave" | "done" | "stuck" {
44  if (s.currentWave !== null) return "wave";
45  return s.counts.done === s.counts.total ? "done" : "stuck";
46}
47
48function stuckRefs(s: DeckSnapshot): string[] {
49  return s.unschedulable.filter((ref) => s.tasks[ref]?.state !== "done");
50}
51
52function diagnostics(s: DeckSnapshot, stale: boolean): Line {
53  const line: Line = [];
54  if (stale) line.push({ text: "stale", dim: true }, { text: SEP });
55  if (s.errors > 0)
56    line.push(
57      { text: `${s.errors} errors`, color: COLOR.invalid },
58      { text: SEP },
59    );
60  return line;
61}
62
63const BAR = "━";
64
65// a bar of `cells` heavy-rule cells: the done share green, the rest dim
66function bar(done: number, total: number, cells: number): Line {
67  const filled = total > 0 ? Math.round((done / total) * cells) : 0;
68  const line: Line = [];
69  if (filled > 0) line.push({ text: BAR.repeat(filled), color: COLOR.done });
70  if (cells - filled > 0) line.push({ text: BAR.repeat(cells - filled), dim: true });
71  return line;
72}
73
74// the web header's pair: the run's wall time and its token rollup; null before any agent started
75export function runTotals(s: DeckSnapshot, now: number): { time: string; tokens: string | null } | null {
76  if (!s.time) return null;
77  const end = s.time.endedAt === null ? now : Date.parse(s.time.endedAt);
78  return {
79    time: formatElapsed(end - Date.parse(s.time.startedAt)),
80    tokens: s.tokens === null ? null : `${formatTokens(s.tokens)} tok`,
81  };
82}
83
84export function summary(s: DeckSnapshot, stale: boolean): Line[] {
85  const state = endState(s);
86  const wave: Line = [
87    state === "wave"
88      ? { text: `wave ${s.currentWave}/${s.waves.length}` }
89      : state === "done"
90        ? { text: "all done" }
91        : { text: `stuck · ${stuckRefs(s).length} unschedulable`, color: COLOR.invalid },
92  ];
93
94  const c = s.counts;
95  const pair = (state: DeckState, n: number, word: string): Seg => {
96    const text = `${GLYPH[state]} ${n}${word ? ` ${word}` : ""}`;
97    return state === "invalid" && n === 0 ? { text } : styled(state, text);
98  };
99  const states: Line = [
100    ...diagnostics(s, stale),
101    pair("in-progress", c.inProgress, "running"),
102    { text: "  " },
103    pair("ready", c.ready, "ready"),
104    { text: "  " },
105    pair("blocked", c.blocked, "waiting"),
106    { text: "  " },
107    pair("invalid", c.invalid, ""),
108  ];
109  return [wave, states];
110}
111
112export function compactSummary(s: DeckSnapshot, stale: boolean): Line {
113  const line: Line = [
114    ...diagnostics(s, stale),
115    { text: `${s.counts.done}/${s.counts.total}${SEP}` },
116  ];
117  const state = endState(s);
118  if (state === "wave")
119    line.push({ text: `W${s.currentWave}/${s.waves.length}` });
120  else if (state === "done") line.push({ text: "all done" });
121  else line.push({ text: "stuck", color: COLOR.invalid });
122  line.push({ text: `${SEP}${s.slug}` });
123  return line;
124}
125
126export function bucketBars(s: DeckSnapshot, w: number): Line[] {
127  const nameW = Math.max(0, ...s.buckets.map((b) => b.name.length));
128  return s.buckets.map((b) => {
129    const count = `${b.done}/${b.total}`;
130    const barW = Math.max(1, w - nameW - 2 - count.length);
131    return [
132      { text: `${b.name.padEnd(nameW)} ` },
133      ...bar(b.done, b.total, barW),
134      { text: ` ${count}` },
135    ];
136  });
137}
138
139// a card's inner width never drops below "Final review" plus a score and some slack, so short refs still show their title
140const MIN_INNER = 17;
141// columns past glyph + space + the longest ref, so the title line has room beside its score
142const SLACK = 4;
143
144export type CardModel = {
145  ref: string;
146  head: string; // glyph + ref
147  sub: string; // title, then attempts/score right-aligned, padded to the inner width
148  time: string | null; // time spent, right of the head; null before any agent started
149  tokens: string | null; // billed tokens right-aligned to the inner width, done tasks only
150  agents: string[]; // one line per in-flight agent on this task: role, attempt, elapsed
151  color?: string;
152  dim?: boolean;
153};
154export type WaveCards = {
155  inner: number; // every card's width inside its border
156  groups: { label: string; cards: CardModel[] }[];
157};
158
159const fit = (text: string, w: number) =>
160  text.length <= w ? text : `${text.slice(0, Math.max(0, w - 1))}…`;
161
162const elapsedOf = (a: DeckAgent, now: number) =>
163  a.startedAt === null ? "—" : formatElapsed(now - Date.parse(a.startedAt));
164
165// the label or text on the left, `right` flush to the inner width
166const spread = (left: string, right: string, inner: number) => {
167  const leftW = Math.max(0, inner - right.length - 1);
168  return `${fit(left, leftW).padEnd(leftW)} ${right}`.slice(0, inner);
169};
170
171// the web fleet's token format, so the two views print one number the same way
172export function formatTokens(n: number): string {
173  if (n < 1_000) return String(Math.trunc(n));
174  if (n < 1_000_000) return `${(n / 1_000).toFixed(1)}K`;
175  return `${(n / 1_000_000).toFixed(1)}M`;
176}
177
178function timeOf(t: DeckTask, now: number): string | null {
179  if (!t.time) return null;
180  const end = t.time.endedAt === null ? now : Date.parse(t.time.endedAt);
181  return formatElapsed(end - Date.parse(t.time.startedAt));
182}
183
184export function waveCards(s: DeckSnapshot, w: number, now: number): WaveCards {
185  const groups = s.waves.map((refs, i) => ({ label: `W${i + 1}`, refs }));
186  if (s.unschedulable.length > 0)
187    groups.push({ label: "W?", refs: s.unschedulable });
188  const labelW = Math.max(0, ...groups.map((g) => g.label.length)) + 1;
189  const longest = Math.max(0, ...groups.flatMap((g) => g.refs.map((r) => r.length)));
190  const inner = Math.max(1, Math.min(Math.max(MIN_INNER, longest + 2 + SLACK), w - labelW - 2));
191
192  const model = (t: DeckTask): CardModel => {
193    const meta = [
194      t.score ? t.score.weighted.toFixed(1) : "",
195      t.attempts > 1 ? `a${t.attempts}` : "",
196    ]
197      .filter(Boolean)
198      .join(" ");
199    const { color, dim } = styled(t.state, "");
200    const time = timeOf(t, now);
201    return {
202      ref: t.ref,
203      head: fit(`${GLYPH[t.state]} ${t.ref}`, time ? inner - time.length - 1 : inner),
204      time,
205      tokens: t.tokens === null ? null : `${formatTokens(t.tokens)} tok`.padStart(inner),
206      sub: meta ? spread(t.title, meta, inner) : fit(t.title, inner).padEnd(inner),
207      agents: s.agents
208        .filter((a) => a.ref === t.ref)
209        .map((a) =>
210          spread(`${a.role}${a.attempt === null ? "" : ` #${a.attempt}`}`, elapsedOf(a, now), inner),
211        ),
212      ...(color && { color }),
213      ...(dim && { dim }),
214    };
215  };
216  return {
217    inner,
218    groups: groups.map((g) => ({
219      label: g.label.padEnd(labelW),
220      cards: g.refs.map((ref) => model(s.tasks[ref]!)),
221    })),
222  };
223}
224
225export function formatElapsed(ms: number): string {
226  const total = Math.max(0, Math.floor(ms / 1000));
227  if (total < 60) return `${total}s`;
228  const m = Math.floor(total / 60);
229  if (m < 60) return `${m}m${String(total % 60).padStart(2, "0")}s`;
230  return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
231}
232
233const CREW_GLYPH = { "in-flight": GLYPH["in-progress"], finished: GLYPH.done, abandoned: GLYPH.invalid };
234
235export function crewLines(s: DeckSnapshot, now: number): Line[] {
236  const roleW = Math.max(0, ...s.crew.map((c) => c.role.length));
237  const labelW = Math.max(0, ...s.crew.map((c) => c.label.length));
238  return s.crew.map((c: DeckCrew) => {
239    const elapsed =
240      c.status === "in-flight"
241        ? c.startedAt === null ? "—" : formatElapsed(now - Date.parse(c.startedAt))
242        : c.elapsedMs === null ? "—" : formatElapsed(c.elapsedMs);
243    const text = `${CREW_GLYPH[c.status]} ${c.role.padEnd(roleW)} ${c.label.padEnd(labelW)}  ${elapsed}`;
244    return [c.status === "in-flight" ? { text, color: COLOR["in-progress"] } : { text, dim: true }];
245  });
246}
247
248export function clip(line: Line, w: number): Line {
249  if (width(line) <= w) return line;
250  if (w < 1) return [];
251  if (w === 1) return [{ ...line[0], text: "…" }];
252  const out: Line = [];
253  let left = w - 1;
254  for (const seg of line) {
255    if (left === 0) break;
256    const text = seg.text.slice(0, left);
257    left -= text.length;
258    if (text.length > 0) out.push({ ...seg, text });
259  }
260  const last = out[out.length - 1];
261  last.text += "…";
262  return out;
263}
264
265export function docked(
266  s: DeckSnapshot,
267  w: number,
268  now: number,
269  stale: boolean,
270): {
271  title: string;
272  totals: ReturnType<typeof runTotals>;
273  bars: Line[];
274  wave: Line;
275  states: Line;
276  cards: WaveCards;
277  crew: Line[];
278} {
279  const [wave, states] = summary(s, stale);
280  return {
281    title: s.slug,
282    wave: clip(wave, w),
283    totals: runTotals(s, now),
284    bars: bucketBars(s, w).map((l) => clip(l, w)),
285    states: clip(states, w),
286    cards: waveCards(s, w, now),
287    // the crew box spends two columns on its border
288    crew: crewLines(s, now).map((l) => clip(l, w - 2)),
289  };
290}
291
292export function inline(s: DeckSnapshot, w: number, stale: boolean): Line[] {
293  const state = endState(s);
294  let second: Line;
295  if (state === "done") second = [{ text: "all done" }];
296  else {
297    const label = state === "wave" ? `W${s.currentWave} ` : "W? ";
298    const refs =
299      state === "wave"
300        ? (s.waves[(s.currentWave as number) - 1] ?? [])
301        : stuckRefs(s);
302    second = [{ text: label }];
303    refs.forEach((ref, i) => {
304      if (i > 0) second.push({ text: "  " });
305      second.push(card(s.tasks[ref]!));
306    });
307  }
308  return [clip(compactSummary(s, stale), w), clip(second, w)];
309}
310
hooks/flightdeck/types.ts 59 lines
1export type DeckState =
2  | "done"
3  | "in-progress"
4  | "ready"
5  | "blocked"
6  | "invalid";
7
8export type DeckTask = {
9  ref: string; // "bucket/NN", or the graph node id
10  title: string;
11  state: DeckState;
12  attempts: number;
13  score: { weighted: number; threshold: number; passed: boolean } | null;
14  time: { startedAt: string; endedAt: string | null } | null; // first agent start to last finish; endedAt null while one runs
15  tokens: number | null; // billed total, done tasks only, and only from a --usage run
16};
17
18export type DeckAgent = {
19  role: string; // FleetRow.role
20  ref: string | null;
21  attempt: number | null;
22  label: string;
23  startedAt: string | null; // ISO; the pane renders elapsed from it at draw time
24};
25
26// a fleet row with no task card to ride (scout, commit), kept after it ends as the web fleet keeps it
27export type DeckCrew = {
28  role: string;
29  label: string;
30  status: "in-flight" | "finished" | "abandoned";
31  startedAt: string | null; // ISO; in-flight elapsed is drawn from it
32  elapsedMs: number | null; // a finished row's duration
33};
34
35export type DeckSnapshot = {
36  deckSource: "tasks" | "graph";
37  plan: string; // absolute plan dir
38  slug: string;
39  planTitle: string;
40  counts: {
41    total: number;
42    done: number;
43    inProgress: number;
44    ready: number;
45    blocked: number;
46    invalid: number;
47  };
48  buckets: { name: string; done: number; total: number }[]; // TreePayload.buckets order
49  waves: string[][]; // waves[0] = wave 1; refs in TreePayload.tasks order
50  unschedulable: string[]; // refs no layer reaches (cycle, dangling dep), sorted
51  currentWave: number | null; // 1-based: lowest wave holding a non-done task; null when no wave holds one (all done, or only unschedulable work left)
52  tasks: Record<string, DeckTask>;
53  agents: DeckAgent[]; // FleetRow.status === "in-flight" only, startedAt ascending
54  crew: DeckCrew[];
55  time: DeckTask["time"]; // the whole run: first agent start to last finish, open while one runs
56  tokens: number | null; // the whole run's billed tokens, crew included, only from a --usage run // the latest 3 taskless fleet rows of any status, newest first
57  errors: number; // TreePayload.errors.length
58};
59
types/index.d.ts 14 lines
1// The flightdeck pane's drawn state: the ticker, the command and the tool hook write it, the render reads it.
2// `snapshot` is a DeckSnapshot (hooks/flightdeck/types.ts), typed loosely because a contract may import nothing.
3export type FlightdeckDeck = {
4  snapshot: object | null;
5  stale: boolean;
6  message: string | null;
7};
8
9declare module "claude-code" {
10  interface PluginState {
11    dispatch: { flightdeck: FlightdeckDeck };
12  }
13}
14