SLOPSHOPPER

session-status

A pane that shows the session's live status: doing now, decisions and surprises with one-click buttons, progress and the last update.

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-status
│ ┃ Session status ✕ › fix the failing auth test and add an audit log call │ ┃ ● Waiting for reply │ ┃ ⏺ Read(src/auth.ts) │ ┃ Now ⎿ Read 6 lines │ ┃ Bash: Show environment file ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Session ⏺ Bash(bun test) │ ┃ ID preview-session ⎿ 3 pass, 1 fail │ ┃ │ ┃ Last update 08:53:21 · just now ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /session-status │ ⎿ session-status: Session status pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session status
● Waiting for reply Now Bash: Show environment file Session ID preview-session Last update 08:53:21 · just now
README

session-status

A pane that shows what the session is doing, what it needs from you and what surprised it. The agent keeps it current with a status tool; you answer in the chat, or with the buttons under each decision, surprise and observation.

claude plugin install session-status@claude-mods

The pane

Sections, top to bottom. An empty section is not drawn.

● In progress    the session's state: Blocked, In progress, Settling, Waiting for reply or Settled
Now              the current task, else the last tool call
Blocked on you   every open blocked decision
You should know  every open blocker: what failed and what unblocks it
Session          ID        cb8cbec3-754b-4b30-80d5-014a9daf1042
                 Branch    pane-width           press the ID, branch or worktree to copy it
                 Worktree  claude-mods-8ec7ac/1
                 Progress 7/19 ███████░░░░░       the session's own items
                 ○ I8 Open the pull request       2 items, open ones first, then "+N more"
                 ✓ I7 Run the tests
                 Building: #3, #5
Effort           video-review-v1                  during an effort
                 Closed 1/13 █░░░░░░░░░░░         the issues GitHub closed
                 ○ #4 Effort progress             2 tickets, open ones first, each a link
                 ○ #5 Observer agent
                 +11 more
Task             Done 2/5 ████████░░░░░           while a task list exists
                 ◐ Write the tests                2 tasks, running ones first
                 ○ Update the README
                 +3 more
Links            󰓂 claude-mods#14 · 󰘭 skills#88 · ◎ claude-mods#15
Places (2)       skills  3 files · 2 commands
                 docs  1 command
Decide before settling     the newest 5, asked ones first, then a pressable "+N more"
Follow-up after settling   the newest 3, then a pressable "+N more"
Surprises        the newest 2, then a pressable "+N more"
Observations     the observer's findings, dim: the newest 2, then "+N more"
Subagents        running · finished
Cron jobs        1 active · 1 fired · 1 expired · 1 cancelled
                 weekdays 09:00 · Check the build and report.
Answered (N) · Last update
  1. State: one word for the whole session, by priority.
  2. Blocked while a blocked decision or a blocker is open.
  3. In progress while a turn or a subagent runs.
  4. Settling while a turn that runs settle-session or settle-effort runs.
  5. Waiting for reply once the turn ended.
  6. Settled once a turn that ran settle-session or settle-effort ended, as a Skill call or as the slash command you typed; the next turn clears it.
  7. Now: the current task, or the last tool call (its description, file name or programs).
  8. Blocked on you: decisions that stop the work until you answer.
  9. You should know: what the agent tried that failed and that it cannot finish alone: a denied action, a check that cannot run, a tool that refuses to start. Each says what you can do to unblock it (run a command, restart Claude Code, allow an action), pings you, and stays until the agent resolves it.
  10. Session: this session's own work, in every session.
  11. ID, Branch and Worktree (the two folders above the repository folder, or worktrees/<name> for a worktree in a worktrees folder). Press a value to copy it to the clipboard: the full session ID, the branch name, or the worktree's full path.
  12. Progress: this session's own items, done of all (see Session progress). The total grows as the session takes on more work. Effort tickets and tasks never count here.
  13. The items, each with its id: two of them, the open ones first (○, oldest first), then the done ones (✓, newest first), then a pressable "+N more" for every item behind Progress. Dropped items are left out.
  14. Building: the tickets the orchestrator builds now, the first 4, then a pressable "+N more".
  15. Effort, during an effort: its name, then the tracker's count once it has one: closed tickets of all the effort's issues, QA: tickets included. It never mixes in the orchestrator's reports. Below it, two tickets, the open ones first (○), then the closed ones (✓), each in issue-number order; each number links to its issue. A pressable "+N more" lists every ticket of the effort. Task, while Claude Code's task list (TaskCreate, TodoWrite) has a task: the tasks completed of all, then two tasks, the running ones first (◐), then the pending (○), then the completed (✓), and a pressable "+N more" that lists them all.
  16. Links: the pull requests and issues attached to the session, in every repository, newest 5 first, then "+N more". The session's gh pr create and gh issue create add theirs by themselves, and so do its gh pr merge (auto-merge too), gh pr close, gh pr reopen, gh issue close and gh issue reopen, though another session made the page. A page named by number alone joins only when the session works in a GitHub repository. The agent adds any other page it works on, such as a pull request it reviews, with the status tool's link action and the page's URL. All look the same. A mark before each gives its kind and state:
PageOpenDone
Pull request󰓂 greenmerged: 󰘭 purple; closed without merging: 󰓂 red
Issue◎ greenclosed: ⊙ purple

