SLOPSHOPPER

deck

A pane beside the conversation for the work behind it: running shells and agents, the model and a clickable effort meter, and step-by-step progress of any job…

newpanespinnerrowsguardcommand
v0.8.5MITupdated 2026-10-09barisdemirhan/claude-deck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · deck
│ ┃ Deck ✕ › fix the failing auth test and add an audit log call │ ┃ Opus 5.5 effort shows with the first reque │ ┃ $0.42 · 5h 31% ⏺ Read(src/auth.ts) │ ┃ ─────────────────────────────────────────… ⎿ Read 6 lines │ ┃ Nothing runs right now. ⏺ Update(src/auth.ts) │ ┃ ─────────────────────────────────────────… ⎿ Added 2 lines, removed 1 line │ ┃ RECENT ⏺ Bash(bun test) │ ┃ ▸ ✗ Run the test suite shell 0s ⎿ 3 pass, 1 fail │ ┃ × clear │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /deck │ ⎿ deck: Deck pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ ◨ Opus 5.5 ×

Draws

Pane · Deck
Opus 5.5 effort shows with the first request ctx ▰▰▰▰▱▱▱▱ $0.42 · 5h 31% ────────────────────────────────────────────────────── Nothing runs right now. ────────────────────────────────────────────────────── RECENT ▸ ✗ Run the test suite shell 0s × clear
Prompt hint
⟨Claude Code's own drawing⟩ ◨ Opus 5.5 ×
README

claude-deck

A pane beside the conversation in Claude Code for the work behind it: the shells and agents that run, the model, an effort meter you can press, and the steps of each job as a tree.

Type /deck, or press its mark, ◨, under the prompt:

<img src="docs/deck.png" width="604" alt="Deck's pane: the model with its effort meter, the context's fill, the session's cost and limits; two shells that run, one opened to its command with a stop button; an agent with the shell it started and the run it opened under it; a run as a tree with a finished branch folded; and the two commands that lately ended, one failed">

Install

claude plugin marketplace add barisdemirhan/claude-mods
claude plugin install deck@claude-mods

Restart Claude Code, then run /deck.

The same two steps work from inside a session with /plugin marketplace add barisdemirhan/claude-mods and /plugin install deck@claude-mods.

claude-mods is one marketplace for all of these mods, so its first line is needed once for the lot.

If you installed from claude-deck

Nothing has to change. This repository is a marketplace of its own too, and deck@claude-deck goes on getting updates. Keep one of the two installs, not both: with both on, every hook runs twice.

Use

CommandWhat it does
/deckOpens the pane, or closes the open one. /deck open only opens it. It asks for 52 columns beside the conversation; a width you drag it to stands. Opened by you, with a command or a press, it is drawn at any width: beside the conversation in the fullscreen layout from 110 columns, else above the prompt. Where Claude Code still keeps it waiting undrawn (a surface that draws no panes), the answer or a toast says why
/deck watchWith GitHub checks on, follows the checks of the branch you are on. /deck watch 12 follows a pull request, and a workflow run's id, a branch, a commit or a page's address on GitHub works the same. /deck unwatch stops following; the runs stay where they stood. See GitHub checks
/deck clearTakes what is over out of the pane: the ended shells and agents, and the runs that finished or stopped. What still runs stays
/deck rowKeeps the label as text on the hint line, or brings its row back. The row's × does the first. Every open session follows within two seconds
/deck closeTakes the deck away in every open session within two seconds: the label, the pane, the reading of shell calls, what it follows on GitHub and the effort it set. What was running ends where it stood, since the deck sees no end while it is closed. /deck exit and /deck quit do the same; /deck brings it back

The label

Under the prompt the deck keeps one label: the model, its effort, how many shells and agents run, and the job under way with its steps done.

<img src="docs/label.png" width="604" alt="The conversation with the pane closed: under a group of three shell commands, a row for each with its mark and time; under the prompt, the deck's label with its mark, the model, the effort in its level's color, the count of what runs, the run under way and a close mark">

Where there is a pointer, the terminal's fullscreen layout or the desktop app, the label is a row of its own right under the hint line, and it begins with the deck's mark, ◨: a press on it opens or closes the pane, and so does a press on the run at the row's end. The model's name between them is plain text. Its effort is drawn in its level's color, and a press on the level's name steps it up, as on the pane's meter. What runs has the color of a running row, a failed step's ✗ is red, the rest is dim. × closes the row: the label is then text on the hint line. Where another mod has already drawn a row of its own there, the label joins that row at its end, so the two take one row between them. The row fits what the screen leaves it beside the docked pane and another mod's row: the run's title is cut first, then the model's name goes, then the run, so × stays on the screen. A shell Claude Code asks you about is not counted as running. The model follows /model within two seconds; its effort shows again with its first request. On the terminal's main screen it is text at the end of the hint line. The Hint label setting keeps it text everywhere, or takes it off.

The pane

PartWhat it shows
The first rowsThe main thread's model, its effort meter, and how full the context window is. Under them what the session cost and how full each rate limit is ($1.24 · 5h 34% · 7d 12%); above the prompt, where the pane has few rows, these share the first row while it has room
SHELLS and AGENTSEach shell command and each subagent that runs now, in a section of its own, with how long it has run. Each row starts with ▸ closed or ▾ open, before its state mark. A title too long for its row is cut with …. A shell is named by what its call says it does, or by the first line of its command where the call says nothing; a subagent by its type and task, as general-purpose(Ship the release). At its row's end a subagent shows its model and its effort, Sonnet ▰▰▰▱▱: the model's first word as its start named it, then the effort's bar in the level's color once its first request carried one. A narrow pane keeps the name and leaves out the bar. What a running agent started, its own shells and agents and the run it opened, is drawn under its row. A shell Claude Code asks you about shows ? and waits, with no clock, until it runs: its time starts when it does, two seconds before Claude Code draws its ctrl+b hint under it
The runsEach job's steps as a tree, between the two lists. See below
RECENTThe last eight that ended: ✓ done, ✗ failed, ■ stopped, · ended with no word on how. A foreground command that went well in under three seconds is left out. Above the prompt the newest three are listed and the rest counted, +5 more

A press on a row's mark or title opens its detail under it, and another closes it. If the title was cut, its full text comes first, wrapped over as many lines as it needs; a title that fits is not repeated. Under that is a shell's command, its first line, wrapped and bounded to eight lines' worth of characters with a final … where it is cut, or an agent's model and effort. An open title is no longer dim. Where the job runs in the background, a line under the detail has a ■ stop button, which asks Claude Code to stop that task as its own TaskStop tool does. × clear on the pane's last line does what /deck clear does. What is over also leaves the pane by itself after half an hour.

A shell that Claude runs in the background, or that a timeout or ctrl+b moves there, stays under SHELLS until Claude Code reports its end. That report waits for the tool call Claude is in, so a background shell's time can read longer than it ran.

Transcript rows

Where Claude Code folds a run of tool calls into one line of the conversation, the deck adds a row under it for each shell command of the group:

Ran 3 shell commands
  ✓ Check the version           2s
  ✗ Verify the profile          8s
  ⏵ Restart the daemon         14s

Each row is that call's own: matched by the call's id, in the order Claude made them, with the state the transcript gives it. A command that went to the background keeps ⏵ and its time until it ends, its time running whether the pane is open or not. A title is cut to the conversation's width beside a docked pane, and to 56 cells at most. A press on a title opens the pane with that command's row open. Past four commands the rest are counted, +2 more. Claude Code's own line stays as it is, other tools get no row, and a group you expand with ctrl+o is left alone. The Transcript rows setting turns them off.

Runs

A run is one job's steps, with how many are done and how long each took. The deck keeps the last six. When the subagent that opened a run ends, the run's clock stops and a step it left running goes back to waiting. When an agent opens a new plan, the one it had under way stops the same way: the agent moved on, and nothing would end it.

A loop shows one run at a time: when an agent opens a plan, the task list it was following leaves the pane, and its later tasks open none while the plan is under way. /clear takes every run away with the conversation.

  • Claude Code's own task list shows as a run by itself: when Claude makes tasks and moves them along, as it already does, each task is a step. A list is flat, one level.
  • A plan with levels needs the Tool for Claude setting, below.

The run under way is open, and the others are one row each. Inside a run, a branch is open while it is under way and folds by itself before it starts and once it is over. A press on a row's mark folds or unfolds it: ▾ open, ▸ folded. A step that failed is a red ✗, and so is every branch above it, so a folded run still shows it. A run a subagent opened carries the agent's name: Ship the release · general-purpose.

A step whose title does not fit has its own ▸ / ▾ mark beside its state. A press on that mark or the cut title opens the full title wrapped under it; a step that fits stays one line.

Only the steps at the ends of the tree have a state. A branch's state, its count and its time come from the steps under it.

Tool for Claude

With Tool for Claude on, Claude can call two tools:

  • plan takes a job's title and its steps as indented text, one step a line, and opens a run. Each line gets an id from its place: 1, 1.2, 1.2.1.
  • step moves one step: start, done or fail. Starting a step ends any step still running before it in the plan, in the same branch or an earlier one, so moving on is one call. The first step starts with the plan, and a subagent's last step is done by itself when the agent answers, so a job of three stages costs an agent four calls: one to load the tools, the plan, and two moves. The note below also tells agents to skip the tree for a short job. The deck keeps the times; Claude never sends one. An agent moves its own plan; another's only by naming its run.

Every agent of the session gets them with that one setting: the main thread and each subagent alike, of any type, with no line added to an agent's definition. So that an agent uses them without being asked, the same setting adds one note, the same words in two places: as a section of the main thread's system prompt, and at the end of the task each subagent is given.

The person follows progress in a pane called Deck, which shows them the title and steps of a plan and the description of each shell command you run. They read these to understand what is happening: write them in the language the person writes in. If you have the tool mcp__deck__plan and this job will take more than a couple of minutes or more than three stages, call it once as you begin with three to six steps (load it and mcp__deck__step with ToolSearch if their schemas are not loaded); its first step starts by itself, so call mcp__deck__step only as you move to the next one. Skip it for a short job: each call costs the person a round trip.

The note also asks for the words you will read in the pane, a plan's title and steps and each shell command's description, in the language you write in. A tool's description alone does not do this: where many tools are installed, Claude Code lists most by name only, and an agent reads a description only after it loads the tool. You can still ask, "plan this in the deck". The one agent that cannot is one whose definition lists its tools and leaves these two out: Claude Code refuses it any other tool. Claude Code asks your permission for them as for any tool, and their descriptions take a little of every prompt's context, which is why they are off by default.

GitHub checks

With GitHub checks on, the deck follows checks on GitHub and shows them as a run: the CI of a pull request, a deploy or any other workflow run, the checks of a branch or a commit.

▾ PR #12 · Fix the wallet            ▰▰▰▰▰▱▱▱ 3/5    4m 10s
    ✓ lint                                              41s
    ✓ unit                                           2m 03s
    ⏵ e2e                                            3m 20s
    ✗ deploy/preview                                    12s
    ○ smoke
You giveIt follows
12, #12, or a pull request's addressThat pull request's checks: its check runs, and the statuses other services set on its last commit
A workflow run's id or addressThat run's jobs, each with its steps under it
A branch, a tag, a commit, or the address of oneThe checks of that commit; for a branch, of its newest one
NothingThe branch the session is on

A number of eight digits or more is read as a workflow run's id, a shorter one as a pull request's. A check that was skipped, cancelled or timed out says so beside its name; a skipped one counts as passed.

The deck asks GitHub every 30 seconds while something is followed, four things at most. A pull request's or a commit's checks are over once two polls in a row find each one over, since a later workflow may still add its own; a workflow run is over when GitHub says so. A watch is given up when no check shows up in ten minutes, when no check moves for half an hour while none runs (a job that waits on a runner or an approval may wait for good; one that runs holds the watch however long it takes), after two hours in all, or when GitHub does not answer five times in a row: its run stops where it stands, a toast says why, and Claude is told where it asked to be woken. One question to GitHub may take 20 seconds. A toast tells you of a failed check and of the end, as for any run.

How it asks. With the gh command installed and signed in, the deck runs gh api for each question, so it holds no token and a company's own GitHub host works. Where there is no gh, or nobody is signed in to it, it asks api.github.com directly: with the token in GH_TOKEN or GITHUB_TOKEN where one is set, and with none for a public repository, then every two and a half minutes, as GitHub answers a nameless caller 60 requests an hour. Every question is a read. A host other than github.com needs gh, and gh runs only where Claude Code runs commands for a mod, which is the terminal.

For Claude. The same setting lists one tool, watch, with a note that tells every agent of the session it is there, added as the note of Tool for Claude is:

When you wait on GitHub checks (the CI of a pull request, a deploy or any other workflow run, the checks of a branch you pushed), call mcp__deck__watch once in place of polling in a shell (load it with ToolSearch if its schema is not loaded): the person follows each check in the Deck, and with wake you get a message when they are over.

watch takes what to follow, as /deck watch does, and wake. With wake, the deck submits one prompt of its own when the checks are over or the watch is given up, which starts a turn: how many checks passed and failed, the failed ones by name, and the page's address. So Claude can push, call watch, end its turn, and merge when the word comes, with no shell left polling. Without wake you see the checks and Claude is told nothing.

The effort meter

▰▰▰▱▱ high is the effort the main thread's requests go out with, drawn in its level's color: low dim, medium green, high yellow, xhigh orange, max red. Press the level's name and it goes one step up: low, medium, high, xhigh, max, then low again. The name's button is as wide at every level as the longest name, so a pointer that stays where it pressed goes all the way round. From the next request on, the deck sends that level in place of Claude Code's own, on the main thread only; a subagent keeps its own. The mark beside the meter is ⟳ while the deck sets the effort and ↑ while Claude Code does.

It lasts for the session, until one of these hands the effort back to Claude Code: /effort with another level, a step that lands on Claude Code's own level, or /deck close. A model that takes no effort setting shows no meter.

Settings

In Claude Code's /config menu, under the plugin's name:

SettingDefaultWhat it does
Hint labelbuttonbutton: a row under the hint line with parts you can press, where there is a pointer, and text elsewhere. text: always at the end of the hint line. off: no label
Tool for ClaudeoffLists the plan and step tools for Claude. See above
GitHub checksoffFollows checks on GitHub as a run: /deck watch, and the watch tool for Claude. See above
Transcript rowsonThe rows under a group of tool calls in the conversation. See above
ToastsonA toast when a background job fails or ends after half a minute, when a step fails and when a run finishes

Requirements

  • A Claude Code build with mod support (plugins that ship a hooks module). Built on 2.1.289 and tested on 2.1.295. Mods sit behind a rollout switch, so if /deck does not show up after installing, the switch may still be off for you.
  • The terminal or the desktop app: the label and the pane are drawn only there. A press needs a pointer: the terminal's fullscreen layout or the desktop app. On the terminal's main screen the commands do it all.
  • The pane fits its rows to the width it gets: a long title is cut first, and the context meter moves to a row of its own when the first row is full. Under 44 columns a run loses its bar and keeps its count; under 36 a row loses its shell or agent. The label's row and the transcript's rows fit the screen too, as told above.

Privacy and data handling

The mod registers one slash command, adds one label under the prompt, adds rows under the conversation's lines for groups of tool calls, and draws its pane when you open it. Out of the box it reads no files, writes none, runs no processes and makes no network request; with GitHub checks on it runs gh and git, or asks GitHub directly, as told below. Out of the box it changes one thing of Claude's work, and only after you ask: the effort of the main thread's model requests, after a press on the meter. A press on a row's ■ stop asks Claude Code to stop that one background task. With Tool for Claude on it changes one more: it adds the note quoted under that setting to the main thread's system prompt and to the end of each subagent's task. GitHub checks adds its own note the same way, and for a watch Claude asked to be woken for, the deck submits one prompt of its own when the checks are over. It never changes the prompt you type, a tool call or a tool's result.

What it reads. More than the other mods of this marketplace, which read no argument of a tool call:

  • Of each Bash call: its description, cut to 80 characters, as the row's title, and the first line of its command, cut to 200, as the row's detail (and its title where the call has no description). Both are kept in memory until the session ends, eight newer rows push the row out, or half an hour passes. Of the call's result: whether it failed, whether it was interrupted, and the id of the background task it became. Not its output.
  • Of a group of tool calls the conversation draws as one line: which of its calls are shell commands, and of those the id, the state, and the same description and first line of command as above. Of the group's other calls, the tool's name only, to pass them by.
  • Of each TaskStop call: the id of the task that was stopped.
  • Of each TaskCreate call: the new task's id and its subject, cut to 80 characters and kept in memory as a step's title. Of each TaskUpdate call: the task's id, its new status and, when it changes, its subject. Not a task's description.
  • Of each subagent: its type, its name and the few words its call names the task with, and when its turn ends, how.
  • Of each model request: the model, the effort, and whether a subagent made it. Not the conversation.
  • Of the row Claude Code draws when a background task ends: the task's id, how it ended and how long it ran. Not the row's text.
  • When Claude stops: the ids of the background tasks still running.
  • When Claude Code asks you whether a shell may run: the tool's name and the first line of its command, to find that shell's row. Nothing else of the dialog, and not your answer.
  • When Claude Code draws its ctrl+b hint under a shell: the call's id, which says the shell runs.
  • From Claude Code: the main thread's model, how full the context window is, what the session cost and how full its rate limits are.
  • With GitHub checks on, when a watch starts: the address of the session's origin remote, for the repository's host and name, and for a watch with no target the name of the branch you are on. Of GitHub's answers: a pull request's title; a workflow run's name, title and state; each check's, job's and step's name, state and times. The names are cut to 80 characters and kept in memory as a run. With no gh, the value of GH_TOKEN or GITHUB_TOKEN, to send it to GitHub with each question.

It reads nothing of a prompt's text, of any other tool's call, or of an answer.

What it sends. Out of the box, nothing: it makes no network request. It has no server, no account and no analytics, and nothing goes to the author of this mod.

With GitHub checks on, and only while something is watched, it asks GitHub, read only, every 30 seconds. What goes out is the question: the repository's owner and name, and the pull request's number, the workflow run's id, or the branch's or commit's name. With gh, the deck runs gh api and gh makes the request, to the host of your remote or of the address you gave, with the sign-in gh holds. Without gh, the deck itself requests https://api.github.com, and no other host, with GH_TOKEN or GITHUB_TOKEN as the request's authorization where one is set. /deck unwatch, /deck close, the checks' end or the session's end stops it; with the setting off it never starts. GitHub's own policy covers what it keeps of a request.

What reaches Claude. What /deck answers is a row of the conversation, as any command's output is: one fixed sentence saying what the command did. No title of a shell, an agent or a step is in it. Out of the box the mod gives Claude no tool. With Tool for Claude on, it lists two, and what they answer goes into the conversation the same way: plan answers the steps Claude itself sent, each with its id, and step the step's id and how many are done.

