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

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
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
settle-session or settle-effort runs.settle-session or settle-effort ended, as a Skill call or as the slash command you typed; the next turn clears it.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.○, oldest first), then the done ones (✓, newest first), then a pressable "+N more" for every item behind Progress. Dropped items are left out.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.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:| Page | Open | Done |
|---|---|---|
| Pull request | green | merged: purple; closed without merging: red |
| Issue | ◎ green | closed: ⊙ 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.
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.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.
/session-status opens or closes the pane; /session-status reset clears the whole session status (see below).The mod adds the tool mcp__session-status__status and a short system prompt section that tells the agent how to use it. Actions:
| Action | What it does |
|---|---|
record_decision | Records a decision: question, two to four options, a default, what unblocks it, and urgency blocked, before_settling or after_settling. |
record_surprise | Records a surprise: what occurred, what it changed and, when one clear next step exists, suggested_action in a few words. |
record_blocker | Records a blocker: what failed (failed) and what you can do to unblock it (needs). It pings you. |
resolve | Marks 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. |
dismiss | Dismisses a surprise (S1, ...) when you ask. A button press dismisses the surprise itself. |
post_decide_list | Marks the decide list posted. |
ticket | Reports a ticket's state during an effort: number, title, state (started, landed or stopped) and, on the first call, effort. |
item | Reports a session item: state added with a title (the reply names its id, I1, ...), or done or dropped with its id. |
create | Adds one entry of any kind: kind and fields. |
read | Returns the entries an optional filter keeps (see below); without one, everything. |
update | Changes the given fields of one entry: kind, id and fields. A closed entry can open again. |
delete | Removes 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.
| Kind | Id | Fields update can set | Automatic source |
|---|---|---|---|
item | I5 | title, state | none |
decision | D1 | question, options, default, unblocks, urgency, state | none |
surprise | S2 | occurred, changed, state | the observer |
blocker | B1 | failed, needs, state | none |
ticket | #4 | title, state | ticket reports |
link | claude-mods#27 | state (open, merged, closed) | gh pr create, gh issue create, gh merge, close and reopen commands, the GitHub read |
effort | its name | name | effort skills, effort: labels in gh commands |
task | the task id | subject, status | the task tools and Task events |
cron | the job id | state | the Cron tools |
place | owner/repo | none (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 ]
D4: Postgres as your own words. The default's button has a ✓.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.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 ]
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 an issue for S2: <occurred>. The agent files it as the project's issue tracker says.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.The pane shows three kinds of progress, and they never mix:
| Section | Counts | Done when |
|---|---|---|
Session (Progress 7/19) | the items this session is responsible for | the session marks the item done |
Effort (Closed 1/14) | the effort's GitHub issues, without the Spec: issue | GitHub closes the issue |
Task (Done 2/5) | Claude Code's task list | the 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.
| State | When the agent reports it | What Progress does |
|---|---|---|
added | You 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. |
done | The item's work is finished and verified. | Counts it as done. |
dropped | The item is no longer needed, or a later item replaced it. | Takes it out of the total, even after it was done. |
item call is refused.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:
| State | When the orchestrator reports it | What Session does |
|---|---|---|
started | It delegates the ticket. | Names the ticket: Building: #3. |
landed | The ticket's commit is on the effort branch. | Takes it off the Building line. |
stopped | A 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. |
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.stopped for a ticket never reported changes nothing.gh the Effort section is left out; Session still counts its items.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:
Edit, Write, NotebookEdit), each once;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.
/clear and /compact/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.
| Key | Values | Default |
|---|---|---|
kind | item, decision, blocker, surprise, observation, ticket, effort, link, task, cron, place | all kinds |
state | open; closed (resolved, dismissed, done, dropped, landed, a merged or closed link, a completed task, a cron job no longer active); all | open |
id | ids 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.
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.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
hooks/register.tsx 1052 lines1// 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}
1052hooks/activity.ts 78 lines1// 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}
78hooks/crons.ts 117 lines1// 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}
117hooks/auto-open.ts 24 lines1// 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}
24hooks/band.tsx 78 lines1// 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}
78hooks/crud.ts 663 lines1// 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}
663hooks/describe-tool-call.ts 130 lines1// "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}
130hooks/effort.ts 103 lines1// 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}
103hooks/effort-progress.ts 149 lines1// 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}
149hooks/instructions.ts 63 lines1// 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}
63hooks/observer.ts 308 lines1// 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}
308hooks/pane.tsx 30 lines1// 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