The pull request marks are Nerd Font glyphs (Material Design's source_pull and source_merge), drawn in the terminal; the desktop shows ⇄ in the same colours. The pane reads each page's state from GitHub with one gh api graphql call: when a link joins, right after a gh pr or gh issue command, and at most every two minutes while the session works. A page merged or closed anywhere, by anyone, shows so; without gh or a network, the marks keep their last state. Each label is a link to its page: Cmd+click opens it in Ghostty, and Cmd+Shift+click when the pane runs inside Herdr.

  1. Places: the other repositories the session changed (see Places).
  2. Decide before settling: decisions the agent went on with a default for, which you answer before the session settles. The ones it asked you about in the chat (its decide list) come first, their ids in the warning colour; then the newest of the rest, five in all.
  3. Follow-up after settling: decisions that can wait until after the session settles, each with the default the agent went on with. They never hold up settling.
  4. Surprises: unexpected things the agent recorded and what they changed, each with its action buttons (see Surprise actions under The status tool).
  5. Observations: the observer's findings (see The observer), kept apart from what the agent itself knows, under a dim heading, each with its action buttons.
  6. Subagents: running and finished.
  7. Cron jobs: the jobs the session scheduled with CronCreate, counted as active, fired, expired or cancelled, then each active job's schedule and prompt (the newest 3, then "+N more"). The schedule is short text (every 5m, hourly, daily 09:00, weekdays 09:00, Mondays 08:00, monthly on the 1st 09:00, Dec 25 03:07), or the cron expression when it has no short form. A job fires when a turn starts with its prompt; a one-shot job is then done. A recurring job expires 7 days after CronCreate scheduled it, as Claude Code expires it. CronDelete cancels a job, and a one-shot job that CronList no longer lists has fired.
  8. Answered: how many decisions and blockers were resolved and surprises dismissed.
  9. Last update: its time and age.

Every "+N more" is a button: under the session items, the Building line, the effort's tickets, the tasks, the links, Decide before settling, Follow-up after settling, Surprises, Observations and the cron jobs. A new section that shows "+N more" follows the same rule. It is a button: pressing it turns the pane into that whole list, newest first (the items: and the tickets: open ones first), with a ← Back button (or the b key) to return. Closing the pane returns it to every section.

Links go to GitHub when the repository's origin remote is there: the effort links its issues list (label:effort:<name>), the branch its tree, each ticket being built its issue, and each pull request, issue and place its page.

Colour carries meaning, from Claude Code's theme, so it reads in light and dark themes: headings are bold and labels dim; the state line takes the warning colour when blocked, the success colour in progress, the accent colour waiting for a reply, and is dim when settled; blocked decisions, blockers and the decisions asked in the chat take the warning colour, and decision, surprise and session item ids and the tickets being built the accent colour. The progress bars stay neutral.

When the terminal is too narrow for the pane, a one-line band above the prompt shows the counts instead: <n> blocked · <n> decide · <n> follow-up · progress 7/19 · tasks 2/5 · closed 1/13 · <n> surprise. blocked counts blocked decisions and blockers; surprise leaves the observer's findings out. follow-up shows while there is one, tasks while a task list exists, and closed during an effort. Without items the session figure is the tasks' <done>/<total> done; with no items and no tasks the band leaves it out.

Opening it

  • /session-status opens or closes the pane; /session-status reset clears the whole session status (see below).
  • The pane opens by itself, once per session, when a subagent starts, a task list is made, an effort is found, a ticket or a session item is reported, or a decision or surprise is recorded. A pane you closed stays closed until you open it again.

The status tool

The mod adds the tool mcp__session-status__status and a short system prompt section that tells the agent how to use it. Actions:

ActionWhat it does
record_decisionRecords a decision: question, two to four options, a default, what unblocks it, and urgency blocked, before_settling or after_settling.
record_surpriseRecords a surprise: what occurred, what it changed and, when one clear next step exists, suggested_action in a few words.
record_blockerRecords a blocker: what failed (failed) and what you can do to unblock it (needs). It pings you.
resolveMarks a decision (D1, ...) resolved after you answer it in the chat, or a blocker (B1, ...) resolved once it works. A button answer resolves the decision itself.
dismissDismisses a surprise (S1, ...) when you ask. A button press dismisses the surprise itself.
post_decide_listMarks the decide list posted.
ticketReports a ticket's state during an effort: number, title, state (started, landed or stopped) and, on the first call, effort.
itemReports a session item: state added with a title (the reply names its id, I1, ...), or done or dropped with its id.
createAdds one entry of any kind: kind and fields.
readReturns the entries an optional filter keeps (see below); without one, everything.
updateChanges the given fields of one entry: kind, id and fields. A closed entry can open again.
deleteRemoves one entry: kind and id.

The other actions are shortcuts for the generic four. Each shortcut and its generic action share the same pure function, such as closeItem for resolve and an update to resolved. The agent uses the generic actions when you ask, and when an automatic path recorded something wrong, such as a false link or a false effort.

KindIdFields update can setAutomatic source
itemI5title, statenone
decisionD1question, options, default, unblocks, urgency, statenone
surpriseS2occurred, changed, statethe observer
blockerB1failed, needs, statenone
ticket#4title, stateticket reports
linkclaude-mods#27state (open, merged, closed)gh pr create, gh issue create, gh merge, close and reopen commands, the GitHub read
effortits namenameeffort skills, effort: labels in gh commands
taskthe task idsubject, statusthe task tools and Task events
cronthe job idstatethe Cron tools
placeowner/reponone (delete only)changing commands and file edits

create takes the fields the record actions take, such as title, question or url. A cron job and a place are created only by their automatic sources. A task the agent creates gets the id manual-1, ...

Manual and automatic values. A value the agent sets with update stays until its automatic source reports a new change. A link you set to closed stays closed until GitHub reports another state than it reported before. A gh pr reopen or a task event is a new change each time, so it overwrites the value. An entry that delete removed does not come back from an automatic source, and its id is not given again. create or link brings a deleted entry back.

Each generic call returns one line that says what changed, for example Link skills#88 deleted. An unknown kind, an unknown id or a field that the kind does not have returns an error that names the allowed values. A subagent can change only the entries that it created.

Short items. The prompt section, the tool's description and the observer's instructions ask for the same style: each field one short, clear sentence in plain technical style, with active voice, one idea per sentence, no lists and no filler. There is no character limit.

Decide list. The agent collects before-settling decisions while it works. When the work is done, it posts one numbered list of the open ones in the chat and calls post_decide_list. The pane highlights each asked decision until it is resolved. Follow-ups are never on the list.

Quick reply. Each open decision in Blocked on you, Decide before settling and Follow-up after settling has a row of buttons under it:

D4 · Use SQLite or Postgres for the cache?
  Default: SQLite
  [ SQLite ✓ ]  [ Postgres ]  [ Discuss ]
  • An option button resolves the decision, withdraws its ping when it is blocked, and sends D4: Postgres as your own words. The default's button has a ✓.
  • Option buttons show only when every option has 30 characters or fewer; otherwise only Discuss shows. The agent is told to write options in a few words.
  • Discuss sends Let's discuss D4: <question>. The agent explains the context and each option's trade-off in the chat and waits for you. The decision stays open with a (discussing) mark, and its buttons stay, until you answer or the agent resolves it.
  • A plugin's prompt runs once the session is idle, so a press during a turn reaches the agent when that turn ends.

Surprise actions. Each open surprise and observation, in its section and in its full list, has a row of buttons under it:

S2 · CI runs Node 18, but the code needs Node 22
  Changed: The build fails on CI only
  [ Pin Node 22 ]  [ File issue ]  [ Discuss ]  [ Dismiss ]
  • The first button is the suggested action, when the surprise has one of 30 characters or fewer. It dismisses the surprise and sends S2: Pin Node 22 as your own words. The agent gives it with record_surprise, the observer with its finding, each only when one clear next step exists.
  • File issue dismisses the surprise and sends File an issue for S2: <occurred>. The agent files it as the project's issue tracker says.
  • Discuss sends Let's discuss S2: <occurred>. The agent gives the context and the possible next steps in the chat and waits for you. The surprise stays open with a (discussing) mark, and its buttons stay.
  • Dismiss dismisses the surprise and sends nothing.
  • Two quick presses send one prompt. A press during a turn reaches the agent when that turn ends.

Session progress

The pane shows three kinds of progress, and they never mix:

SectionCountsDone when
Session (Progress 7/19)the items this session is responsible forthe session marks the item done
Effort (Closed 1/14)the effort's GitHub issues, without the Spec: issueGitHub closes the issue
Task (Done 2/5)Claude Code's task listthe task is completed

A session without an effort shows only Session. A session that builds an effort adds an item for each ticket it takes on and marks it done when its work lands; the issue, and the Effort bar, move only when GitHub closes it, usually when you merge. So a settled session reads full while its issues are still open:

1. the agent adds  I5 Build #4         Session 4/5   Effort 3/9
2. its work lands in the pull request  Session 5/5   Effort 3/9   (#4 still open)
3. you merge; GitHub closes #4         Session 5/5   Effort 4/9

Items. The main session reports each piece of work it must do before it settles with the item action. The total grows as the session goes on.

StateWhen the agent reports itWhat Progress does
addedYou ask for something: one item for each request or sub-request. Also each effort ticket it takes on, follow-up work, and each step left before the session settles (review, pull request, your approval, merge, settle).Counts one more item and lists it as open.
doneThe item's work is finished and verified.Counts it as done.
droppedThe item is no longer needed, or a later item replaced it.Takes it out of the total, even after it was done.
  • An item is never reopened: rework is a new item. A dropped item stays dropped.
  • Only the main session reports items; a subagent's item call is refused.

Effort progress

Effort (Closed 1/14) is what GitHub says: the closed issues with the label effort:<name>, of all of them, without the Spec: issue. An issue often closes long after its ticket is built: when the pull request merges, after QA, or never in a run that shares its issues. It counts the QA: tickets: the effort is not finished until they close.

The orchestrator reports each ticket with the ticket action:

StateWhen the orchestrator reports itWhat Session does
startedIt delegates the ticket.Names the ticket: Building: #3.
landedThe ticket's commit is on the effort branch.Takes it off the Building line.
stoppedA started ticket is no longer being built.Takes it off the Building line. A ticket started again after it landed (rework) goes back to landed.
  • Ticket reports feed only the Building line; they never change a bar.
  • The effort's name comes from the effort field of a ticket call, from an effort:<name> label in a command, or from the git branch. A report that names another effort switches the session to it; only that effort's reports count.
  • A stopped for a ticket never reported changes nothing.
  • A ticket without an issue number is reported by its title alone.
  • Without gh the Effort section is left out; Session still counts its items.

Places

Places is the blast radius: every repository other than the session's own that the session, or one of its subagents, changed files or ran changing commands in. Each line counts:

  • the files edited or written there (Edit, Write, NotebookEdit), each once;
  • the commands that changed something there: git commit, push, merge, rebase, tag; gh pr edit, merge, close; gh issue edit, close, comment; mv, rm, cp.

The pull requests and issues made there show in Links, so gh pr create and gh issue create are not counted here. With one place its line shows alone; with two or more the heading Places (N) leads them.

Reads never count. A file's repository comes from git rev-parse --show-toplevel in its folder, read once per folder in the background. A command's repository is the session's directory, or the one that cd <dir> &&, git -C <dir> or gh --repo <owner>/<name> names. A zero count is left out of the line.

Resume, /clear and /compact

  • Resume restores the session's saved status. A session with no saved status starts empty.
  • /clear starts a new, empty status, because it starts a new session id: nothing carries over, and ids start over at 1. The old session keeps its saved status, which /resume of that session restores.
  • /compact keeps the status as it is.

To start an unrelated task from nothing, type /session-status reset. It removes everything the status recorded: the session items, the decisions, surprises and blockers (open ones too), the links, the reported tickets, the effort and its ticket count, and the record of what delete removed of those kinds. Ids start over at 1. What the session runs now stays: its tasks, crons, subagents, place and places. The model can do the same with the status tool's reset action, which it calls only when you explicitly ask for a reset, never on its own or because of /clear. Its list action names every open item, decision, blocker, surprise and observation with its id, so the model can close them after /compact took the ids out of its context.

list takes an optional filter, so the model reads only what you ask about. Different keys must all match; the values of one key match if one of them matches.

KeyValuesDefault
kinditem, decision, blocker, surprise, observation, ticket, effort, link, task, cron, placeall kinds
stateopen; closed (resolved, dismissed, done, dropped, landed, a merged or closed link, a completed task, a cron job no longer active); allopen
idids such as D1, S2, I16, a ticket as #3, a link as claude-mods#27 (any case)all ids

An observation is a surprise the observer found; surprise leaves it out. The effort and a place have no state, so they show with any state. For example, "read the observations" is { "kind": ["observation"] }, and "which decisions did I answer" is { "kind": ["decision"], "state": "closed" }. Without a filter, list also names the done items and every reported ticket, and leaves out the links, tasks, cron jobs and places that the pane shows by themselves; read without a filter returns everything. An unknown kind or state returns an error that names the allowed values.

Optional tools

All are skipped silently when they are missing.

  • git for the branch, the worktree, the GitHub links and Places. It runs in the background and never holds a tool call.
  • shipyard and Herdr for pings: a ping for each blocked decision (withdrawn when it is resolved), and one ping when the decide list is posted.
  • gh for links and effort progress: created pull requests and issues are read from gh output, and the effort's tickets are counted with gh issue list. Without it, the Effort section is left out.

The observer

While the pane is open, a small model (Haiku 5.5, through Claude Code's haiku alias) checks the recent work for what the agent does not see itself: the same failing step tried three or more times, many turns spent on a side issue, and steps that contradict the effort, the task list or the session items. Small details and single errors are not findings, and an unsure check gives none; one check adds at most one finding. Findings

Source 56 files
hooks/register.tsx 1052 lines
1// The session-status mod's entry: the one file that calls `$`, because the
2// engine follows `$` only within the hooks module's own file. It wires the
3// engine's events to the pure modules beside it and does their reads and
4// writes. Keep feature logic in those modules; keep only I/O here.
5
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register, RenderSurface, Timer } from 'claude-code'
8
9import type { Decision, GitHubRepo, SessionStatus, StatusItem, Surprise } from '../types'
10import { afterTurn, settles, settlesByPrompt } from './activity'
11import { cronFired } from './crons'
12import { meetsAutoOpenTrigger } from './auto-open'
13import { drawBand } from './band'
14import { createEntry, deleteEntry, linkPage, pings, readEntries, recordDraft, updateEntry } from './crud'
15import type { CrudOutcome } from './crud'
16import { describeToolCall } from './describe-tool-call'
17import { branchEffortName, effortSighting, withEffort } from './effort'
18import { TICKET_TIMEOUT_MS, isTicketReadDue, parseTicketList, ticketListArgv, withTickets } from './effort-progress'
19import { INSTRUCTIONS } from './instructions'
20import { isCheckDue, observerRequest, parseFindings, recordCheck } from './observer'
21import type { Trigger } from './observer'
22import { AGE_TICK_MS, drawPane } from './pane'
23import { blockedPing, blockerPing, endListPing, pingId, withdrawPing } from './pings'
24import { changesBranch, githubRepo, repoOf, withPlace } from './place'
25import { commandTargets, editedFile, folderOf, withChange } from './places'
26import type { ChangedRepo } from './places'
27import { LINK_STATE_TIMEOUT_MS, isLinkStateReadDue, linkStatesArgv, parseLinkStates, withLinkStates } from './link-states'
28import { linkedText } from './links'
29import { listText, resetProgress, resetText } from './reset'
30import type { ResetRemoved } from './reset'
31import { reportItem } from './session-items'
32import { sessionProgress } from './session-progress'
33import {
34  STATUS_TOOL,
35  STATUS_TOOL_SPEC,
36  closedText,
37  endListText,
38  itemText,
39  readStatusToolInput,
40  recordedText,
41  ticketText,
42} from './status-tool'
43import {
44  KEPT_SESSIONS,
45  applyChange,
46  closeItem,
47  emptyStatus,
48  keysToPrune,
49  markDiscussing,
50  onEndList,
51  isOpen,
52  openToDecide,
53  savedStatus,
54  statusForSession,
55  withDefaults,
56} from './status'
57import { isRunningSubagent, subagentStarted, subagentStopped } from './subagents'
58import { taskCreated, taskUpdated } from './tasks'
59import { reportTicket } from './ticket-reports'
60import { toolResultChange } from './tool-results'
61
62const COMMAND = 'session-status'
63// Written here as literals so `claude plugin validate` can read the matcher.
64const PANE_ID = 'session-status'
65const PANE_TITLE = 'Session status'
66// The dock's starting width, in columns: about a third of a wide terminal,
67// enough for the section lines. A width the person drags the dock to wins.
68const PANE_COLUMNS = 48
69
70/** The session's status in `$.state`; null before its first change. */
71const statusAtom = atom({ plugin: 'session-status', key: 'status' } as const, null)
72
73/**
74 * Where the pane stands for the session. Auto-open opens only an `unopened`
75 * pane, so a pane the person closed stays closed until they open it again.
76 */
77const paneAtom = atom({ plugin: 'session-status', key: 'pane' } as const, 'unopened')
78
79/** What the pane shows: every section, or one list in full after its "+N more" was pressed. */
80const viewAtom = atom({ plugin: 'session-status', key: 'view' } as const, 'main')
81
82/** Moved by the age timer; the pane reads it only to draw again when it moves. */
83const tickAtom = atom({ plugin: 'session-status', key: 'tick' } as const, 0)
84
85/** Main-loop turns that ended with the pane open since the observer's last check. */
86const observerTurnsAtom = atom({ plugin: 'session-status', key: 'observerTurns' } as const, 0)
87
88// The status store: the status in `$.state`, every change saved to
89// `$.store` with the session id as the key.
90
91/**
92 * Makes `$.state` hold the status of `sessionId` when it holds another
93 * session's or none: the saved one on a resume; else none, so a `/clear` or
94 * a fork starts empty. A new session also drops the oldest saved sessions
95 * beyond KEPT_SESSIONS.
96 */
97async function holdSession($: EngineInterface, sessionId: string): Promise<void> {
98  const held = await read($, statusAtom)
99  if (held?.sessionId === sessionId) {
100    return
101  }
102  const saved = savedStatus(await $.store.get(sessionId), sessionId)
103  const status = await update($, statusAtom, current => statusForSession(withDefaults(current), sessionId, saved))
104  if (status !== null) {
105    await saveStatus($, status)
106  }
107  await pruneStore($, sessionId)
108  // A restored status that meets a trigger opens the pane, unless the person
109  // closed it.
110  if (status !== null && meetsAutoOpenTrigger(status)) {
111    await autoOpenPane($)
112  }
113}
114
115/** Deletes the saved statuses beyond the newest KEPT_SESSIONS; keeps `current`. */
116async function pruneStore($: EngineInterface, current: string): Promise<void> {
117  const keys = await $.store.keys()
118  const all = keys.includes(current) ? keys : [...keys, current]
119  if (all.length <= KEPT_SESSIONS) {
120    return
121  }
122  const entries = await Promise.all(
123    all.map(async key => ({ key, value: key === current ? null : await $.store.get(key) })),
124  )
125  for (const key of keysToPrune(entries, current)) {
126    await $.store.delete(key)
127  }
128}
129
130/**
131 * Makes `$.state` hold the status of the session that runs now (see
132 * holdSession) and resolves to its id.
133 */
134async function holdCurrentSession($: EngineInterface): Promise<string> {
135  const sessionId = await $.session.id()
136  await holdSession($, sessionId)
137
138  return sessionId
139}
140
141/** The status of the session that runs now, held first; empty before its first change. */
142async function currentStatus($: EngineInterface): Promise<SessionStatus> {
143  const sessionId = await holdCurrentSession($)
144
145  return withDefaults(await read($, statusAtom)) ?? emptyStatus(sessionId)
146}
147
148/**
149 * Saves a status to `$.store` under its session id. A refused write (a full
150 * store) goes to the debug log: the status in `$.state` stands, and the next
151 * change saves it again.
152 */
153async function saveStatus($: EngineInterface, status: SessionStatus): Promise<void> {
154  try {
155    await $.store.set(status.sessionId, status)
156  } catch (error) {
157    $.ui.log(`session-status: the status was not saved: ${String(error)}`, { to: 'debug' })
158  }
159}
160
161/**
162 * Applies one change to the status and saves it; resolves to the new status.
163 * See `applyChange`. `save: false` changes `$.state` alone, for a change a
164 * later one saves with it.
165 */
166async function changeStatus(
167  $: EngineInterface,
168  change: (status: SessionStatus) => SessionStatus,
169  options: { save?: boolean } = {},
170): Promise<SessionStatus> {
171  const sessionId = await holdCurrentSession($)
172  const stamp = { sessionId, now: await $.clock.now() }
173  let status = emptyStatus(sessionId)
174  await update($, statusAtom, current => (status = applyChange(current, change, stamp)))
175  if (options.save !== false) {
176    await saveStatus($, status)
177  }
178  if (meetsAutoOpenTrigger(status)) {
179    await autoOpenPane($)
180  }
181
182  return status
183}
184
185// Shipyard pings.
186
187/**
188 * Runs a `shipyard ping` command and forgets it: queued on a timer so it
189 * never holds the status call, every failure (no shipyard, an error, a
190 * timeout) swallowed. The child inherits Claude Code's environment, so
191 * `--herdr` finds `$HERDR_PANE_ID` when Claude Code runs in Herdr.
192 */
193function sendPing($: EngineInterface, argv: string[]): void {
194  try {
195    $.clock.after(0, () => {
196      void $.process.run(argv, { timeoutMs: 10_000 }).catch(() => undefined)
197    })
198  } catch {
199    // A ping is a courtesy: the status call goes on without it.
200  }
201}
202
203// The pane.
204
205async function isPaneOpen($: EngineInterface): Promise<boolean> {
206  return (await $.ui.panes()).some(pane => pane.id === PANE_ID)
207}
208
209/**
210 * Opens the pane, or closes it when it is open and drawn. A pane that waits
211 * undrawn is opened again instead: the person's own open seats it at any width.
212 * Says which it did.
213 */
214async function togglePane($: EngineInterface): Promise<'opened' | 'closed'> {
215  if ((await isPaneOpen($)) && !(await isPaneWaiting($))) {
216    await update($, paneAtom, () => 'closed')
217    await update($, viewAtom, () => 'main')
218    await $.ui.close({ id: PANE_ID })
219
220    return 'closed'
221  }
222  await update($, paneAtom, () => 'open')
223  await $.ui.open({ id: PANE_ID, title: PANE_TITLE, columns: PANE_COLUMNS })
224
225  return 'opened'
226}
227
228/**
229 * Opens the pane unasked, once per session: only a pane that was never
230 * opened. The engine seats it from 144 columns (110 for an id the person
231 * opened before) and keeps it waiting below; the band shows meanwhile.
232 */
233async function autoOpenPane($: EngineInterface): Promise<void> {
234  let isOpening = false
235  await update($, paneAtom, state => {
236    isOpening = state === 'unopened'
237
238    return isOpening ? 'open' : state
239  })
240  if (isOpening) {
241    await $.ui.open({ id: PANE_ID, title: PANE_TITLE, columns: PANE_COLUMNS })
242  }
243}
244
245/** Whether the pane is open but waits undrawn: the terminal is too narrow. */
246async function isPaneWaiting($: EngineInterface): Promise<boolean> {
247  return (await $.ui.panes()).some(pane => pane.id === PANE_ID && !pane.isPlaced)
248}
249
250// The observer agent: a small model's check on the recent work, shown as
251// surprises tagged `observer`. It adds nothing to the main agent's context.
252
253/** True while a check runs, so a second trigger does not start another. */
254let isObserving = false
255
256/**
257 * Counts a main-loop turn toward the observer's interval and runs one check
258 * when it is due: the pane open, no check running, under the cap, and the
259 * turn interval reached or a subagent finished (see isCheckDue). The hooks
260 * start it without waiting, so a check never holds up the session.
261 */
262async function observe($: EngineInterface, trigger: Trigger): Promise<void> {
263  if (!(await isPaneOpen($))) {
264    return
265  }
266  // A subagent trigger reads the turns since the last check: after a
267  // dismissal it waits for the turn interval too (see isCheckDue).
268  const turns =
269    trigger === 'turn' ? await update($, observerTurnsAtom, n => n + 1) : await read($, observerTurnsAtom)
270  const status = withDefaults(await read($, statusAtom)) ?? emptyStatus(await $.session.id())
271  // No await between the test and the set: a second trigger sees the flag.
272  if (isObserving || !isCheckDue(status, turns, trigger)) {
273    return
274  }
275  isObserving = true
276  try {
277    await update($, observerTurnsAtom, () => 0)
278    const request = observerRequest(status, await $.session.messages())
279    // A refused request (a blocked model) counts as a check that found nothing.
280    const reply = await $.model.complete(request).catch(() => null)
281    const findings = reply?.isAnswered === true ? parseFindings(reply.text) : []
282    const now = await $.clock.now()
283    await changeStatus($, current => recordCheck(current, findings, now))
284  } finally {
285    isObserving = false
286  }
287}
288
289/** Starts `observe` without waiting for it; a failure goes to the debug log. */
290function startObserver($: EngineInterface, trigger: Trigger): void {
291  observe($, trigger).catch((error: unknown) => {
292    $.ui.log(`session-status observer: ${String(error)}`, { to: 'debug' })
293  })
294}
295
296/** This module load's age timer; a reload starts the module, and this, over. */
297let ageTicker: Timer | undefined
298
299/** Copies a value the person pressed in the pane, and says so in a toast. */
300async function copyValue($: EngineInterface, text: string, surface: RenderSurface): Promise<void> {
301  const copied = await $.ui.copy({ text, surface })
302  $.ui.toast(copied.isCopied ? `Copied ${text}` : `Could not copy ${text}`)
303}
304
305/**
306 * Answers a decision from its option button: resolves it, withdraws its ping
307 * when it pinged, and sends `<id>: <option>` as the person's own words. A
308 * decision already closed (a second press) sends nothing.
309 */
310async function answerDecision($: EngineInterface, decision: Decision, option: string): Promise<void> {
311  const closed = await closeStatusItem($, { kind: 'decision', id: decision.id }, await $.clock.now())
312  if ('error' in closed) {
313    return
314  }
315  await $.prompt.submit({ text: `${decision.id}: ${option}`, asUser: true })
316}
317
318/**
319 * Discusses a decision or a surprise from its Discuss button: marks it,
320 * keeps it open, and asks the agent in the person's own words to discuss it
321 * in the chat. An item closed meanwhile sends nothing.
322 */
323async function discussItem($: EngineInterface, item: Decision | Surprise): Promise<void> {
324  const now = await $.clock.now()
325  const target = { kind: item.kind, id: item.id }
326  if (!isOpenItem(await currentStatus($), target)) {
327    return
328  }
329  let isMarked = false
330  await changeStatus($, status => {
331    isMarked = isOpenItem(status, target)
332
333    return markDiscussing(status, target, now)
334  })
335  if (!isMarked) {
336    return
337  }
338  const subject = item.kind === 'decision' ? item.question : item.occurred
339  await $.prompt.submit({ text: `Let's discuss ${item.id}: ${subject}`, asUser: true })
340}
341
342/** Whether the status holds an open item of the kind with the id. */
343function isOpenItem(status: SessionStatus, target: { kind: StatusItem['kind']; id: string }): boolean {
344  return status.items.some(item => item.kind === target.kind && item.id === target.id && isOpen(item))
345}
346
347/**
348 * Closes a surprise from one of its buttons. Dismiss (no `prompt`) sends
349 * nothing; File issue and the suggested action mark it acted on and send
350 * `prompt` as the person's own words. A surprise already closed (a second
351 * press) sends nothing.
352 */
353async function closeSurprise($: EngineInterface, surprise: Surprise, prompt?: string): Promise<void> {
354  const target = { kind: 'surprise', id: surprise.id } as const
355  const closed = await closeStatusItem($, target, await $.clock.now(), { acted: prompt !== undefined })
356  if ('error' in closed || prompt === undefined) {
357    return
358  }
359  await $.prompt.submit({ text: prompt, asUser: true })
360}
361
362/**
363 * Closes one open item at `now`, saved, and withdraws its ping when it
364 * pinged: the status tool's `resolve` and `dismiss` and the pane's buttons
365 * share it. Says what is wrong when no open item of that kind has the id.
366 */
367async function closeStatusItem(
368  $: EngineInterface,
369  target: { kind: StatusItem['kind']; id: string },
370  now: number,
371  options: { acted?: boolean } = {},
372): Promise<{ item: StatusItem } | { error: string }> {
373  const checked = closeItem(await currentStatus($), target, now, options)
374  if ('error' in checked) {
375    return checked
376  }
377  // Closed inside the change, so of two quick presses only one closes it.
378  let outcome: ReturnType<typeof closeItem> = checked
379  const after = await changeStatus($, status => {
380    outcome = closeItem(status, target, now, options)
381
382    return 'error' in outcome ? status : outcome.status
383  })
384  if ('error' in outcome) {
385    return outcome
386  }
387  if (pings(outcome.item)) {
388    withdrawItemPing($, after.sessionId, outcome.item)
389  }
390
391  return { item: outcome.item }
392}
393
394/** Withdraws the ping sent for a decision or blocker. */
395function withdrawItemPing($: EngineInterface, sessionId: string, item: StatusItem): void {
396  // An item carried over a /clear keeps the ping id its own session sent.
397  sendPing($, withdrawPing(('pingId' in item ? item.pingId : undefined) ?? pingId(sessionId, item.id)))
398}
399
400/** Resets the session progress (see `resetProgress`), saved at once; resolves to the reply. */
401async function reset($: EngineInterface): Promise<string> {
402  let removed: ResetRemoved | undefined
403  await changeStatus($, status => {
404    const outcome = resetProgress(status)
405    removed = outcome.removed
406
407    return outcome.status
408  })
409
410  return resetText(removed ?? { items: 0, entries: 0, links: 0, tickets: 0, effort: null })
411}
412
413/**
414 * Draws the pane again every AGE_TICK_MS while it is open, so "last update"
415 * ages without a status change. A reload drops the timer, and the
416 * `session.start` that follows a reload starts it again. A `session.start`
417 * that runs again in the same load stops the timer before it, so one runs.
418 * (`/clear` fires no `session.start`; the timer goes on across it.)
419 */
420function startAgeTicker($: EngineInterface): void {
421  ageTicker?.cancel()
422  ageTicker = $.clock.every(AGE_TICK_MS, () => {
423    void (async () => {
424      if (await isPaneOpen($)) {
425        const now = await $.clock.now()
426        await update($, tickAtom, () => now)
427      }
428    })().catch((error: unknown) => {
429      $.ui.log(`session-status age timer: ${String(error)}`, { to: 'debug' })
430    })
431  })
432}
433
434export const register: Register = on => {
435  on('session.start', async ($, e, next) => {
436    await $.command.register({
437      name: COMMAND,
438      description: 'Open or close the session status pane; `reset` clears the whole session status',
439    })
440    await $.tool.register(STATUS_TOOL_SPEC)
441    startAgeTicker($)
442    // An open pane stays open for the session: a reload that dropped it
443    // opens it again.
444    if ((await read($, paneAtom)) === 'open' && !(await isPaneOpen($))) {
445      await $.ui.open({ id: PANE_ID, title: PANE_TITLE, columns: PANE_COLUMNS })
446    }
447    readPlace($)
448
449    return next(e)
450  })
451
452  // Resume, /clear and fork move the process to another session id; the
453  // status follows it (see holdSession): a status belongs to one session id,
454  // so a /clear starts empty and a resume restores what that id saved.
455  // /compact keeps the session and its status, so `compact` changes nothing,
456  // and no hook here touches the status on PreCompact or PostCompact.
457  on('classic.SessionStart', async ($, e, next) => {
458    if (e.source !== 'compact') {
459      await holdSession($, e.session_id)
460      readPlace($)
461    }
462
463    return next(e)
464  })
465
466  on('classic.CwdChanged', async ($, e, next) => {
467    readPlace($)
468
469    return next(e)
470  })
471
472  // `/session-status` opens or closes the pane; `/session-status reset`
473  // clears the session progress a `/clear` carried over.
474  on('command.run', { command: COMMAND }, async ($, e) => {
475    const arg = e.args.trim().toLowerCase()
476    if (arg === 'reset') {
477      return { text: await reset($) }
478    }
479    if (arg !== '') {
480      return { text: `Unknown argument "${e.args.trim()}". Use /${COMMAND} to open or close the pane, or /${COMMAND} reset to clear the whole session status.` }
481    }
482    const done = await togglePane($)
483
484    return { text: `Session status pane ${done}.` }
485  })
486
487  on('prompt.compose', async (_$, e, next) => {
488    const { sections } = await next(e)
489
490    return { sections: [...sections, INSTRUCTIONS] }
491  })
492
493  // The status tool: the model records a decision or a surprise, closes one,
494  // marks the decide list posted, or reports a ticket's state. The
495  // matcher spells STATUS_TOOL out, so `claude plugin validate` can read it.
496  on('tool.call', { tool: 'mcp__session-status__status' }, async ($, e) => {
497    const input = readStatusToolInput(e as unknown as Record<string, unknown>)
498    if ('error' in input) {
499      return { deny: input.error }
500    }
501    const now = await $.clock.now()
502
503    if ('close' in input) {
504      const closed = await closeStatusItem($, input.close, now)
505
506      return 'error' in closed ? { deny: closed.error } : { result: closedText(closed.item) }
507    }
508
509    if ('postEndList' in input) {
510      const current = await currentStatus($)
511      if (openToDecide(current).length === 0) {
512        return { result: endListText([]) }
513      }
514      const posted = await changeStatus($, status => ({ ...status, endListPostedAt: now }))
515      const listed = onEndList(posted)
516      sendPing($, endListPing(posted.sessionId, listed.map(item => item.id)))
517
518      return { result: endListText(listed) }
519    }
520
521    if ('list' in input) {
522      return { result: listText(await currentStatus($), input.list) }
523    }
524
525    if ('reset' in input) {
526      return { result: await reset($) }
527    }
528
529    if ('crud' in input) {
530      const request = input.crud
531      if (request.action === 'read') {
532        return { result: readEntries(await currentStatus($), request.filter) }
533      }
534      const context = { now, agentId: input.agentId }
535      // The reply comes from the change the status took, not from a check
536      // made before it: another change can land in between.
537      let outcome: CrudOutcome | { error: string } | undefined
538      const after = await changeStatus($, status => {
539        outcome =
540          request.action === 'create'
541            ? createEntry(status, request.kind, request.fields, context)
542            : request.action === 'update'
543              ? updateEntry(status, request.kind, request.id, request.fields, context)
544              : deleteEntry(status, request.kind, request.id, context)
545
546        return 'error' in outcome ? status : outcome.status
547      })
548      if (outcome === undefined || 'error' in outcome) {
549        return { deny: outcome?.error ?? 'Nothing changed.' }
550      }
551      if (outcome.pinged !== undefined) {
552        pingChange($, after.sessionId, outcome.pinged.before, outcome.pinged.after)
553      }
554      if (request.kind === 'link') {
555        await readLinkStatesIfDue($, e)
556      }
557
558      return { result: outcome.text }
559    }
560
561    if ('link' in input) {
562      const { link } = input
563      // The reply comes from the change the status took: linkPage says
564      // whether the page was listed already.
565      let isAdded = false
566      await changeStatus($, status => {
567        const linked = linkPage(status, link, { agentId: input.agentId, at: now })
568        isAdded = linked.isAdded
569
570        return linked.status
571      })
572
573      await readLinkStatesIfDue($, e)
574
575      return { result: linkedText(link, isAdded) }
576    }
577
578    if ('ticket' in input) {
579      const request = input.ticket
580      // The reply comes from the change the status took, not from a check
581      // made before it: another change can land in between.
582      let outcome: ReturnType<typeof reportTicket> | undefined
583      const reported = await changeStatus($, status => {
584        outcome = reportTicket(status, request, now)
585
586        return 'error' in outcome ? status : outcome.status
587      })
588      if (outcome === undefined || 'error' in outcome) {
589        return { deny: outcome?.error ?? 'The ticket was not reported.' }
590      }
591      // A ticket report shows an effort run, as a call to an effort skill does.
592      // One without an `effort` field names it after the branch while it has none.
593      await effortSeen($, null)
594      await countTicketsIfDue($, e)
595
596      return { result: ticketText(request, outcome, sessionProgress(reported)) }
597    }
598
599    if ('item' in input) {
600      const request = input.item
601      let outcome: ReturnType<typeof reportItem> | undefined
602      const reported = await changeStatus($, status => {
603        outcome = reportItem(status, request, now)
604
605        return 'error' in outcome ? status : outcome.status
606      })
607      if (outcome === undefined || 'error' in outcome) {
608        return { deny: outcome?.error ?? 'The item was not reported.' }
609      }
610
611      return { result: itemText(outcome.item, outcome.change, sessionProgress(reported)) }
612    }
613
614    const { draft } = input
615    let recorded: StatusItem | undefined
616    const after = await changeStatus($, status => {
617      const { status: next, item } = recordDraft(status, draft, now)
618      recorded = item
619
620      return next
621    })
622    if (recorded !== undefined) {
623      pingChange($, after.sessionId, null, recorded)
624    }
625
626    return { result: recorded === undefined ? 'Recorded.' : recordedText(recorded) }
627  })
628
629  // Subagents' tool calls arrive here too, with `agentId` set: they count
630  // as the main loop's do.
631  on('tool.call', async ($, e, next) => {
632    // A call to the status tool is about the status, not the work.
633    if (e.tool === STATUS_TOOL) {
634      return next(e)
635    }
636    // Doing now reaches `$.state` (and the pane) before the tool runs, and
637    // `$.store` once with the call's result, after it.
638    const doing = describeToolCall(e, await $.clock.now())
639    await changeStatus($, status => ({ ...status, doingNow: doing }), { save: false })
640    const sighting = effortSighting(e)
641    if (sighting !== null) {
642      await effortSeen($, sighting.name)
643    }
644    if (settles(e as { tool: string; skill?: unknown })) {
645      await changeStatus($, status => ({ ...status, activity: 'settling' }))
646    }
647    const answer = await next(e)
648    const change = toolResultChange(e, answer, await $.clock.now())
649    await changeStatus($, change ?? (status => status))
650    await countTicketsIfDue($, e)
651    await readLinkStatesIfDue($, e)
652    if (answer.deny === undefined && answer.isError !== true) {
653      countPlaces($, e)
654    }
655    if (changesBranch(e)) {
656      readPlace($)
657    }
658
659    return answer
660  })
661
662  on('classic.TaskCreated', async ($, e, next) => {
663    const at = await $.clock.now()
664    await changeStatus($, status =>
665      taskCreated(status, { id: e.task_id, subject: e.task_subject }, at),
666    )
667
668    return next(e)
669  })
670
671  on('classic.TaskCompleted', async ($, e, next) => {
672    const at = await $.clock.now()
673    await changeStatus($, status =>
674      taskUpdated(status, { id: e.task_id, status: 'completed', subject: e.task_subject }, at),
675    )
676
677    return next(e)
678  })
679
680  // The subagent counters count only the subagents an Agent tool call
681  // started: `agent.spawn` fires for those alone and answers the started
682  // agent's id. Claude Code's own agents (compaction and the like) fire the
683  // classic SubagentStart and SubagentStop too, but no `agent.spawn`, so
684  // SubagentStart moves nothing and a SubagentStop counts only an agent the
685  // counters hold as running.
686  on('agent.spawn', async ($, e, next) => {
687    const answer = await next(e)
688    const agentId = answer.deny === undefined ? answer.agentId : undefined
689    if (agentId !== undefined) {
690      await changeStatus($, status => subagentStarted(status, agentId))
691    }
692
693    return answer
694  })
695
696  on('classic.SubagentStop', async ($, e, next) => {
697    const sessionId = await $.session.id()
698    const held = withDefaults(await read($, statusAtom))
699    if (held?.sessionId === sessionId && isRunningSubagent(held, e.agent_id)) {
700      await changeStatus($, status => subagentStopped(status, e.agent_id))
701      startObserver($, 'subagent')
702    }
703
704    return next(e)
705  })
706
707  // A turn that starts puts the session in progress, a settled one too; a
708  // prompt that types a settle skill's slash command settles the turn, as a
709  // Skill call to it does; a prompt that is a cron job's is that job firing.
710  on('turn.start', async ($, e, next) => {
711    const activity = settlesByPrompt(e.text) ? 'settling' : 'working'
712    const at = await $.clock.now()
713    await changeStatus($, status => {
714      const fired = cronFired(status, e.text, at)
715
716      return fired.activity === activity ? fired : { ...fired, activity }
717    })
718
719    return next(e)
720  })
721
722  // The main loop's turn ends: the session waits for a reply, or is settled
723  // when a settle skill ran in it. Only these turns count toward the
724  // observer's interval.
725  on('turn.complete', async ($, e, next) => {
726    if (e.agentId === undefined) {
727      await changeStatus($, status => ({ ...status, activity: afterTurn(status.activity, e.reason === 'aborted') }))
728      startObserver($, 'turn')
729    }
730
731    return next(e)
732  })
733
734  // The person closing the pane by its mark or key: auto-open leaves it
735  // closed for the rest of the session.
736  on('ui.close', { id: 'session-status' }, async ($, e, next) => {
737    if (e.origin.kind === 'person') {
738      await update($, paneAtom, () => 'closed')
739      await update($, viewAtom, () => 'main')
740    }
741
742    return next(e)
743  })
744
745  // The band: counts only, while the pane waits on a narrow terminal.
746  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
747    const status = await read($, statusAtom)
748    if ((await read($, paneAtom)) !== 'open' || e.props.hasSurvey || !(await isPaneWaiting($))) {
749      return next(e)
750    }
751
752    return drawBand($.ui.resolve(e), withDefaults(status))
753  })
754
755  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
756    await read($, tickAtom)
757
758    return drawPane({
759      ui: $.ui.resolve(e),
760      surface: e.surface,
761      status: withDefaults(await read($, statusAtom)),
762      now: await $.clock.now(),
763      columns: e.props.bodyColumns,
764      view: await read($, viewAtom),
765      show: view => update($, viewAtom, () => view),
766      copy: (text, surface) => copyValue($, text, surface),
767      replies: {
768        answer: (decision, option) => answerDecision($, decision, option),
769        discuss: item => discussItem($, item),
770        dismiss: surprise => closeSurprise($, surprise),
771        fileIssue: surprise => closeSurprise($, surprise, `File an issue for ${surprise.id}: ${surprise.occurred}`),
772        act: (surprise, action) => closeSurprise($, surprise, `${surprise.id}: ${action}`),
773      },
774    })
775  })
776}
777
778/**
779 * Sends or withdraws the ping of a decision or blocker that a change made
780 * ping or stop pinging: recorded or opened again blocked, or closed,
781 * deleted or no longer blocked.
782 */
783function pingChange($: EngineInterface, sessionId: string, before: StatusItem | null, after: StatusItem | null): void {
784  const wasPinging = before !== null && isOpen(before) && pings(before)
785  const isPinging = after !== null && isOpen(after) && pings(after)
786  if (!wasPinging && isPinging) {
787    sendPing($, after.kind === 'blocker' ? blockerPing(sessionId, after) : blockedPing(sessionId, after))
788  } else if (wasPinging && !isPinging) {
789    withdrawItemPing($, sessionId, before)
790  }
791}
792
793/**
794 * Records an effort run a tool call shows, and opens the pane for it. A run
795 * that names no label takes the branch's name while the effort has none.
796 */
797async function effortSeen($: EngineInterface, name: string | null): Promise<void> {
798  if (name !== null) {
799    await changeStatus($, status => withEffort(status, { name, from: 'label' }))
800  } else if (withDefaults(await read($, statusAtom))?.effort == null) {
801    nameEffortFromBranch($)
802  }
803  // A run whose name is still unknown opens the pane too.
804  await autoOpenPane($)
805}
806
807// Effort progress: during an effort, progress counts the effort's tickets.
808
809/** The effort and time of the last ticket read started; null before the first. */
810let lastTicketRead: { effort: string; at: number } | null = null
811
812/** True while a ticket read is queued or runs, so a second one does not start. */
813let isCountingTickets = false
814
815/** The effort of a read that fell due while one ran: it runs once when that one ends. */
816let waitingTicketRead: string | null = null
817
818/**
819 * Starts a read of the effort's tickets when one is due after a tool call
820 * (see `isTicketReadDue`). One read runs at a time: a read due meanwhile
821 * runs once after it. The read runs on a timer, so it never holds the tool
822 * call; a failed read keeps the last good count.
823 */
824async function countTicketsIfDue($: EngineInterface, call: { tool: string }): Promise<void> {
825  const status = withDefaults(await read($, statusAtom))
826  const now = await $.clock.now()
827  const effort = status?.effort?.name
828  if (effort === undefined || !isTicketReadDue(status, lastTicketRead, call, now)) {
829    return
830  }
831  lastTicketRead = { effort, at: now }
832  // No await between the test and the set: a second call sees the flag.
833  if (isCountingTickets) {
834    waitingTicketRead = effort
835
836    return
837  }
838  isCountingTickets = true
839  startTicketRead($, effort)
840}
841
842/** Queues one ticket read, and the waiting one after it; clears the flag at the end. */
843function startTicketRead($: EngineInterface, effort: string): void {
844  const finish = async () => {
845    await countTickets($, effort)
846    const waiting = waitingTicketRead
847    if (waiting !== null) {
848      waitingTicketRead = null
849      await countTickets($, waiting)
850    }
851  }
852  try {
853    $.clock.after(0, () => {
854      void finish().finally(() => {
855        isCountingTickets = false
856      })
857    })
858  } catch {
859    isCountingTickets = false
860  }
861}
862
863/** The last read of the links' states in this module load: how many links it read, and when. */
864let lastLinkStateRead: { links: number; at: number } | null = null
865
866/** Whether a read of the links' states runs now: one at a time. */
867let isReadingLinkStates = false
868
869/**
870 * Starts a read of the links' states when one is due after a tool call (see
871 * `isLinkStateReadDue`). The read runs on a timer, so it never holds the
872 * tool call; a read due while one runs is left to the next call, and a
873 * failed read keeps the states the links had.
874 */
875async function readLinkStatesIfDue($: EngineInterface, call: { tool: string }): Promise<void> {
876  const status = withDefaults(await read($, statusAtom))
877  const now = await $.clock.now()
878  if (isReadingLinkStates || status === null || !isLinkStateReadDue(status, lastLinkStateRead, call, now)) {
879    return
880  }
881  const argv = linkStatesArgv(status.links)
882  if (argv === null) {
883    return
884  }
885  lastLinkStateRead = { links: status.links.length, at: now }
886  isReadingLinkStates = true
887  inBackground($, 'link states', async () => {
888    try {
889      // A repository GitHub cannot find fails the call but still answers for the others.
890      const { stdout } = await $.process.run(argv, { timeoutMs: LINK_STATE_TIMEOUT_MS })
891      const states = parseLinkStates(stdout, status.links)
892      await changeStatus($, current => withLinkStates(current, states))
893    } finally {
894      isReadingLinkStates = false
895    }
896  })
897}
898
899/** Reads the effort's tickets with `gh` and keeps the count; swallows every failure. */
900async function countTickets($: EngineInterface, effort: string): Promise<void> {
901  try {
902    const { exitCode, stdout } = await $.process.run(ticketListArgv(effort), { timeoutMs: TICKET_TIMEOUT_MS })
903    const count = exitCode === 0 ? parseTicketList(stdout) : null
904    if (count !== null) {
905      const at = await $.clock.now()
906      await changeStatus($, status => withTickets(status, { effort, ...count, at }))
907    }
908  } catch {
909    // No gh, no network, a timeout: the last good count holds.
910  }
911}
912
913/**
914 * Runs `work` on a timer, so it never holds the hook that starts it; a
915 * failure goes to the debug log, and the status stays as it was.
916 */
917function inBackground($: EngineInterface, what: string, work: () => Promise<void>): void {
918  try {
919    $.clock.after(0, () => {
920      work().catch((error: unknown) => {
921        $.ui.log(`session-status ${what}: ${String(error)}`, { to: 'debug' })
922      })
923    })
924  } catch {
925    // The status stays as it was until the next read.
926  }
927}
928
929/**
930 * Names the effort after the session's git branch, in the background. A
931 * label or a report found meanwhile wins (see `withEffort`).
932 */
933function nameEffortFromBranch($: EngineInterface): void {
934  inBackground($, 'branch read', async () => {
935    const branch = await readBranch($)
936    if (branch !== null) {
937      await changeStatus($, status => withEffort(status, { name: branch, from: 'branch' }))
938    }
939  })
940}
941
942/** The session's git branch, or null when git gives none. */
943async function readBranch($: EngineInterface): Promise<string | null> {
944  const branch = await gitLine($, ['git', 'branch', '--show-current'])
945
946  return branch === null ? null : branchEffortName(branch)
947}
948
949// Where the session works: its repository, branch and worktree, read from
950// git in the background and cached per folder, so a tool call never waits.
951
952/**
953 * Each folder's repository top folder, as git answered it. Only an answer is
954 * kept: a folder outside a repository, or not made yet, is asked again.
955 */
956const repoRoots = new Map<string, Promise<string | null>>()
957
958/** Each repository's GitHub repository, from its `origin` remote; null when it has none there. */
959const githubRepos = new Map<string, Promise<GitHubRepo | null>>()
960
961/** The top folder of the repository that holds `dir`, asked of git once per folder. */
962function repoRootOf($: EngineInterface, dir: string): Promise<string | null> {
963  let root = repoRoots.get(dir)
964  if (root === undefined) {
965    root = gitLine($, ['git', '-C', dir, 'rev-parse', '--show-toplevel'])
966    repoRoots.set(dir, root)
967    void root.then(found => {
968      if (found === null) {
969        repoRoots.delete(dir)
970      }
971    })
972  }
973
974  return root
975}
976
977/** The GitHub repository of the repository at `root`, asked of git once per repository. */
978function githubRepoOf($: EngineInterface, root: string): Promise<GitHubRepo | null> {
979  let repo = githubRepos.get(root)
980  if (repo === undefined) {
981    repo = gitLine($, ['git', '-C', root, 'remote', 'get-url', 'origin']).then(url =>
982      url === null ? null : githubRepo(url),
983    )
984    githubRepos.set(root, repo)
985  }
986
987  return repo
988}
989
990/**
991 * Reads where the session works, in the background, into the status: in
992 * `$.state` alone, so a session that does nothing saves nothing; the next
993 * change saves it. Outside a repository the status keeps what it had.
994 */
995function readPlace($: EngineInterface): void {
996  inBackground($, 'place read', async () => {
997    const root = await repoRootOf($, await $.session.cwd())
998    if (root === null) {
999      return
1000    }
1001    const [branch, repo] = await Promise.all([readBranch($), githubRepoOf($, root)])
1002    await changeStatus($, status => withPlace(status, { root, branch, repo }), { save: false })
1003  })
1004}
1005
1006/** The repository that holds `dir`, with its GitHub repository; null outside one. */
1007async function changedRepoAt($: EngineInterface, dir: string): Promise<ChangedRepo | null> {
1008  const root = await repoRootOf($, dir)
1009
1010  return root === null ? null : { root, repo: await githubRepoOf($, root) }
1011}
1012
1013/**
1014 * Counts what a finished tool call changed in its repository, in the
1015 * background: the file an edit or a write touched, and each step of a
1016 * shell command that changes something (see CHANGING_COMMANDS).
1017 */
1018function countPlaces($: EngineInterface, call: { tool: string }): void {
1019  const file = editedFile(call)
1020  const command = call.tool === 'Bash' ? (call as unknown as { command?: unknown }).command : undefined
1021  if (file === null && typeof command !== 'string') {
1022    return
1023  }
1024  inBackground($, 'place count', async () => {
1025    const at = await $.clock.now()
1026    const where = file === null ? null : await changedRepoAt($, folderOf(file))
1027    if (file !== null && where !== null) {
1028      await changeStatus($, status => withChange(status, where, { file }, at))
1029    }
1030    const targets = typeof command === 'string' ? commandTargets(command, await $.session.cwd()) : []
1031    for (const target of targets) {
1032      const repo: ChangedRepo | null =
1033        'slug' in target ? { root: null, repo: repoOf(target.slug) } : await changedRepoAt($, target.dir)
1034      if (repo !== null) {
1035        await changeStatus($, status => withChange(status, repo, { command: true }, at))
1036      }
1037    }
1038  })
1039}
1040
1041/** What a git command prints, trimmed; null when it fails or prints nothing. */
1042async function gitLine($: EngineInterface, argv: string[]): Promise<string | null> {
1043  try {
1044    const { exitCode, stdout } = await $.process.run(argv, { timeoutMs: 5_000 })
1045    const line = stdout.trim()
1046
1047    return exitCode === 0 && line !== '' ? line : null
1048  } catch {
1049    return null
1050  }
1051}
1052
hooks/activity.ts 78 lines
1// The session's state in one word, for the top of the pane: Blocked, In
2// progress, Settling, Waiting for reply or Settled. The turn events and the settle
3// skills set what the session does; an open blocked decision or blocker wins over it.
4
5import type { Activity, SessionStatus } from '../types'
6import { isOpen } from './status'
7
8/** The skills that settle a session: a Skill call to one marks it settled. */
9export const SETTLE_SKILLS: readonly string[] = ['settle-session', 'settle-effort']
10
11/** The state the pane shows, by priority; null before the first turn. */
12export type Headline = 'blocked' | 'working' | 'settling' | 'waiting' | 'settled'
13
14/** What the pane's top line says for each state. */
15export const HEADLINE_TEXT: Record<Headline, string> = {
16  blocked: 'Blocked',
17  working: 'In progress',
18  settling: 'Settling',
19  waiting: 'Waiting for reply',
20  settled: 'Settled',
21}
22
23/**
24 * The session's state: Blocked while a blocked decision or a blocker is open; Settling
25 * while a turn that runs a settle skill runs; In progress while another turn
26 * or a subagent runs; else what the last turn left, waiting for a reply or
27 * settled.
28 */
29export function headline(status: SessionStatus): Headline | null {
30  const isBlocked = status.items.some(
31    item => (item.kind === 'blocker' || (item.kind === 'decision' && item.urgency === 'blocked')) && isOpen(item),
32  )
33  if (isBlocked) {
34    return 'blocked'
35  }
36  if (status.activity === 'settling') {
37    return 'settling'
38  }
39  if (status.activity === 'working' || status.subagents.running.length > 0) {
40    return 'working'
41  }
42
43  return status.activity
44}
45
46/** Whether a tool call runs a settle skill (a plugin's `<plugin>:<skill>` too). */
47export function settles(call: { tool: string; skill?: unknown }): boolean {
48  if (call.tool !== 'Skill' || typeof call.skill !== 'string') {
49    return false
50  }
51
52  return SETTLE_SKILLS.includes(call.skill.slice(call.skill.lastIndexOf(':') + 1))
53}
54
55/** A settle skill's slash command: `/settle-session`, `/settle-effort` or a plugin's `/<plugin>:settle-effort`. */
56const SETTLE_NAME = `\\/(?:[\\w-]+:)?(?:${SETTLE_SKILLS.join('|')})`
57
58/**
59 * A prompt that runs a settle skill as a slash command. A typed command
60 * reaches `turn.start` as tags, `<command-message>…</command-message>
61 * <command-name>/settle-session</command-name><command-args>…`, so the tag
62 * counts wherever it is; a bare `/settle-session` counts first in the prompt.
63 */
64const SETTLE_COMMAND = new RegExp(`<command-name>${SETTLE_NAME}</command-name>|^\\s*${SETTLE_NAME}(?:\\s|$)`)
65
66/** Whether a turn's prompt runs a settle skill as a slash command. */
67export function settlesByPrompt(text: string): boolean {
68  return SETTLE_COMMAND.test(text)
69}
70
71/**
72 * The activity a turn's end leaves: settled after a settle skill ran in it,
73 * else waiting for a reply. An interrupted turn did not finish its settle.
74 */
75export function afterTurn(activity: Activity | null, isAborted: boolean): Activity {
76  return !isAborted && (activity === 'settling' || activity === 'settled') ? 'settled' : 'waiting'
77}
78
hooks/crons.ts 117 lines
1// The cron jobs the session scheduled: what CronCreate made, CronDelete
2// cancelled and CronList still lists, each fire, seen as a turn that starts
3// with the job's prompt, and each recurring job's expiry. Nothing here calls
4// `$`.
5
6import type { CronJob, SessionStatus } from '../types'
7import { autoSet, isDeleted } from './set-by'
8
9/**
10 * How long a recurring job lives: CronCreate's `recurring` input says a
11 * recurring job fires "until deleted or auto-expired after 7 days".
12 */
13export const CRON_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000
14
15/** A new job from CronCreate's result: active, never fired; never again once the agent deleted it. */
16export function cronCreated(
17  status: SessionStatus,
18  job: { id: string; schedule: string; prompt: string; recurring: boolean },
19  at: number,
20): SessionStatus {
21  if (status.crons.some(known => known.id === job.id) || isDeleted(status, 'cron', job.id)) {
22    return status
23  }
24
25  return { ...status, crons: [...status.crons, { ...job, state: 'active', fires: 0, createdAt: at, at }] }
26}
27
28/** A job CronDelete removed: cancelled, unless it already ended. A CronDelete is a new change, over a state the agent set too. */
29export function cronDeleted(status: SessionStatus, id: string, at: number): SessionStatus {
30  return withJob(cronsExpired(status, at), id, job =>
31    job.state === 'active' || job.fieldsSetBy?.state !== undefined ? { ...autoSet(job, 'state', 'cancelled', 'event'), at } : job,
32  )
33}
34
35/**
36 * The jobs CronList still lists stay active; an active one-shot job it no
37 * longer lists has fired (it deletes itself once it has). A list is a read:
38 * a state the agent set stays.
39 */
40export function cronListed(status: SessionStatus, listed: readonly string[], at: number): SessionStatus {
41  const current = cronsExpired(status, at)
42  const crons = current.crons.map(job => {
43    if (job.state !== 'active' || job.recurring || listed.includes(job.id)) {
44      return job
45    }
46    const fired = autoSet(job, 'state', 'fired', 'read')
47
48    return fired === job ? job : { ...fired, fires: Math.max(job.fires, 1), at }
49  })
50
51  return crons.some((job, index) => job !== current.crons[index]) ? { ...current, crons } : current
52}
53
54/**
55 * A turn whose prompt is an active job's prompt is that job firing: a
56 * one-shot job is then fired and done, a recurring one counts the fire. An
57 * expired job no longer fires.
58 */
59export function cronFired(status: SessionStatus, prompt: string, at: number): SessionStatus {
60  const current = cronsExpired(status, at)
61  const job = current.crons.find(known => known.state === 'active' && known.prompt.trim() === prompt.trim())
62  if (job === undefined) {
63    return current
64  }
65
66  return withJob(current, job.id, known => ({
67    ...known,
68    state: known.recurring ? 'active' : 'fired',
69    fires: known.fires + 1,
70    at,
71  }))
72}
73
74/**
75 * The jobs as they stand at `now`: an active recurring job scheduled 7 days
76 * or more before `now` is expired. The same array when none expired. An
77 * expiry is a read: a job the agent set active after it expired stays active.
78 */
79export function expireCrons(crons: readonly CronJob[], now: number): readonly CronJob[] {
80  const next = crons.map(job => {
81    // A job held from before createdAt was kept counts from its last change.
82    const expiresAt = (job.createdAt ?? job.at) + CRON_EXPIRY_MS
83
84    if (job.state !== 'active' || !job.recurring || now < expiresAt) {
85      return job
86    }
87    const expired = autoSet(job, 'state', 'expired', 'read')
88
89    return expired === job ? job : { ...expired, at: expiresAt }
90  })
91
92  return next.some((job, index) => job !== crons[index]) ? next : crons
93}
94
95/** How many jobs are active, fired (done), expired and cancelled. */
96export function cronCounts(crons: readonly CronJob[]): Record<CronJob['state'], number> {
97  const counts = { active: 0, fired: 0, expired: 0, cancelled: 0 }
98  for (const job of crons) {
99    counts[job.state] += 1
100  }
101
102  return counts
103}
104
105/** The status with its jobs expired as of `at`. */
106function cronsExpired(status: SessionStatus, at: number): SessionStatus {
107  const crons = expireCrons(status.crons, at)
108
109  return crons === status.crons ? status : { ...status, crons: [...crons] }
110}
111
112function withJob(status: SessionStatus, id: string, change: (job: CronJob) => CronJob): SessionStatus {
113  const crons = status.crons.map(job => (job.id === id ? change(job) : job))
114
115  return crons.some((job, index) => job !== status.crons[index]) ? { ...status, crons } : status
116}
117
hooks/auto-open.ts 24 lines
1// When the pane opens by itself: the pure rule. register.tsx opens the pane,
2// once per session, when a status change makes this true.
3
4import type { SessionStatus } from '../types'
5
6/**
7 * Whether the status has met an auto-open trigger: a subagent started, a
8 * task list was made, an effort run was found, a ticket or a session item
9 * was reported, or a decision or a surprise was recorded. The turn count is no trigger: a
10 * short session stays closed.
11 */
12export function meetsAutoOpenTrigger(status: SessionStatus): boolean {
13  const { subagents } = status
14
15  return (
16    subagents.running.length + subagents.finished.length > 0 ||
17    status.tasks.length > 0 ||
18    status.effort !== null ||
19    status.ticketReports.length > 0 ||
20    status.sessionItems.length > 0 ||
21    status.items.length > 0
22  )
23}
24
hooks/band.tsx 78 lines
1// The band above the prompt: counts only, for a terminal too narrow to place
2// the pane. Whether the pane waits is read in register.tsx, where `$` is.
3
4import type { ElementTable, RenderElement } from 'claude-code'
5
6import type { SessionStatus } from '../types'
7import { effortProgress } from './effort-progress'
8import { COLOR } from './palette'
9import { sessionProgress, taskProgress } from './session-progress'
10import { isOpen } from './status'
11
12/**
13 * The open blocked decisions and blockers, the decisions before and after settling, and the
14 * agent's surprises (the observer's findings are left out); closed items too.
15 */
16function openCounts(status: SessionStatus | null): { blocked: number; decide: number; followUp: number; surprises: number } {
17  const open = (status?.items ?? []).filter(isOpen)
18  const decisions = open.filter(item => item.kind === 'decision')
19
20  return {
21    blocked:
22      decisions.filter(item => item.urgency === 'blocked').length + open.filter(item => item.kind === 'blocker').length,
23    decide: decisions.filter(item => item.urgency === 'before_settling').length,
24    followUp: decisions.filter(item => item.urgency === 'after_settling').length,
25    surprises: open.filter(item => item.kind === 'surprise' && item.source !== 'observer').length,
26  }
27}
28
29/**
30 * `<n> blocked · <n> decide · <n> follow-up · <session> · <effort> · <n> surprise`,
31 * the follow-up figure only when there is one. The session figure is `progress 7/10` once the session has items, followed by `tasks 2/5` while a task list exists; without items
32 * it is the tasks' `<done>/<total> done`, and with no items and no tasks it is left out. The effort figure, `closed 1/13`,
33 * shows only while the tracker counts the effort's tickets.
34 */
35export function bandText(status: SessionStatus | null): string {
36  const { blocked, decide, followUp, surprises } = openCounts(status)
37  const session = sessionProgress(status)
38  const tasks = taskProgress(status)
39  const effort = effortProgress(status)
40  const parts = [
41    `${blocked} blocked`,
42    `${decide} decide`,
43    ...(followUp === 0 ? [] : [`${followUp} follow-up`]),
44    ...(session === null || session.total === 0
45      ? tasks === null || tasks.total === 0
46        ? []
47        : [`${tasks.done}/${tasks.total} done`]
48      : [`progress ${session.done}/${session.total}`, ...(tasks === null ? [] : [`tasks ${tasks.done}/${tasks.total}`])]),
49    ...(effort === null ? [] : [`closed ${effort.closed}/${effort.total}`]),
50    `${surprises} surprise`,
51  ]
52
53  return parts.join(' · ')
54}
55
56/** The band's tree: one line in a Box keyed `session-status-band`. */
57export function drawBand(
58  ui: Pick<ElementTable, 'Box' | 'Text'>,
59  status: SessionStatus | null,
60): RenderElement {
61  const { Box, Text } = ui
62  const isBlocked = openCounts(status).blocked > 0
63
64  return (
65    <Box key="session-status-band">
66      {isBlocked ? (
67        <Text wrap="truncate-end" color={COLOR.attention}>
68          {bandText(status)}
69        </Text>
70      ) : (
71        <Text wrap="truncate-end" dimColor>
72          {bandText(status)}
73        </Text>
74      )}
75    </Box>
76  )
77}
78
hooks/crud.ts 663 lines
1// The status tool's generic actions as data: `create`, `read`, `update` and
2// `delete`, the same way on every kind of entry. One table names each kind's
3// fields; the four pure functions change a SessionStatus by it. The older
4// actions (`record_decision`, `item`, `link`, `resolve`, ...) are shortcuts
5// that call the same functions. register.tsx runs them and sends the pings;
6// nothing here calls `$`.
7
8import type { Blocker, CronJob, Decision, DecisionUrgency, LinkState, SessionLink, SessionStatus, StatusItem, Task } from '../types'
9import type { ListFilter } from './reset'
10import { EVERYTHING, listText } from './reset'
11import { linkLabel, linksFound, readPageUrl } from './links'
12import type { FoundLink } from './links'
13import { pingId } from './pings'
14import { placeId } from './places'
15import { movedItem, reportItem } from './session-items'
16import type { ItemState } from './session-items'
17import { manualSet, sameKey, withDeleted, withoutDeleted } from './set-by'
18import { closeItem, isOpen, recordItem } from './status'
19import type { ItemDraft } from './status'
20import { deletedTaskKey, taskCreated, taskUpdated, withTasks } from './tasks'
21import { effortReports, reportTicket, ticketShortName } from './ticket-reports'
22
23/** The kinds of entry the generic actions change. An observation is a `surprise`. */
24export const CRUD_KINDS = ['item', 'decision', 'surprise', 'blocker', 'ticket', 'link', 'effort', 'task', 'cron', 'place'] as const
25export type CrudKind = (typeof CRUD_KINDS)[number]
26
27/** What the table says of one kind. */
28type KindSpec = {
29  /** The kind's name in a reply: `Item I5 deleted.` */
30  noun: string
31  /** How an id of the kind is written, for an error. */
32  id: string
33  /** The fields `create` takes; none when only an automatic source creates the kind. */
34  create: readonly string[]
35  /** The fields `update` can set. */
36  update: readonly string[]
37  /** The values each field of a fixed set takes. */
38  values?: Readonly<Record<string, readonly string[]>>
39}
40
41export const URGENCIES: readonly DecisionUrgency[] = ['blocked', 'before_settling', 'after_settling']
42export const MIN_OPTIONS = 2
43export const MAX_OPTIONS = 4
44const DECISION_FIELDS = ['urgency', 'question', 'options', 'default', 'unblocks'] as const
45
46/** Each kind's id, fields and values: what the status tool checks a call against. */
47export const KINDS: Readonly<Record<CrudKind, KindSpec>> = {
48  item: { noun: 'Item', id: 'I5', create: ['title'], update: ['title', 'state'], values: { state: ['added', 'done', 'dropped'] } },
49  decision: {
50    noun: 'Decision',
51    id: 'D1',
52    create: DECISION_FIELDS,
53    update: [...DECISION_FIELDS, 'state'],
54    values: { state: ['open', 'resolved'], urgency: URGENCIES },
55  },
56  surprise: { noun: 'Surprise', id: 'S2', create: ['occurred', 'changed'], update: ['occurred', 'changed', 'state'], values: { state: ['open', 'dismissed'] } },
57  blocker: { noun: 'Blocker', id: 'B1', create: ['failed', 'needs'], update: ['failed', 'needs', 'state'], values: { state: ['open', 'resolved'] } },
58  ticket: { noun: 'Ticket', id: '#4', create: ['number', 'title', 'state', 'effort'], update: ['title', 'state'], values: { state: ['started', 'landed'] } },
59  link: { noun: 'Link', id: 'claude-mods#27', create: ['url'], update: ['state'], values: { state: ['open', 'merged', 'closed'] } },
60  effort: { noun: 'Effort', id: "the effort's name", create: ['name'], update: ['name'] },
61  task: { noun: 'Task', id: 'the task id', create: ['subject', 'status'], update: ['subject', 'status'], values: { status: ['pending', 'in_progress', 'completed'] } },
62  cron: { noun: 'Cron job', id: 'the job id', create: [], update: ['state'], values: { state: ['active', 'fired', 'expired', 'cancelled'] } },
63  place: { noun: 'Place', id: 'owner/repo', create: [], update: [] },
64}
65
66/** One generic call, read from its input. */
67export type CrudRequest =
68  | { action: 'create'; kind: CrudKind; fields: Fields }
69  | { action: 'read'; filter: ListFilter | null }
70  | { action: 'update'; kind: CrudKind; id: string; fields: Fields }
71  | { action: 'delete'; kind: CrudKind; id: string }
72
73type Fields = Readonly<Record<string, unknown>>
74
75/** Who calls, and when: a subagent's id rides the call. */
76export type CrudContext = { now: number; agentId?: string }
77
78/**
79 * What a create, update or delete did: the status after it and its one-line
80 * reply. A decision or blocker it touched comes before and after, so
81 * register.tsx can send or withdraw its ping.
82 */
83export type CrudOutcome = {
84  status: SessionStatus
85  text: string
86  pinged?: { before: StatusItem | null; after: StatusItem | null }
87}
88
89/** A failed call: what is wrong, for the model to fix and call again. */
90export type CrudError = { error: string }
91
92/**
93 * The error for a call whose kind or fields the table does not have; null
94 * when they all are. `create` and `update` check their own field lists.
95 */
96export function checkFields(action: 'create' | 'update', kind: CrudKind, fields: Fields): string | null {
97  const spec = KINDS[kind]
98  const allowed = spec[action]
99  if (allowed.length === 0) {
100    return action === 'create'
101      ? `A ${spec.noun.toLowerCase()} is created only by ${kind === 'cron' ? 'CronCreate' : 'the changes the session makes'}. Use \`create\` for: ${CRUD_KINDS.filter(k => KINDS[k].create.length > 0).join(', ')}.`
102      : `A ${spec.noun.toLowerCase()} has no field that \`update\` can set. Use \`delete\` to remove it.`
103  }
104  const unknown = Object.keys(fields).filter(field => !allowed.includes(field))
105  if (unknown.length > 0) {
106    return `A ${spec.noun.toLowerCase()} has no field ${unknown.map(f => `\`${f}\``).join(', ')} that \`${action}\` can set. Use: ${allowed.join(', ')}.`
107  }
108  if (action === 'update' && Object.keys(fields).length === 0) {
109    return `\`update\` needs \`fields\`: the fields to change. A ${spec.noun.toLowerCase()} has: ${allowed.join(', ')}.`
110  }
111  for (const [field, values] of Object.entries(spec.values ?? {})) {
112    const value = fields[field]
113    if (value !== undefined && !values.includes(value as string)) {
114      return `A ${spec.noun.toLowerCase()}'s \`${field}\` is one of: ${values.join(', ')}.`
115    }
116  }
117
118  return null
119}
120
121/** What `read` returns: the entries `filter` keeps, or every entry without one. */
122export function readEntries(status: SessionStatus, filter: ListFilter | null): string {
123  return listText(status, filter ?? EVERYTHING)
124}
125
126// create
127
128/**
129 * Reads a decision's, surprise's or blocker's fields into the item to
130 * record, or says which one is missing. `record_decision`, `record_surprise`
131 * and `record_blocker` read their input with it too.
132 */
133export function readDraft(kind: StatusItem['kind'], fields: Fields): ItemDraft | CrudError {
134  switch (kind) {
135    case 'decision': {
136      // `review_later` is the name before 0.4.0 of `before_settling`.
137      const urgency = fields.urgency === 'review_later' ? 'before_settling' : fields.urgency
138      if (!URGENCIES.includes(urgency as DecisionUrgency)) {
139        return { error: 'A decision needs an urgency: `blocked`, `before_settling` or `after_settling`.' }
140      }
141      const question = text(fields.question)
142      if (question === null) {
143        return { error: 'A decision needs a question.' }
144      }
145      const options = readOptions(fields.options)
146      if (options === null) {
147        return { error: 'A decision needs two to four options, each a non-empty string.' }
148      }
149      const fallback = text(fields.default)
150      if (fallback === null) {
151        return { error: 'A decision needs a default: the answer you recommend.' }
152      }
153      const unblocks = text(fields.unblocks)
154      if (unblocks === null) {
155        return { error: 'A decision needs `unblocks`: what the user must say or do to settle it.' }
156      }
157
158      return { kind, urgency: urgency as DecisionUrgency, question, options, default: fallback, unblocks }
159    }
160    case 'surprise': {
161      const occurred = text(fields.occurred)
162      if (occurred === null) {
163        return { error: 'A surprise needs `occurred`: what occurred.' }
164      }
165      const changed = text(fields.changed)
166      if (changed === null) {
167        return { error: 'A surprise needs `changed`: what it changed in the work or the plan.' }
168      }
169      // `suggested_action`: the input's `action` names the tool's own action.
170      const action = text(fields.suggested_action)
171
172      return { kind, occurred, changed, ...(action === null ? {} : { action }) }
173    }
174    case 'blocker': {
175      const failed = text(fields.failed)
176      if (failed === null) {
177        return { error: 'A blocker needs `failed`: what you tried that failed.' }
178      }
179      const needs = text(fields.needs)
180      if (needs === null) {
181        return { error: 'A blocker needs `needs`: what the user can do to unblock you.' }
182      }
183
184      return { kind, failed, needs }
185    }
186  }
187}
188
189/**
190 * The status with one more decision, surprise or blocker, and that item. A
191 * blocked decision or a blocker keeps the id of the ping it sends, so a
192 * resolve after a /clear withdraws that ping.
193 */
194export function recordDraft(status: SessionStatus, draft: ItemDraft, now: number): { status: SessionStatus; item: StatusItem } {
195  const recorded = recordItem(status, draft, now)
196  if (!pings(recorded.item)) {
197    return recorded
198  }
199  const pinged = { ...recorded.item, pingId: pingId(status.sessionId, recorded.item.id) }
200
201  return {
202    status: { ...recorded.status, items: recorded.status.items.map(item => (item === recorded.item ? pinged : item)) },
203    item: pinged,
204  }
205}
206
207/**
208 * The status with a page the agent links: listed once, and no longer
209 * deleted, so the automatic sources keep it again. `link` and `create` both
210 * call it. `isAdded` is false when the page was listed already.
211 */
212export function linkPage(
213  status: SessionStatus,
214  link: FoundLink,
215  stamp: { agentId?: string; at: number },
216): { status: SessionStatus; isAdded: boolean } {
217  const restored = withoutDeleted(status, 'link', link.url)
218  const linked = linksFound(restored, [link], stamp)
219  if (linked === restored) {
220    return { status, isAdded: false }
221  }
222
223  return { status: { ...linked, links: linked.links.map(known => (known.url === link.url ? { ...known, setBy: 'manual' as const } : known)) }, isAdded: true }
224}
225
226/** The status with one new entry of `kind`, made from `fields`. */
227export function createEntry(
228  status: SessionStatus,
229  kind: CrudKind,
230  fields: Fields,
231  context: CrudContext,
232): CrudOutcome | CrudError {
233  const wrong = checkFields('create', kind, fields)
234  if (wrong !== null) {
235    return { error: wrong }
236  }
237  const { now } = context
238  switch (kind) {
239    case 'item': {
240      if (context.agentId !== undefined) {
241        return { error: 'Only the main session reports items. Put the work in your final report.' }
242      }
243      const title = text(fields.title)
244      if (title === null) {
245        return { error: 'A new item needs `title`: the work, in a few words.' }
246      }
247      const outcome = reportItem(status, { state: 'added', title }, now)
248      if ('error' in outcome) {
249        return outcome
250      }
251
252      return { status: outcome.status, text: `Item ${outcome.item.id} created.` }
253    }
254    case 'decision':
255    case 'surprise':
256    case 'blocker': {
257      const draft = readDraft(kind, fields)
258      if ('error' in draft) {
259        return draft
260      }
261      const { status: next, item } = recordDraft(status, context.agentId === undefined ? draft : { ...draft, agentId: context.agentId }, now)
262
263      return { status: next, text: `${KINDS[kind].noun} ${item.id} created.`, pinged: { before: null, after: item } }
264    }
265    case 'ticket': {
266      const number = ticketNumber(fields.number)
267      if (number === null) {
268        return { error: "A ticket's `number` is its issue number: a positive integer." }
269      }
270      const title = text(fields.title)
271      if (number === undefined && title === null) {
272        return { error: 'A ticket needs `number` (its issue number) or, when it has none, `title`.' }
273      }
274      const state = fields.state === undefined ? 'started' : (fields.state as 'started' | 'landed')
275      const effort = text(fields.effort)
276      const restored = withoutDeleted(status, 'ticket', ticketShortName({ number, title: title ?? '' }))
277      const outcome = reportTicket(
278        restored,
279        { state, ...(number === undefined ? {} : { number }), ...(title === null ? {} : { title }), ...(effort === null ? {} : { effort }) },
280        now,
281      )
282      if ('error' in outcome) {
283        return outcome
284      }
285      const name = ticketShortName(outcome.ticket ?? { number, title: title ?? '' })
286
287      return outcome.change === 'same'
288        ? { status, text: `Ticket ${name} exists already. Nothing changed.` }
289        : { status: outcome.status, text: `Ticket ${name} created.` }
290    }
291    case 'link': {
292      const link = readPageUrl(text(fields.url) ?? '')
293      if (link === null) {
294        return { error: 'A link needs `url`: the page of a GitHub pull request or issue, as https://github.com/<owner>/<repo>/pull/<number> or .../issues/<number>.' }
295      }
296      const linked = linkPage(status, link, { agentId: context.agentId, at: now })
297
298      return linked.isAdded
299        ? { status: linked.status, text: `Link ${linkLabel(link)} created.` }
300        : { status, text: `Link ${linkLabel(link)} exists already. Nothing changed.` }
301    }
302    case 'effort': {
303      const name = text(fields.name)
304      if (name === null) {
305        return { error: "An effort needs `name`: the name in its `effort:<name>` label." }
306      }
307      const restored = withoutDeleted(status, 'effort', name)
308
309      return { status: { ...restored, effort: { name, from: 'report', setBy: 'manual' } }, text: `Effort ${name} created.` }
310    }
311    case 'task': {
312      const subject = text(fields.subject)
313      if (subject === null) {
314        return { error: 'A task needs `subject`: its title.' }
315      }
316      const id = nextTaskId(status)
317      const created = taskCreated(status, { id, subject }, now)
318      const moved = fields.status === undefined ? created : taskUpdated(created, { id, status: fields.status as Task['status'] }, now)
319
320      return {
321        status: withTasks(
322          moved,
323          moved.tasks.map(task =>
324            task.id === id ? { ...task, setBy: 'manual' as const, ...(context.agentId === undefined ? {} : { agentId: context.agentId }) } : task,
325          ),
326        ),
327        text: `Task ${id} created.`,
328      }
329    }
330    case 'cron':
331    case 'place':
332      // checkFields refused these above: only their automatic sources create them.
333      return { error: checkFields('create', kind, {}) ?? 'Not created.' }
334  }
335}
336
337// update
338
339/** The status with the given fields of one entry changed; a closed entry can open again. */
340export function updateEntry(
341  status: SessionStatus,
342  kind: CrudKind,
343  id: string,
344  fields: Fields,
345  context: CrudContext,
346): CrudOutcome | CrudError {
347  const wrong = checkFields('update', kind, fields)
348  if (wrong !== null) {
349    return { error: wrong }
350  }
351  const found = findEntry(status, kind, id, context)
352  if ('error' in found) {
353    return found
354  }
355  const { now } = context
356  const said = `${KINDS[kind].noun} ${found.name} updated: ${Object.entries(fields)
357    .map(([field, value]) => `${field} ${Array.isArray(value) ? value.join(' / ') : String(value)}`)
358    .join(', ')}.`
359  switch (found.kind) {
360    case 'item': {
361      const title = fields.title === undefined ? undefined : text(fields.title)
362      if (title === null) {
363        return { error: "An item's `title` is a non-empty string." }
364      }
365      const moved = movedItem(status, found.entry, { title, state: fields.state as ItemState | undefined }, now)
366
367      return { status: moved.status, text: said }
368    }
369    case 'status-item': {
370      const before = found.entry
371      const changed = changedItem(before, fields)
372      if ('error' in changed) {
373        return changed
374      }
375      let next = { ...status, items: status.items.map(item => (item === before ? changed.item : item)) }
376      let after = changed.item
377      if (fields.state !== undefined && fields.state !== 'open' && isOpen(after)) {
378        const closed = closeItem(next, { kind: after.kind, id: after.id }, now)
379        if ('error' in closed) {
380          return closed
381        }
382        next = closed.status
383        after = closed.item
384      }
385      if (pings(after) && after.pingId === undefined && isOpen(after)) {
386        const pinged = { ...after, pingId: pingId(status.sessionId, after.id) }
387        next = { ...next, items: next.items.map(item => (item === after ? pinged : item)) }
388        after = pinged
389      }
390
391      return { status: next, text: said, pinged: { before, after } }
392    }
393    case 'ticket': {
394      const title = fields.title === undefined ? undefined : text(fields.title)
395      if (title === null) {
396        return { error: "A ticket's `title` is a non-empty string." }
397      }
398      const known = found.entry
399      const state = fields.state as 'started' | 'landed' | undefined
400      let ticket = title === undefined ? known : manualSet(known, 'title', title)
401      if (state !== undefined && state !== known.state) {
402        ticket = { ...manualSet(ticket, 'state', state), at: now }
403      }
404
405      return { status: { ...status, ticketReports: status.ticketReports.map(report => (report === known ? ticket : report)) }, text: said }
406    }
407    case 'link': {
408      const known = found.entry
409      const link = manualSet({ ...known, state: known.state ?? 'open' }, 'state', fields.state as LinkState)
410
411      return { status: { ...status, links: status.links.map(candidate => (candidate === known ? link : candidate)) }, text: said }
412    }
413    case 'effort': {
414      const name = text(fields.name)
415      if (name === null) {
416        return { error: "An effort's `name` is a non-empty string." }
417      }
418
419      return { status: { ...status, effort: manualSet(found.entry, 'name', name) }, text: said }
420    }
421    case 'task': {
422      const subject = fields.subject === undefined ? undefined : text(fields.subject)
423      if (subject === null) {
424        return { error: "A task's `subject` is a non-empty string." }
425      }
426      let task: Task = { ...found.entry, at: now }
427      if (subject !== undefined) {
428        task = manualSet(task, 'subject', subject)
429      }
430      if (fields.status !== undefined) {
431        task = manualSet(task, 'status', fields.status as Task['status'])
432      }
433
434      return { status: withTasks(status, status.tasks.map(candidate => (candidate === found.entry ? task : candidate))), text: said }
435    }
436    case 'cron': {
437      const job = { ...manualSet(found.entry, 'state', fields.state as CronJob['state']), at: now }
438
439      return { status: { ...status, crons: status.crons.map(candidate => (candidate === found.entry ? job : candidate)) }, text: said }
440    }
441    case 'place':
442      // checkFields refused this above: a place has no field to set.
443      return { error: checkFields('update', 'place', { any: true }) ?? 'Not updated.' }
444  }
445}
446
447/** A decision, surprise or blocker with the given fields set, opened again when `state` is `open`. */
448function changedItem(item: StatusItem, fields: Fields): { item: StatusItem } | CrudError {
449  const next: Record<string, unknown> = { ...item }
450  for (const [field, value] of Object.entries(fields)) {
451    if (field === 'state') {
452      if (value === 'open') {
453        // Open again, so no longer acted on from the pane either.
454        delete next.resolvedAt
455        delete next.actedAt
456      }
457      continue
458    }
459    if (field === 'options') {
460      const options = readOptions(value)
461      if (options === null) {
462        return { error: 'A decision needs two to four options, each a non-empty string.' }
463      }
464      next.options = options
465      continue
466    }
467    const set = field === 'urgency' ? value : text(value)
468    if (set === null) {
469      return { error: `A ${item.kind}'s \`${field}\` is a non-empty string.` }
470    }
471    next[field] = set
472  }
473
474  return { item: next as StatusItem }
475}
476
477// delete
478
479/** The status without one entry. An automatic source does not add it again, and its id is not given again. */
480export function deleteEntry(status: SessionStatus, kind: CrudKind, id: string, context: CrudContext): CrudOutcome | CrudError {
481  const found = findEntry(status, kind, id, context)
482  if ('error' in found) {
483    return found
484  }
485  const said = `${KINDS[kind].noun} ${found.name} deleted.`
486  const { now } = context
487  switch (found.kind) {
488    case 'item':
489      return {
490        status: withDeleted({ ...status, sessionItems: status.sessionItems.filter(item => item !== found.entry) }, 'item', found.entry.id, now),
491        text: said,
492      }
493    case 'status-item': {
494      const before = found.entry
495      const next = withDeleted({ ...status, items: status.items.filter(item => item !== before) }, before.kind, before.id, now)
496
497      return { status: next, text: said, pinged: { before, after: null } }
498    }
499    case 'ticket':
500      return {
501        status: withDeleted({ ...status, ticketReports: status.ticketReports.filter(report => report !== found.entry) }, 'ticket', found.name, now),
502        text: said,
503      }
504    case 'link':
505      return {
506        status: withDeleted({ ...status, links: status.links.filter(link => link !== found.entry) }, 'link', found.entry.url, now),
507        text: said,
508      }
509    case 'effort':
510      return { status: withDeleted({ ...status, effort: null, tickets: null }, 'effort', found.entry.name, now), text: said }
511    case 'task':
512      return {
513        status: withDeleted(taskUpdated(status, { id: found.entry.id, status: 'deleted' }, now), 'task', deletedTaskKey(found.entry), now),
514        text: said,
515      }
516    case 'cron':
517      return {
518        status: withDeleted({ ...status, crons: status.crons.filter(job => job !== found.entry) }, 'cron', found.entry.id, now),
519        text: said,
520      }
521    case 'place': {
522      // A place is known by its GitHub name and by its folder: both are remembered.
523      const removed = status.places.filter(place => sameKey(placeId(place)) === sameKey(found.name))
524      const kept = { ...status, places: status.places.filter(place => !removed.includes(place)) }
525      const next = [found.name, ...removed.map(place => place.key)].reduce((current, key) => withDeleted(current, 'place', key, now), kept)
526
527      return { status: next, text: said }
528    }
529  }
530}
531
532// finding an entry
533
534/** One entry `findEntry` found, with the name a reply gives it. */
535type Found =
536  | { kind: 'item'; entry: SessionStatus['sessionItems'][number]; name: string }
537  | { kind: 'status-item'; entry: StatusItem; name: string }
538  | { kind: 'ticket'; entry: SessionStatus['ticketReports'][number]; name: string }
539  | { kind: 'link'; entry: SessionLink; name: string }
540  | { kind: 'effort'; entry: NonNullable<SessionStatus['effort']>; name: string }
541  | { kind: 'task'; entry: Task; name: string }
542  | { kind: 'cron'; entry: CronJob; name: string }
543  | { kind: 'place'; entry: SessionStatus['places'][number]; name: string }
544
545/**
546 * The entry of `kind` with `id`, or what is wrong: an unknown id names the
547 * ids there are, and a subagent finds only the entries it created.
548 */
549function findEntry(status: SessionStatus, kind: CrudKind, id: string, context: CrudContext): Found | CrudError {
550  const wanted = sameKey(id)
551  const same = (candidate: string) => sameKey(candidate) === wanted
552  const candidates = entriesOf(status, kind)
553  const found = candidates.find(candidate => candidate.keys.some(same))
554  if (found === undefined) {
555    const ids = candidates.map(candidate => candidate.found.name)
556
557    return {
558      error: `No ${KINDS[kind].noun.toLowerCase()} ${id.trim()}. ${
559        ids.length === 0 ? `The status has no ${KINDS[kind].noun.toLowerCase()}.` : `Use one of: ${ids.slice(0, 20).join(', ')}${ids.length > 20 ? ', ...' : ''}.`
560      }`,
561    }
562  }
563  if (context.agentId !== undefined) {
564    const owner = 'agentId' in found.found.entry ? found.found.entry.agentId : undefined
565    if (owner !== context.agentId) {
566      return { error: `A subagent can change only the entries that it created. ${KINDS[kind].noun} ${found.found.name} is not one of them.` }
567    }
568  }
569
570  return found.found
571}
572
573/** Every entry of `kind`, with the keys an id can name it by. */
574function entriesOf(status: SessionStatus, kind: CrudKind): { found: Found; keys: string[] }[] {
575  switch (kind) {
576    case 'item':
577      return status.sessionItems.map(entry => ({ found: { kind, entry, name: entry.id }, keys: [entry.id] }))
578    case 'decision':
579    case 'surprise':
580    case 'blocker':
581      return status.items
582        .filter(entry => entry.kind === kind)
583        .map(entry => ({ found: { kind: 'status-item', entry, name: entry.id }, keys: [entry.id] }))
584    case 'ticket':
585      return effortReports(status).map(entry => ({
586        found: { kind, entry, name: ticketShortName(entry) },
587        keys: [ticketShortName(entry), entry.title],
588      }))
589    case 'link':
590      return status.links.map(entry => ({
591        found: { kind, entry, name: linkLabel(entry) },
592        keys: [linkLabel(entry), `${entry.repo}#${entry.number}`, entry.url],
593      }))
594    case 'effort':
595      return status.effort === null ? [] : [{ found: { kind, entry: status.effort, name: status.effort.name }, keys: [status.effort.name] }]
596    case 'task':
597      return status.tasks.map(entry => ({ found: { kind, entry, name: entry.id }, keys: [entry.id] }))
598    case 'cron':
599      return status.crons.map(entry => ({ found: { kind, entry, name: entry.id }, keys: [entry.id] }))
600    case 'place': {
601      const seen = new Set<string>()
602
603      return status.places.flatMap(entry => {
604        const name = placeId(entry)
605        if (seen.has(name.toLowerCase())) {
606          return []
607        }
608        seen.add(name.toLowerCase())
609
610        return [{ found: { kind, entry, name }, keys: [name, entry.key, entry.name] }]
611      })
612    }
613  }
614}
615
616/** The id of a task the agent creates: `manual-1`, `manual-2`, ..., never one used before. */
617function nextTaskId(status: SessionStatus): string {
618  const used = [...status.tasks.map(task => task.id), ...status.deleted.filter(entry => entry.kind === 'task').map(entry => entry.key)]
619  const highest = used
620    .map(id => (/^manual-(\d+)$/.exec(id)?.[1] ?? '0'))
621    .map(Number)
622    .reduce((max, n) => (n > max ? n : max), 0)
623
624  return `manual-${highest + 1}`
625}
626
627/** Whether an item pings the person: a blocked decision or a blocker. */
628export function pings(item: StatusItem): item is Decision | Blocker {
629  return item.kind === 'blocker' || (item.kind === 'decision' && item.urgency === 'blocked')
630}
631
632/** A decision's options: two to four non-empty strings, or null. */
633function readOptions(value: unknown): string[] | null {
634  const options = Array.isArray(value) ? value.map(text) : []
635
636  return options.length < MIN_OPTIONS || options.length > MAX_OPTIONS || options.some(option => option === null)
637    ? null
638    : (options as string[])
639}
640
641/**
642 * A ticket's issue number from the input: `3`, `"3"` and `"#3"` all read as
643 * 3; undefined when the input has none; null when it is no issue number.
644 */
645export function ticketNumber(value: unknown): number | undefined | null {
646  if (value === undefined || value === null || value === '') {
647    return undefined
648  }
649  const number = typeof value === 'string' ? Number(value.trim().replace(/^#/, '')) : value
650
651  return typeof number === 'number' && Number.isInteger(number) && number > 0 ? number : null
652}
653
654/** A trimmed, non-empty string, or null. */
655export function text(value: unknown): string | null {
656  if (typeof value !== 'string') {
657    return null
658  }
659  const trimmed = value.trim()
660
661  return trimmed === '' ? null : trimmed
662}
663
hooks/describe-tool-call.ts 130 lines
1// "Doing now" from a tool call: the tool and one short line about the call.
2
3import type { ToolCallInput } from 'claude-code'
4
5import type { DoingNow } from '../types'
6
7const MAX_TEXT = 120
8
9/** Fields that name a file: shown by the file's name alone. */
10const PATH_FIELDS = ['file_path', 'notebook_path'] as const
11
12/**
13 * Fields that say what the call does, best first. A `command` shows only
14 * its programs (see `commandShape`): doing now is saved to `$.store`, and a
15 * command line can hold a token or a password.
16 */
17const TEXT_FIELDS = [
18  'description',
19  'subject',
20  'command',
21  'pattern',
22  'url',
23  'query',
24  'skill',
25  'prompt',
26] as const
27
28/** What the call does now, as one line the pane shows. */
29export function describeToolCall(e: ToolCallInput, at: number): DoingNow {
30  const fields = e as unknown as Record<string, unknown>
31  const doing: DoingNow = { tool: e.tool, text: summarize(fields), at }
32  if (e.agentId !== undefined) {
33    doing.agentId = e.agentId
34  }
35
36  return doing
37}
38
39function summarize(fields: Record<string, unknown>): string {
40  for (const field of PATH_FIELDS) {
41    const value = fields[field]
42    if (typeof value === 'string' && value !== '') {
43      return oneLine(value.split(/[\\/]/).filter(Boolean).pop() ?? value)
44    }
45  }
46  for (const field of TEXT_FIELDS) {
47    const value = fields[field]
48    if (typeof value === 'string' && value.trim() !== '') {
49      return oneLine(field === 'command' ? commandShape(value) : value)
50    }
51  }
52
53  return ''
54}
55
56/**
57 * Programs whose second and third words name a subcommand (`gh pr create`,
58 * `npm run test`). Any other program shows by its name alone, because its
59 * words can be free text (`echo <token>`).
60 */
61const SUBCOMMAND_PROGRAMS = new Set([
62  'bun',
63  'cargo',
64  'claude',
65  'docker',
66  'gh',
67  'git',
68  'go',
69  'kubectl',
70  'npm',
71  'pnpm',
72  'uv',
73  'yarn',
74])
75
76/** Steps that only set up the shell: left out of the shape. */
77const SETUP_PROGRAMS = new Set(['cd', 'export', 'set', 'unset'])
78
79/** A word that can name a program or a subcommand, not a value. */
80const PLAIN_WORD = /^[a-z][a-z-]*$/
81/** `NAME=value`: an environment assignment before a program. */
82const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
83
84/**
85 * A shell command as its programs and subcommands only, joined by the
86 * operators between them: `export GH_TOKEN=x && gh pr create --title y`
87 * shows as `gh pr create`. No argument value, assignment or path is kept.
88 */
89export function commandShape(command: string): string {
90  const parts = command.split(/(&&|\|\||[;|\n])/)
91  const shown: string[] = []
92  for (let i = 0; i < parts.length; i += 2) {
93    const step = stepShape(parts[i] ?? '')
94    if (step !== null) {
95      shown.push(shown.length === 0 ? step : `${(parts[i - 1] ?? ';').trim() || ';'} ${step}`)
96    }
97  }
98
99  return shown.join(' ')
100}
101
102/** One step's program and subcommand words; null for a setup step or none. */
103function stepShape(step: string): string | null {
104  const words = step.trim().split(/\s+/).filter(word => word !== '')
105  while (words.length > 0 && ASSIGNMENT.test(words[0] ?? '')) {
106    words.shift()
107  }
108  const program = (words[0] ?? '').split('/').pop() ?? ''
109  if (!/^[A-Za-z0-9][\w.+-]*$/.test(program) || SETUP_PROGRAMS.has(program)) {
110    return null
111  }
112  const shape = [program]
113  if (SUBCOMMAND_PROGRAMS.has(program)) {
114    for (const word of words.slice(1, 3)) {
115      if (!PLAIN_WORD.test(word)) {
116        break
117      }
118      shape.push(word)
119    }
120  }
121
122  return shape.join(' ')
123}
124
125function oneLine(text: string): string {
126  const line = text.replace(/\s+/g, ' ').trim()
127
128  return line.length > MAX_TEXT ? `${line.slice(0, MAX_TEXT - 1)}…` : line
129}
130
hooks/effort.ts 103 lines
1// Effort detection as data: whether a tool call shows that the session runs
2// an effort, and the effort's name. register.tsx feeds it the tool calls and
3// reads the branch name for it; nothing here calls `$`.
4
5import type { Effort, SessionStatus } from '../types'
6import { isDeleted } from './set-by'
7import { simpleCommands } from './shell'
8
9/** The skills that run an effort: a Skill call to one of them is an effort run. */
10export const EFFORT_SKILLS: readonly string[] = ['orchestrate-effort', 'orchestrate-with-handoff']
11
12/**
13 * An `effort:<name>` label in a command: after a start, a space, a quote, `=`,
14 * `,` or `:`, as `--label effort:x`, `--label "effort:x"` or `label:effort:x`
15 * write it.
16 */
17const EFFORT_LABEL = /(?:^|[\s"'=,:])effort:([A-Za-z0-9][A-Za-z0-9._-]*)/
18
19/**
20 * What one tool call shows about an effort run: null when it shows none;
21 * else the run, with the effort's name when the call names its label.
22 */
23export type EffortSighting = { name: string | null }
24
25/**
26 * The effort run a tool call shows: a Skill call to an effort skill (a
27 * plugin's `<plugin>:<skill>` too), or a Bash command that runs a `gh`
28 * command with an `effort:<name>` label. A label anywhere else, as in a
29 * heredoc's script or an `echo`, is no effort run.
30 */
31export function effortSighting(call: { tool: string }): EffortSighting | null {
32  const input = call as unknown as Record<string, unknown>
33  if (call.tool === 'Skill' && typeof input.skill === 'string') {
34    const skill = input.skill.slice(input.skill.lastIndexOf(':') + 1)
35    if (!EFFORT_SKILLS.includes(skill)) {
36      return null
37    }
38
39    return { name: typeof input.args === 'string' ? effortLabel(input.args) : null }
40  }
41  if (call.tool === 'Bash' && typeof input.command === 'string') {
42    for (const part of simpleCommands(input.command)) {
43      const name = /^gh\s/.test(part) ? effortLabel(part) : null
44      if (name !== null) {
45        return { name }
46      }
47    }
48
49    return null
50  }
51
52  return null
53}
54
55/** The name in the first `effort:<name>` label of `text`; null when it has none. */
56export function effortLabel(text: string): string | null {
57  return EFFORT_LABEL.exec(text)?.[1] ?? null
58}
59
60/**
61 * The effort's name from `git branch --show-current`'s output: the branch,
62 * or null on a detached head or an empty answer.
63 */
64export function branchEffortName(stdout: string): string | null {
65  const branch = stdout.trim()
66
67  return branch === '' || branch === 'HEAD' ? null : branch
68}
69
70/**
71 * The status with the effort it runs. A ticket report's name always wins: a
72 * report for another effort switches the session to it. Otherwise the first
73 * name found holds, except that a label's name replaces a branch's.
74 *
75 * No source brings back an effort the agent deleted; `create` does. A name
76 * the agent set with `update` stays until a label or a branch names another
77 * effort than the one found before (a branch never wins over a label's or a
78 * report's effort); a report is a new change and always wins.
79 */
80export function withEffort(status: SessionStatus, found: Effort): SessionStatus {
81  const known = status.effort
82  if (isDeleted(status, 'effort', found.name)) {
83    return status
84  }
85  const mark = known?.fieldsSetBy?.name
86  if (found.from !== 'report' && known !== null && mark !== undefined) {
87    const isWeaker = found.from === 'branch' && known.from !== undefined && known.from !== 'branch'
88    if (found.name === mark.lastAuto || found.name === known.name || isWeaker) {
89      return status
90    }
91
92    return { ...status, effort: found }
93  }
94  if (found.from === 'report') {
95    return known?.name === found.name ? status : { ...status, effort: found }
96  }
97  if (known !== null && (known.from === 'label' || known.from === 'report' || found.from !== 'label')) {
98    return status
99  }
100
101  return { ...status, effort: found }
102}
103
hooks/effort-progress.ts 149 lines
1// Effort progress as data: the tracker's count of the effort's tickets,
2// closed of all its `effort:<name>` issues. It never mixes in what the
3// orchestrator reported: that is the session's progress (see
4// session-progress.ts). This module builds the `gh` command, reads its output
5// and says when to read again; register.tsx runs the command and keeps the
6// count in the status.
7
8import type { SessionStatus, TicketCount, TrackedTicket } from '../types'
9import { isQaTitle } from './session-progress'
10
11/** The least time between two ticket reads that no ticket change asked for. */
12export const TICKET_REFRESH_MS = 2 * 60_000
13
14/**
15 * The least time between two reads while the status holds no count for the
16 * effort: a read failed, or a `/clear` started the status over.
17 */
18export const TICKET_RETRY_MS = 30_000
19
20/** How long one `gh issue list` may run. */
21export const TICKET_TIMEOUT_MS = 15_000
22
23/** A Bash command that likely changed a ticket: closed, reopened, edited, or a PR merged. */
24const TICKET_CHANGE = /\bgh\s+(?:issue\s+(?:close|reopen|edit)|pr\s+merge)\b/
25
26/**
27 * An issue whose title starts with `Spec:` is the effort's spec, not one of
28 * its tickets: it stays open while the tickets close, so it is not counted.
29 */
30const SPEC_TITLE = /^\s*spec:/i
31
32/** The `gh` command that lists every issue of the effort, open and closed. */
33export function ticketListArgv(effort: string): string[] {
34  return [
35    'gh',
36    'issue',
37    'list',
38    '--label',
39    `effort:${effort}`,
40    '--state',
41    'all',
42    '--limit',
43    '200',
44    '--json',
45    'number,title,state,labels',
46  ]
47}
48
49/**
50 * The tickets closed, the total, the tickets to build and each ticket from
51 * `gh issue list --json` output; null when the output is not such a list, so the last good
52 * count holds. The total keeps the `QA:` tickets: the effort is not finished
53 * until they close. The tickets to build leave them out: the orchestrator
54 * never lands one, so the session's count would stop short of them.
55 */
56export function parseTicketList(stdout: string): Pick<TicketCount, 'done' | 'total' | 'builds' | 'list'> | null {
57  let issues: unknown
58  try {
59    issues = JSON.parse(stdout)
60  } catch {
61    return null
62  }
63  if (!Array.isArray(issues)) {
64    return null
65  }
66  const tickets = issues.filter(
67    (issue): issue is { number?: unknown; title?: unknown; state?: unknown } =>
68      typeof issue === 'object' && issue !== null && !(typeof issue.title === 'string' && SPEC_TITLE.test(issue.title)),
69  )
70  const closed = tickets.filter(issue => typeof issue.state === 'string' && issue.state.toUpperCase() === 'CLOSED')
71
72  const qa = tickets.filter(issue => typeof issue.title === 'string' && isQaTitle(issue.title))
73
74  const list = tickets.flatMap((issue): TrackedTicket[] =>
75    typeof issue.number === 'number' && typeof issue.title === 'string'
76      ? [{ number: issue.number, title: issue.title, isClosed: closed.includes(issue) }]
77      : [],
78  )
79
80  return { done: closed.length, total: tickets.length, builds: tickets.length - qa.length, list }
81}
82
83/** Whether a tool call likely changed the effort's tickets. */
84export function changesTickets(call: { tool: string }): boolean {
85  const command = (call as unknown as Record<string, unknown>).command
86
87  return call.tool === 'Bash' && typeof command === 'string' && TICKET_CHANGE.test(command)
88}
89
90/**
91 * Whether to read the effort's tickets after a tool call: never outside an
92 * effort; else on the effort's first read, after a call that likely changed
93 * a ticket, or when the last read is TICKET_REFRESH_MS old (TICKET_RETRY_MS
94 * while the status holds no count for the effort).
95 */
96export function isTicketReadDue(
97  status: SessionStatus | null,
98  lastRead: { effort: string; at: number } | null,
99  call: { tool: string },
100  now: number,
101): boolean {
102  const effort = status?.effort?.name
103  if (effort === undefined) {
104    return false
105  }
106  if (lastRead?.effort !== effort || changesTickets(call)) {
107    return true
108  }
109  const isCounted = status?.tickets?.effort === effort
110
111  return now - lastRead.at >= (isCounted ? TICKET_REFRESH_MS : TICKET_RETRY_MS)
112}
113
114/**
115 * The effort's tickets closed and the total, as the tracker last counted
116 * them: null outside an effort, before the first count for it, and while the
117 * effort has no tickets.
118 */
119export function effortProgress(status: SessionStatus | null): { closed: number; total: number } | null {
120  const effort = status?.effort?.name
121  const count = status?.tickets ?? null
122  if (effort === undefined || count === null || count.effort !== effort || count.total === 0) {
123    return null
124  }
125
126  return { closed: count.done, total: count.total }
127}
128
129/**
130 * The effort's tickets the Effort section lists: the open ones first, then
131 * the closed ones, each in issue-number order. Empty outside an effort and
132 * before a count for it.
133 */
134export function effortTickets(status: SessionStatus | null): TrackedTicket[] {
135  const effort = status?.effort?.name
136  const count = status?.tickets ?? null
137  if (effort === undefined || count === null || count.effort !== effort) {
138    return []
139  }
140  const byNumber = [...(count.list ?? [])].sort((a, b) => a.number - b.number)
141
142  return [...byNumber.filter(ticket => !ticket.isClosed), ...byNumber.filter(ticket => ticket.isClosed)]
143}
144
145/** The status with a new ticket count. */
146export function withTickets(status: SessionStatus, count: TicketCount): SessionStatus {
147  return { ...status, tickets: count }
148}
149
hooks/instructions.ts 63 lines
1// The system prompt section the mod adds: how the agent records decisions
2// and surprises, and how an orchestrator reports its tickets. register.tsx
3// adds it in `prompt.compose`.
4
5import type { PromptComposeSection } from 'claude-code'
6
7import { CONCISE_RULE, STATUS_TOOL } from './status-tool'
8
9/** The section's id: the mod's own, `<plugin>:<name>`. */
10export const INSTRUCTIONS_ID = 'session-status:status'
11
12const TEXT = `# Session status
13
14The user watches a status pane for this session. Use the \`${STATUS_TOOL}\` tool to keep it current.
15
16- When the work needs a choice from the user, record a decision: the question, two to four options, your default (the answer you recommend) and what unblocks it.
17- Write each option in a few words, so the pane can show it as a button.
18- Choose a safe default and continue. Do not stop the work for it. Record that decision with urgency \`before_settling\` when the user must decide before the session settles. Use \`after_settling\` when it can wait until after the session settles, as a follow-up.
19- Stop only when no safe default exists. Then record the decision with urgency \`blocked\`. Recommend one option, and say in \`unblocks\` the one thing the user must say or do.
20- When something unexpected changes the work or the plan, record a surprise: what occurred and what it changed. Do not record ordinary errors that you fixed yourself.
21- Give \`suggested_action\` on \`record_surprise\` only when one clear next step exists. Write it in a few words, such as \`Pin Node 22\`. The pane shows it as a button.
22- When you try something and it fails, and you cannot finish it yourself, record a blocker with action \`record_blocker\`. Give what failed and what the user can do to unblock you, such as run a command, restart Claude Code or allow an action. Examples: a denied action that has no other way, a test or check that cannot run, a tool that refuses to start. The user gets a ping. Do not record it as a surprise.
23- When a blocker works again, call the tool with action \`resolve\` and the blocker's id.
24- ${CONCISE_RULE} The user reads the pane at a glance.
25- The user answers decisions in the chat, or with the buttons under each decision in the pane. When you read the user's answer to a decision in the chat, call the tool with action \`resolve\` and the decision's id.
26- A prompt \`<id>: <option>\`, such as \`D2: Yes\`, is the user's answer from a pane button. The pane resolved the decision already, so do not call \`resolve\`. If \`resolve\` says a decision is already resolved, the user answered it in the pane: read the answer and go on.
27- A prompt \`Let's discuss <id>: <question>\` asks you to discuss that decision in the chat. Explain its context and the trade-off of each option, then wait for the user's answer. The decision stays open until you call \`resolve\`.
28- When the user asks in the chat to dismiss a surprise, call the tool with action \`dismiss\` and the surprise's id.
29- Each surprise and observation in the pane has buttons too: its action, \`File issue\`, \`Discuss\` and \`Dismiss\`. \`Dismiss\` sends you nothing.
30- A prompt \`<id>: <action>\` for a surprise, such as \`S2: Pin Node 22\`, asks for that action. The pane dismissed the surprise already, so do not call \`dismiss\`.
31- A prompt \`File an issue for <id>: <occurred>\` asks for an issue about that surprise. File it as the project's issue tracker says. The pane dismissed the surprise already.
32- A prompt \`Let's discuss <id>: <occurred>\` for a surprise asks for its context and the possible next steps in the chat. Wait for the user's answer. Then call \`dismiss\`, or do the action the user picks.
33- Collect before-settling decisions while you work. Do not ask about them in the middle of the work.
34- At the end of your work, if before-settling decisions are still open, post one numbered list of them in the chat. Give each item its id, its question, its default and its options. Then call the tool with action \`post_decide_list\`.
35- Report the work this session must do before it settles with action \`item\`. The pane counts the items done of all.
36  - Call it with state \`added\` and a short \`title\` for each request or sub-request from the user. Do this when the user asks, before you start the work.
37  - Also add an item for follow-up work that you take on, and for each step left before the session settles: for example the review, the pull request, the user's approval, the merge and the settle.
38  - Call it with state \`done\` and the item's \`id\` (I1, ...) when its work is finished and verified.
39  - Call it with state \`dropped\` and the item's \`id\` when the item is no longer needed, or a later item replaced it.
40  - Add an item for each effort ticket you take on, and mark it done when its work lands. The Session bar counts only items; the Effort bar shows which issues GitHub closed.
41  - Only the main session reports items. A subagent does not.
42- When you orchestrate an effort's tickets, report each ticket with action \`ticket\`. Give the ticket's issue \`number\` and its \`title\`. On your first ticket call, also give \`effort\`: the name in the effort's \`effort:<name>\` issue label.
43  - Call it with state \`started\` when you delegate the ticket.
44  - Call it with state \`landed\` when the ticket's commit is on the effort branch. Do not wait for the issue to close: a ticket that waits for QA or for the merge has landed.
45  - Call it with state \`stopped\` when a started ticket is no longer being built.
46  - Only the orchestrator reports tickets. A delegate that builds one ticket does not.
47- When you work on a pull request or issue that this session did not create, such as one from an earlier session, call the tool with action \`link\` and its \`url\`. The pane then lists it under Links, with the pages this session created. Pages made with \`gh pr create\` or \`gh issue create\`, and pages merged, closed or reopened with \`gh\`, are found by themselves.
48- When you need an id that is no longer in your context, for example after \`/compact\`, call the tool with action \`list\`. It names every open id.
49- When the user asks about only part of the status, such as the observations or the answered decisions, call \`list\` with a \`filter\` that reads only that: \`kind\`, \`state\` (\`open\`, \`closed\` or \`all\`) and \`id\`.
50- To change any entry, use the generic actions \`create\`, \`read\`, \`update\` and \`delete\`. Give \`kind\` (item, decision, surprise, blocker, ticket, link, effort, task, cron or place), \`id\` for an update or a delete, and \`fields\` for a create or an update.
51  - Use them when the user asks, and when an automatic path recorded something wrong. For example, delete a false link or a false effort, set a link's state, open a closed item again or fix a title.
52  - A value that you set stays until its automatic source reports a new change. A deleted entry does not come back from an automatic source.
53  - The other actions stay as shortcuts. Use them for the usual reports.
54- Only when the user explicitly asks you to reset, clear or start the session status over, call the tool with action \`reset\`. It removes everything the status recorded, open decisions and blockers too. Never reset on your own or because of \`/clear\`.
55- If you are a subagent and cannot call the status tool, put your decisions and surprises in your final report, with the same fields. The orchestrator records them with the status tool.`
56
57/** The section, added after the engine's own on the session side. */
58export const INSTRUCTIONS: PromptComposeSection = {
59  id: INSTRUCTIONS_ID,
60  text: TEXT,
61  scope: 'session',
62}
63
hooks/observer.ts 308 lines
1// The observer agent as data: when it checks, what it asks a small model,
2// and how its answer becomes surprises. register.tsx makes the model call
3// and the reads and writes; these functions only decide.
4//
5// The observer reports to the person alone: its findings go into the status
6// as surprises tagged `observer`, never into the main agent's context.
7
8import type { ModelCompleteRequest, SessionMessage } from 'claude-code'
9
10import type { SessionStatus, Surprise } from '../types'
11import { recordItem } from './status'
12import { CONCISE_RULE } from './status-tool'
13
14/** The small, cheap model the observer asks: an alias the engine resolves. */
15export const OBSERVER_MODEL = 'haiku'
16/** Main-loop turns between checks before any dismissal. */
17export const BASE_TURN_INTERVAL = 5
18/** The longest the turn interval grows to after dismissals. */
19export const MAX_TURN_INTERVAL = 40
20/** Checks for each session; the observer stops there. */
21export const CHECK_CAP = 20
22/** How many of the newest transcript messages a check reads. */
23export const STEP_LIMIT = 30
24/** How much of one text a check reads (a message, a tool's input or result). */
25export const TEXT_LIMIT = 300
26/** How much of the recent steps a check reads in all; the oldest lines drop first. */
27export const STEPS_LIMIT = 12_000
28/** How many findings one check may add. */
29export const FINDING_LIMIT = 1
30/** How many finding keys the status keeps; the oldest drop first. */
31export const SEEN_LIMIT = 200
32/** How much of a finding's action the status keeps; the pane shows one of 30 characters or fewer. */
33const ACTION_LIMIT = 80
34/** The reply's token cap: a few short findings in JSON. */
35const REPLY_TOKENS = 600
36/** How long one check may take before it is abandoned. */
37const TIMEOUT_MS = 60_000
38
39/** What starts a check: a main-loop turn ending, or a subagent finishing. */
40export type Trigger = 'turn' | 'subagent'
41
42/**
43 * One finding as the model gives it: what it saw, what it costs or changes,
44 * and the one clear next step when there is one.
45 */
46export type Finding = { occurred: string; changed: string; action?: string }
47
48function observerSurprises(status: SessionStatus): Surprise[] {
49  return status.items.filter((item): item is Surprise => item.kind === 'surprise' && item.source === 'observer')
50}
51
52/**
53 * Main-loop turns between checks: the base interval doubled for each
54 * observer finding the person dismissed without acting on it, up to the
55 * maximum. The back-off idea of the built-in "You Should Know" plugin:
56 * ignored advice comes less often.
57 */
58export function turnInterval(status: SessionStatus): number {
59  return Math.min(BASE_TURN_INTERVAL * 2 ** dismissedCount(status), MAX_TURN_INTERVAL)
60}
61
62/**
63 * How many observer findings the person dismissed. A finding acted on from
64 * the pane (File issue, its action) was useful, so it does not count.
65 */
66function dismissedCount(status: SessionStatus): number {
67  return observerSurprises(status).filter(item => item.resolvedAt !== undefined && item.actedAt === undefined).length
68}
69
70/**
71 * Whether a check is due: under the cap, and enough turns ended since the
72 * last check (`turns`). A finished subagent starts a check at once until the
73 * person dismisses an observer finding; after that it too waits for the
74 * turn interval, so the back-off holds for both triggers. The pane being
75 * open and no check running are register.tsx's.
76 */
77export function isCheckDue(status: SessionStatus, turns: number, trigger: Trigger): boolean {
78  if (status.observer.checks >= CHECK_CAP) {
79    return false
80  }
81  if (trigger === 'subagent' && dismissedCount(status) === 0) {
82    return true
83  }
84
85  return turns >= turnInterval(status)
86}
87
88function clip(text: string, limit = TEXT_LIMIT): string {
89  const flat = text.replace(/\s+/g, ' ').trim()
90
91  return flat.length > limit ? `${flat.slice(0, limit - 1)}…` : flat
92}
93
94/** The text inside each `<tag>…</tag>` of `text`, in order. */
95function tagged(text: string, tag: string): string[] {
96  return [...text.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g'))].map(match => match[1] ?? '')
97}
98
99/**
100 * Whether a user message records what the person ran in Claude Code itself:
101 * its text starts with one of the tags Claude Code wraps such a command in.
102 * A prompt that only quotes such a tag further in is the person's prompt.
103 */
104const COMMAND_START = /^\s*<(command-message|command-name|bash-input|bash-stdout|bash-stderr|local-command-[a-z]+)>/
105
106/**
107 * The lines for a user message that records what the person ran in Claude
108 * Code itself, not a request to the agent: a slash command (`<command-name>`)
109 * or a `!` shell command (`<bash-input>`), and their output. The caveat
110 * Claude Code puts before them gives no line. Undefined for any other message.
111 */
112function userCommandLines(text: string): string[] | undefined {
113  if (!COMMAND_START.test(text)) {
114    return undefined
115  }
116  const lines: string[] = []
117  for (const name of tagged(text, 'command-name')) {
118    const args = tagged(text, 'command-args').join(' ').trim()
119    lines.push(`user ran ${clip(args === '' ? name : `${name} ${args}`)}`)
120  }
121  for (const command of tagged(text, 'bash-input')) {
122    lines.push(`user ran !${clip(command)}`)
123  }
124  const output = ['local-command-stdout', 'local-command-stderr', 'bash-stdout', 'bash-stderr']
125    .flatMap(tag => tagged(text, tag))
126    .join(' ')
127    .trim()
128  if (output !== '') {
129    lines.push(`user's command output: ${clip(output)}`)
130  }
131
132  return lines
133}
134
135/**
136 * The lines for a user message that Claude Code adds when a background task
137 * the agent started finishes (a background agent or a background shell
138 * command): its summary and result. Undefined for any other message.
139 */
140function backgroundTaskLines(text: string): string[] | undefined {
141  if (!/^\s*<task-notification>/.test(text)) {
142    return undefined
143  }
144
145  return tagged(text, 'task-notification').map(notice => {
146    const summary = tagged(notice, 'summary').join(' ').trim()
147    const result = tagged(notice, 'result').join(' ').trim()
148    const what = summary === '' ? notice.replace(/<[^>]*>/g, ' ') : summary
149
150    return `background task finished: ${clip(what)}${result === '' ? '' : ` -> ${clip(result)}`}`
151  })
152}
153
154/**
155 * The lines for a user message Claude Code itself added rather than the
156 * person: a command the person ran, a finished background task, or only
157 * `<system-reminder>` context (no line). Undefined for the person's own prompt.
158 */
159function harnessLines(text: string): string[] | undefined {
160  if (text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim() === '') {
161    return []
162  }
163
164  return userCommandLines(text) ?? backgroundTaskLines(text)
165}
166
167/**
168 * One transcript message as the lines a check reads, each naming who did
169 * it: the user, the agent, or a subagent or background task the agent started.
170 */
171function stepLines(message: SessionMessage): string[] {
172  if (message.text !== '' && message.role === 'user') {
173    const lines = harnessLines(message.text)
174    if (lines !== undefined) {
175      return lines
176    }
177  }
178  const lines: string[] = []
179  if (message.text !== '') {
180    lines.push(`${message.role === 'user' ? 'user' : 'agent'}: ${clip(message.text)}`)
181  }
182  for (const use of message.toolUses) {
183    const outcome = use.text === undefined ? 'running' : `${use.isError === true ? 'error: ' : ''}${clip(use.text)}`
184    const who = use.agentId === undefined ? `agent tool ${use.tool}` : 'subagent'
185    lines.push(`${who} ${clip(JSON.stringify(use.input))} -> ${outcome}`)
186  }
187
188  return lines
189}
190
191/** The newest lines whose length together stays within `limit`, oldest first. */
192function newestWithin(lines: readonly string[], limit: number): string[] {
193  const kept: string[] = []
194  let used = 0
195  for (const line of [...lines].reverse()) {
196    used += line.length + 1
197    if (used > limit) {
198      break
199    }
200    kept.unshift(line)
201  }
202
203  return kept
204}
205
206const SYSTEM = `You watch a coding agent's session for the user. You never talk to the agent.
207The agent reports what it is stuck on by itself. Find only what it does not see:
208- a loop: the same failing step tried three or more times with no change;
209- a time sink: many turns spent on a side issue that the task does not need;
210- work against the plan: steps that contradict the effort phase, the task list or the session items.
211Each step starts with who did it: the user, the agent or a subagent.
212The user runs slash commands and shell commands in Claude Code itself; "user ran" marks them and their output.
213Never report the user's own actions as the agent's, and do not find fault with the agent for them.
214"background task finished" marks a background task the agent started; it is the agent's work, not the user's.
215Small details, style, single errors and errors the agent fixed are not findings.
216When you are not sure, give no findings.
217${CONCISE_RULE}
218Do not repeat a finding that was already shown, even in other words.
219When you find nothing, give no findings. Most checks find nothing.
220Answer with strict JSON only, no other text, in this shape:
221{"findings":[{"occurred":"what you saw, one short sentence","changed":"what it costs or what to change, one short sentence","action":"the next step, a few words"}]}
222Give "action" only when one clear next step exists, such as "Pin Node 22"; leave it out otherwise.
223Give at most ${FINDING_LIMIT} findings.`
224
225/** The model request for one check: the recent steps, the tasks, the session items and the effort phase. */
226export function observerRequest(status: SessionStatus, messages: readonly SessionMessage[]): ModelCompleteRequest {
227  const steps = newestWithin(messages.slice(-STEP_LIMIT).flatMap(stepLines), STEPS_LIMIT)
228  const tasks = status.tasks.map(task => `- [${task.status}] ${clip(task.subject, 120)}`)
229  const items = status.sessionItems.map(item => `- [${item.state}] ${item.id} ${clip(item.title, 120)}`)
230  const effort = status.effort?.name
231  const shown = observerSurprises(status).map(item => `- ${clip(item.occurred, 160)}`)
232  const prompt = [
233    `Effort phase: ${effort ?? 'none'}`,
234    `Progress: ${status.progress === null ? 'no tasks' : `${status.progress.done} of ${status.progress.total} done`}`,
235    'Task list:',
236    ...(tasks.length === 0 ? ['(none)'] : tasks),
237    'Session items:',
238    ...(items.length === 0 ? ['(none)'] : items),
239    'Findings already shown:',
240    ...(shown.length === 0 ? ['(none)'] : shown),
241    `Recent steps, oldest first:`,
242    ...(steps.length === 0 ? ['(none)'] : steps),
243  ].join('\n')
244
245  return { model: OBSERVER_MODEL, system: SYSTEM, prompt, maxTokens: REPLY_TOKENS, effort: 'low', timeoutMs: TIMEOUT_MS }
246}
247
248/**
249 * The findings in the model's reply; none when the reply is not the agreed
250 * JSON. A code fence around the JSON is taken off first.
251 */
252export function parseFindings(text: string): Finding[] {
253  const json = text.trim().replace(/^```(?:json)?\s*([\s\S]*?)\s*```$/, '$1')
254  let reply: unknown
255  try {
256    reply = JSON.parse(json)
257  } catch {
258    return []
259  }
260  const findings = (reply as { findings?: unknown } | null)?.findings
261  if (!Array.isArray(findings)) {
262    return []
263  }
264
265  return findings
266    .filter(
267      (f): f is Finding =>
268        typeof f === 'object' &&
269        f !== null &&
270        typeof f.occurred === 'string' &&
271        typeof f.changed === 'string' &&
272        f.occurred.trim() !== '' &&
273        f.changed.trim() !== '',
274    )
275    .slice(0, FINDING_LIMIT)
276    .map(f => {
277      const action = typeof f.action === 'string' ? clip(f.action, ACTION_LIMIT) : ''
278
279      return { occurred: clip(f.occurred, 200), changed: clip(f.changed, 200), ...(action === '' ? {} : { action }) }
280    })
281}
282
283/** A finding's key: its words lowercased, numbers and punctuation dropped. */
284export function findingKey(finding: Finding): string {
285  return finding.occurred
286    .toLowerCase()
287    .replace(/[^a-z]+/g, ' ')
288    .trim()
289}
290
291/**
292 * The status after one check: the check counted, and each finding whose key
293 * was not seen before added as an observer surprise.
294 */
295export function recordCheck(status: SessionStatus, findings: readonly Finding[], now: number): SessionStatus {
296  let next: SessionStatus = { ...status, observer: { ...status.observer, checks: status.observer.checks + 1 } }
297  for (const finding of findings) {
298    const key = findingKey(finding)
299    if (key === '' || next.observer.seen.includes(key)) {
300      continue
301    }
302    next = recordItem(next, { kind: 'surprise', ...finding, source: 'observer' }, now).status
303    next = { ...next, observer: { ...next.observer, seen: [...next.observer.seen, key].slice(-SEEN_LIMIT) } }
304  }
305
306  return next
307}
308
hooks/pane.tsx 30 lines
1// The status pane's tree: each section in order. Opening, closing and
2// reading the status live in register.tsx, where `$` is.
3
4import type { RenderElement } from 'claude-code'
5
6import { SECTIONS } from './sections'
7import { drawListView } from './sections/list-view'
8import type { SectionContext } from './sections/section'
9
10/** How often an open pane draws again, so the ages it shows stay current. */
11export const AGE_TICK_MS = 15_000
12
13/**
14 * The pane's tree: every section in order, the ones with nothing left out;
15 * or, after a "+N more" was pressed, that list in full.
16 */
17export function drawPane(context: SectionContext): RenderElement {
18  const { Box } = context.ui
19  const drawn =
20    context.view === 'main'
21      ? SECTIONS.map(section => section(context)).filter(node => node !== null)
22      : [drawListView(context)].filter(node => node !== null)
23
24  return (
25    <Box flexDirection="column" gap={1}>
26      {drawn}
27    </Box>
28  )
29}
30