With GitHub checks on, what /deck watch answers names what is shown, with the pull request's or the workflow run's title, how many checks are over, and the page's address; an error of gh or of GitHub is answered as it came, cut to its first line. The watch tool answers the same to Claude. For a watch with wake, the prompt the deck submits carries the run's title, how many checks passed and failed, the failed checks' names and the page's address.

What it keeps. On disk, in the plugin's own Claude Code store, one JSON file under ~/.claude/plugins/store/: whether you closed the deck with /deck close, and whether you closed the label's row. Everything else, the rows' titles and times, the runs and their steps, what you folded, the model and the effort you set, is in the session's memory and gone with it.

Sound. None.

Files and processes. It reads no files and writes none. Out of the box it runs no processes. With GitHub checks on it runs two commands, each by its arguments with no shell: gh api --hostname <host> <path> for each question to GitHub, and git rev-parse --abbrev-ref HEAD on

Source 10 files
hooks/register.tsx 1453 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Job, JobStatus, Meter, Run, Watch } from '../types'
5
6import {
7  GITHUB,
8  MAX_WATCHES,
9  POLL_ANON_MS,
10  POLL_MS,
11  checksOf,
12  givenUp,
13  homeOf,
14  isSame,
15  pathsOf,
16  polled,
17  pullPath,
18  pullTitle,
19  startText,
20  summaryOf,
21  targetOf,
22  watched,
23} from './checks'
24import type { Found } from './checks'
25import {
26  LEVELS,
27  NO_METER,
28  canStep,
29  effortColor,
30  effortOf,
31  effortText,
32  held,
33  isSameMeter,
34  modelName,
35  seen,
36  stepped,
37} from './meter'
38import { beside, besideCells, fittedLabel, groupTree, labelRow, paneTree } from './pane'
39import type { CallRow, LabelView } from './pane'
40import {
41  RUN_TITLE_CELLS,
42  activeOf,
43  isLive,
44  keptFolds,
45  newsOf,
46  opened,
47  stopped,
48  stoppedAll,
49  swept,
50  leavesOf,
51  planOf,
52  planText,
53  planned,
54  replanned,
55  rowsOf,
56  runCount,
57  runOf,
58  runText,
59  statusOf as statusOfSteps,
60  stepped as stepTurned,
61  taskCreated,
62  taskUpdated,
63} from './runs'
64import { NOTE, NOTE_SECTION, TOOLS, WATCH_NOTE, WATCH_SECTION, WATCH_TOOL, WORDS } from './tools'
65import { isRecord, toText } from './values'
66import { GLYPHS, jobSpan, labelText, spanText } from './view'
67import {
68  NO_WORK,
69  agentTitle,
70  asked as askedAbout,
71  backgrounded,
72  begun,
73  cleared,
74  dropped,
75  ended,
76  endedAll,
77  reported,
78  revived,
79  settled,
80  shellDetail,
81  shellTitle,
82  started,
83  statusOf,
84  timed,
85  tuned,
86} from './work'
87
88const PANE = 'deck'
89// The size asked for: beside the conversation 52 cells across, above the
90// prompt 14 rows. A size the person dragged it to stands over both.
91const PANE_OPEN = { id: PANE, title: 'Deck', columns: 52, rows: 14 } as const
92/** The store's one key: `/deck close` for every session. */
93const CLOSED = 'closed'
94/** The store's other key: the label's row closed with its `×`, for every session. */
95const ROW_CLOSED = 'row-closed'
96const TICK_MS = 1000
97/** The store is read for another session's `/deck close` every this many ticks. */
98const FOLLOW_TICKS = 2
99const CLOSE_WORDS = ['close', 'exit', 'quit']
100const USAGE =
101  'Usage: /deck (opens or closes the pane) · /deck open · /deck clear (takes what is over out of the pane) · /deck row (the label as a row, or as text) · /deck watch [a pull request, a workflow run, a branch or a commit] (follows its GitHub checks) · /deck unwatch · /deck close (takes the deck away in every session)'
102const CHECKS_OFF =
103  'GitHub checks are off. Turn on "GitHub checks" in /config, under the plugin\'s name, to follow them in the deck.'
104/** How long one question to GitHub may take. */
105const ASK_MS = 20 * 1000
106/** `gh` ends with this where nobody is signed in to the host. */
107const GH_NO_AUTH = 4
108const ERROR_CHARS = 160
109/** What is over leaves the pane by itself after this long. */
110const STALE_MS = 30 * 60 * 1000
111const SWEEP_TICKS = 60
112/** A background job's end is told only when it ran this long, or failed. */
113const TOLD_MS = 30 * 1000
114const work = atom({ plugin: 'deck', key: 'work' } as const, NO_WORK)
115const meter = atom({ plugin: 'deck', key: 'meter' } as const, NO_METER)
116const now = atom({ plugin: 'deck', key: 'now' } as const, 0)
117const switches = atom(
118  { plugin: 'deck', key: 'switches' } as const,
119  { isClosed: false, isRowClosed: false },
120)
121const NO_RUNS: Run[] = []
122const NO_FOLDS: Record<string, boolean> = {}
123const runs = atom({ plugin: 'deck', key: 'runs' } as const, NO_RUNS)
124const folds = atom({ plugin: 'deck', key: 'folds' } as const, NO_FOLDS)
125const NO_WATCHES: Watch[] = []
126const watches = atom({ plugin: 'deck', key: 'watches' } as const, NO_WATCHES)
127const NO_SPANS: Record<string, number> = {}
128const spans = atom({ plugin: 'deck', key: 'spans' } as const, NO_SPANS)
129const docked = atom({ plugin: 'deck', key: 'docked' } as const, 0)
130/** A docked pane's frame around its body, and the cell between it and the conversation. */
131const DOCK_FRAME_CELLS = 3
132/** A shell Claude Code draws its ctrl+b hint under has run this long. */
133const HINT_MS = 2000
134/** How many shell commands' lengths are kept for the transcript's rows. */
135const SPANS_KEPT = 60
136
137type Label = 'button' | 'text' | 'off'
138
139const labelOf = (options: Readonly<Record<string, unknown>>): Label =>
140  (['button', 'text', 'off'] as const).find((place) => place === options.hintLabel) ?? 'button'
141
142const isClosed = async ($: EngineInterface): Promise<boolean> =>
143  (await read($, switches)).isClosed
144
145/** True while the pane is drawn. One that waits undrawn, opened where it had no room, is not up. */
146const isPaneUp = async ($: EngineInterface): Promise<boolean> =>
147  (await $.ui.panes().catch(() => [])).some((pane) => pane.id === PANE && pane.isPlaced)
148
149const PANE_OPENED = 'Deck pane opened.'
150const PANE_CLOSED = 'Deck pane closed.'
151
152/**
153 * Opens the pane. Resolves what to tell the person: that it is open, or why
154 * Claude Code keeps it waiting undrawn.
155 */
156const paneOpened = async ($: EngineInterface): Promise<string> => {
157  const answer = await $.ui.open(PANE_OPEN)
158
159  return answer.isPlaced ? PANE_OPENED : `Deck's pane waits: ${answer.reason}`
160}
161
162/** The meter with the effort handed back to Claude Code. */
163const unheld = (kept: Meter): Meter => held(kept, '')
164
165/**
166 * What closing takes away in this session, whichever session closed it: the
167 * pane, the effort the deck set and what it follows on GitHub. What runs
168 * ends where it stands: the deck reads nothing while it is closed, so a job
169 * or a run left running would run on in the pane when it came back.
170 */
171const shutDown = async ($: EngineInterface): Promise<void> => {
172  await $.ui.close({ id: PANE }).catch(() => undefined)
173  await update($, meter, unheld)
174  await update($, watches, () => [])
175  const at = await $.clock.now()
176  await update($, work, (kept) => endedAll(kept, at))
177  await update($, runs, (kept) => stoppedAll(kept, at))
178}
179
180/**
181 * Keeps `/deck close` or its undoing for every session and takes it up here
182 * at once.
183 */
184const closedAs = async ($: EngineInterface, isOff: boolean): Promise<void> => {
185  await switched($, CLOSED, isOff)
186  await update($, switches, (kept) => ({ ...kept, isClosed: isOff }))
187
188  if (isOff) {
189    await shutDown($)
190  }
191}
192
193/**
194 * Whether a switch of every session's is on. Read every two seconds in every
195 * session, so the store is listed first and a key read only where it is set:
196 * one read of the store's file where there were two. A key an older version
197 * left `false` is taken away on the way.
198 */
199const isSet = async ($: EngineInterface, keys: readonly string[], key: string): Promise<boolean> => {
200  if (!keys.includes(key)) {
201    return false
202  }
203
204  const isOn = (await $.store.get(key)) === true
205
206  if (!isOn) {
207    await $.store.delete(key).catch(() => undefined)
208  }
209
210  return isOn
211}
212
213/** Sets a switch of every session's: kept while on, taken away when off. */
214const switched = async ($: EngineInterface, key: string, isOn: boolean): Promise<void> => {
215  await (isOn ? $.store.set(key, true) : $.store.delete(key))
216}
217
218/** Takes up a `/deck close` another session made, or its undoing. */
219const followed = async ($: EngineInterface): Promise<void> => {
220  const keys = await $.store.keys()
221  const isOff = await isSet($, keys, CLOSED)
222  const isRowOff = await isSet($, keys, ROW_CLOSED)
223  const before = await read($, switches)
224
225  if (isOff === before.isClosed && isRowOff === before.isRowClosed) {
226    return
227  }
228
229  await update($, switches, () => ({ isClosed: isOff, isRowClosed: isRowOff }))
230
231  if (isOff && !before.isClosed) {
232    await shutDown($)
233  }
234}
235
236/**
237 * The label's row closed or opened again, for every session: closed, the
238 * label is text at the end of the hint line.
239 */
240const rowClosedAs = async ($: EngineInterface, isOff: boolean): Promise<void> => {
241  await switched($, ROW_CLOSED, isOff)
242  await update($, switches, (kept) => ({ ...kept, isRowClosed: isOff }))
243}
244
245/** Opens the pane, or closes the open one. Resolves what to tell the person. */
246const toggled = async ($: EngineInterface): Promise<string> => {
247  if (await isPaneUp($)) {
248    await $.ui.close({ id: PANE })
249
250    return PANE_CLOSED
251  }
252
253  return paneOpened($)
254}
255
256/**
257 * A press that opens or closes the pane says nothing but where the pane
258 * waits, so a press that drew nothing is not silent.
259 *
260 * A Button hands the promise back to the press: Claude Code draws a pane at
261 * any width only while the person's press is answered, and counts the
262 * press as answered until its handler's promise settles. A press that
263 * returned at once and opened the pane after an await (`toggled` asks
264 * whether it is up first) made an open of the deck's own, which waits
265 * undrawn below 144 columns.
266 */
267const pressed = async ($: EngineInterface, said: Promise<string>): Promise<void> => {
268  const text = await said
269
270  if (text !== PANE_OPENED && text !== PANE_CLOSED) {
271    $.ui.toast(text)
272  }
273}
274
275/** A press on a transcript row: the pane opens with that command's row open. */
276const shown = async ($: EngineInterface, id: string): Promise<void> => {
277  const opening = pressed($, paneOpened($))
278  await update($, folds, (kept) => ({ ...kept, [`job:${id}`]: true }))
279  await opening
280}
281
282/** What Claude Code measured of the session: the context's fill, the cost, the rate limits. */
283type Measure = {
284  context: { percent?: number }
285  cost?: { usd: number }
286  rateLimits: readonly { kind: string; percentUsed: number }[]
287}
288
289/** Takes up the session's figures as Claude Code reports them. */
290const measured = async ($: EngineInterface, usage: Measure): Promise<void> => {
291  const before = await read($, meter)
292  const after = {
293    ...before,
294    context: usage.context.percent ?? before.context,
295    cost: usage.cost?.usd ?? -1,
296    limits: usage.rateLimits.map((limit) => ({ kind: limit.kind, percent: limit.percentUsed })),
297  }
298
299  if (!isSameMeter(before, after)) {
300    await update($, meter, (kept) => ({
301      ...kept,
302      context: after.context,
303      cost: after.cost,
304      limits: after.limits,
305    }))
306  }
307}
308
309/**
310 * The main thread's model as `/model` left it, so the label follows a change
311 * at once and not with the next request. A model that takes another name
312 * starts with its effort unknown: the old one's level is not carried over.
313 */
314const named = async ($: EngineInterface): Promise<void> => {
315  const model = await $.session.model().catch(() => '')
316  const before = await read($, meter)
317
318  if (model !== '' && modelName(model) !== modelName(before.model)) {
319    await update($, meter, (kept) => ({ ...kept, model, effort: '', isSeen: false, wanted: '', over: '' }))
320  }
321}
322
323/** The folds of what is no longer shown go, so a run that takes a freed id starts with none. */
324const foldsPruned = async ($: EngineInterface): Promise<void> => {
325  const shown = await read($, runs)
326  const jobs = await read($, work)
327  const ids = [...jobs.running, ...jobs.recent].map((job) => job.id)
328  const before = await read($, folds)
329
330  if (Object.keys(keptFolds(before, shown, ids)).length !== Object.keys(before).length) {
331    await update($, folds, (kept) => keptFolds(kept, shown, ids))
332  }
333}
334
335/** `/deck clear` and the pane's button: what is over leaves the pane. */
336const sweptAway = async ($: EngineInterface, before?: number): Promise<void> => {
337  const jobs = await read($, work)
338  const all = await read($, runs)
339
340  if (cleared(jobs, before).recent.length !== jobs.recent.length) {
341    await update($, work, (kept) => cleared(kept, before))
342  }
343
344  if (swept(all, before).length !== all.length) {
345    await update($, runs, (kept) => swept(kept, before))
346  }
347
348  await foldsPruned($)
349}
350
351/** The name a run a subagent opened carries: the agent's own, else its type. */
352const ownerOf = async ($: EngineInterface, agentId: string | undefined): Promise<string> => {
353  if (agentId === undefined) {
354    return ''
355  }
356
357  const agent = (await $.agent.list().catch(() => [])).find((one) => one.id === agentId)
358
359  return agent?.name ?? agent?.type ?? 'agent'
360}
361
362/** The runs open first, the newest touched at the top, then the finished. */
363const ordered = (all: readonly Run[]): Run[] =>
364  [...all].sort(
365    (one, other) =>
366      Number(!isLive(one)) - Number(!isLive(other)) ||
367      other.touchedAt - one.touchedAt,
368  )
369
370const STATUS_BY_REASON: Readonly<Record<string, JobStatus>> = {
371  answer: 'done',
372  aborted: 'killed',
373  refusal: 'failed',
374  error: 'failed',
375}
376
377/**
378 * What this session's module holds between events, lost at a reload: the
379 * tick's count, a count for calls with no id, and how each background task
380 * ended by its notification's own row, which is read while that row is
381 * drawn, where nothing may be written, and taken up by the next tick.
382 */
383type Session = {
384  ticks: number
385  minted: number
386  lengths: Map<string, { status: string; ms: number }>
387  /** When Claude Code drew its ctrl+b hint under a shell, by its call's id: that shell runs. */
388  hints: Map<string, number>
389  /** The cells the pane took from the screen as it was last drawn docked; 0 drawn inline. */
390  dockCells: number
391  hasTool: boolean
392  hasToasts: boolean
393  hasRows: boolean
394  hasChecks: boolean
395  /** How GitHub is asked: by `gh`, or directly where the first try found no `gh` signed in. */
396  via: 'untried' | 'gh' | 'http'
397  /** True where GitHub is asked directly with no token, and answers few requests an hour. */
398  isAnon: boolean
399  /** When the watches were last polled, and whether a poll is under way. */
400  polledAt: number
401  isPolling: boolean
402}
403
404/** A toast, where the person left them on. */
405const told = ($: EngineInterface, session: Session, text: string): void => {
406  if (session.hasToasts) {
407    $.ui.toast(text)
408  }
409}
410
411/** Changes the runs, and tells the person of a run that finished or a step that failed. */
412const moved = async (
413  $: EngineInterface,
414  session: Session,
415  change: (kept: Run[]) => Run[],
416): Promise<void> => {
417  const before = await read($, runs)
418  const after = await update($, runs, change)
419
420  for (const line of newsOf(before, after)) {
421    told($, session, line)
422  }
423}
424
425/** The first free id for a new run: `r1`, `r2`. */
426const freeId = (all: readonly Run[], at: number): string => {
427  const taken = all.map((one) => one.id)
428  const free = Array.from({ length: taken.length + 1 }, (_, index) => `r${index + 1}`)
429
430  return free.find((id) => !taken.includes(id)) ?? `r${at}`
431}
432
433/** What GitHub answered to one path of its API, or why there is no answer. */
434type Answer = { data: unknown; error?: undefined } | { error: string; data?: undefined }
435
436const parsed = (text: string): Answer => {
437  try {
438    return { data: JSON.parse(text) as unknown }
439  } catch {
440    return { error: 'GitHub answered something that is not JSON.' }
441  }
442}
443
444const errorOf = (text: string): string =>
445  (text.trim().split('\n').find((line) => line.trim() !== '') ?? '').slice(0, ERROR_CHARS)
446
447/**
448 * Asks GitHub's API for one path, read only. By `gh api` where the machine
449 * has `gh` signed in, so the mod holds no token. Else directly, and only
450 * `github.com`: with the token `GH_TOKEN` or `GITHUB_TOKEN` names where one
451 * is set, with none for a public repository.
452 */
453const asked = async ($: EngineInterface, session: Session, host: string, path: string): Promise<Answer> => {
454  if (session.via !== 'http') {
455    const ran = await $.process
456      .run(['gh', 'api', '--hostname', host, path], { timeoutMs: ASK_MS })
457      .catch(() => undefined)
458    const isMissing = ran === undefined || ran.exitCode === GH_NO_AUTH
459
460    if (ran !== undefined && ran.exitCode === 0) {
461      session.via = 'gh'
462
463      return parsed(ran.stdout)
464    }
465
466    if (!isMissing || session.via === 'gh') {
467      return { error: errorOf(ran?.stderr ?? '') || 'gh did not answer.' }
468    }
469
470    session.via = 'http'
471  }
472
473  if (host !== GITHUB) {
474    return { error: `Checks on ${host} need the gh command, signed in to it.` }
475  }
476
477  const token = (await $.env.get('GH_TOKEN')) ?? (await $.env.get('GITHUB_TOKEN')) ?? ''
478  session.isAnon = token === ''
479  const answer = await $.http
480    .fetch(`https://api.github.com/${path}`, {
481      headers: {
482        accept: 'application/vnd.github+json',
483        ...(token === '' ? {} : { authorization: `Bearer ${token}` }),
484      },
485    })
486    .catch(() => undefined)
487
488  if (answer === undefined) {
489    return { error: 'GitHub could not be reached.' }
490  }
491
492  if (!answer.ok) {
493    return {
494      error: `GitHub answered ${answer.status}.${token === '' ? ' With no gh signed in and no GH_TOKEN, only a public repository answers.' : ''}`,
495    }
496  }
497
498  return parsed(answer.text)
499}
500
501/** One poll of what a watch follows: its checks, or why GitHub gave none. */
502const looked = async (
503  $: EngineInterface,
504  session: Session,
505  watch: Pick<Watch, 'kind' | 'host' | 'repo' | 'target'>,
506): Promise<Found | { error: string }> => {
507  const [first, second] = pathsOf(watch)
508  const one = await asked($, session, watch.host, first)
509
510  if (one.error !== undefined) {
511    return { error: one.error }
512  }
513
514  const other = await asked($, session, watch.host, second)
515
516  return other.error === undefined ? checksOf(watch.kind, one.data, other.data) : { error: other.error }
517}
518
519/**
520 * Starts following what the words name, for `/deck watch` and the watch
521 * tool: asks GitHub once, shows the answer as a run, and keeps asking while
522 * a check is left. Resolves what to answer, and whether nothing was shown.
523 */
524const watching = async (
525  $: EngineInterface,
526  session: Session,
527  words: string,
528  wake: boolean,
529): Promise<{ text: string; isRefused: boolean }> => {
530  const home = homeOf((await $.session.repo().catch(() => null))?.remote)
531  // With no words, the branch the session is on.
532  const branch =
533    words.trim() === ''
534      ? ((await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD']).catch(() => undefined))?.stdout ?? '')
535      : ''
536  const target = targetOf(words, home, branch)
537
538  if ('error' in target) {
539    return { text: target.error, isRefused: true }
540  }
541
542  const all = await read($, watches)
543  const twin = all.find((one) => isSame(one, target))
544
545  if (twin !== undefined) {
546    await update($, watches, (kept) =>
547      kept.map((one) => (one.run === twin.run ? { ...one, wake: one.wake || wake } : one)),
548    )
549
550    return {
551      text: `It is shown in the Deck already.${wake ? ' A message will tell you when its checks are over.' : ''}`,
552      isRefused: false,
553    }
554  }
555
556  if (all.length >= MAX_WATCHES) {
557    return { text: `The Deck follows ${MAX_WATCHES} at a time, and it does now.`, isRefused: true }
558  }
559
560  const found = await looked($, session, target)
561
562  if ('error' in found) {
563    return { text: `Nothing is shown: ${found.error}`, isRefused: true }
564  }
565
566  const pull = target.pull > 0 ? await asked($, session, target.host, pullPath(target)) : undefined
567  const at = await $.clock.now()
568  await foldsPruned($)
569  const id = freeId(await read($, runs), at)
570  const title = pull?.data === undefined ? target.title : pullTitle(target, pull.data)
571  const run = watched(runOf(id, title, 'checks', `watch:${id}`, '', [], at), found, at)
572  const next = polled(
573    { run: id, kind: target.kind, host: target.host, repo: target.repo, target: target.target, url: target.url, wake, startedAt: at, settled: 0, failures: 0 },
574    found,
575  )
576  await update($, runs, (kept) => opened(kept, run))
577
578  if (next === undefined) {
579    return { text: summaryOf(run, target.url), isRefused: false }
580  }
581
582  session.polledAt = at
583  await update($, watches, (kept) => [...kept, next])
584
585  return { text: startText(run, target.url, wake), isRefused: false }
586}
587
588/** Every watch ends here: its run stops where it stands. */
589const unwatched = async ($: EngineInterface): Promise<number> => {
590  const all = await read($, watches)
591  const at = await $.clock.now()
592  await update($, watches, () => [])
593  await update($, runs, (kept) =>
594    kept.map((run) =>
595      all.some((watch) => watch.run === run.id) && isLive(run) ? { ...run, stoppedAt: at, touchedAt: at } : run,
596    ),
597  )
598
599  return all.length
600}
601
602/**
603 * One poll of every watch. A watch whose row was cleared away ends; one
604 * whose checks are over ends, and tells Claude where Claude asked; one that
605 * cannot go on is given up, its run stopped where it stands.
606 */
607const polledAll = async ($: EngineInterface, session: Session): Promise<void> => {
608  for (const watch of await read($, watches)) {
609    const run = (await read($, runs)).find((one) => one.id === watch.run)
610    const found = run === undefined ? undefined : await looked($, session, watch)
611    const at = await $.clock.now()
612
613    if (run === undefined || found === undefined) {
614      await update($, watches, (kept) => kept.filter((one) => one.run !== watch.run))
615      continue
616    }
617
618    const isAnswered = !('error' in found)
619    const current = isAnswered ? watched(run, found, at) : run
620    const next = isAnswered ? polled(watch, found) : { ...watch, failures: watch.failures + 1 }
621    const why = next === undefined ? undefined : givenUp(next, current, at)
622
623    if (isAnswered) {
624      await moved($, session, (kept) => kept.map((one) => (one.id === watch.run ? watched(one, found, at) : one)))
625    }
626
627    if (next !== undefined && why === undefined) {
628      await update($, watches, (kept) => kept.map((one) => (one.run === watch.run ? { ...next, wake: one.wake } : one)))
629      continue
630    }
631
632    const wake = (await read($, watches)).find((one) => one.run === watch.run)?.wake ?? watch.wake
633    await update($, watches, (kept) => kept.filter((one) => one.run !== watch.run))
634
635    if (why !== undefined) {
636      await update($, runs, (kept) =>
637        kept.map((one) => (one.id === watch.run && isLive(one) ? { ...one, stoppedAt: at, touchedAt: at } : one)),
638      )
639      told($, session, `${GLYPHS.killed} ${current.title}: ${why}`)
640    }
641
642    // A deck closed meanwhile wakes nobody.
643    if (wake && !(await isClosed($))) {
644      await $.prompt.submit({ text: summaryOf(current, watch.url, why) }).catch(() => undefined)
645    }
646  }
647}
648
649/**
650 * A press on an opened row's stop button: asks Claude Code to stop that
651 * background task, as its own TaskStop tool does, in the person's name.
652 */
653const halted = async ($: EngineInterface, job: Job): Promise<void> => {
654  const answer = await $.tool
655    .call({
656      tool: 'TaskStop',
657      task_id: job.taskId,
658      consent: `The user pressed "stop" on "${job.title}" in the Deck pane.`,
659    })
660    .catch(() => undefined)
661
662  if (answer !== undefined && answer.deny === undefined && answer.isError !== true) {
663    const at = await $.clock.now()
664    await update($, work, (kept) => reported(kept, { taskId: job.taskId, status: 'killed' }, at))
665  }
666}
667
668/**
669 * Once a second: every other time, another session's `/deck close`; then
670 * what the notification rows said; then the clock for the open pane, while
671 * something runs.
672 */
673const ticked = async ($: EngineInterface, session: Session): Promise<void> => {
674  session.ticks += 1
675
676  if (session.ticks % FOLLOW_TICKS === 0) {
677    await followed($)
678
679    if (!(await isClosed($))) {
680      await named($)
681    }
682  }
683
684  if (session.lengths.size > 0) {
685    const at = await $.clock.now()
686    const rows = [...session.lengths]
687    session.lengths.clear()
688
689    // A background job that ran long, or failed, is told as it ends.
690    for (const job of (await read($, work)).running) {
691      const row = rows.find(([taskId]) => taskId === job.taskId)
692      const status = row === undefined ? undefined : statusOf(row[1].status)
693      const isWorth = status !== undefined && (status !== 'done' || at - job.startedAt >= TOLD_MS)
694
695      if (isWorth && job.kind === 'shell') {
696        told($, session, `${GLYPHS[status]} ${job.title} · ${jobSpan({ ...job, endedAt: at }, at)}`)
697      }
698    }
699
700    await update($, work, (kept) =>
701      rows.reduce(
702        (left, [taskId, row]) =>
703          timed(
704            reported(left, { taskId, status: statusOf(row.status) }, at),
705            taskId,
706            row.ms,
707          ),
708        kept,
709      ),
710    )
711  }
712
713  if (session.ticks % SWEEP_TICKS === 0) {
714    await sweptAway($, (await $.clock.now()) - STALE_MS)
715  }
716
717  // A shell Claude Code drew its ctrl+b hint under runs: one the person was
718  // asked about started its clock that long before.
719  if (session.hints.size > 0) {
720    const hints = [...session.hints]
721    session.hints.clear()
722    await update($, work, (kept) => hints.reduce((left, [id, at]) => begun(left, id, at - HINT_MS), kept))
723  }
724
725  const jobs = await read($, work)
726  const isBusy = jobs.running.length > 0 || (await read($, runs)).some(isLive)
727  const isUp = await isPaneUp($)
728
729  // The clock moves the open pane's times, and the transcript's rows of a
730  // shell that runs, which show while the pane is closed.
731  if ((isBusy && isUp) || (session.hasRows && jobs.running.some((job) => job.kind === 'shell'))) {
732    const at = await $.clock.now()
733    await update($, now, () => at)
734  }
735
736  // What the docked pane takes from the screen: the label and the transcript's
737  // rows fit what is left.
738  const cells = isUp ? session.dockCells : 0
739
740  if (cells !== (await read($, docked))) {
741    await update($, docked, () => cells)
742  }
743
744  // Last, so a slow answer of GitHub holds nothing above: what is watched is
745  // polled, one poll at a time, and nothing while the deck is closed.
746  if (
747    session.hasChecks &&
748    !session.isPolling &&
749    (await read($, watches)).length > 0 &&
750    !(await isClosed($))
751  ) {
752    const at = await $.clock.now()
753
754    if (at - session.polledAt >= (session.isAnon ? POLL_ANON_MS : POLL_MS)) {
755      session.polledAt = at
756      session.isPolling = true
757      await polledAll($, session).finally(() => {
758        session.isPolling = false
759      })
760    }
761  }
762}
763
764export const register: Register = (on, options) => {
765  const place = labelOf(options)
766  const session: Session = {
767    ticks: 0,
768    minted: 0,
769    lengths: new Map(),
770    hints: new Map(),
771    dockCells: 0,
772    hasTool: options.modelTool === true,
773    hasToasts: options.toasts !== false,
774    hasRows: options.transcriptRows !== false,
775    hasChecks: options.github === true,
776    via: 'untried',
777    isAnon: false,
778    polledAt: 0,
779    isPolling: false,
780  }
781  // What tells an agent of the mod's tools, by the settings that are on.
782  const notes = [
783    ...(session.hasTool ? [{ id: NOTE_SECTION, text: NOTE }] : []),
784    ...(session.hasChecks ? [{ id: WATCH_SECTION, text: WATCH_NOTE }] : []),
785  ]
786
787  on('session.start', async ($, e, next) => {
788    await $.command.register({
789      name: 'deck',
790      description: 'A pane for the work behind the conversation: shells, agents, the model and its effort',
791      argumentHint: '[open|clear|row|watch|unwatch|close]',
792    })
793    await followed($)
794
795    if (session.hasTool) {
796      for (const tool of TOOLS) {
797        await $.tool.register(tool)
798      }
799    }
800
801    if (session.hasChecks) {
802      await $.tool.register(WATCH_TOOL)
803    }
804
805    const usage = await $.session.usage().catch(() => undefined)
806
807    if (usage !== undefined) {
808      await measured($, usage)
809    }
810
811    await named($)
812
813    $.clock.every(TICK_MS, () => {
814      void ticked($, session).catch(() => undefined)
815    })
816
817    return next(e)
818  })
819
820  // Claude Code's own measure of the session, as it moves: after each turn of
821  // the main thread, and when a rate limit moves a point. Read, and passed on.
822  on('session.measure', async ($, e, next) => {
823    if (!(await isClosed($))) {
824      await measured($, e)
825    }
826
827    return next(e)
828  })
829
830  // `/clear` ends the conversation the rows belong to: what is over and every
831  // run go with it, and what still runs stays.
832  on('session.end', async ($, e, next) => {
833    if (e.reason === 'clear') {
834      await update($, work, (kept) => cleared(kept))
835      await update($, runs, () => [])
836      await update($, folds, () => ({}))
837      await update($, watches, () => [])
838    }
839
840    return next(e)
841  })
842
843  on('command.run', { command: 'deck' }, async ($, e) => {
844    const [word = '', ...rest] = e.args.trim().split(/\s+/)
845    const first = word.toLowerCase()
846
847    // `/deck watch`, with what to follow or with nothing for the branch the
848    // session is on. Its answer names what is shown.
849    if (first === 'watch' && rest.length <= 1) {
850      if (!session.hasChecks) {
851        return { text: CHECKS_OFF }
852      }
853
854      if (await isClosed($)) {
855        return { text: 'Deck is closed. /deck brings it back.' }
856      }
857
858      const answer = await watching($, session, rest[0] ?? '', false)
859
860      if (answer.isRefused) {
861        return { text: answer.text }
862      }
863
864      const opened = await paneOpened($)
865
866      return { text: opened === PANE_OPENED ? answer.text : `${answer.text} ${opened}` }
867    }
868
869    if (rest.length > 0) {
870      return { text: USAGE }
871    }
872
873    if (first === 'unwatch') {
874      const count = session.hasChecks ? await unwatched($) : 0
875
876      return {
877        text: count === 0 ? 'Deck follows nothing on GitHub.' : 'Deck stopped following GitHub: the runs stay where they stood.',
878      }
879    }
880
881    if (CLOSE_WORDS.includes(first)) {
882      await closedAs($, true)
883
884      return {
885        text: 'Deck is closed in every session: its label and its pane are away, it reads nothing and it leaves the effort to Claude Code. /deck brings it back.',
886      }
887    }
888
889    if (first === 'row') {
890      const isOff = !(await read($, switches)).isRowClosed
891      await rowClosedAs($, isOff)
892
893      return {
894        text: isOff
895          ? 'Deck keeps its label as text on the hint line. /deck row brings its row back.'
896          : 'Deck draws its label on a row under the hint line, where there is a pointer.',
897      }
898    }
899
900    if (first === 'clear') {
901      await sweptAway($)
902
903      return { text: 'Deck is cleared: what is over is out of the pane. What still runs stays.' }
904    }
905
906    if (first !== '' && first !== 'open') {
907      return { text: USAGE }
908    }
909
910    if (await isClosed($)) {
911      await closedAs($, false)
912      const opened = await paneOpened($)
913
914      return { text: opened === PANE_OPENED ? 'Deck is back, and its pane is open.' : `Deck is back. ${opened}` }
915    }
916
917    return { text: first === 'open' ? await paneOpened($) : await toggled($) }
918  })
919
920  // A shell command, from its start to its end. Of the call it reads what it
921  // says it does (`description`), or the command where it says nothing, and
922  // keeps that line in memory for the row; of the result, whether it failed
923  // and whether it went to the background. The call goes on as it came.
924  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
925    if (e.tool !== 'Bash' || (await isClosed($))) {
926      return next(e)
927    }
928
929    session.minted += 1
930    const id = e.tool_use_id ?? `deck-${session.minted}`
931    const startedAt = await $.clock.now()
932    await update($, work, (kept) =>
933      started(kept, {
934        id,
935        kind: 'shell',
936        title: shellTitle(e.description, e.command),
937        startedAt,
938        endedAt: 0,
939        status: 'running',
940        taskId: '',
941        detail: shellDetail(e.command),
942        model: '',
943        effort: '',
944        owner: e.agentId ?? '',
945        isAsking: false,
946      }),
947    )
948
949    const answer = await next(e).catch(async (failure: unknown) => {
950      const at = await $.clock.now()
951      await update($, work, (kept) => ended(kept, id, 'failed', at))
952
953      throw failure
954    })
955    const at = await $.clock.now()
956    // The clock starts once the person let it run, where they were asked.
957    const job = (await read($, work)).running.find((one) => one.id === id)
958    const from = job === undefined ? startedAt : job.isAsking ? at : job.startedAt
959
960    if (answer.deny !== undefined) {
961      await update($, work, (kept) => dropped(kept, id))
962    } else if (answer.isError === true) {
963      await update($, work, (kept) => ended(kept, id, 'failed', at))
964    } else if (answer.result.backgroundTaskId !== undefined) {
965      const taskId = answer.result.backgroundTaskId
966      await update($, work, (kept) => backgrounded(kept, id, taskId, at))
967    } else {
968      const status = answer.result.interrupted ? 'killed' : 'done'
969      await update($, work, (kept) => ended(kept, id, status, at))
970    }
971
972    if (session.hasRows) {
973      await update($, spans, (kept) =>
974        Object.fromEntries([...Object.entries(kept), [id, at - from]].slice(-SPANS_KEPT)),
975      )
976    }
977
978    return answer
979  })
980
981  // Claude Code asks the person whether a shell may run. The dialog names no
982  // call, so the shell is found by its command's first line, the one its row
983  // keeps, among those its loop runs; nothing else of the dialog is read.
984  // The row waits with no clock until the shell runs.
985  on('classic.PermissionRequest', async ($, e, next) => {
986    if (e.tool_name === 'Bash' && !(await isClosed($))) {
987      const input = isRecord(e.tool_input) ? e.tool_input : {}
988      const line = shellDetail(toText(input.command))
989      const owner = e.agent_id ?? ''
990      const job = (await read($, work)).running.findLast(
991        (one) => one.kind === 'shell' && !one.isAsking && one.taskId === '' && one.owner === owner && one.detail === line,
992      )
993
994      if (job !== undefined) {
995        await update($, work, (kept) => askedAbout(kept, job.id))
996      }
997    }
998
999    return next(e)
1000  })
1001
1002  // Claude Code draws its ctrl+b hint under a shell once it has run two
1003  // seconds: a shell the person was asked about runs since then. Only the
1004  // call's id is read, while the row is drawn, and taken up by the next tick.
1005  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
1006    if (e.props.kind === 'background_hint' && !session.hints.has(e.props.tool_use_id)) {
1007      session.hints.set(e.props.tool_use_id, await $.clock.now())
1008    }
1009
1010    return next(e)
1011  })
1012
1013  // A background task stopped by Claude or the person: only the id of the
1014  // task that was stopped is read.
1015  on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
1016    const answer = await next(e)
1017
1018    if (
1019      e.tool === 'TaskStop' &&
1020      answer.deny === undefined &&
1021      answer.isError !== true &&
1022      !(await isClosed($))
1023    ) {
1024      const taskId = toText(answer.result.task_id)
1025      const at = await $.clock.now()
1026      await update($, work, (kept) => reported(kept, { taskId, status: 'killed' }, at))
1027    }
1028
1029    return answer
1030  })
1031
1032  // Claude Code's own task list, watched: a task's id and subject as it is
1033  // made, and its new state as it changes. Both calls go on as they came.
1034  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
1035    const answer = await next(e)
1036
1037    if (
1038      e.tool === 'TaskCreate' &&
1039      answer.deny === undefined &&
1040      answer.isError !== true &&
1041      !(await isClosed($))
1042    ) {
1043      const { id, subject } = answer.result.task
1044      const loop = e.agentId ?? ''
1045      const owner = await ownerOf($, e.agentId)
1046      const at = await $.clock.now()
1047      await moved($, session, (kept) =>
1048        taskCreated(kept, loop, owner, id, subject, at, () => `tasks-${at}`),
1049      )
1050    }
1051
1052    return answer
1053  })
1054
1055  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
1056    const answer = await next(e)
1057
1058    if (
1059      e.tool === 'TaskUpdate' &&
1060      answer.deny === undefined &&
1061      answer.isError !== true &&
1062      answer.result.success &&
1063      !(await isClosed($))
1064    ) {
1065      const loop = e.agentId ?? ''
1066      const change = { status: e.status, subject: e.subject }
1067      const at = await $.clock.now()
1068      await moved($, session, (kept) => taskUpdated(kept, loop, e.taskId, change, at))
1069    }
1070
1071    return answer
1072  })
1073
1074  // The mod's own two tools, listed only with Tool for Claude on: a plan as
1075  // indented text opens a run, and a step changes one leaf of it.
1076  if (session.hasTool) {
1077    on('tool.call', { tool: 'mcp__deck__plan' }, async ($, e) => {
1078      if (await isClosed($)) {
1079        return { result: 'The person closed the deck: no plan is shown. Go on without it.' }
1080      }
1081
1082      const steps = planned(toText(e.steps))
1083
1084      if (steps.length === 0) {
1085        return { deny: 'The plan has no steps. Give one step a line.' }
1086      }
1087
1088      const at = await $.clock.now()
1089      await foldsPruned($)
1090      const made = runOf(
1091        freeId(await read($, runs), at),
1092        toText(e.title),
1093        'plan',
1094        e.agentId ?? '',
1095        await ownerOf($, e.agentId),
1096        steps,
1097        at,
1098      )
1099      // The first step starts with the plan, which saves the agent a call.
1100      const first = leavesOf(made)[0]?.id ?? ''
1101      const run = stepTurned(made, first, 'start', at).run ?? made
1102      await update($, runs, (kept) => replanned(kept, run))
1103
1104      return {
1105        result: `Run ${run.id} is shown, and step ${first} is started. Call step as you move to the next one.\n${planText(run)}`,
1106      }
1107    })
1108
1109    on('tool.call', { tool: 'mcp__deck__step' }, async ($, e) => {
1110      if (await isClosed($)) {
1111        return { result: 'The person closed the deck: no plan is shown. Go on without it.' }
1112      }
1113
1114      const word = WORDS.find((one) => one === toText(e.state).trim().toLowerCase())
1115      const run = planOf(await read($, runs), e.agentId ?? '', toText(e.run).trim())
1116
1117      if (word === undefined) {
1118        return { deny: `The state is one of: ${WORDS.join(', ')}.` }
1119      }
1120
1121      if (run === undefined) {
1122        return { deny: 'No plan of yours is open. Call plan first, or name its run.' }
1123      }
1124
1125      const id = toText(e.id).trim()
1126      const at = await $.clock.now()
1127      const turned = stepTurned(run, id, word, at)
1128
1129      if (turned.error !== undefined) {
1130        return { deny: turned.error }
1131      }
1132
1133      await moved($, session, (kept) =>
1134        kept.map((one) => {
1135          const again = one.id === run.id ? stepTurned(one, id, word, at) : undefined
1136
1137          return again?.run ?? one
1138        }),
1139      )
1140
1141      const leaves = leavesOf(turned.run)
1142
1143      return {
1144        result: `${id} ${word} (${leaves.filter((leaf) => leaf.status === 'done').length}/${leaves.length} done)`,
1145      }
1146    })
1147  }
1148
1149  // With GitHub checks on, one more tool: what it is given is followed on
1150  // GitHub and shown as a run.
1151  if (session.hasChecks) {
1152    on('tool.call', { tool: 'mcp__deck__watch' }, async ($, e) => {
1153      if (await isClosed($)) {
1154        return { result: 'The person closed the deck: nothing is shown. Wait for the checks yourself.' }
1155      }
1156
1157      const answer = await watching($, session, toText(e.target), e.wake === true)
1158
1159      return answer.isRefused ? { deny: answer.text } : { result: answer.text }
1160    })
1161  }
1162
1163  // With Tool for Claude or GitHub checks on, the main thread's system prompt
1164  // gains a section for each saying its tools are there; every other section
1165  // stays as it is.
1166  if (notes.length > 0) {
1167    on('prompt.compose', async ($, e, next) => {
1168      const answer = await next(e)
1169
1170      return (await isClosed($))
1171        ? answer
1172        : {
1173            sections: [
1174              ...answer.sections.filter((section) => !notes.some((note) => note.id === section.id)),
1175              ...notes.map((note) => ({ ...note, scope: 'session' as const })),
1176            ],
1177          }
1178    })
1179  }
1180
1181  // A subagent as it starts: its type and the few words its call names the
1182  // task with. Its task goes on as it came, but for the line below.
1183  on('agent.spawn', async ($, e, next) => {
1184    // With Tool for Claude or GitHub checks on, a line each at the end of the
1185    // subagent's task says the tools are there. A fork has the main thread's
1186    // prompt, which says so.
1187    const isTold = notes.length > 0 && !e.fork && !(await isClosed($))
1188    const said = notes.map((note) => note.text).join('\n\n')
1189    const answer = await next(isTold ? { ...e, prompt: `${e.prompt}\n\n${said}` } : e)
1190
1191    if (answer.agentId !== undefined && !(await isClosed($))) {
1192      const id = answer.agentId
1193      const startedAt = await $.clock.now()
1194      await update($, work, (kept) =>
1195        started(kept, {
1196          id,
1197          kind: 'agent',
1198          title: agentTitle(e.subagentType, e.description),
1199          startedAt,
1200          endedAt: 0,
hooks/checks.ts 362 lines
1// GitHub checks as a run: what a watch follows, the API's answers as checks,
2// and the checks as a run's steps. Plain functions over plain values; the
3// hooks module asks GitHub and calls them with what it answered.
4
5import type { Run, Step, StepStatus, Watch } from '../types'
6
7import { titleOf } from './runs'
8import { isRecord, toText } from './values'
9
10/** How often GitHub is asked while something is watched. */
11export const POLL_MS = 30 * 1000
12/** The same with no token: GitHub answers 60 requests an hour to a nameless caller. */
13export const POLL_ANON_MS = 150 * 1000
14/** A commit's checks are over once this many polls in a row found every one over: a later workflow may still add its own. */
15export const SETTLED_POLLS = 2
16/** A watch whose checks never showed up is given up after this long. */
17export const EMPTY_MS = 10 * 60 * 1000
18/** A watch none of whose checks moved for this long is given up: a job that waits on a runner or an approval may wait for good. */
19export const STALL_MS = 30 * 60 * 1000
20/** Any watch is given up after this long. */
21export const GIVE_UP_MS = 2 * 60 * 60 * 1000
22/** A watch is given up after this many polls in a row that GitHub did not answer. */
23export const MAX_FAILURES = 5
24export const MAX_WATCHES = 4
25
26/** The one host asked without `gh`: a token of the environment goes nowhere else. */
27export const GITHUB = 'github.com'
28
29/** A repository on a GitHub host: `owner/name`. */
30export type Home = { host: string; repo: string }
31
32/**
33 * The repository a git remote names: `https://github.com/o/r.git`,
34 * `git@github.com:o/r.git`, `ssh://git@github.com/o/r`.
35 */
36export const homeOf = (remote: string | null | undefined): Home | undefined => {
37  const found = /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:]+)(?::\d+)?[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/i.exec(
38    (remote ?? '').trim(),
39  )
40
41  return found === null ? undefined : { host: found[1] ?? '', repo: `${found[2]}/${found[3]}` }
42}
43
44/** What to watch, before GitHub was asked: where it is and how its row is named. */
45export type Target = Pick<Watch, 'kind' | 'host' | 'repo' | 'target' | 'url'> & {
46  title: string
47  /** The pull request's number, where it is one. */
48  pull: number
49}
50
51const PAGE = /^https?:\/\/([^/]+)\/([\w.-]+)\/([\w.-]+)\/(pull|actions\/runs|commit|tree)\/([^?#]+)/i
52/** A number this long is a workflow run's id; a shorter one is a pull request's. */
53const RUN_DIGITS = 8
54
55const pullOf = (home: Home, pull: number): Target => ({
56  kind: 'ref',
57  ...home,
58  target: `pull/${pull}/head`,
59  url: `https://${home.host}/${home.repo}/pull/${pull}`,
60  title: `PR #${pull}`,
61  pull,
62})
63
64const jobsOf = (home: Home, id: string): Target => ({
65  kind: 'run',
66  ...home,
67  target: id,
68  url: `https://${home.host}/${home.repo}/actions/runs/${id}`,
69  title: `Run ${id}`,
70  pull: 0,
71})
72
73const refOf = (home: Home, ref: string): Target => ({
74  kind: 'ref',
75  ...home,
76  target: ref,
77  url: `https://${home.host}/${home.repo}/commits/${ref}`,
78  title: `Checks · ${ref}`,
79  pull: 0,
80})
81
82/**
83 * What the words name: a page's address on GitHub (a pull request, a
84 * workflow run, a commit, a branch), `#12` or `12` for a pull request, a
85 * long number for a workflow run, anything else for a branch, a tag or a
86 * commit; nothing for the branch the session is on. Resolves why not, where
87 * the words name nothing that can be asked for.
88 */
89export const targetOf = (
90  words: string,
91  home: Home | undefined,
92  branch: string,
93): Target | { error: string } => {
94  const text = words.trim()
95  const page = PAGE.exec(text)
96
97  if (page !== null) {
98    const there = { host: page[1] ?? '', repo: `${page[2]}/${page[3]}` }
99    const rest = (page[5] ?? '').replace(/\/+$/, '')
100    const first = rest.split('/')[0] ?? ''
101
102    if (page[4] === 'pull') {
103      return /^\d+$/.test(first) ? pullOf(there, Number(first)) : { error: 'That address names no pull request.' }
104    }
105
106    if (page[4] === 'actions/runs') {
107      return /^\d+$/.test(first) ? jobsOf(there, first) : { error: 'That address names no workflow run.' }
108    }
109
110    return refOf(there, page[4] === 'commit' ? first : rest)
111  }
112
113  if (/^https?:\/\//i.test(text)) {
114    return { error: 'That address is no pull request, workflow run, commit or branch on GitHub.' }
115  }
116
117  if (home === undefined) {
118    return { error: 'This folder has no GitHub remote. Give the address of a pull request or a workflow run.' }
119  }
120
121  const number = /^#?(\d+)$/.exec(text)?.[1]
122
123  if (number !== undefined) {
124    return number.length >= RUN_DIGITS && !text.startsWith('#')
125      ? jobsOf(home, number)
126      : pullOf(home, Number(number))
127  }
128
129  const ref = text === '' ? branch.trim() : text
130
131  if (ref === '' || ref === 'HEAD' || /\s/.test(ref)) {
132    return { error: 'Name what to watch: a pull request, a workflow run, a branch or a commit.' }
133  }
134
135  return refOf(home, ref)
136}
137
138/** True where both follow the same thing. */
139export const isSame = (one: Pick<Watch, 'kind' | 'host' | 'repo' | 'target'>, other: typeof one): boolean =>
140  one.kind === other.kind && one.host === other.host && one.repo === other.repo && one.target === other.target
141
142/**
143 * The API's paths a poll asks, in the order `checksOf` takes their answers:
144 * a commit's check runs and its statuses, or a workflow run and its jobs.
145 */
146export const pathsOf = (watch: Pick<Watch, 'kind' | 'repo' | 'target'>): [string, string] =>
147  watch.kind === 'run'
148    ? [
149        `repos/${watch.repo}/actions/runs/${watch.target}`,
150        `repos/${watch.repo}/actions/runs/${watch.target}/jobs?per_page=100`,
151      ]
152    : [
153        `repos/${watch.repo}/commits/${encodeURIComponent(watch.target)}/check-runs?per_page=100`,
154        `repos/${watch.repo}/commits/${encodeURIComponent(watch.target)}/status?per_page=100`,
155      ]
156
157/** The path that names a pull request: its title is the row's. */
158export const pullPath = (target: Pick<Target, 'repo' | 'pull'>): string =>
159  `repos/${target.repo}/pulls/${target.pull}`
160
161/** `PR #12 · Fix the wallet`, from the pull request as the API answers it. */
162export const pullTitle = (target: Target, answer: unknown): string => {
163  const title = isRecord(answer) ? toText(answer.title) : ''
164
165  return title === '' ? target.title : `${target.title} · ${title}`
166}
167
168/** One check, or one job of a workflow run with its steps. */
169export type Check = {
170  title: string
171  status: StepStatus
172  startedAt: number
173  endedAt: number
174  steps: Check[]
175}
176
177const timeOf = (value: unknown): number => Date.parse(toText(value)) || 0
178
179const listOf = (value: unknown): Readonly<Record<string, unknown>>[] =>
180  Array.isArray(value) ? value.filter(isRecord) : []
181
182/** A check run's, a job's or a job's step's state, from its `status` and `conclusion`. */
183const stateOf = (status: string, conclusion: string): StepStatus => {
184  if (status !== 'completed') {
185    return status === 'in_progress' ? 'running' : 'pending'
186  }
187
188  return ['success', 'neutral', 'skipped'].includes(conclusion) ? 'done' : 'failed'
189}
190
191/** An end that is neither a pass nor a plain failure is said beside the name. */
192const namedOf = (name: string, status: string, conclusion: string): string =>
193  status === 'completed' && !['success', 'failure', ''].includes(conclusion)
194    ? `${name} (${conclusion.replace(/_/g, ' ')})`
195    : name
196
197const checkOf = (one: Readonly<Record<string, unknown>>): Check => {
198  const status = toText(one.status)
199  const conclusion = toText(one.conclusion)
200  const state = stateOf(status, conclusion)
201
202  return {
203    title: namedOf(toText(one.name) || 'check', status, conclusion),
204    status: state,
205    startedAt: state === 'pending' ? 0 : timeOf(one.started_at),
206    endedAt: status === 'completed' ? timeOf(one.completed_at) : 0,
207    steps: listOf(one.steps).map(checkOf),
208  }
209}
210
211const STATES: Readonly<Record<string, StepStatus>> = {
212  success: 'done',
213  failure: 'failed',
214  error: 'failed',
215}
216
217/** What a poll found: the checks, whether GitHub says no more will move, and the row's name where the answer has one. */
218export type Found = { checks: Check[]; isOver: boolean; title: string }
219
220/**
221 * A poll's two answers as checks. For a commit: its check runs, then the
222 * statuses other services set on it; over when there is one and each is.
223 * For a workflow run: its jobs, each with its steps; over when the run is.
224 */
225export const checksOf = (kind: Watch['kind'], first: unknown, second: unknown): Found => {
226  if (kind === 'run') {
227    const run = isRecord(first) ? first : {}
228    const name = toText(run.name)
229    const what = toText(run.display_title)
230
231    return {
232      checks: listOf(isRecord(second) ? second.jobs : undefined).map(checkOf),
233      isOver: toText(run.status) === 'completed',
234      title: name === '' || name === what ? what : `${name} · ${what}`,
235    }
236  }
237
238  const runs = listOf(isRecord(first) ? first.check_runs : undefined).map(checkOf)
239  const statuses = listOf(isRecord(second) ? second.statuses : undefined).map((one): Check => {
240    const status = STATES[toText(one.state)] ?? 'running'
241
242    return {
243      title: toText(one.context) || 'status',
244      status,
245      startedAt: timeOf(one.created_at),
246      endedAt: status === 'running' ? 0 : timeOf(one.updated_at),
247      steps: [],
248    }
249  })
250  // A job's steps are not a commit's checks.
251  const checks = [...runs, ...statuses].map((check) => ({ ...check, steps: [] }))
252
253  return { checks, isOver: checks.length > 0 && checks.every(isEnded), title: '' }
254}
255
256const isEnded = (check: Check): boolean => check.status === 'done' || check.status === 'failed'
257
258/** The row a watch shows before GitHub lists a check. */
259const WAITING = 'Waiting for checks'
260
261const stepsOf = (checks: readonly Check[], parent = ''): Step[] =>
262  checks.flatMap((check, index) => {
263    const id = parent === '' ? String(index + 1) : `${parent}.${index + 1}`
264    const { steps, ...own } = check
265
266    return [{ ...own, id, title: titleOf(check.title) || 'check' }, ...stepsOf(steps, id)]
267  })
268
269/**
270 * The run with what a poll found as its steps: a check a step, a job's steps
271 * under the job. It counts as touched only when something moved.
272 */
273export const watched = (run: Run, found: Found, at: number): Run => {
274  const steps =
275    found.checks.length === 0
276      ? [{ id: '1', title: WAITING, status: 'pending' as const, startedAt: 0, endedAt: 0 }]
277      : stepsOf(found.checks)
278  const title = found.title === '' ? run.title : titleOf(found.title)
279
280  return JSON.stringify([steps, title]) === JSON.stringify([run.steps, run.title])
281    ? run
282    : { ...run, steps, title, touchedAt: at }
283}
284
285/** True while the run shows no check yet. */
286export const isWaiting = (run: Run): boolean => run.steps.length === 1 && run.steps[0]?.title === WAITING
287
288/**
289 * The watch after a poll, or nothing once it is over: a workflow run when
290 * GitHub says so, a commit's checks when enough polls in a row found each
291 * one over.
292 */
293export const polled = (watch: Watch, found: Found): Watch | undefined => {
294  const settled = found.isOver ? watch.settled + 1 : 0
295  const isDone = found.isOver && (watch.kind === 'run' || settled >= SETTLED_POLLS)
296
297  return isDone ? undefined : { ...watch, settled, failures: 0 }
298}
299
300/** Why a watch is given up at this time, or nothing while it goes on. */
301export const givenUp = (watch: Watch, run: Run, at: number): string | undefined => {
302  if (watch.failures >= MAX_FAILURES) {
303    return 'GitHub did not answer'
304  }
305
306  if (at - watch.startedAt >= GIVE_UP_MS) {
307    return 'still not over after two hours'
308  }
309
310  if (isWaiting(run)) {
311    return at - watch.startedAt >= EMPTY_MS ? 'no check showed up' : undefined
312  }
313
314  // A check that runs holds the watch however long it takes, up to the two
315  // hours; a stall is checks that wait and none that runs.
316  const isRunning = run.steps.some((step) => step.status === 'running')
317
318  return !isRunning && at - Math.max(run.touchedAt, watch.startedAt) >= STALL_MS
319    ? 'no check moved for half an hour'
320    : undefined
321}
322
323const MAX_NAMED = 8
324
325/**
326 * What Claude is told when the checks it asked to be woken for are over:
327 * how many passed and failed, the failed ones by name, and the page.
328 */
329export const summaryOf = (run: Run, url: string, reason = ''): string => {
330  const leaves = run.steps.filter((step) => !run.steps.some((one) => one.id.startsWith(`${step.id}.`)))
331  const failed = leaves.filter((leaf) => leaf.status === 'failed')
332  const passed = leaves.filter((leaf) => leaf.status === 'done').length
333  const head =
334    reason === ''
335      ? `The GitHub checks of "${run.title}" are over: ${passed} passed, ${failed.length} failed.`
336      : `The Deck stopped watching the GitHub checks of "${run.title}": ${reason}. ${passed} passed and ${failed.length} failed so far.`
337
338  return [
339    head,
340    ...failed.slice(0, MAX_NAMED).map((leaf) => `✗ ${leaf.title}`),
341    ...(failed.length > MAX_NAMED ? [`and ${failed.length - MAX_NAMED} more`] : []),
342    url,
343  ].join('\n')
344}
345
346/** What starting a watch answers: where it stands, and whether word will come. */
347export const startText = (run: Run, url: string, wake: boolean): string => {
348  const leaves = isWaiting(run)
349    ? []
350    : run.steps.filter((step) => !run.steps.some((one) => one.id.startsWith(`${step.id}.`)))
351  const over = leaves.filter((leaf) => leaf.status === 'done' || leaf.status === 'failed').length
352  const stand = leaves.length === 0 ? 'no check is listed yet' : `${over} of ${leaves.length} checks are over`
353
354  return [
355    `"${run.title}" is shown in the Deck: ${stand}.`,
356    wake
357      ? 'A message will tell you when they are over. Do not poll for them: end your turn, or go on with other work.'
358      : 'The person sees them move. You are not told when they are over.',
359    url,
360  ].join(' ')
361}
362
hooks/meter.ts 160 lines
1// The model's name and the effort meter, as the label and the pane show them.
2
3import type { Meter } from '../types'
4
5export const LEVELS = ['low', 'medium', 'high', 'xhigh', 'max']
6
7/**
8 * The longest level's name, in cells. A button that names the level is drawn
9 * this wide at every level: one that shrank from `xhigh` to `max` would leave
10 * the pointer that pressed it on the cells after it, and the next press on
11 * nothing.
12 */
13export const LEVEL_CELLS = Math.max(...LEVELS.map((level) => level.length))
14
15export const NO_METER: Meter = {
16  model: '',
17  effort: '',
18  isSeen: false,
19  wanted: '',
20  over: '',
21  context: -1,
22  cost: -1,
23  limits: [],
24}
25
26const FULL = '▰'
27const EMPTY = '▱'
28
29/** `filled` of `cells` cells, as `▰▰▰▱▱`. */
30export const barOf = (filled: number, cells: number): string => {
31  const count = Math.max(0, Math.min(cells, Math.round(filled)))
32
33  return FULL.repeat(count) + EMPTY.repeat(cells - count)
34}
35
36/**
37 * A model id as a name: `claude-fable-5-1` is `Fable 5.1`,
38 * `claude-haiku-4-5-20251001` is `Haiku 4.5`, `sonnet[1m]` is `Sonnet 1M`.
39 */
40export const modelName = (id: string): string => {
41  const [base = '', window = ''] = id.trim().replace(/\]$/, '').split('[')
42  const parts = base
43    .replace(/^.*\//, '')
44    .replace(/^(us|eu|apac|global)\./, '')
45    .replace(/^anthropic\./, '')
46    .replace(/^claude-/, '')
47    .replace(/-v\d+(:\d+)?$/, '')
48    .split(/[-_@:]/)
49    .filter((part) => part !== '' && !/^\d{6,}$/.test(part))
50  const words = parts.filter((part) => !/^\d+$/.test(part))
51  const numbers = parts.filter((part) => /^\d+$/.test(part))
52  const name = [
53    words.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join(' '),
54    numbers.join('.'),
55    window.toUpperCase(),
56  ]
57
58  return name.filter((part) => part !== '').join(' ')
59}
60
61/** A level's color, cooler to warmer as it rises. */
62export const levelColor = (level: string): string | undefined =>
63  [undefined, 'success', 'warning', 'claude', 'error'][LEVELS.indexOf(level)]
64
65/** A level's bar, `▰▰▰▱▱`; empty for a budget in tokens or no effort. */
66export const levelBar = (level: string): string =>
67  LEVELS.includes(level) ? barOf(LEVELS.indexOf(level) + 1, LEVELS.length) : ''
68
69/** The effort in force: the click's while one stands, else Claude Code's own. */
70export const effortOf = (meter: Meter): string => meter.wanted || meter.effort
71
72/** True while the deck sets the effort, not Claude Code. */
73export const isHeld = (meter: Meter): boolean => meter.wanted !== ''
74
75/** True where a click can change the effort: a model that takes one, seen at work. */
76export const canStep = (meter: Meter): boolean => meter.isSeen && meter.effort !== ''
77
78/** `▰▰▰▱▱ high`; a budget in tokens reads as its number; empty with no effort. */
79export const effortText = (meter: Meter): string => {
80  const effort = effortOf(meter)
81  const level = LEVELS.indexOf(effort)
82
83  if (effort === '') {
84    return ''
85  }
86
87  return level < 0 ? `effort ${effort}` : `${levelBar(effort)} ${effort}`
88}
89
90/**
91 * The effort's color, cooler to warmer as the level rises: low is dim, then
92 * green, yellow, orange and red for max. A budget in tokens has none.
93 */
94export const effortColor = (meter: Meter): string | undefined => levelColor(effortOf(meter))
95
96/** `ctx ▰▰▱▱▱▱▱▱ 23%`; empty before the window's fill is known. */
97export const contextText = (meter: Meter): string =>
98  meter.context < 0 ? '' : `ctx ${barOf((meter.context / 100) * 8, 8)} ${Math.round(meter.context)}%`
99
100const WINDOWS: Readonly<Record<string, string>> = {
101  five_hour: '5h',
102  seven_day: '7d',
103  spend_limit: 'spend',
104}
105
106/** `$1.24 · 5h 34% · 7d 12%`: the session's cost and each limit's fill; empty with neither. */
107export const usageText = (meter: Meter): string =>
108  [
109    meter.cost < 0 ? '' : `$${meter.cost.toFixed(2)}`,
110    ...meter.limits.map(
111      (limit) => `${WINDOWS[limit.kind] ?? limit.kind} ${Math.round(limit.percent)}%`,
112    ),
113  ]
114    .filter((part) => part !== '')
115    .join(' · ')
116
117/** True once a limit is nearly used up. */
118export const isTight = (meter: Meter): boolean => meter.limits.some((limit) => limit.percent >= 80)
119
120/**
121 * The meter set to `level` by the person. Claude Code's own level hands the
122 * effort back to it, as does no level at all.
123 */
124export const held = (meter: Meter, level: string): Meter =>
125  level === '' || level === meter.effort
126    ? { ...meter, wanted: '', over: '' }
127    : { ...meter, wanted: level, over: meter.effort }
128
129/** One step up from the effort in force, and from `max` back to `low`. */
130export const stepped = (meter: Meter): Meter =>
131  canStep(meter)
132    ? held(meter, LEVELS[(LEVELS.indexOf(effortOf(meter)) + 1) % LEVELS.length] ?? '')
133    : meter
134
135/**
136 * The meter after a request of the main thread that named this model and
137 * effort. An effort other than the one the click was made over is `/effort`,
138 * or another model: the click's level is dropped.
139 */
140export const seen = (meter: Meter, model: string, effort: string): Meter => {
141  const isChanged = meter.wanted !== '' && meter.over !== '' && effort !== meter.over
142  const wanted = isChanged || effort === '' || meter.wanted === effort ? '' : meter.wanted
143
144  return { ...meter, model, effort, isSeen: true, wanted, over: wanted === '' ? '' : effort }
145}
146
147export const isSameMeter = (one: Meter, other: Meter): boolean =>
148  one.model === other.model &&
149  one.effort === other.effort &&
150  one.isSeen === other.isSeen &&
151  one.wanted === other.wanted &&
152  one.over === other.over &&
153  one.context === other.context &&
154  one.cost === other.cost &&
155  one.limits.length === other.limits.length &&
156  one.limits.every(
157    (limit, index) =>
158      limit.kind === other.limits[index]?.kind && limit.percent === other.limits[index]?.percent,
159  )
160
hooks/pane.tsx 807 lines
1// The pane's drawing, and the label's row under the hint line. Nothing here
2// calls Claude Code: the hooks module hands in the surface's elements, what
3// to show and what a press does.
4
5import type {
6  BoxProps,
7  ButtonProps,
8  ElementConstructor,
9  RenderElement,
10  RenderNode,
11  TextProps,
12} from 'claude-code'
13
14import type { Job, Meter, Work } from '../types'
15
16import {
17  LEVEL_CELLS,
18  canStep,
19  contextText,
20  effortColor,
21  effortOf,
22  effortText,
23  isHeld,
24  isTight,
25  levelBar,
26  levelColor,
27  modelName,
28  usageText,
29} from './meter'
30import type { Row } from './runs'
31import { GLYPHS, jobSpan } from './view'
32
33export type Kit = {
34  Box: ElementConstructor<BoxProps>
35  Text: ElementConstructor<TextProps>
36  Button: ElementConstructor<ButtonProps>
37}
38
39export type PaneView = {
40  meter: Meter
41  work: Work
42  now: number
43  /** Cells across the pane's body. */
44  columns: number
45  /** True where the pane sits above the prompt, a few rows tall, and not beside the conversation. */
46  isInline: boolean
47  /** A press on the effort meter. */
48  onEffort: () => void
49  /** Each run as rows, the newest touched first, but for those drawn under their agent. */
50  runs: readonly (readonly Row[])[]
51  /** The runs a running agent opened, by the agent's id: drawn under its row. */
52  nested: Readonly<Record<string, readonly (readonly Row[])[]>>
53  /** A press on a run's, a branch's or a cut step's fold mark. */
54  onFold: (row: Row) => void
55  /** The rows the person opened, by `job:<id>` or `step:<key>`. */
56  open: Readonly<Record<string, boolean>>
57  /** A press on a shell's or an agent's mark or title: opens and closes its detail. */
58  onJob: (job: Job) => void
59  /** A press on an opened row's stop button. */
60  onStop: (job: Job) => void
61  /** A press on the clear button. */
62  onClear: () => void
63}
64
65const COLORS: Readonly<Record<Job['status'], string | undefined>> = {
66  running: 'claude',
67  done: 'success',
68  failed: 'error',
69  killed: 'warning',
70  ended: undefined,
71}
72
73/** The pane's body is drawn one cell in from each side. */
74const PAD = 1
75/** A shell Claude Code asks the person about: its mark, and what stands in for its clock. */
76const ASKING = '?'
77const ASKING_SPAN = 'waits'
78/** How many ended jobs the pane lists above the prompt, where it has a few rows. */
79const RECENT_INLINE = 3
80const KIND_CELLS = 7
81const SPAN_CELLS = 8
82/** A pane narrower than this leaves out a run's bar, and one narrower than the other a row's kind: the title needs the cells. */
83const BAR_FROM = 44
84const KIND_FROM = 36
85
86const rule = ({ Text }: Kit, columns: number): RenderElement => (
87  <Text dimColor wrap="truncate">
88    {'─'.repeat(Math.max(1, columns))}
89  </Text>
90)
91
92/** A shell's command, at most eight lines' worth of cells, or an agent's model and effort. */
93const detailOf = (job: Job, cells: number): string =>
94  job.kind === 'shell'
95    ? fitted(`$ ${job.detail}`, Math.max(1, cells) * 8)
96    : [modelName(job.model), job.effort === '' ? '' : `${job.effort} effort`]
97        .filter((part) => part !== '')
98        .join(' · ') || 'agent'
99
100/**
101 * An agent's model and effort at the end of its row, `Sonnet ▰▰▰▱▱`: the
102 * model's first word, then its effort's bar in the level's color. The bar is
103 * left out where the pane is too narrow for one, and both where it is too
104 * narrow for a kind. The opened row has the full name and the level.
105 */
106const tuneOf = (job: Job, columns: number): { name: string; bar: string; cells: number } => {
107  const name = job.kind === 'agent' && columns >= KIND_FROM ? (modelName(job.model).split(' ')[0] ?? '') : ''
108  const bar = name !== '' && columns >= BAR_FROM ? levelBar(job.effort) : ''
109
110  return { name, bar, cells: name === '' ? 0 : 1 + name.length + (bar === '' ? 0 : 1 + bar.length) }
111}
112
113/** A title cut to the cells it has, so a row stays one line: a button's label does not cut itself. */
114const fitted = (title: string, cells: number): string => {
115  const letters = [...title]
116
117  return letters.length > cells ? `${letters.slice(0, Math.max(0, cells - 1)).join('')}…` : title
118}
119
120/**
121 * `▸ ⏵ Typecheck, test and lint        shell    1m 12s`
122 *
123 * The mark and title are buttons: a press opens the detail under it, where a
124 * job that runs in the background has a stop button. Under a running agent's
125 * row come the shells and agents it started and the run it opened. Where the
126 * section's name says the kind, the kind's column is left out; an agent's
127 * model and effort take its place in every section.
128 */
129const jobRow = (
130  kit: Kit,
131  job: Job,
132  view: PaneView,
133  indent = 0,
134  wantsKind = true,
135): RenderElement => {
136  const { Box, Text, Button } = kit
137  const tune = tuneOf(job, view.columns)
138  // An agent's model stands in for its kind, which it says as well.
139  const hasKind = tune.cells === 0 && wantsKind && view.columns >= KIND_FROM
140  // The row's cells less its fold and state marks, its indent and its columns at the right.
141  const room =
142    view.columns - 2 * PAD - 4 - 2 * indent - SPAN_CELLS - (hasKind ? KIND_CELLS : tune.cells) - 1
143  const title = fitted(job.title, room)
144  // What the agent of this row started and still runs is drawn under it.
145  const own =
146    job.kind === 'agent' && job.status === 'running'
147      ? view.work.running.filter((one) => one.owner === job.id)
148      : []
149  const isOpen = view.open[`job:${job.id}`] === true
150  const canStop = job.status === 'running' && job.taskId !== ''
151
152  return (
153    <Box flexDirection="column">
154      <Box>
155        <Box width={2 * indent} flexShrink={0} />
156        <Box width={2} flexShrink={0}>
157          <Button
158            key={`job-mark:${job.id}`}
159            plain
160            label={isOpen ? '▾' : '▸'}
161            onPress={() => {
162              view.onJob(job)
163            }}
164          />
165        </Box>
166        <Box width={2} flexShrink={0}>
167          <Text color={job.isAsking ? 'warning' : COLORS[job.status]} dimColor={job.status === 'ended'}>
168            {job.isAsking ? ASKING : GLYPHS[job.status]}
169          </Text>
170        </Box>
171        <Box flexGrow={1} flexShrink={1}>
172          <Button
173            key={`job:${job.id}`}
174            plain
175            dimColor={!isOpen && job.status !== 'running' && job.status !== 'failed'}
176            label={title}
177            onPress={() => {
178              view.onJob(job)
179            }}
180          />
181        </Box>
182        {hasKind && (
183          <Box width={KIND_CELLS} flexShrink={0} justifyContent="flex-end">
184            <Text dimColor>{job.kind}</Text>
185          </Box>
186        )}
187        {tune.cells > 0 && (
188          <Box width={tune.cells} flexShrink={0} justifyContent="flex-end">
189            <Text dimColor>{tune.name}</Text>
190            {tune.bar !== '' && <Text> </Text>}
191            {tune.bar !== '' && (
192              <Text color={levelColor(job.effort)} dimColor={job.status !== 'running' || levelColor(job.effort) === undefined}>
193                {tune.bar}
194              </Text>
195            )}
196          </Box>
197        )}
198        <Box width={SPAN_CELLS} flexShrink={0} justifyContent="flex-end">
199          <Text dimColor={job.status !== 'running' || job.isAsking}>
200            {job.isAsking ? ASKING_SPAN : jobSpan(job, view.now)}
201          </Text>
202        </Box>
203      </Box>
204      {isOpen && (
205        <Box flexDirection="column" paddingLeft={4 + 2 * indent}>
206          {title !== job.title && <Text wrap="wrap">{job.title}</Text>}
207          <Text dimColor wrap="wrap">
208            {detailOf(job, view.columns - 2 * PAD - 4 - 2 * indent)}
209          </Text>
210          {canStop && (
211            <Box>
212              <Button
213                key={`stop:${job.id}`}
214                plain
215                label="■ stop"
216                onPress={() => {
217                  view.onStop(job)
218                }}
219              />
220            </Box>
221          )}
222        </Box>
223      )}
224      {own.map((one) => jobRow(kit, one, view, indent + 1, wantsKind))}
225      {job.status === 'running' &&
226        (view.nested[job.id] ?? []).flatMap((rows) =>
227          rows.map((row) => stepRow(kit, row, view, indent + 1)),
228        )}
229    </Box>
230  )
231}
232
233/**
234 * The model, its effort meter (a button where a press can change it) and the
235 * context's fill; under them the session's cost and its limits. Above the
236 * prompt, where the pane has a few rows, the cost and the limits share the
237 * first row while it has room.
238 */
239const head = (kit: Kit, view: PaneView): RenderElement => {
240  const { Box, Text, Button } = kit
241  const { meter } = view
242  const effort = effortText(meter)
243  const context = contextText(meter)
244  const usage = usageText(meter)
245  const spent = (
246    <Text color={isTight(meter) ? 'warning' : undefined} dimColor={!isTight(meter)} wrap="truncate-end">
247      {usage}
248    </Text>
249  )
250
251  return (
252    <Box flexDirection="column">
253      <Box columnGap={2} flexWrap="wrap" justifyContent="space-between">
254        <Box columnGap={2}>
255          <Text bold>{modelName(meter.model) || 'Claude'}</Text>
256          {canStep(meter) ? (
257            <Box columnGap={1}>
258              <Text color={effortColor(meter)} dimColor={effortOf(meter) === 'low'}>
259                {effort.split(' ')[0]}
260              </Text>
261              <Button
262                key="effort"
263                plain
264                label={`${effortOf(meter)} ${isHeld(meter) ? '⟳' : '↑'}`.padEnd(LEVEL_CELLS + 2)}
265                onPress={view.onEffort}
266              />
267            </Box>
268          ) : (
269            <Text dimColor>
270              {effort || (meter.isSeen ? 'no effort setting' : 'effort shows with the first request')}
271            </Text>
272          )}
273        </Box>
274        {view.isInline && usage !== '' && spent}
275        {context !== '' && <Text dimColor={meter.context < 80}>{context}</Text>}
276      </Box>
277      {!view.isInline && usage !== '' && spent}
278    </Box>
279  )
280}
281
282const section = (
283  kit: Kit,
284  title: string,
285  aside: RenderElement,
286  jobs: readonly Job[],
287  view: PaneView,
288  hasKind = true,
289): RenderElement => {
290  const { Box, Text } = kit
291
292  return (
293    <Box flexDirection="column">
294      <Box justifyContent="space-between">
295        <Text bold dimColor>
296          {title}
297        </Text>
298        {aside}
299      </Box>
300      {jobs.map((job) => jobRow(kit, job, view, 0, hasKind))}
301    </Box>
302  )
303}
304
305const STEP_GLYPHS: Readonly<Record<Row['status'], string>> = {
306  pending: '○',
307  running: GLYPHS.running,
308  done: GLYPHS.done,
309  failed: GLYPHS.failed,
310}
311
312const STEP_COLORS: Readonly<Record<Row['status'], string | undefined>> = {
313  pending: undefined,
314  running: COLORS.running,
315  done: COLORS.done,
316  failed: COLORS.failed,
317}
318
319const COUNT_CELLS = 6
320const METER_CELLS = 9
321
322/**
323 * One row of a run. A run's and a branch's mark is a button that folds and
324 * unfolds it: `▾` open, `▸` folded while under way, and its state's own mark
325 * when folded before its start or after its end. A leaf whose title is cut
326 * opens to the full title; a leaf that fits keeps its state mark alone.
327 *
328 * `▾ Auth renewal                 ▰▰▰▰▰▱▱▱ 5/8   12m 40s`
329 * `    ✓ Move the old table                        1m 30s`
330 */
331const stepRow = (kit: Kit, row: Row, view: PaneView, indent = 0): RenderElement => {
332  const { Box, Text, Button } = kit
333  const isQuiet = row.status === 'done' || row.status === 'pending'
334  const folded = row.status === 'running' || row.kind === 'run' ? '▸' : STEP_GLYPHS[row.status]
335  // A run folded away still says how it stands, beside its mark.
336  const state = row.kind === 'run' && (row.status === 'done' || row.status === 'failed')
337  const hasMeter = row.meter !== '' && view.columns >= BAR_FROM
338  const inset = 2 * (row.depth + indent)
339  const room =
340    view.columns - 2 * PAD - inset - 2 - (state ? 2 : 0) - COUNT_CELLS -
341    SPAN_CELLS - (hasMeter ? METER_CELLS : 0) - 1
342  const isCut = row.kind === 'leaf' && fitted(row.title, room) !== row.title
343  const key = `step:${row.key}`
344  const isOpen = isCut && view.open[key] === true
345  const onPress = () => {
346    view.onFold({ ...row, key, isOpen })
347  }
348  const mark =
349    isCut ? (
350      <Button key={`step-mark:${row.key}`} plain label={isOpen ? '▾' : '▸'} onPress={onPress} />
351    ) : row.kind === 'leaf' ? (
352      <Text color={STEP_COLORS[row.status]} dimColor={row.status === 'pending'}>
353        {STEP_GLYPHS[row.status]}
354      </Text>
355    ) : (
356      <Button
357        key={`fold:${row.key}`}
358        plain
359        label={row.isOpen ? '▾' : folded}
360        onPress={() => {
361          view.onFold(row)
362        }}
363      />
364    )
365
366  return (
367    <Box flexDirection="column">
368      <Box>
369        <Box width={inset} flexShrink={0} />
370        <Box width={2} flexShrink={0}>
371          {mark}
372        </Box>
373        {(state || isCut) && (
374          <Box width={2} flexShrink={0}>
375            <Text color={STEP_COLORS[row.status]} dimColor={row.status === 'pending'}>
376              {STEP_GLYPHS[row.status]}
377            </Text>
378          </Box>
379        )}
380        <Box flexGrow={1} flexShrink={1}>
381          {isCut ? (
382            <Button
383              key={key}
384              plain
385              dimColor={!isOpen && isQuiet}
386              label={fitted(row.title, room - 2)}
387              onPress={onPress}
388            />
389          ) : (
390            <Text
391              bold={row.kind === 'run'}
392              color={row.status === 'failed' ? STEP_COLORS.failed : undefined}
393              dimColor={row.kind !== 'run' && isQuiet}
394              wrap="truncate-end"
395            >
396              {row.title}
397            </Text>
398          )}
399        </Box>
400        {hasMeter && (
401          <Box width={METER_CELLS} flexShrink={0} justifyContent="flex-end">
402            <Text color={STEP_COLORS[row.status]} dimColor={row.status === 'pending'}>
403              {row.meter}
404            </Text>
405          </Box>
406        )}
407        <Box width={COUNT_CELLS} flexShrink={0} justifyContent="flex-end">
408          <Text dimColor={row.kind !== 'run'}>{row.count}</Text>
409        </Box>
410        <Box width={SPAN_CELLS} flexShrink={0} justifyContent="flex-end">
411          <Text dimColor={row.status !== 'running'}>{row.span}</Text>
412        </Box>
413      </Box>
414      {isOpen && (
415        <Box paddingLeft={inset + 4}>
416          <Text wrap="wrap">{row.title}</Text>
417        </Box>
418      )}
419    </Box>
420  )
421}
422
423/**
424 * The runs as sections: an open run is a section of its own, and the folded
425 * ones that follow each other share one.
426 */
427const runSections = (kit: Kit, view: PaneView): RenderElement[] => {
428  const { Box } = kit
429  const groups = view.runs.reduce<Row[][]>((left, rows) => {
430    const last = left.at(-1)
431    const isFolded = rows.length === 1
432
433    return isFolded && last !== undefined && last.every((row) => row.kind === 'run' && !row.isOpen)
434      ? [...left.slice(0, -1), [...last, ...rows]]
435      : [...left, [...rows]]
436  }, [])
437
438  return groups.map((rows) => (
439    <Box flexDirection="column">{rows.map((row) => stepRow(kit, row, view))}</Box>
440  ))
441}
442
443/**
444 * The pane: the model and its meters, then the shells that run, then the
445 * agents that run with what each started, then the runs, then what lately
446 * ended. Where something is over, a last
447 * line has the button that clears it away.
448 */
449export const paneTree = (kit: Kit, view: PaneView): RenderElement => {
450  const { Box, Text, Button } = kit
451  const { work } = view
452  const columns = Math.max(1, view.columns - 2 * PAD)
453  const line = rule(kit, columns)
454  const more = runSections(kit, view)
455  const isIdle = work.running.length === 0 && work.recent.length === 0 && more.length === 0
456  const isOver = (rows: readonly Row[]): boolean =>
457    rows[0]?.status === 'done' || rows[0]?.status === 'failed' || rows[0]?.isStopped === true
458  const canClear = work.recent.length > 0 || view.runs.some(isOver)
459  // What runs, in two sections: the shells nobody's agent started, then each
460  // agent with what it started under it.
461  const isTop = (job: Job): boolean => !work.running.some((one) => one.id === job.owner)
462  const shells = work.running.filter((job) => job.kind === 'shell' && isTop(job))
463  const agents = work.running.filter((job) => job.kind === 'agent' && isTop(job))
464  const blank = <Text> </Text>
465  // Above the prompt the newest few ended jobs are listed, and the rest counted.
466  const recent = view.isInline ? work.recent.slice(0, RECENT_INLINE) : work.recent
467  const hidden = work.recent.length - recent.length
468  const clear = (
469    <Button key="clear" plain dimColor label="× clear" onPress={view.onClear} />
470  )
471
472  return (
473    <Box flexDirection="column" paddingX={PAD}>
474      {head(kit, view)}
475      {line}
476      {work.running.length === 0 && (
477        <Text dimColor>{isIdle ? 'Nothing runs yet.' : 'Nothing runs right now.'}</Text>
478      )}
479      {shells.length > 0 && section(kit, `SHELLS · ${shells.length}`, blank, shells, view, false)}
480      {shells.length > 0 && agents.length > 0 && line}
481      {agents.length > 0 && section(kit, `AGENTS · ${agents.length}`, blank, agents, view, false)}
482      {isIdle && (
483        <Text dimColor>Shells, agents and task lists Claude starts show here while they run.</Text>
484      )}
485      {more.flatMap((part) => [line, part])}
486      {work.recent.length > 0 && line}
487      {work.recent.length > 0 &&
488        section(kit, 'RECENT', hidden > 0 ? <Text dimColor>{`+${hidden} more`}</Text> : blank, recent, view)}
489      {canClear && <Box justifyContent="flex-end">{clear}</Box>}
490    </Box>
491  )
492}
493
494/** The deck's mark on its label's row: a pane docked at the right. */
495const MARK = '◨'
496
497/** What the label's row says, part by part. */
498export type LabelView = {
499  /** The model's name; empty before it is known. */
500  head: string
501  /** Its effort's bar, `▰▰▰▱▱`, the level's name and its color; the bar is empty with no effort. */
502  bar: string
503  level: string
504  effortColor: string | undefined
505  /** True where a press can change the effort. */
506  canStep: boolean
507  /** A press on the effort's level: one step up, as on the pane's meter. */
508  onEffort: () => void
509  /** How many shells and agents run. */
510  running: number
511  /** The run under way, `Auth renewal`, and its count, `5/8`; both empty with none. */
512  runTitle: string
513  runCount: string
514  /** How many cells of the run's title show. */
515  runCells: number
516  /** True when a step of that run failed. */
517  isFailed: boolean
518  /**
519   * A press on the deck's mark, or on the run: opens or closes the pane. Its
520   * promise goes back to the press, so the pane it opens is the person's ask.
521   */
522  onPress: () => Promise<void>
523  /** A press on the row's `×`: the label goes back to the hint line as text. */
524  onClose: () => void
525}
526
527/**
528 * The label as a row of its own, where there is a pointer. It begins with
529 * the deck's mark, a button that opens or closes the pane, as the run at its
530 * end does. Between them the model's name is text, and its effort's level is
531 * a button that steps it up as the pane's meter does, as wide at every level
532 * as the longest level's name. The effort's bar has its level's color, what
533 * runs the color of a running row, a failure is red.
534 */
535export const labelRow = (kit: Kit, view: LabelView): RenderElement => {
536  const { Box, Text, Button } = kit
537  const dot = <Text dimColor>·</Text>
538  const run = runLabel(view)
539
540  return (
541    <Box columnGap={1}>
542      <Button key="deck" plain label={MARK} onPress={view.onPress} />
543      {view.head !== '' && <Text>{view.head}</Text>}
544      {view.bar !== '' && (
545        <Text color={view.effortColor} dimColor={view.effortColor === undefined}>
546          {view.bar}
547        </Text>
548      )}
549      {view.bar !== '' &&
550        (view.canStep ? (
551          <Button
552            key="deck-effort"
553            plain
554            label={view.level.padEnd(LEVEL_CELLS)}
555            onPress={view.onEffort}
556          />
557        ) : (
558          <Text dimColor>{view.level}</Text>
559        ))}
560      {view.running > 0 && dot}
561      {view.running > 0 && <Text color={COLORS.running}>{`${GLYPHS.running} ${view.running}`}</Text>}
562      {run !== '' && dot}
563      {run !== '' && view.isFailed && <Text color={COLORS.failed}>{GLYPHS.failed}</Text>}
564      {run !== '' && <Button key="deck-run" plain dimColor label={run} onPress={view.onPress} />}
565      <Button key="deck-row-close" plain dimColor label="×" onPress={view.onClose} />
566    </Box>
567  )
568}
569
570/** The run as the label shows it: `Auth renewal 5/8`, its title cut to its cells. */
571const runLabel = (view: LabelView): string =>
572  view.runTitle === '' ? '' : `${fitted(view.runTitle, view.runCells)} ${view.runCount}`
573
574const widthOf = (text: string): number => [...text].length
575
576/** The cells the label's row takes, as `labelRow` draws it: its parts, a cell between each. */
577export const labelCells = (view: LabelView): number => {
578  const run = runLabel(view)
579  const parts = [
580    1,
581    widthOf(view.head),
582    widthOf(view.bar),
583    view.bar === '' ? 0 : view.canStep ? LEVEL_CELLS : widthOf(view.level),
584    view.running > 0 ? 1 : 0,
585    view.running > 0 ? widthOf(`${GLYPHS.running} ${view.running}`) : 0,
586    run === '' ? 0 : 1,
587    run !== '' && view.isFailed ? 1 : 0,
588    widthOf(run),
589    1,
590  ].filter((cells) => cells > 0)
591
592  return parts.reduce((sum, cells) => sum + cells, 0) + parts.length - 1
593}
594
595/** The fewest cells of a run's title the label keeps before it leaves the run out. */
596const RUN_MIN_CELLS = 6
597
598/**
599 * The label fitted to `room` cells, so its run and its `×` are not pushed
600 * past the screen's edge: the run's title is cut first, then the model's
601 * name goes, then the run. What still does not fit is drawn as it is.
602 */
603export const fittedLabel = (view: LabelView, room: number): LabelView => {
604  const over = labelCells(view) - room
605
606  if (over <= 0) {
607    return view
608  }
609
610  const shown = Math.min(view.runCells, widthOf(view.runTitle))
611
612  if (view.runTitle !== '' && shown - over >= RUN_MIN_CELLS) {
613    return { ...view, runCells: shown - over }
614  }
615
616  if (view.head !== '') {
617    return fittedLabel({ ...view, head: '' }, room)
618  }
619
620  return view.runTitle === '' ? view : fittedLabel({ ...view, runTitle: '', runCount: '' }, room)
621}
622
623/** The cells a drawn row takes, near enough: its text, and its gaps where it is a row. */
624const cellsOf = (node: RenderNode): number => {
625  if (typeof node === 'string') {
626    return widthOf(node)
627  }
628
629  if (node.type === 'Button') {
630    return widthOf(node.props.label)
631  }
632
633  if (node.type === 'Text') {
634    return (node.children ?? []).map(cellsOf).reduce((sum: number, cells: number) => sum + cells, 0)
635  }
636
637  if (node.type !== 'Box') {
638    return 0
639  }
640
641  const kids: number[] = (node.children ?? []).map(cellsOf)
642
643  if (node.props?.flexDirection === 'column') {
644    return Math.max(0, ...kids)
645  }
646
647  const gap = typeof node.props?.columnGap === 'number' ? node.props.columnGap : 0
648
649  return kids.reduce((sum, cells) => sum + cells, 0) + gap * Math.max(0, kids.length - 1)
650}
651
652/**
653 * The cells of the row another mod drew under the hint line, which the
654 * label joins as `beside` puts it, with its gap; 0 where the label gets a
655 * row of its own.
656 */
657export const besideCells = (tree: RenderNode): number => {
658  if (typeof tree === 'string' || tree.type !== 'Box' || tree.children === undefined) {
659    return 0
660  }
661
662  const [first, second] = tree.children
663  const isLine = typeof first === 'string' || first?.type !== 'Box'
664
665  if (
666    tree.props?.flexDirection !== 'column' ||
667    !isLine ||
668    second === undefined ||
669    typeof second === 'string' ||
670    second.type !== 'Box' ||
671    second.props?.flexDirection === 'column' ||
672    second.children === undefined
673  ) {
674    return 0
675  }
676
677  const gap = typeof second.props?.columnGap === 'number' ? second.props.columnGap : 0
678
679  return cellsOf(second) + gap
680}
681
682/**
683 * `tree` with `row` right under the hint line. The engine draws its line
684 * over a tree's first row, and another mod's drawing may hold the line's
685 * place first with rows of its own after it: `row` goes in right after that
686 * place, so what the others drew stays where it was.
687 */
688export const under = (tree: RenderNode, row: RenderElement): RenderElement => {
689  if (typeof tree === 'string' || tree.type !== 'Box' || tree.children === undefined) {
690    return { type: 'Box', props: { flexDirection: 'column' }, children: [tree, row] }
691  }
692
693  const [first, ...rest] = tree.children
694  const isLine = typeof first === 'string' || first?.type !== 'Box'
695
696  if (first === undefined) {
697    return { ...tree, children: [row] }
698  }
699
700  if (tree.props?.flexDirection === 'column' && isLine) {
701    return { ...tree, children: [first, row, ...rest] }
702  }
703
704  return { ...tree, children: [under(first, row), ...rest] }
705}
706
707/**
708 * `tree` with the label's `row`. Where another mod has already drawn a row
709 * of its own right under the hint line, the label joins that row at its end,
710 * so two mods take one row between them; else it gets a row of its own.
711 */
712export const beside = (tree: RenderNode, row: RenderElement): RenderElement => {
713  if (typeof tree === 'string' || tree.type !== 'Box' || tree.children === undefined) {
714    return under(tree, row)
715  }
716
717  const [first, second, ...rest] = tree.children
718  const isLine = typeof first === 'string' || first?.type !== 'Box'
719
720  if (
721    tree.props?.flexDirection !== 'column' ||
722    !isLine ||
723    first === undefined ||
724    second === undefined ||
725    typeof second === 'string' ||
726    second.type !== 'Box' ||
727    second.props?.flexDirection === 'column' ||
728    second.children === undefined
729  ) {
730    return under(tree, row)
731  }
732
733  return { ...tree, children: [first, { ...second, children: [...second.children, row] }, ...rest] }
734}
735
736/** One shell command of a transcript's group of tool calls, as its row says it. */
737export type CallRow = {
738  /** The call's id. */
739  id: string
740  status: Job['status']
741  /** True while Claude Code asks the person whether it may run. */
742  isAsking: boolean
743  title: string
744  /** `8s`; empty where it is not known, or under a second. */
745  span: string
746}
747
748/** How many calls of a group get a row; the rest are counted. */
749const GROUP_ROWS = 4
750const GROUP_TITLE_CELLS = 56
751/** A group row's cells but its title: its indent, mark, gaps and the longest time, `14m 03s`. */
752const GROUP_FIXED_CELLS = 13
753const GROUP_MIN_CELLS = 12
754
755/**
756 * `line`, the transcript's own row for a group of tool calls, with a row
757 * under it for each shell command of the group: its mark, what it does and
758 * how long it ran. A press on a title opens the pane at that command; its
759 * promise goes back to the press, so the pane it opens is the person's ask.
760 *
761 *     Ran 3 shell commands
762 *       ✓ Check the version           2s
763 *       ✗ Verify the profile          8s
764 *
765 * A title is cut to what the conversation's `columns` leave it, and to 56
766 * cells at most.
767 */
768export const groupTree = (
769  kit: Kit,
770  line: RenderElement,
771  rows: readonly CallRow[],
772  onPress: (row: CallRow) => Promise<void>,
773  columns = Number.POSITIVE_INFINITY,
774): RenderElement => {
775  const { Box, Text, Button } = kit
776  const more = rows.length - GROUP_ROWS
777  const cells = Math.max(GROUP_MIN_CELLS, Math.min(GROUP_TITLE_CELLS, columns - GROUP_FIXED_CELLS))
778
779  return (
780    <Box flexDirection="column">
781      {line}
782      {rows.slice(0, GROUP_ROWS).map((row) => (
783        <Box columnGap={2} paddingLeft={2}>
784          <Box columnGap={1}>
785            <Text color={row.isAsking ? 'warning' : COLORS[row.status]} dimColor={row.status === 'ended'}>
786              {row.isAsking ? ASKING : GLYPHS[row.status]}
787            </Text>
788            <Button
789              key={`call:${row.id}`}
790              plain
791              dimColor={row.status === 'done'}
792              label={fitted(row.title, cells)}
793              onPress={() => onPress(row)}
794            />
795          </Box>
796          {row.span !== '' && <Text dimColor>{row.span}</Text>}
797        </Box>
798      ))}
799      {more > 0 && (
800        <Box paddingLeft={2}>
801          <Text dimColor>{`+${more} more`}</Text>
802        </Box>
803      )}
804    </Box>
805  )
806}
807
hooks/runs.ts 525 lines
1// Runs: step-by-step progress of a job, as a tree of any depth. A run's
2// steps are one flat list in planned order; a step's id is its place in the
3// tree (`1`, `1.2`, `1.2.1`), so its parent and its children are told from
4// the id alone. Only leaves hold a state: a parent's comes from its leaves.
5
6import type { Run, RunFeed, Step, StepStatus } from '../types'
7
8import { barOf } from './meter'
9import { spanText } from './view'
10
11/** How many runs are kept; the oldest finished one goes first. */
12const KEPT = 6
13const MAX_STEPS = 200
14const MAX_DEPTH = 6
15const TITLE_CHARS = 80
16const METER_CELLS = 8
17
18const cut = (text: string, chars: number): string => {
19  const line = [...text.trim().replace(/\s+/g, ' ')]
20
21  return line.length > chars ? `${line.slice(0, chars - 1).join('')}…` : line.join('')
22}
23
24export const titleOf = (text: string): string => cut(text, TITLE_CHARS)
25
26const isUnder = (id: string, parent: string): boolean => id.startsWith(`${parent}.`)
27
28export const isLeaf = (run: Run, id: string): boolean =>
29  !run.steps.some((step) => isUnder(step.id, id))
30
31/** The leaves a step stands for: itself, or every leaf beneath it. */
32export const leavesOf = (run: Run, id?: string): Step[] =>
33  run.steps.filter(
34    (step) => isLeaf(run, step.id) && (id === undefined || step.id === id || isUnder(step.id, id)),
35  )
36
37const isOver = (step: Step): boolean => step.status === 'done' || step.status === 'failed'
38
39/**
40 * A branch's state from its leaves: failed once one failed, done when all
41 * are, running while one runs or some are over and some are not.
42 */
43export const statusOf = (leaves: readonly Step[]): StepStatus => {
44  if (leaves.some((leaf) => leaf.status === 'failed')) {
45    return 'failed'
46  }
47
48  if (leaves.length > 0 && leaves.every((leaf) => leaf.status === 'done')) {
49    return 'done'
50  }
51
52  return leaves.some((leaf) => leaf.status !== 'pending') ? 'running' : 'pending'
53}
54
55/** True once no leaf is left to run. */
56export const isFinished = (leaves: readonly Step[]): boolean =>
57  leaves.length > 0 && leaves.every(isOver)
58
59const doneOf = (leaves: readonly Step[]): number =>
60  leaves.filter((leaf) => leaf.status === 'done').length
61
62/** From the first start among the leaves to the last end, or to now while one is not over. */
63const spanOf = (leaves: readonly Step[], now: number): string => {
64  const starts = leaves.filter((leaf) => leaf.startedAt > 0).map((leaf) => leaf.startedAt)
65
66  if (starts.length === 0) {
67    return ''
68  }
69
70  const end = isFinished(leaves) ? Math.max(...leaves.map((leaf) => leaf.endedAt)) : now
71  const ms = Math.max(0, end - Math.min(...starts))
72
73  // A step over within the second, or one whose times nobody saw, shows none.
74  return isFinished(leaves) && ms < 1000 ? '' : spanText(ms)
75}
76
77/**
78 * A plan as indented text, one step a line, as steps. A line indented
79 * deeper than the one before it is its child; a bullet or a number before
80 * the title is dropped. Empty when the text holds no step.
81 */
82export const planned = (text: string): Step[] => {
83  const lines = text
84    .split('\n')
85    .map((line) => line.replace(/\t/g, '  '))
86    .filter((line) => line.trim() !== '')
87    .slice(0, MAX_STEPS)
88  // The indent of each open level, and how many steps each has so far.
89  const indents: number[] = []
90  const counts: number[] = []
91
92  return lines.map((line) => {
93    const indent = line.length - line.trimStart().length
94
95    while (indents.length > 1 && indent < (indents.at(-1) ?? 0)) {
96      indents.pop()
97      counts.pop()
98    }
99
100    if (indents.length === 0 || (indent > (indents.at(-1) ?? 0) && indents.length < MAX_DEPTH)) {
101      indents.push(indent)
102      counts.push(0)
103    }
104
105    counts[counts.length - 1] = (counts.at(-1) ?? 0) + 1
106
107    return {
108      id: counts.join('.'),
109      title: titleOf(line.trim().replace(/^([-*•]|\d+[.)])\s+/, '')) || 'step',
110      status: 'pending',
111      startedAt: 0,
112      endedAt: 0,
113    }
114  })
115}
116
117/** The runs with one more, the oldest finished ones dropped past what is kept. */
118export const opened = (runs: readonly Run[], run: Run): Run[] => {
119  const all = [...runs.filter((one) => one.id !== run.id), run]
120  const spare = all.length - KEPT
121  const old = all
122    .filter((one) => one.id !== run.id && isFinished(leavesOf(one)))
123    .slice(0, Math.max(0, spare))
124    .map((one) => one.id)
125
126  return all.filter((one) => !old.includes(one.id)).slice(-KEPT)
127}
128
129export const runOf = (
130  id: string,
131  title: string,
132  feed: RunFeed,
133  loop: string,
134  owner: string,
135  steps: Step[],
136  at: number,
137): Run => ({
138  id,
139  title: titleOf(title) || 'Run',
140  feed,
141  loop,
142  owner,
143  steps,
144  touchedAt: at,
145  stoppedAt: 0,
146})
147
148const replaced = (runs: readonly Run[], run: Run): Run[] =>
149  runs.map((one) => (one.id === run.id ? run : one))
150
151export type StepWord = 'start' | 'done' | 'fail'
152
153const turned = (step: Step, word: StepWord, at: number): Step => {
154  if (word === 'start') {
155    return { ...step, status: 'running', startedAt: step.startedAt || at, endedAt: 0 }
156  }
157
158  return {
159    ...step,
160    status: word === 'done' ? 'done' : 'failed',
161    startedAt: step.startedAt || at,
162    endedAt: at,
163  }
164}
165
166const parentOf = (id: string): string => id.split('.').slice(0, -1).join('.')
167
168/**
169 * One leaf of a run starts, is done or fails. A start ends every leaf still
170 * running before it in the plan, in its own branch or an earlier one, so
171 * moving on is one call. Resolves the run, or why nothing changed.
172 */
173export const stepped = (
174  run: Run,
175  id: string,
176  word: StepWord,
177  at: number,
178): { run: Run; error?: undefined } | { error: string; run?: undefined } => {
179  const step = run.steps.find((one) => one.id === id)
180
181  if (step === undefined) {
182    return { error: `No step ${id}. The steps: ${leavesOf(run).map((leaf) => leaf.id).join(', ')}.` }
183  }
184
185  if (!isLeaf(run, id)) {
186    return {
187      error: `${id} has steps of its own and follows them. Update one of: ${leavesOf(run, id).map((leaf) => leaf.id).join(', ')}.`,
188    }
189  }
190
191  const place = run.steps.indexOf(step)
192  const steps = run.steps.map((one, index) => {
193    if (one.id === id) {
194      return turned(one, word, at)
195    }
196
197    const isBefore =
198      word === 'start' && index < place && one.status === 'running' && isLeaf(run, one.id)
199
200    return isBefore ? turned(one, 'done', at) : one
201  })
202
203  return { run: { ...run, steps, touchedAt: at, stoppedAt: 0 } }
204}
205
206/**
207 * The runs with a loop's new plan. The task list the loop was following goes:
208 * the plan tells the same job with its levels. A plan of the loop's that is
209 * still under way stops where it stands: the loop has moved on to another
210 * job, and nothing would end the old one.
211 */
212export const replanned = (runs: readonly Run[], run: Run): Run[] =>
213  opened(
214    runs
215      .filter((one) => !(one.feed === 'tasks' && one.loop === run.loop && isLive(one)))
216      .map((one) => (one.feed === 'plan' && one.loop === run.loop && isLive(one) ? halted(one, run.touchedAt) : one)),
217    run,
218  )
219
220/**
221 * The plan a loop's step is for: the one named, else the loop's newest
222 * unfinished plan, else its newest. Never another loop's by itself: a
223 * subagent with no plan of its own would move the main thread's.
224 */
225export const planOf = (runs: readonly Run[], loop: string, id: string): Run | undefined => {
226  const plans = runs.filter((run) => run.feed === 'plan')
227
228  if (id !== '') {
229    return plans.find((run) => run.id === id)
230  }
231
232  const own = plans.filter((run) => run.loop === loop)
233
234  return own.findLast((run) => !isFinished(leavesOf(run))) ?? own.at(-1)
235}
236
237/**
238 * A task Claude Code's own list gained. The loop's list is one flat run; a
239 * task made after every earlier one is over begins a new run.
240 */
241export const taskCreated = (
242  runs: readonly Run[],
243  loop: string,
244  owner: string,
245  taskId: string,
246  subject: string,
247  at: number,
248  mint: () => string,
249): Run[] => {
250  const step: Step = { id: taskId, title: titleOf(subject) || 'task', status: 'pending', startedAt: 0, endedAt: 0 }
251  const list = runs.findLast((run) => run.feed === 'tasks' && run.loop === loop)
252
253  // A loop that follows a plan shows that plan: its task list would be the
254  // same job a second time.
255  if (runs.some((run) => run.feed === 'plan' && run.loop === loop && isLive(run))) {
256    return [...runs]
257  }
258
259  if (list === undefined || isFinished(leavesOf(list))) {
260    return opened(runs, runOf(mint(), 'Tasks', 'tasks', loop, owner, [step], at))
261  }
262
263  return replaced(runs, {
264    ...list,
265    steps: [...list.steps.filter((one) => one.id !== taskId), step],
266    touchedAt: at,
267  })
268}
269
270/** A task of the loop's list changed: its state, its title, or it was deleted. */
271export const taskUpdated = (
272  runs: readonly Run[],
273  loop: string,
274  taskId: string,
275  change: { status?: string; subject?: string },
276  at: number,
277): Run[] => {
278  const list = runs.findLast(
279    (run) => run.feed === 'tasks' && run.loop === loop && run.steps.some((step) => step.id === taskId),
280  )
281
282  if (list === undefined) {
283    return [...runs]
284  }
285
286  if (change.status === 'deleted') {
287    const steps = list.steps.filter((step) => step.id !== taskId)
288
289    return steps.length === 0
290      ? runs.filter((run) => run.id !== list.id)
291      : replaced(runs, { ...list, steps, touchedAt: at })
292  }
293
294  const steps = list.steps.map((step) => {
295    if (step.id !== taskId) {
296      return step
297    }
298
299    const named = change.subject === undefined ? step : { ...step, title: titleOf(change.subject) || step.title }
300
301    if (change.status === 'in_progress') {
302      return turned(named, 'start', at)
303    }
304
305    if (change.status === 'completed') {
306      return turned(named, 'done', at)
307    }
308
309    return change.status === 'pending'
310      ? { ...named, status: 'pending' as const, startedAt: 0, endedAt: 0 }
311      : named
312  })
313
314  return replaced(runs, { ...list, steps, touchedAt: at })
315}
316
317/** True while a run can still move: steps are left, and its agent has not ended. */
318export const isLive = (run: Run): boolean => run.stoppedAt === 0 && !isFinished(leavesOf(run))
319
320/**
321 * The agent of this loop ended: each run it left unfinished stops there.
322 * When it ended with its answer, the step it left running is done, so an
323 * agent need not spend a call on its last step; when it was cut short, that
324 * step goes back to waiting, since nobody will end it.
325 */
326export const stopped = (
327  runs: readonly Run[],
328  loop: string,
329  at: number,
330  isAnswered = false,
331): Run[] => runs.map((run) => (run.loop !== loop || !isLive(run) ? run : halted(run, at, isAnswered)))
332
333/** One live run stopped where it stands, as `stopped` stops a loop's. */
334const halted = (run: Run, at: number, isAnswered = false): Run => {
335  const steps = run.steps.map((step) =>
336    step.status === 'running' && isLeaf(run, step.id)
337      ? { ...step, status: isAnswered ? ('done' as const) : ('pending' as const), endedAt: at }
338      : step,
339  )
340  const left = { ...run, steps, touchedAt: at }
341
342  return isFinished(leavesOf(left)) ? left : { ...left, stoppedAt: at }
343}
344
345/**
346 * Every run still under way stops where it stands: the deck was closed and
347 * hears no step from now on. A step a loop calls later moves its run again.
348 */
349export const stoppedAll = (runs: readonly Run[], at: number): Run[] =>
350  runs.map((run) => (isLive(run) ? halted(run, at) : run))
351
352/** The run a fold's key belongs to: `r1` of `r1`, `r1/2.1` and `step:r1/2.1`. */
353const runOfFold = (key: string): string => key.replace(/^step:/, '').split('/')[0] ?? ''
354
355/**
356 * The folds of what is still shown: a job's by its id, a run's, a branch's
357 * and a step's by their run. A run that takes a freed id starts with none.
358 */
359export const keptFolds = (
360  folds: Readonly<Record<string, boolean>>,
361  runs: readonly Run[],
362  jobIds: readonly string[],
363): Record<string, boolean> =>
364  Object.fromEntries(
365    Object.entries(folds).filter(([key]) =>
366      key.startsWith('job:') ? jobIds.includes(key.slice('job:'.length)) : runs.some((run) => run.id === runOfFold(key)),
367    ),
368  )
369
370/** The runs that can still move: what a clear leaves, or those touched since a time. */
371export const swept = (runs: readonly Run[], before = Number.POSITIVE_INFINITY): Run[] =>
372  runs.filter((run) => isLive(run) || Math.max(run.touchedAt, run.stoppedAt) >= before)
373
374/**
375 * What to tell the person of a change of the runs: a run that finished, or a
376 * step that failed, a line each.
377 */
378export const newsOf = (before: readonly Run[], after: readonly Run[]): string[] =>
379  after.flatMap((run) => {
380    const old = before.find((one) => one.id === run.id)
381    const leaves = leavesOf(run)
382    const failed = leaves.filter((leaf) => leaf.status === 'failed')
383    const wasFailed = old === undefined ? 0 : leavesOf(old).filter((leaf) => leaf.status === 'failed').length
384    const isNew = old === undefined || !isFinished(leavesOf(old))
385
386    if (failed.length > wasFailed) {
387      return [`✗ ${cut(run.title, 32)}: ${cut(failed.at(-1)?.title ?? '', 40)} failed`]
388    }
389
390    return isFinished(leaves) && isNew && old !== undefined
391      ? [`✓ ${cut(run.title, 40)} ${doneOf(leaves)}/${leaves.length}`]
392      : []
393  })
394
395/** The run the label and the open section follow: the newest touched one that can still move. */
396export const activeOf = (runs: readonly Run[]): Run | undefined =>
397  runs
398    .filter(isLive)
399    .reduce<Run | undefined>(
400      (best, run) => (best === undefined || run.touchedAt >= best.touchedAt ? run : best),
401      undefined,
402    )
403
404/** How many of a run's title the label shows at most. */
405export const RUN_TITLE_CELLS = 24
406
407/** `5/8`: a run's leaves done of all. */
408export const runCount = (run: Run): string => {
409  const leaves = leavesOf(run)
410
411  return `${doneOf(leaves)}/${leaves.length}`
412}
413
414/** `Auth renewal 5/8`, for the label: the title cut to `chars`. */
415export const runText = (run: Run, chars = RUN_TITLE_CELLS): string => `${cut(run.title, chars)} ${runCount(run)}`
416
417/** One drawn row of a run: the run itself, a branch or a leaf. */
418export type Row = {
419  /** `run/step`, or the run's id for its own row: the fold's key for a row that opens. */
420  key: string
421  kind: 'run' | 'branch' | 'leaf'
422  depth: number
423  status: StepStatus
424  title: string
425  /** `▰▰▰▰▰▱▱▱`, on a run's row. */
426  meter: string
427  /** `5/8`: leaves done of all, on a run's or a branch's row. */
428  count: string
429  span: string
430  isOpen: boolean
431  /** On a run's row: its agent ended with steps left. */
432  isStopped: boolean
433}
434
435const depthOf = (id: string): number => id.split('.').length
436
437/**
438 * A run as rows, top to bottom, with what is folded left out. A branch is
439 * open by itself while it is under way, and folded before it starts and
440 * once it is over; the person's own fold stands over both.
441 */
442export const rowsOf = (
443  run: Run,
444  folds: Readonly<Record<string, boolean>>,
445  now: number,
446  isActive: boolean,
447): Row[] => {
448  const all = leavesOf(run)
449  // A stopped run's clocks read the moment it stopped.
450  const at = run.stoppedAt || now
451  const isRunOpen = folds[run.id] ?? isActive
452  const top: Row = {
453    key: run.id,
454    kind: 'run',
455    depth: 0,
456    status: statusOf(all),
457    title: run.owner === '' ? run.title : `${run.title} · ${run.owner}`,
458    meter: barOf((doneOf(all) / Math.max(1, all.length)) * METER_CELLS, METER_CELLS),
459    count: `${doneOf(all)}/${all.length}`,
460    span: spanOf(all, at),
461    isOpen: isRunOpen,
462    isStopped: run.stoppedAt > 0,
463  }
464
465  if (!isRunOpen) {
466    return [top]
467  }
468
469  const open = new Map<string, boolean>()
470  const rows = run.steps.flatMap((step): Row[] => {
471    const parent = parentOf(step.id)
472
473    if (parent !== '' && open.get(parent) !== true) {
474      open.set(step.id, false)
475
476      return []
477    }
478
479    const leaves = leavesOf(run, step.id)
480    const key = `${run.id}/${step.id}`
481
482    if (isLeaf(run, step.id)) {
483      return [
484        {
485          key,
486          kind: 'leaf',
487          depth: depthOf(step.id),
488          status: step.status,
489          title: step.title,
490          meter: '',
491          count: '',
492          span: spanOf([step], at),
493          isOpen: false,
494          isStopped: false,
495        },
496      ]
497    }
498
499    const status = statusOf(leaves)
500    const isOpen = folds[key] ?? (!isFinished(leaves) && status !== 'pending')
501    open.set(step.id, isOpen)
502
503    return [
504      {
505        key,
506        kind: 'branch',
507        depth: depthOf(step.id),
508        status,
509        title: step.title,
510        meter: '',
511        count: `${doneOf(leaves)}/${leaves.length}`,
512        span: spanOf(leaves, at),
513        isOpen,
514        isStopped: false,
515      },
516    ]
517  })
518
519  return [top, ...rows]
520}
521
522/** The plan as the tool answers it: each step's id and title, a line each. */
523export const planText = (run: Run): string =>
524  run.steps.map((step) => `${step.id} ${step.title}`).join('\n')
525
hooks/tools.ts 81 lines
1// The tools the model can call, for a person who turned them on. Here
2// only as the model reads them: the hooks module serves them. Their words
3// are in every request's context, so they are kept short.
4
5import type { ToolSpec } from 'claude-code'
6
7/**
8 * What tells an agent the tools are there. Where many tools are listed, the
9 * model sees these two by name only and reads their descriptions only once
10 * it loads them, so the descriptions alone bring no agent to call them. With
11 * the setting on, this goes into the main thread's system prompt and at the
12 * end of each subagent's task.
13 */
14export const NOTE =
15  'The person follows progress in a pane called Deck, which shows them the title and steps of a plan and the description of each shell command you run. They read these to understand what is happening: write them in the language the person writes in. If you have the tool mcp__deck__plan and this job will take more than a couple of minutes or more than three stages, call it once as you begin with three to six steps (load it and mcp__deck__step with ToolSearch if their schemas are not loaded); its first step starts by itself, so call mcp__deck__step only as you move to the next one. Skip it for a short job: each call costs the person a round trip.'
16
17/** The note's id among the system prompt's sections. */
18export const NOTE_SECTION = 'deck:plan'
19
20export const WORDS = ['start', 'done', 'fail'] as const
21
22export const TOOLS: readonly ToolSpec[] = [
23  {
24    name: 'plan',
25    description:
26      "Shows your job's steps to the person as a live progress tree. Call it once, as you begin a job of several stages, whichever agent you are; skip it for a short job. The first step starts by itself. Answers each step's id (1, 1.2, 1.2.1).",
27    inputSchema: {
28      type: 'object',
29      properties: {
30        title: { type: 'string', description: 'The job, in a few words' },
31        steps: {
32          type: 'string',
33          description: 'One step a line; indent a step under its parent with two spaces',
34        },
35      },
36      required: ['title', 'steps'],
37    },
38  },
39  {
40    name: 'step',
41    description:
42      'Updates one leaf step of the plan. start also ends any step still running before it in the plan, so moving on needs only start. The last step of a subagent is done by itself when it answers; the main thread marks its last step done. Parents follow their steps; times are kept for you.',
43    inputSchema: {
44      type: 'object',
45      properties: {
46        id: { type: 'string', description: 'The leaf step, as plan answered it: 1.2.1' },
47        state: { type: 'string', enum: WORDS },
48        run: { type: 'string', description: 'Only with several plans open: the run plan answered' },
49      },
50      required: ['id', 'state'],
51    },
52  },
53]
54
55/**
56 * What tells an agent the watch tool is there, with the GitHub checks
57 * setting on: beside the note above, or alone.
58 */
59export const WATCH_NOTE =
60  'When you wait on GitHub checks (the CI of a pull request, a deploy or any other workflow run, the checks of a branch you pushed), call mcp__deck__watch once in place of polling in a shell (load it with ToolSearch if its schema is not loaded): the person follows each check in the Deck, and with wake you get a message when they are over.'
61
62/** The watch note's id among the system prompt's sections. */
63export const WATCH_SECTION = 'deck:watch'
64
65export const WATCH_TOOL: ToolSpec = {
66  name: 'watch',
67  description:
68    "Shows GitHub checks to the person in the Deck as they run: a pull request's CI, a workflow run's jobs (a deploy), a branch's or a commit's checks. Call it once after you push, open a pull request or start a deploy, in place of polling. With wake, a message tells you when they are over, so you can end your turn.",
69  inputSchema: {
70    type: 'object',
71    properties: {
72      target: {
73        type: 'string',
74        description:
75          'A pull request (12, #12 or its address), a workflow run (its id or address), a branch or a commit. Leave out for the branch you are on',
76      },
77      wake: { type: 'boolean', description: 'True to be told when the checks are over' },
78    },
79  },
80}
81
hooks/values.ts 8 lines
1// Readers for values that come from outside the module: the store's JSON,
2// a setting, an event's loosely typed field.
3
4export const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
5  typeof value === 'object' && value !== null && !Array.isArray(value)
6
7export const toText = (value: unknown): string => (typeof value === 'string' ? value : '')
8
hooks/view.ts 52 lines
1// What the label and the pane say, as text.
2
3import type { Job, Meter, Work } from '../types'
4
5import { effortText, modelName } from './meter'
6
7const two = (count: number): string => String(count).padStart(2, '0')
8
9/** `48s`, `1m 12s`, `14m 03s`, `1h 04m`. */
10export const spanText = (ms: number): string => {
11  const seconds = Math.max(0, Math.floor(ms / 1000))
12  const minutes = Math.floor(seconds / 60)
13
14  if (minutes === 0) {
15    return `${seconds}s`
16  }
17
18  return minutes < 60
19    ? `${minutes}m ${two(seconds % 60)}s`
20    : `${Math.floor(minutes / 60)}h ${two(minutes % 60)}m`
21}
22
23/** How long the job has run, or ran. */
24export const jobSpan = (job: Job, now: number): string =>
25  spanText((job.endedAt === 0 ? Math.max(now, job.startedAt) : job.endedAt) - job.startedAt)
26
27export const GLYPHS: Readonly<Record<Job['status'], string>> = {
28  running: '⏵',
29  done: '✓',
30  failed: '✗',
31  killed: '■',
32  ended: '·',
33}
34
35/**
36 * The hint line's label: `Fable 5.1 ▰▰▰▱▱ high · ⏵ 3`. The model and its
37 * effort once known, then how many shells and agents run, then what the
38 * caller adds (a run's progress).
39 */
40export const labelText = (meter: Meter, work: Work, more: readonly string[] = []): string => {
41  const model = [modelName(meter.model), effortText(meter)].filter((part) => part !== '')
42  // A shell Claude Code asks the person about is not counted as running.
43  const running = work.running.filter((job) => !job.isAsking).length
44  const parts = [
45    model.join(' '),
46    running > 0 ? `${GLYPHS.running} ${running}` : '',
47    ...more,
48  ].filter((part) => part !== '')
49
50  return parts.length === 0 ? '✻ deck' : parts.join(' · ')
51}
52
hooks/work.ts 187 lines
1// The shells and agents the deck lists: what runs, and what lately ended.
2// Plain functions over plain values; the hooks module calls them with what
3// the events said.
4
5import type { Job, JobStatus, Work } from '../types'
6
7export const NO_WORK: Work = { running: [], recent: [] }
8
9/** How many ended jobs are kept. */
10const KEPT = 8
11/** A foreground command that went well is kept only when it ran this long. */
12const WORTH_MS = 3000
13const TITLE_CHARS = 80
14const DETAIL_CHARS = 200
15
16const STATUSES: Readonly<Record<string, JobStatus>> = {
17  completed: 'done',
18  failed: 'failed',
19  killed: 'killed',
20}
21
22/** A notification's word for how its task ended, as a status. */
23export const statusOf = (word: string): JobStatus => STATUSES[word] ?? 'ended'
24
25/** One printable line of at most `chars`. */
26const lineOf = (text: string, chars = TITLE_CHARS): string => {
27  const line = [...(text.trim().split('\n')[0] ?? '').replace(/\s+/g, ' ')]
28
29  return line.length > chars ? `${line.slice(0, chars - 1).join('')}…` : line.join('')
30}
31
32/** A shell's command as its opened row shows it: the first line. */
33export const shellDetail = (command: string): string => lineOf(command, DETAIL_CHARS)
34
35/** A shell's row: what the call said it does, or the command where it said nothing. */
36export const shellTitle = (description: string | undefined, command: string): string =>
37  lineOf(description ?? '') || lineOf(command) || 'shell'
38
39/** An agent's row, as Claude Code names a subagent: `type(task)`. */
40export const agentTitle = (type: string, description: string): string => {
41  const task = lineOf(description)
42
43  return task === '' ? type : `${type}(${task})`
44}
45
46export const started = (work: Work, job: Job): Work => ({
47  running: [...work.running.filter((one) => one.id !== job.id), job],
48  recent: work.recent.filter((one) => one.id !== job.id),
49})
50
51/** The job went to the background under this task id, and runs on. */
52export const backgrounded = (work: Work, id: string, taskId: string, at: number): Work => ({
53  ...work,
54  running: work.running.map((job) =>
55    job.id === id ? { ...job, taskId, ...(job.isAsking ? { isAsking: false, startedAt: at } : {}) } : job,
56  ),
57})
58
59/** Claude Code asks the person whether this shell may run. */
60export const asked = (work: Work, id: string): Work => ({
61  ...work,
62  running: work.running.map((job) => (job.id === id ? { ...job, isAsking: true } : job)),
63})
64
65/**
66 * The shell the person was asked about runs since `at`: its clock starts
67 * there. One nobody asked about keeps the start its call had.
68 */
69export const begun = (work: Work, id: string, at: number): Work => ({
70  ...work,
71  running: work.running.map((job) =>
72    job.id === id && job.isAsking ? { ...job, isAsking: false, startedAt: Math.max(job.startedAt, at) } : job,
73  ),
74})
75
76const isWorthKeeping = (job: Job): boolean =>
77  job.status !== 'done' ||
78  job.kind === 'agent' ||
79  job.taskId !== '' ||
80  job.endedAt - job.startedAt >= WORTH_MS
81
82const closed = (work: Work, job: Job, status: JobStatus, at: number): Work => {
83  // One that ended while the person was still asked ran under the two seconds
84  // that show it began, or never ran: the wait is not its time.
85  const from = job.isAsking ? Math.max(at, job.startedAt) : job.startedAt
86  const ended = { ...job, status, startedAt: from, endedAt: Math.max(at, from), isAsking: false }
87  const running = work.running.filter((one) => one.id !== job.id)
88
89  return isWorthKeeping(ended)
90    ? { running, recent: [ended, ...work.recent].slice(0, KEPT) }
91    : { running, recent: work.recent }
92}
93
94/** The running job of this id ended, newest first among the recent. */
95export const ended = (work: Work, id: string, status: JobStatus, at: number): Work => {
96  const job = work.running.find((one) => one.id === id)
97
98  return job === undefined ? work : closed(work, job, status, at)
99}
100
101/** The job never ran (its call was refused): it leaves no row. */
102export const dropped = (work: Work, id: string): Work => ({
103  ...work,
104  running: work.running.filter((job) => job.id !== id),
105})
106
107/** A background task's end, as Claude Code reports it: which task, and how it ended. */
108export type Notice = { taskId: string; status: JobStatus }
109
110/**
111 * A background task was reported ended. One that still runs ends now; one
112 * closed before with no word on how takes the status it is given here.
113 */
114export const reported = (work: Work, notice: Notice, at: number): Work => {
115  const job = work.running.find((one) => one.taskId === notice.taskId)
116
117  if (job !== undefined) {
118    return closed(work, job, notice.status, at)
119  }
120
121  return {
122    ...work,
123    recent: work.recent.map((one) =>
124      one.taskId === notice.taskId && one.status === 'ended'
125        ? { ...one, status: notice.status }
126        : one,
127    ),
128  }
129}
130
131/**
132 * Closes the background shells Claude Code no longer lists as in flight:
133 * they ended with no notification this session saw.
134 */
135export const settled = (work: Work, alive: readonly string[], at: number): Work =>
136  work.running
137    .filter((job) => job.kind === 'shell' && job.taskId !== '' && !alive.includes(job.taskId))
138    .reduce((left, job) => closed(left, job, 'ended', at), work)
139
140/**
141 * Every job still running ends here with no status: the deck was closed and
142 * sees no end from now on, so none runs on in the pane when it comes back.
143 */
144export const endedAll = (work: Work, at: number): Work =>
145  work.running.reduce((left, job) => closed(left, job, 'ended', at), work)
146
147/** An agent that ended is at work again (a message woke it): its row runs on. */
148export const revived = (work: Work, id: string): Work => {
149  const job = work.recent.find((one) => one.id === id && one.kind === 'agent')
150
151  return job === undefined
152    ? work
153    : {
154        running: [...work.running, { ...job, status: 'running', endedAt: 0 }],
155        recent: work.recent.filter((one) => one.id !== id),
156      }
157}
158
159/**
160 * The task ran this long by its own notification, which is delivered late
161 * while a turn runs. A notification that names no length changes nothing.
162 */
163export const timed = (work: Work, taskId: string, ms: number): Work =>
164  ms < 0
165    ? work
166    : {
167        ...work,
168        recent: work.recent.map((job) =>
169          job.taskId === taskId ? { ...job, endedAt: job.startedAt + ms } : job,
170        ),
171      }
172
173/** An agent's model or effort became known. */
174export const tuned = (work: Work, id: string, patch: { model?: string; effort?: string }): Work => ({
175  ...work,
176  running: work.running.map((job) => (job.id === id ? { ...job, ...patch } : job)),
177})
178
179/** The ended rows taken away: all of them, or those that ended before a time. */
180export const cleared = (work: Work, before = Number.POSITIVE_INFINITY): Work => ({
181  ...work,
182  recent: work.recent.filter((job) => job.endedAt >= before),
183})
184
185export const countOf = (work: Work, kind: Job['kind']): number =>
186  work.running.filter((job) => job.kind === kind).length
187
types/index.d.ts 143 lines
1// What the mod keeps for the session, in memory: `$.state` holds each of
2// these, and none of it is written to disk.
3
4export type JobKind = 'shell' | 'agent'
5
6/** `ended` is an end nobody reported: neither done nor failed is known. */
7export type JobStatus = 'running' | 'done' | 'failed' | 'killed' | 'ended'
8
9/** One shell command or one subagent, running or lately ended. */
10export type Job = {
11  /** The call's id for a shell, the agent's id for a subagent. */
12  id: string
13  kind: JobKind
14  /** A shell's description, or its command where it has none; an agent's type and task. */
15  title: string
16  startedAt: number
17  /** 0 while it runs. */
18  endedAt: number
19  status: JobStatus
20  /** The background task's id, as its notification names it; empty in the foreground. */
21  taskId: string
22  /** A shell's command, its first line; shown when the row is opened. Empty for an agent. */
23  detail: string
24  /** An agent's model, as its spawn answered it; empty for a shell. */
25  model: string
26  /** An agent's effort, as its last request carried it; empty when unknown. */
27  effort: string
28  /** The agent whose loop started it, by id; empty when the main thread did. */
29  owner: string
30  /**
31   * True while Claude Code asks the person whether a shell may run: its clock
32   * starts once it does.
33   */
34  isAsking: boolean
35}
36
37export type Work = { running: Job[]; recent: Job[] }
38
39export type Meter = {
40  /** The main thread's model, as its last request named it. */
41  model: string
42  /** The effort Claude Code set on that request; empty for a model without one. */
43  effort: string
44  /** False until the main thread has made a request this session. */
45  isSeen: boolean
46  /** The level a click asked for; empty while Claude Code's own stands. */
47  wanted: string
48  /** Claude Code's effort when the click was made: a change of it is `/effort`. */
49  over: string
50  /** The context window's fill, 0 to 100; -1 before it is known. */
51  context: number
52  /** What the session has cost in US dollars; -1 where Claude Code keeps no count. */
53  cost: number
54  /** The rate-limit windows the last response reported. */
55  limits: Limit[]
56}
57
58/** One rate-limit window: `five_hour`, `seven_day` or `spend_limit`, and how full it is. */
59export type Limit = { kind: string; percent: number }
60
61export type StepStatus = 'pending' | 'running' | 'done' | 'failed'
62
63/** One line of a run's plan. Its id is its place: `1`, `1.2`, `1.2.1`. */
64export type Step = {
65  id: string
66  title: string
67  /** A leaf's own state; a parent's is told from its leaves. */
68  status: StepStatus
69  /** 0 until it starts. */
70  startedAt: number
71  /** 0 until it ends. */
72  endedAt: number
73}
74
75/** Which feed a run comes from: Claude Code's task list, the mod's tool, or GitHub's checks. */
76export type RunFeed = 'tasks' | 'plan' | 'checks'
77
78/** One job's steps, in the order they were planned. */
79export type Run = {
80  id: string
81  title: string
82  feed: RunFeed
83  /** The agent whose loop opened it, by id; empty for the main thread. */
84  loop: string
85  /** That agent's name, for the row; empty for the main thread. */
86  owner: string
87  steps: Step[]
88  /** When a step of it last changed: the newest unfinished run is the one shown open. */
89  touchedAt: number
90  /** When the agent that opened it ended with steps left; 0 while it can still move. */
91  stoppedAt: number
92}
93
94/** One thing followed on GitHub, shown as a run: a commit's checks, or a workflow run's jobs. */
95export type Watch = {
96  /** The run that shows it, by id. */
97  run: string
98  kind: 'ref' | 'run'
99  /** The GitHub host, `github.com` or a company's own. */
100  host: string
101  /** `owner/name`. */
102  repo: string
103  /** A branch, a tag, a commit or `pull/12/head`; for a workflow run, its id. */
104  target: string
105  /** Its page on GitHub. */
106  url: string
107  /** True where Claude asked to be told when it is over. */
108  wake: boolean
109  startedAt: number
110  /** How many polls in a row found every check over. */
111  settled: number
112  /** How many polls in a row GitHub did not answer. */
113  failures: number
114}
115
116export type Switches = {
117  /** `/deck close`: the label and the pane away in every session. */
118  isClosed: boolean
119  /** The label's row closed with its `×`: the label is text on the hint line. */
120  isRowClosed: boolean
121}
122
123declare module 'claude-code' {
124  interface PluginState {
125    deck: {
126      work: Work
127      meter: Meter
128      /** The clock as the last tick read it: the open pane draws again with it. */
129      now: number
130      switches: Switches
131      runs: Run[]
132      /** What is followed on GitHub, with the GitHub checks setting on. */
133      watches: Watch[]
134      /** How long each of the last shell commands ran, by its call's id, for the transcript's rows. */
135      spans: Record<string, number>
136      /** The branches the person folded or unfolded, by `run/step`: true is open. */
137      folds: Record<string, boolean>
138      /** The cells the deck's pane takes from the screen's width while it is docked; 0 while it is not. */
139      docked: number
140    }
141  }
142}
143