Goal, task and agent progress above the Claude Code prompt — zero setup

Goal, tasks and agents above your Claude Code prompt — zero setup.
Type a prompt that asks for more than one thing. Claude reports the steps; headway draws the goal, the step it is on, every subagent under the step it works on, who is blocked or stalled, and what the session costs. No plan file needed; pin one if you keep one. A demo and screenshots are on the repository page, github.com/atharv6f/headway.
Inside Claude Code:
/plugin marketplace add atharv6f/headway
/plugin install headway@headway
then restart Claude Code. Needs Claude Code 2.1.287 or later. No dependencies.
Update with claude plugin marketplace update headway and claude plugin update headway@headway, then restart.
/headway): the tree of groups, steps and the agents under each. The plan comes from the first of these that has data: a plan file you pin, the list Claude reports, a plan you approve in plan mode, Claude's task tools, or a Workflow's phases.SendMessage takes.meta.phases is tracked as one group, one task per phase, each agent() call under its phase.agent() without model or effort, or a role sent to a model your role file does not allow gets an amber ⚠, a toast and one line in Claude's context so the next dispatch is right. Warnings never block a call.No setup. In any directory, type a prompt that asks for more than one thing:
Create hello.py that prints a greeting, add a --name flag to it, then write a test for it.
Claude calls the headway tool once with the three steps, then marks each one as it starts and finishes. The band appears above your prompt:
◆ Add a --name flag to hello.py ██████████▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░ done 1 · partial 1 · open 1 / 3 reported
▸ 2 Add a --name flag ◐ in progress
ctx 6% $0.04 ✓ 1 done
and ends at 3/3 done. The goal label is the short title Claude passes with the list (six words at most), else the first line of your prompt. The dim word after the count (reported) names where the plan came from. /headway opens the pane, 1–4 switch tabs, q closes it. A prompt that asks for one thing draws nothing.
The goal is drawn from the first source in this order that has data. A lower source keeps its data while a higher one is shown; when the higher one goes (an untrack, a cleared report) the next shows.
| # | Source | Tag | Has data when | | :- | :- | :- | :- | | 1 | a plan file you pin | file | /headway track (or the tool's track) has pinned a filter | | 2 | a list Claude reports | reported | Claude called report with at least one task | | 3 | a plan-mode plan you approve | plan | an approved plan parsed to at least one step | | 4 | Claude's task tools | tasks | TaskCreate/TaskUpdate/TodoWrite built a list (off by default, see below) | | 5 | a Workflow's phases | phases | a Workflow run whose script declares meta.phases has started | | 6 | nothing | | agents exist: they are grouped by where they came from |
Your plan file stays the only record; headway reads it and never writes it. Two shapes, which may share a file: a Markdown checklist and a GFM table.
A checklist, say ROADMAP.md:
## Milestone A
- [x] A1 Parse the config file
- [ ] A2 Wire the `--name` flag
- [x] argparse
- [ ] tests
- [ ] Write the release note
/headway track ROADMAP.md "Milestone A"
A1 is done, A2 is open with a 1/2 bar from its sub-items (sub-items are the parent's bar, not tasks), and the third item has the ID #3. The section argument matches a heading as a case-insensitive prefix (Milestone A matches ## Milestone A (4 items)) and runs to the next heading of the same or a higher level; no section means the whole file.
A table, say a release checklist in the same file:
## Release
| id | title | shipped | announced |
| :- | :------------------------ | :--------- | :-------- |
| R1 | Tag and build | 2026-10-02 | yes |
| R2 | Publish the package | 2026-10-03 | |
| R3 | Update the install docs | | |
/headway track ROADMAP.md "Release" column=shipped partial=announced
counts shipped 2 · announced 1 · open 1 / 3: the column headers, as written, are the words the counts use. Done rules: column=<name> (done when the cell is non-empty; blank, -, –, — and [ ] are empty), check=<name> (done when the cell holds [x]), or auto (the default: a [ ]/[x] column if the first row has one, else a column named done, else every row open with no done rule after the count). partial=<column> is independent of the done rule and uses the same emptiness test. A rule naming a column the table lacks is refused (column not found: shipped).
Groups. A task's group is the nearest heading above it with an ID of its own (else the nearest heading). A group map replaces that for the tasks it names:
/headway track ROADMAP.md groups=groups.json
{ "groups": [
{ "id": "G1", "title": "Parser", "rows": ["A1", "A2"] },
{ "id": "G2", "title": "Release", "rows": ["R1", "R2", "R3"] }
] }
A row ID matching no task is counted in the Plan tab footer as n unmapped rows.
IDs. The leading token of a row's first cell or an item's text (A1, R2, Step 2, T08-25) is the task ID when it holds a digit; otherwise the task is #<n>, its ordinal in the section. Agents are matched to tasks by these IDs (below), as literal text.
The file is stat-polled every 2 s and re-parsed when it changes; a task that flips is logged. A read or parse error keeps the last good parse and shows plan unreadable on line 1 until the next good read. Pinning the same filter again re-reads; another filter replaces it (agents still at work stay and are re-matched, finished ones are dropped). /headway untrack unpins; the next source shows.
With no file pinned, headway adds one short section to Claude's system prompt (the text is in Trust): for a request that asks for two or more things, call the headway tool once with action: "report" and one short title per thing asked, then mark each step partial before starting it and done after. That is what the quickstart shows. Claude also gives the list a short title (six words at most), which is the goal label; the steps get ids 1..n unless it names its own. The list stands until a plan-mode approval or Claude's next full list; a pinned file hides it without ending it. A list holds at most 200 steps and a title at most 200 characters; a longer list is refused, a longer title is cut. Text that Claude or a plan file wrote is shown as one plain line: line breaks, control characters and terminal escape sequences (colour codes, links, title changes) are removed.
Enter plan mode (shift+tab), let Claude write a plan, approve it. The approved plan becomes the source: the # Plan: ... title is the goal label, and the steps are the first of these the plan body has: its checklist items; its numbered items (numbering may continue across ## Phase N headings, which become groups; indented sub-steps are the step's bar); its headings of bullets (one step per heading); or its headings that carry an ID (### Step 2, ## 3. Wire it), prose under them or not. Sections about the work rather than steps of it are left out first: a heading reading Context, Background, Summary, Overview, Goal(s), Files, Critical files, Key files, Risk(s), Out of scope, Non-goals, Notes, Open questions, Questions, Alternatives, Assumptions, Dependencies, Verification, Testing, Test plan, Acceptance (criteria), Done when, Rollback or References, in any case, and everything under it. Only a plan with no step outside those sections takes its steps from them. Prose is ignored.
Every step is open at approval; Claude's report updates by step id and the agents it dispatches move them. A rejected plan changes nothing. An approval starts a new piece of work: it clears the reported list and the task-tool list.
The guidance tells Claude that an approved plan is already tracked and to send updates with the step ids state shows (1, 2, … or 1.1, 2.1, … where numbering restarts under each phase), and the approval itself carries a short note that names the steps and the two calls to make (see Trust). Two things are also turned away, so a Claude that forgets cannot hide the plan: while the plan is unfinished, the tool's track of the plan's own file pins nothing, reads nothing and replies with the ids (any other file is held to the project as usual, so another plan file, and this one once the plan is finished, is refused unread); and a full report list in the turn of the approval is refused with the ids. That turn ends when Claude's turn completes, at the next prompt of any kind, or at Claude's first updates call; after it a full list replaces the plan as usual.
Claude Code's TaskCreate/TaskUpdate (and the older TodoWrite) are off by default on current models. To turn them on, put CLAUDE_CODE_ENABLE_TODO_TOOLS=true in the env block of your settings (CLAUDE_CODE_ENABLE_TASKS=false as well selects TodoWrite). With them on, the list Claude keeps is the source: pending is open, in_progress is partial, completed is done, deleted removes the item. The list survives claude --resume (rebuilt from Claude Code's task files for the session, or from headway's own saved copy once those are gone). A subagent's task calls are ignored; subagents have no task tools.
A Workflow script that declares meta.phases (export const meta = { name, description, phases: ['Read', 'Write'] }) is a plan: the run is one group titled by meta.description, each phase is a task, and each agent() call sits under the phase named by its phase: option (or the last top-level phase('name') statement before it). A phase is partial while one of its agents runs, done when every agent of it has finished. A phase whose latest attempt failed shows ✕ and does not count as done. The source stays after the run ends until a higher source appears or a newer run with phases replaces it.
With no source, agents are grouped by where they came from: a Workflow agent under its run (titled by meta.description, else meta.name, else Workflow <id>), a plain Agent-tool agent under Agents. Line 1 reads ◆ no plan while agents are at work. These groups also take an agent that matches no task when a source is active; they never count toward the goal.
An agent lands under the step whose ID appears as a whole word in its name, description, the first 2000 characters of its prompt, or (for a Workflow agent) its label or phase; longer IDs win (A12 before A1). A bare numeric step (3, from a reported or plan-mode list) matches only as step 3, #3, or 3:/3. at the start of the text, never a loose 3; a heading-text ID (no digit) matches in any case in the agent's name, description or label, and in its own case in the prompt; an ID with a digit is case-sensitive everywhere. With no ID match and exactly one step partial in a reported, plan-mode or task-tool list, the agent attaches to that step. Otherwise it goes to its origin group.
| Command | Does | | :- | :- | | /headway | opens the pane (or the expanded band when the pane cannot be placed); again closes it | | /headway track <path> ["Section text"] [column=<name> \| check=<name> \| auto] [partial=<column>] [groups=<path>] | pins a plan file; prints tracking <file> § <section>: <counts> tasks, <n> groups or refused: <reason> | | /headway untrack | unpins the file; the next source with data shows | | /headway plan · agents · log · usage | opens the pane on that tab | | /headway close | closes the pane or the expanded band | | /headway status | prints the state block: goal, current step, blocked and stalled agents, every agent with its short id (for SendMessage), the log, context and cost | | /headway band off · band on | hides or shows the band; remembered across sessions |
Paths are made absolute against the session's working directory when pinned. track, untrack and band off/on run only when you type them at the prompt; run any other way (a message a model or another session wrote, a notification, another plugin, a scheduled prompt) they answer refused: run this from the prompt. status and the pane forms work from anywhere.
| Key (pane focused) | Does | | :- | :- | | 1 2 3 4 | Plan, Agents, Log, Usage | | e | expand all / collapse all | | h | hide done tasks / show them | | r | re-read the plan file | | q | close the pane | | Enter | open or close the row under the cursor | | Esc | back to the prompt; the pane stays |
When the pane cannot be placed (an unasked open in a terminal under 110 columns), the band draws the pane body under its three lines instead, with letter hotkeys p a l u for the tabs and x to close; they act once the band has focus (Ctrl+X, then Tab).
Claude has the same through one tool, headway (action: track, state, report, focus, untrack); /headway status is the tool's state.
Claude's calls to the headway tool would otherwise show as full tool rows with raw JSON. headway draws each one as a single dim line instead, in the band's colours and glyphs (ascii when your skin says so), and folds the result into it:
◆ headway · 3 steps: Create red.txt · Create blue.txt · Read both
◐ step 1
✓ step 1 · ◐ step 2
✓ step 3
◆ tracking ROADMAP.md § Release 1
A refusal or an error draws in the error colour with its text (✕ headway · refused: ...), and an unknown step id adds a warning beside the line. Set quietToolRows to false to get the stock rows back. See Compatibility for when the skins mod is installed.
Set with /plugin configure headway@headway inside Claude Code (or claude plugin configure headway@headway from a shell). Values are read when the plugin loads, so restart after a change.
| Key | Type | Default | Meaning | | :- | :- | :- | :- | | stallMinutes | number ≥ 1 | 10 | minutes without a step before a running agent counts as stalled | | startMinutes | number ≥ 1 | 2 | minutes a spawned agent may wait for its first step before it counts as stalled | | compactAtPercent | number 1–100 | 70 | context fill at which the compact hint is armed | | guideClaude | boolean | true | add the guidance section to the system prompt so Claude reports its steps (read at the session's first turn), and the short note a plan-mode approval carries | | routerWarnings | boolean | true | run the router rules below | | theme | auto \| skins \| plain | auto | colour source: auto uses the skins mod's palette when skins is installed and enabled, else Claude Code's theme colours; skins uses its palette whenever its files are readable; plain never reads skins | | quietToolRows | boolean | true | draw headway's own tool calls in the transcript as one dim line each instead of the stock row with its JSON | | bandDefault | on \| off | on | whether the band shows before /headway band on or off has been run |
The three number settings show no value in /plugin configure until you set one; that is how Claude Code draws a number option (checked on 2.1.296), not a missing default. With nothing set, the default in the table applies.
Warnings inform; they never deny or rewrite a dispatch (the hook reads an agent definition or the Workflow script and may toast before the call goes on, and a fault in it lets the call through). Each one marks the agent's row with an amber ⚠, writes a log entry, shows one toast, and adds one line to Claude's context after the call so it can correct the next dispatch. routerWarnings: false turns every rule off.
| Rule | Fires when | Default | | :- | :- | :- | | R1 | an Agent call names no model and its agent type's definition sets none (fork agents excepted; see below): headway: Agent "<description>" (<role>) names no model; pass model on every dispatch. | on | | R2 | a Workflow script has agent() calls whose options lack model: or effort:: headway: Workflow "<name>": <n> agent() call(s) without model or effort: <labels>. Pass both on every call. | on | | R3 | an agent of a role in your role file is dispatched on a model family the role does not allow (the call's model, else the model its definition file sets): headway: <role> "<description>" dispatched on <family>; the role table expects <set>. | off until a role file names the role | | R4 | an agent of a listed role runs at an effort the role does not allow (row mark and log only) | off until a role file names the role |
Role rules come from one optional file, headway-roles.json in your Claude config directory ($CLAUDE_CONFIG_DIR, else ~/.claude), read at session start and again within about ten seconds of a change. The role is the last :-separated segment of the agent's subagent_type, lower-cased; the model family is fable, opus, sonnet or haiku as found in the model alias or id (an inherit or unknown family is never judged).
{ "roles": {
"scout": { "models": ["sonnet"], "efforts": ["high"] },
"implementer": { "models": ["sonnet", "opus"], "efforts": ["high", "xhigh"] },
"reviewer": { "models": ["opus"], "efforts": ["high"] },
"runner": { "models": ["sonnet"], "efforts": ["low"] }
} }
A file that does not parse turns R3 and R4 off and shows the parse error once per session.
R1 does not fire for an agent type whose definition file sets a model: in its frontmatter (the dispatch is routed already), and R3 then judges that model against your role file. headway looks for <type>.md in .claude/agents of the session directory and each parent, then in <configDir>/agents, and for a plugin:type name also in that plugin's agents directory under <configDir>/plugins/cache; the first file found decides, and a model: inherit or no model: line counts as none. The walk up from the session directory ends at the first directory that holds a .git, or at your home directory, whichever comes first, and never reads the filesystem root; a session directory outside home with no .git is searched alone. A type name is used only when it is made of letters, digits, ., - and _ (plugin:type allowed): any other name (../x, a path) is looked up nowhere and R1 fires as for any type with no definition. A definition file is read only if it is a regular file of at most 64 KiB; anything else (a pipe, a device, a larger file) counts as no definition and is noted in the debug log. A definition found is kept for the session and read again when its modification time changes or it goes; a type with no definition is looked up again after 60 seconds. A definition it cannot find (an agent file named differently from its type, a plugin loaded with --plugin-dir) is treated as having no model.
$12.34 on band line 3 and in the Usage tab) is the engine's own ledger figure, the same one Claude Code's session cost shows: /cost on a pay-as-you-go account, and on a subscription plan the usage view in settings rather than /cost. It is exact. cost n/a means the engine reported none.est.: the agent's token counts from its steps, priced by the table in hooks/prices.ts (USD per million tokens for input, output, cache read and cache write, per model family). The table's source is in a comment above it and its date is shown on the Usage tab (prices as of 2026-10-06). A model the table does not name is priced at the Sonnet row and marked est.?.ctx 42%) turns amber at compactAtPercent and red at 90%. From the threshold, the band reads compact now: task boundary at the first moment a compact loses the least: after a turn that follows a subagent finishing or a step flipping done, with no agent running. The hint hides when the next agent is dispatched or the next step starts.After /compact, Claude's one call to the headway tool with action: "state" returns the same block as /headway status, so it can pick up without re-reading the plan.
What headway does with your machine, in full:
track action, only inside the project (where the path lands, links followed, must be under the session directory, with no exception: headway never reads a file in the Claude Code config directory's plans for Claude; the plan you approve in plan mode reaches it with the approval). A /headway track you did not type yourself at the prompt is refused. Such a pin is checked again at every read: headway resolves the path, checks where it lands and reads that resolved path, so a link swapped after the check is not followed; <configDir>/headway-roles.json if you create it; the Workflow script file by path (scriptPath, which the tool prefers to an inline script, so headway reads the text that runs), only when it lands inside the project or the Claude Code config directory (the resolved path is what is read), else the inline script is parsed and no file is read; agent definition files by type name, for R1 and R3 (.claude/agents from the session directory up to the first .git or your home directory and never the filesystem root, <configDir>/agents, plugin agents directories; a type name is used only when it is letters, digits, ., - and _, with plugin:type allowed, so a path or .. is read nowhere; a definition linked out of the directory it sits in and out of the config directory is not read); the skins mod's store and theme files when theme resolves to skins; Claude Code's task files for this session under <configDir>/tasks/<session_id>/; the plan-mode plan text the engine hands it; the merged settings through $.settings.read() (to see whether skins is enabled); the first message of a subagent's transcript through $.session.messages({ agentId }), once, at the first step of an agent it has not seen step before, to tell which recorded spawn it is; $.session.usage(), $.agent.list(), $.config.list() and the CLAUDE_CONFIG_DIR, HOME, USERPROFILE, SKINS_THEME and COLORFGBG variables (each explained below).hooks/register.tsx 2012 lines1// Every on(...) and every $.* call. The pure modules (plan.ts, format.ts, agents.ts, theme.ts,
2// band.tsx, pane.tsx, pin.ts, tool.ts, persist.ts) carry the work.
3import { atom, read, update } from 'claude-code'
4import { JOIN_TEXT, RUN_TITLE_MAX, addPlanned, appendLog, bandCurrent, bindAgent, bindRun, buildTree, completeAgent, dropPlanned, dropUnjoined, isFinished, isShown, pinEntry, planFlips, promptHead, reconcileList, repinAgents, repointLog, rereadEntry, settleRestored, spawnAgent, spawnRecordId, stepEnd, stepStart, tick, unpinEntry } from './agents.ts'
5import type { AgentRecord, JoinHints, LogEntry, Outcome, PlannedInput } from './agents.ts'
6import { bandLines, bandTree, bandVisible } from './band.tsx'
7import { clean, plainName } from './format.ts'
8import { quietSegs, quietTree } from './quiet.tsx'
9import { bandBody, DEFAULT_UI, isTab, paneTree } from './pane.tsx'
10import type { Handlers, PaneData, Tab, UiState, UsageData } from './pane.tsx'
11import { parseGroupMap, parsePlan } from './plan.ts'
12import type { GroupMapEntry, Plan } from './plan.ts'
13import { filterFromInput, insideDir, parsePinArgs, readFailure, sameFilter } from './pin.ts'
14import type { Filter, Typed } from './pin.ts'
15import { asSaved, BAND_PREF, capSession, isSessionKey, isStale, recordText, SAVE_MS, sessionKey } from './persist.ts'
16import type { SavedSession } from './persist.ts'
17import type { StatusUpdate } from './tool.ts'
18import { GUIDE_ID, GUIDE_TEXT, guideWanted, idsText, parseReport, planNote, stateBlock, TOOL_SPEC, trackLine, updatedLine } from './tool.ts'
19import type { Reported } from './tool.ts'
20import { definitionModel, definitionName, definitionPlugin, parseRoles, projectDirs, roleOf, ruleR1, ruleR2, ruleR3, searchDirs, warningDetail, warningString } from './router.ts'
21import type { Roles, Warning } from './router.ts'
22import { phaseRun } from './phases.ts'
23import { parseWorkflow } from './workflow.ts'
24import { hideHint, hintAfterMeasure, hintAtTurnComplete, hintOnTurnStart, line3Data, noteBoundary, START } from './usage.ts'
25import type { Boundary } from './usage.ts'
26import type { Value } from './workflow.ts'
27import { buildTheme, parseStore, parseThemeFile, pickVersion, resolveLight, resolveSkin, skinsEntry, themeModeOf, wantSkins } from './theme.ts'
28import type { SkinsEntry, Theme, ThemeMode } from './theme.ts'
29import { parsePlanMode } from './planmode.ts'
30import type { PlanMode } from './planmode.ts'
31import { applyTaskCall, rebuildFromFiles } from './tasks.ts'
32import type { TaskItem } from './tasks.ts'
33import { activeSource, applyReport, approvePlan, goalLabel, isNewWork, isPlanModeFile, isTemporal, promptLabel, sourceFlips, sourcePlan, startLabel, withDoneAt } from './sources.ts'
34import type { SourceLabels, SourceTag, Sources } from './sources.ts'
35
36// `$.state` atoms (SPEC 3.3). `filter` is the pinned plan; `report` the task list the `report`
37// action gave, which stands in for the parsed plan until the next pin; `focus` the task the `focus`
38// action named, until the next spawn; `goal` is the summary of the poll's last parse that the band
39// draws; `theme` the resolved skins look; `agents` and `log` are written by the agent hooks; `ui`
40// is the pane's tab and expand state; `usage` is the context and session figures (M6 writes it).
41const filterAtom = atom({ plugin: 'headway', key: 'filter' } as const, null as Filter | null)
42const goalAtom = atom({ plugin: 'headway', key: 'goal' } as const, null)
43const themeAtom = atom({ plugin: 'headway', key: 'theme' } as const, null)
44const agentsAtom = atom({ plugin: 'headway', key: 'agents' } as const, [] as AgentRecord[])
45const logAtom = atom({ plugin: 'headway', key: 'log' } as const, [] as LogEntry[])
46const uiAtom = atom({ plugin: 'headway', key: 'ui' } as const, DEFAULT_UI as UiState)
47const usageAtom = atom({ plugin: 'headway', key: 'usage' } as const, null as UsageData | null)
48const reportAtom = atom({ plugin: 'headway', key: 'report' } as const, null as Reported | null)
49const focusAtom = atom({ plugin: 'headway', key: 'focus' } as const, null as string | null)
50// The approved plan-mode plan (SPEC 4.4): its parsed steps and, in `$.state` only, its text.
51type PlanModeState = PlanMode & { text?: string }
52const planAtom = atom({ plugin: 'headway', key: 'plan' } as const, null as PlanModeState | null)
53// The prompt labels (SPEC 4.8): each in-memory source's, the prompt line of the turn that started it;
54// and `prompt`, the newest qualifying composer prompt line, which the next source to start takes.
55const labelAtom = atom({ plugin: 'headway', key: 'label' } as const, {} as SourceLabels)
56const promptAtom = atom({ plugin: 'headway', key: 'prompt' } as const, null as string | null)
57
58// The task-tool list (SPEC 4.5): what TaskCreate, TaskUpdate and TodoWrite of the main loop made.
59const tasksAtom = atom({ plugin: 'headway', key: 'tasks' } as const, null as TaskItem[] | null)
60
61const MIN = 60_000
62
63// ---- M2: theme, band, skins composition ------------------------------------------------------
64
65// The theme is read once at session start and again only when the skins store file or the theme file
66// changed (an mtime stat on every fifth 2 s tick), or when the skins plugin was enabled or disabled.
67// A quiet headway (no plan, no agents) stats the store file alone and defers the re-read until it
68// has something to draw. No subprocess is run (SPEC 11).
69const THEME_EVERY_TICKS = 5
70let toasted = false
71let themeTicks = 0
72let themeDirty = false
73let themeCfg: string | undefined
74let themeWatch: { sig?: string; marketplace?: string; store?: string; storeMtime?: number; file?: string; fileMtime?: number } = {}
75
76// The settings (SPEC 13), from `register(on, options)`.
77let themeMode: ThemeMode = 'auto'
78let routerOn = true
79let guideOn = true
80let quietOn = true
81const TOOL_NAME = 'mcp__headway__headway'
82let bandDefaultOff = false
83
84// The plan-mode handoff (SPEC 4.3, 4.4): the approved plan's file, and whether the approval is still
85// fresh: its own turn, until the main loop's turn completes, a prompt arrives or an updates call is made.
86// `awaitStart`: the plan crossed a /clear (12), so the turn that completes next is the one left behind;
87// freshness runs through the new conversation's first turn instead.
88let handoff: { file?: string; fresh: boolean; awaitStart?: true } = { fresh: false }
89
90// An ExitPlanMode of the main loop whose turn has not completed. A /clear before the turn completes,
91// within CARRY_MS, is the plan dialog's "clear context" choice (SPEC 12): the plan crosses it. The
92// engine answers that choice with the rejection text and `isError`, so a rejected call is held too.
93let planBeforeClear: { text: string; file?: string; label: string | null; at: number; approved: boolean } | undefined
94const CARRY_MS = 10_000
95
96// The home directory: HOME, else USERPROFILE (Windows sets that one).
97async function homeDir($): Promise<string | undefined> {
98 return (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || undefined
99}
100
101async function configDir($) {
102 if (themeCfg !== undefined) return themeCfg
103 const dir = await $.env.get('CLAUDE_CONFIG_DIR')
104 themeCfg = dir ? dir : ((await homeDir($)) ?? '') + '/.claude'
105 return themeCfg
106}
107
108async function mtimeOf($, path: string | undefined): Promise<number | undefined> {
109 if (path === undefined) return undefined
110 try {
111 return (await $.fs.stat(path)).mtimeMs
112 } catch {
113 return undefined
114 }
115}
116
117async function tryRead($, path: string): Promise<string | undefined> {
118 try {
119 return await $.fs.read(path)
120 } catch {
121 return undefined
122 }
123}
124
125// A config-directory file is read only as a regular file of at most `max` bytes, read where it lands (the
126// real path that was statted). A missing file is silent; one that is a pipe, a device or too large is
127// not read, with one debug line saying why (SPEC 14).
128const ROLES_MAX = 64 * 1024
129const STORE_MAX = 256 * 1024
130const THEME_MAX = 64 * 1024
131const TASK_FILE_MAX = 64 * 1024
132const TASK_FILES_MAX = 500
133
134async function tryReadBounded($, path: string, max: number): Promise<string | undefined> {
135 let st: { kind?: string; size?: number; realPath?: string }
136 try {
137 st = await $.fs.stat(path, { resolve: true })
138 } catch {
139 return undefined
140 }
141 const why = unreadable(st, max)
142 if (why !== undefined) {
143 fault($, path + ' not read', new Error(why))
144 return undefined
145 }
146 return tryRead($, st.realPath ?? path)
147}
148
149// Where a path lands, every link followed (`$.fs.stat` with `resolve`); undefined when it does not
150// stat or the answer carries no real path. The boundaries of SPEC 14 compare these.
151async function realPathOf($, path: string): Promise<string | undefined> {
152 try {
153 return (await $.fs.stat(path, { resolve: true })).realPath
154 } catch {
155 return undefined
156 }
157}
158
159async function tryList($, path: string): Promise<Array<{ name: string; kind: string }>> {
160 try {
161 return await $.fs.list(path)
162 } catch {
163 return []
164 }
165}
166
167async function isLight($): Promise<boolean> {
168 return resolveLight({
169 override: await $.env.get('SKINS_THEME'),
170 theme: (await $.config.list()).find((row) => row.key === 'theme')?.value,
171 colorfgbg: await $.env.get('COLORFGBG'),
172 })
173}
174
175// The skins plugin as the merged settings name it (B1); undefined when not installed or unreadable.
176async function skinsInstalled($): Promise<SkinsEntry | undefined> {
177 try {
178 return skinsEntry(((await $.settings.read()) as { enabledPlugins?: unknown }).enabledPlugins)
179 } catch {
180 return undefined
181 }
182}
183
184function storeRe(marketplace: string): RegExp {
185 return new RegExp('^skins_' + marketplace.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '-[0-9a-f]+\\.json$')
186}
187
188async function toastOnce($, reason: string) {
189 if (toasted) return
190 toasted = true
191 await $.ui.toast('headway: skins theme unreadable, using ANSI colours (' + reason + ')')
192}
193
194// Which colours to draw: skins' palette when `theme` and the settings say so (SPEC 11), else plain.
195// Writes `theme` when it changed.
196async function refreshTheme($) {
197 themeDirty = false
198 const entry = await skinsInstalled($)
199 const sig = themeSig(entry)
200 if (!wantSkins(themeMode, entry)) {
201 themeWatch = { sig }
202 await writeTheme($, buildTheme({ name: 'plain', icons: 'unicode', file: null, light: await isLight($) }))
203 return
204 }
205 const cfg = await configDir($)
206 const store = (await tryList($, cfg + '/plugins/store')).find((x) => storeRe(entry.marketplace).test(x.name))
207 const storePath = store ? cfg + '/plugins/store/' + store.name : undefined
208 const text = storePath ? await tryReadBounded($, storePath, STORE_MAX) : undefined
209 const settings = parseStore(text, await $.session.cwd())
210 let filePath: string | undefined
211
212 let file = null
213 let reason = ''
214 let overrides = {}
215 let name = settings.skin
216 let custom = false
217 const resolvedOff = settings.skin === 'off'
218 if (!resolvedOff) {
219 const skinsDir = cfg + '/plugins/cache/' + entry.marketplace + '/skins'
220 const version = pickVersion((await tryList($, skinsDir)).map((x) => x.name))
221 if (version === undefined) {
222 reason = 'no skins plugin under ' + skinsDir
223 } else {
224 const themes = skinsDir + '/' + version + '/hooks/themes'
225 const builtins = (await tryList($, themes)).map((x) => x.name.replace(/\.ts$/, '')).filter((n) => n !== 'index')
226 const pick = resolveSkin(settings, builtins)
227 if (!pick.off) {
228 name = pick.name
229 overrides = pick.overrides
230 custom = pick.file !== pick.name
231 filePath = themes + '/' + pick.file + '.ts'
232 const src = await tryReadBounded($, filePath, THEME_MAX)
233 file = src === undefined ? null : parseThemeFile(src)
234 if (file === null) reason = (src === undefined ? 'cannot read ' : 'cannot parse ') + themes + '/' + pick.file + '.ts'
235 }
236 }
237 }
238 if (reason !== '') await toastOnce($, reason)
239 themeWatch = { sig, marketplace: entry.marketplace, store: storePath, storeMtime: await mtimeOf($, storePath), file: filePath, fileMtime: await mtimeOf($, filePath) }
240 await writeTheme($, buildTheme({ name, icons: settings.icons, file, overrides, light: await isLight($), custom }))
241}
242
243async function writeTheme($, theme: Theme): Promise<void> {
244 if (JSON.stringify(await read($, themeAtom)) !== JSON.stringify(theme)) await update($, themeAtom, () => theme)
245}
246
247// What the theme depends on besides the skins files: the setting and the skins entry of the settings.
248function themeSig(entry: SkinsEntry | undefined): string {
249 return themeMode + ':' + (entry ? entry.marketplace + ':' + entry.enabled : '-')
250}
251
252// The band pref, read from the store at session start and kept here for the band: `/headway band
253// on|off` is stored and beats `bandDefault`, which stands until the command has been run.
254let bandOff = false
255
256// ---- M3: agent tracking ----------------------------------------------------------------------
257// Agent events write `agents` through `update`, whose read-apply-write retries on a version miss,
258// so interleaved hooks never lose a transition; the reducers in agents.ts are pure and may run
259// more than once per write.
260
261let stallMs = 10 * MIN
262let startMs = 2 * MIN
263let trackingTimers: Array<{ cancel: () => void }> = []
264
265// The last good parse of the pinned plan, kept in memory: derived from the filter and the file,
266// re-read by the 2 s poll on a changed mtime. A failed read or parse keeps the last good one. It is
267// the only plan read path: the band draws `goal`, written from this parse, and reads it for line 2.
268let planCache: { key: string; mtime: number; gmMtime: number; plan: Plan | null; error?: string } | undefined
269let planLoading: Promise<Plan | null> | undefined
270// Bumped by every pin, unpin and reload: a load begun under an older generation writes no plan
271// cache, goal or log when it lands, and never clears the newer load's slot.
272let planGen = 0
273
274// What a load reads the plan from: every source's data (sources.ts) and the prompt label. A read of
275// an atom inside the dispatch that wrote it still answers the value from before the write, so
276// `readInputs` takes each value from `written` (the newest write this process made) before the atom.
277interface PlanInputs extends Sources {
278 labels: SourceLabels
279 prompt: string | null
280}
281
282async function readInputs($): Promise<PlanInputs> {
283 const w = written
284 return {
285 filter: 'filter' in w ? w.filter ?? null : await read($, filterAtom),
286 report: 'report' in w ? w.report ?? null : await read($, reportAtom),
287 planMode: 'plan' in w ? w.plan ?? null : await read($, planAtom),
288 tasks: 'tasks' in w ? w.tasks ?? null : await read($, tasksAtom),
289 phases: phaseRun('agents' in w ? w.agents ?? [] : await read($, agentsAtom)),
290 labels: 'label' in w ? w.label ?? {} : (await read($, labelAtom)) ?? {},
291 prompt: 'prompt' in w ? w.prompt ?? null : await read($, promptAtom),
292 }
293}
294
295// A source starts (a full report list, a plan approval, a task list out of nothing, a new run with
296// phases): it takes the newest prompt line as its label (SPEC 4.8). Answers the labels written.
297async function startSource($, tag: SourceTag): Promise<SourceLabels> {
298 const { prompt } = await readInputs($)
299 // inside the reducer, so two sources starting at once both keep their label
300 const labels = await update($, labelAtom, (cur) => startLabel(cur ?? {}, tag, prompt))
301 remember({ label: labels })
302 return labels
303}
304
305// The task flips of the active in-memory source between two states of it (not across a start, which
306// is a new list): logged in one wording for every source, each `task-done` a task boundary (SPEC 8).
307async function noteSourceFlips($, before: PlanInputs, after: PlanInputs): Promise<void> {
308 const tag = activeSource(after)
309 if (tag === null || tag === 'file' || activeSource(before) !== tag) return
310 const prev = sourcePlan(before, tag)
311 const next = sourcePlan(after, tag)
312 if (!prev || !next) return
313 // another run is a new list, not a flip; the run is told by its call (the key phaseRun groups by),
314 // since its run id binds only at the first spawn or the call's return
315 const run = (p: Plan) => p.phaseRun?.toolUseId ?? p.phaseRun?.runId
316 if (tag === 'phases' && run(prev) !== run(next)) return
317 // a phase that failed logs no flip: its agent's own `failed` entry says it
318 const failed = new Set(next.tasks.filter((t) => t.failed).map((t) => t.id))
319 const flips = sourceFlips(prev, next, await $.clock.now()).filter((f) => !failed.has(f.ref))
320 if (flips.length === 0) return
321 await addLog($, flips)
322 noteTaskFlips(flips)
323}
324
325function resetPlan(): void {
326 planGen++
327 planCache = undefined
328 planLoading = undefined
329}
330
331// The band's goal summary of a parse; written only when it changed. An error keeps the last counts.
332async function writeGoal($, gen: number, source: SourceTag, label: string, plan: Plan | null, error?: string): Promise<void> {
333 if (gen !== planGen) return
334 const current = await read($, goalAtom)
335 let goal = current
336 if (plan) {
337 goal = {
338 label,
339 done: plan.counts.done,
340 partial: plan.counts.partial,
341 open: plan.counts.open,
342 total: plan.counts.total,
343 doneWord: plan.doneWord,
344 noDoneRule: plan.noDoneRule,
345 ...(plan.partialWord !== undefined ? { partialWord: plan.partialWord } : {}),
346 ...(error !== undefined ? { error } : {}),
347 source,
348 }
349 } else if (current && error !== undefined) {
350 goal = { ...current, error }
351 }
352 if (gen === planGen && JSON.stringify(goal) !== JSON.stringify(current)) await update($, goalAtom, () => goal)
353}
354
355function fileLabel(filter: Filter): string {
356 return goalLabel({ filter, report: null, planMode: null, tasks: null, phases: null }, 'file', null)
357}
358
359// Runs a reducer over the agent list inside `update`, then appends the log entries of the write that
360// landed. Every get of one dispatch reads the moment the dispatch began, so a plain read here would
361// miss a write made earlier in the same dispatch (a step's stepStart, then its stepEnd); `update`
362// reads again after a missed write and the reducer sees what stands. When the reducer changed
363// nothing the same list goes back and nothing is logged or saved. Answers the list written.
364async function applyAgents($, fn: (agents: AgentRecord[]) => Outcome): Promise<AgentRecord[]> {
365 let out: Outcome = { agents: [], log: [], changed: false }
366 let seen: AgentRecord[] = []
367 const written = await update($, agentsAtom, (agents) => {
368 seen = agents
369 out = fn(agents)
370 return out.changed ? out.agents : agents
371 })
372 if (!out.changed) return written
373 remember({ agents: written })
374 prunePrompts(written)
375 // a corrected join moved step data between records: the entries they wrote follow (bindAgent)
376 const moved = out.repoint
377 if (moved !== undefined && moved.length > 0) remember({ log: await update($, logAtom, (log) => appendLog(repointLog(log, written, moved), out.log)) })
378 else if (out.log.length > 0) remember({ log: await update($, logAtom, (log) => appendLog(log, out.log)) })
379 // The phases source is derived from the agents: a write that moved a phase is its flip.
380 if (written.some((a) => a.runPhases !== undefined)) {
381 await guarded($, 'phase flips', async () => {
382 const after = await readInputs($)
383 await noteSourceFlips($, { ...after, phases: phaseRun(seen) }, after)
384 })
385 }
386 await safely(scheduleSave($))
387 return written
388}
389
390async function addLog($, entries: LogEntry[]): Promise<void> {
391 if (entries.length === 0) return
392 remember({ log: await update($, logAtom, (log) => appendLog(log, entries)) })
393 await safely(scheduleSave($))
394}
395
396function currentPlan($, known?: PlanInputs): Promise<Plan | null> {
397 if (!planLoading) {
398 const loading: Promise<Plan | null> = loadPlan($, planGen, known).finally(() => {
399 if (planLoading === loading) planLoading = undefined
400 })
401 planLoading = loading
402 }
403 return planLoading
404}
405
406// A file read for the plan (SPEC 4.2, 14): stat with every link followed, then, for a bounded pin, the
407// real path must land under the bound, and what it leads to must be a regular file of at most `max`
408// bytes (a pipe or a device is never read); the real path that was statted, never the path as given,
409// is what is read, so a link swapped after the check is not followed. Its text, or the refusal in
410// plain words (pin.ts readFailure) naming the path as the user typed it, else as it was resolved.
411type ReadOutcome = { ok: true; text: string } | { ok: false; error: string }
412const PLAN_MAX = 2 * 1024 * 1024
413const GROUP_MAP_MAX = 256 * 1024
414
415// Why a stat's answer is not a file headway reads: not a regular file, or over `max` bytes; else none.
416function unreadable(st: { kind?: string; size?: number }, max: number): string | undefined {
417 if (st.kind !== 'file') return 'not a regular file'
418 if (!(typeof st.size === 'number' && st.size <= max)) return 'file too large'
419 return undefined
420}
421
422async function readText($, path: string, shown: string, max: number, bound?: string): Promise<ReadOutcome> {
423 let st: { kind?: string; size?: number; realPath?: string }
424 try {
425 st = await $.fs.stat(path, { resolve: true })
426 } catch (err) {
427 return { ok: false, error: readFailure(err, shown) }
428 }
429 const real = st.realPath
430 if (bound !== undefined && (real === undefined || !insideDir(real, bound))) return { ok: false, error: shown + ' lands outside ' + bound }
431 const why = unreadable(st, max)
432 if (why !== undefined) return { ok: false, error: why + ': ' + shown }
433 try {
434 return { ok: true, text: await $.fs.read(real ?? path) }
435 } catch (err) {
436 return { ok: false, error: readFailure(err, shown) }
437 }
438}
439
440async function readPlan($, filter: Filter, typed?: Typed): Promise<{ ok: true; plan: Plan } | { ok: false; error: string }> {
441 try {
442 const file = await readText($, filter.file, typed?.file ?? filter.file, PLAN_MAX, filter.bounds?.file)
443 if (!file.ok) return file
444 let groups: GroupMapEntry[] | undefined
445 if (filter.groupMap) {
446 const map = await readText($, filter.groupMap, typed?.groupMap ?? filter.groupMap, GROUP_MAP_MAX, filter.bounds?.groupMap)
447 if (!map.ok) return map
448 const gm = parseGroupMap(map.text)
449 if (!gm.ok) return { ok: false, error: gm.error }
450 groups = gm.groups
451 }
452 return parsePlan(file.text, { section: filter.section, doneRule: filter.doneRule, partial: filter.partial }, groups)
453 } catch (err) {
454 return { ok: false, error: err instanceof Error ? err.message : String(err) }
455 }
456}
457
458// The active source (sources.ts) gives the plan. For the file: stat the plan (and the group map);
459// on a changed mtime re-parse and log the tasks that flipped. No source clears the goal. Every write
460// is skipped once a newer generation has begun.
461async function loadPlan($, gen: number, known?: PlanInputs): Promise<Plan | null> {
462 const inputs = known ?? (await readInputs($))
463 const tag = activeSource(inputs)
464 if (tag !== null && tag !== 'file') return loadInMemory($, gen, inputs, tag)
465 const filter = tag === 'file' ? inputs.filter : null
466 if (!filter) {
467 if (gen !== planGen) return null
468 planCache = undefined
469 if ((await read($, goalAtom)) !== null && gen === planGen) await update($, goalAtom, () => null)
470 return null
471 }
472 const key = JSON.stringify(filter)
473 const prev = planCache && planCache.key === key ? planCache : undefined
474 let mtime = -1
475 let gmMtime = -1
476 try {
477 mtime = (await $.fs.stat(filter.file)).mtimeMs
478 if (filter.groupMap) gmMtime = (await $.fs.stat(filter.groupMap)).mtimeMs
479 } catch (err) {
480 if (gen !== planGen) return prev?.plan ?? null
481 planCache = { key, mtime: -1, gmMtime: -1, plan: prev?.plan ?? null, error: String(err) }
482 await writeGoal($, gen, 'file', fileLabel(filter), planCache.plan, planCache.error)
483 return planCache.plan
484 }
485 if (prev && prev.mtime === mtime && prev.gmMtime === gmMtime) return prev.plan
486 const r = await readPlan($, filter)
487 if (gen !== planGen) return r.ok ? r.plan : prev?.plan ?? null
488 if (!r.ok) {
489 planCache = { key, mtime: -1, gmMtime: -1, plan: prev?.plan ?? null, error: r.error }
490 await writeGoal($, gen, 'file', fileLabel(filter), planCache.plan, planCache.error)
491 return planCache.plan
492 }
493 planCache = { key, mtime, gmMtime, plan: r.plan }
494 await writeGoal($, gen, 'file', fileLabel(filter), r.plan)
495 if (prev?.plan && gen === planGen) {
496 const flips = planFlips(prev.plan, r.plan, await $.clock.now())
497 if (gen !== planGen) return r.plan
498 await addLog($, flips)
499 noteTaskFlips(flips)
500 }
501 return r.plan
502}
503
504// A source held in memory (a reported list, a plan-mode plan): no file to stat, the goal from its plan.
505async function loadInMemory($, gen: number, inputs: PlanInputs, tag: SourceTag): Promise<Plan> {
506 const plan = sourcePlan(inputs, tag)!
507 const label = goalLabel(inputs, tag, inputs.labels[tag])
508 const key = 'src:' + tag + ':' + label + ':' + JSON.stringify(plan)
509 if (planCache && planCache.key === key && planCache.plan) return planCache.plan
510 if (gen !== planGen) return plan
511 planCache = { key, mtime: -1, gmMtime: -1, plan }
512 await writeGoal($, gen, tag, label, plan)
513 return plan
514}
515
516// The active source changed to an in-memory one or to none (an approval, a report, an untrack that
517// reveals a lower source): every agent stays and is re-matched to its plan (SPEC 4.1).
518// `retire`: new work begins (an approval, a new report list), so the finished agents of the earlier
519// work are kept out of the new plan (agents.ts repinAgents).
520async function rematchAgents($, inputs: PlanInputs, retire = false): Promise<void> {
521 const tag = activeSource(inputs)
522 const plan = sourcePlan(inputs, tag)
523 await applyAgents($, (agents) => (agents.length === 0 ? { agents, log: [], changed: false } : repinAgents(agents, { plan, temporal: isTemporal(tag) }, { keepFinished: true, retire })))
524}
525
526// What a newly seen agent is matched against (5.3): the plan drawn now, and whether the source
527// associates by its one partial task.
528async function matchContext($): Promise<{ plan: Plan | null; temporal: boolean }> {
529 const inputs = await readInputs($)
530 return { plan: await currentPlan($, inputs), temporal: isTemporal(activeSource(inputs)) }
531}
532
533async function stallTick($): Promise<void> {
534 const at = await $.clock.now()
535 await applyAgents($, (agents) => tick(agents, { at, stallMs, startMs }))
536 let rows: Array<{ id: string; status: string; name?: string }> = []
537 try {
538 rows = (await $.agent.list()).map((x) => ({ id: x.id, status: x.status, name: x.name }))
539 } catch {
540 rows = []
541 }
542 if (rows.length > 0) await applyAgents($, (agents) => reconcileList(agents, rows, at))
543 // The ledger also takes spend that no turn event announces (background calls); read it here too.
544 await measureUsage($, false)
545}
546
547// Starts the agent timers: the 15 s stall timer and the 2 s plan poll.
548function startTracking($): void {
549 for (const t of trackingTimers) t.cancel()
550 trackingTimers = [
551 $.clock.every(15_000, () => {
552 void guarded($, 'stall timer', () => stallTick($))
553 }),
554 $.clock.every(2000, () => {
555 void guarded($, 'plan poll', () => pollTick($))
556 }),
557 ]
558}
559
560async function startBand($): Promise<void> {
561 themeTicks = 0
562 themeCfg = undefined
563 await refreshTheme($)
564}
565
566// Nothing to draw: no plan or report pinned and no agents tracked.
567async function isQuiet($): Promise<boolean> {
568 return activeSource(await readInputs($)) === null && (await read($, agentsAtom)).length === 0
569}
570
571// Every fifth 2 s tick: whether skins was enabled or disabled; stat the skins store file (and, once
572// there is something to draw, the theme file); re-read the theme when any of them moved.
573async function themeTick($): Promise<void> {
574 const quiet = await isQuiet($)
575 const entry = await skinsInstalled($)
576 if (themeSig(entry) !== themeWatch.sig) themeDirty = true
577 if (wantSkins(themeMode, entry)) {
578 if (themeWatch.store === undefined) {
579 // No store file was found at the last read; a listing is the stat.
580 const cfg = await configDir($)
581 const found = (await tryList($, cfg + '/plugins/store')).some((x) => storeRe(entry.marketplace).test(x.name))
582 if (found) themeDirty = true
583 } else if ((await mtimeOf($, themeWatch.store)) !== themeWatch.storeMtime) {
584 themeDirty = true
585 }
586 if (!quiet && !themeDirty && themeWatch.file !== undefined && (await mtimeOf($, themeWatch.file)) !== themeWatch.fileMtime) themeDirty = true
587 }
588 if (themeDirty && !quiet) await refreshTheme($)
589}
590
591// The 2 s tick: the plan poll, and on every fifth tick the theme check.
592async function pollTick($): Promise<void> {
593 await currentPlan($)
594 themeTicks++
595 if (themeTicks % THEME_EVERY_TICKS === 0) {
596 await guarded($, 'theme', () => themeTick($))
597 await guarded($, 'roles', () => rolesTick($))
598 }
599}
600
601// ---- M4: the pane and the expanded band ------------------------------------------------------
602// Pure trees and rows live in pane.tsx; these top-level functions take `$` and do the calling.
603
604const PANE_ID = 'headway'
605const PANE_REDRAW_MS = 5000
606let paneTimer: { cancel: () => void } | undefined
607let compactAt = 70
608
609const FALLBACK_THEME: Theme = buildTheme({ name: 'noir', icons: 'unicode', file: null, light: false })
610
611// A press must not reject: a failed write is dropped, the pane stays as it was.
612async function safely(work: Promise<unknown>): Promise<void> {
613 try {
614 await work
615 } catch {
616 // nothing to tell the person; the next redraw shows the true state
617 }
618}
619
620// A hook body or a timer callback never rejects: a fault goes to the debug log, named by where it
621// happened, and the work is dropped.
622function fault($, where: string, err: unknown): void {
623 try {
624 $.ui.log('headway: ' + where + ': ' + (err instanceof Error ? err.message : String(err)), { to: 'debug' })
625 } catch {
626 // nowhere left to tell
627 }
628}
629
630async function guarded($, where: string, work: () => unknown): Promise<void> {
631 try {
632 await work()
633 } catch (err) {
634 fault($, where, err)
635 }
636}
637
638async function paneView($, columns: number): Promise<{ theme: Theme; data: PaneData }> {
639 const theme = (await read($, themeAtom)) ?? FALLBACK_THEME
640 const goal = await read($, goalAtom)
641 const agents = await read($, agentsAtom)
642 const data: PaneData = {
643 columns,
644 ascii: theme.icons === 'ascii',
645 now: await $.clock.now(),
646 ui: await read($, uiAtom),
647 goal: goal === null ? null : { ...goal, note: await goalNote($, goal) },
648 tree: buildTree(planCache?.plan ?? null, agents, await $.clock.now()),
649 agents,
650 log: await read($, logAtom),
651 usage: await read($, usageAtom),
652 compactAt,
653 }
654 return { theme, data }
655}
656
657async function goalNote($, goal: { error?: string }): Promise<'unreadable' | undefined> {
658 return goal.error !== undefined ? 'unreadable' : undefined
659}
660
661// A pane the person opens by command is placed; one opened unasked below 110 columns waits, and the
662// band then draws the pane body (8.1).
663async function paneWaiting($): Promise<boolean> {
664 try {
665 const mine = (await $.ui.panes()).find((p) => p.id === PANE_ID)
666 return mine !== undefined && mine.isPlaced === false
667 } catch {
668 return false
669 }
670}
671
672async function rereadPlan($): Promise<void> {
673 if (planCache) planCache = { ...planCache, mtime: -2 }
674 await currentPlan($)
675 $.ui.invalidate('ui.render')
676}
677
678async function openPane($, tab?: Tab): Promise<void> {
679 await update($, uiAtom, (u) => ({ ...u, tab: tab ?? u.tab, opened: true }))
680 await $.ui.open({ id: PANE_ID, title: 'Headway', focus: true, rows: 18, columns: 72 })
681 paneTimer?.cancel()
682 // Elapsed times and "step 14m ago" age without any event; redraw while the pane is up.
683 paneTimer = $.clock.every(PANE_REDRAW_MS, () => {
684 try {
685 $.ui.invalidate('ui.render')
686 } catch (err) {
687 fault($, 'pane redraw', err)
688 }
689 })
690}
691
692async function closePane($): Promise<void> {
693 await $.ui.close({ id: PANE_ID })
694}
695
696function paneHandlers($): Handlers {
697 const set = (fn: (u: UiState) => UiState) => safely(update($, uiAtom, fn).then(() => $.ui.invalidate('ui.render')))
698 return {
699 tab: (tab) => set((u) => ({ ...u, tab })),
700 toggle: (key, open) => set((u) => ({ ...u, open: { ...u.open, [key]: open } })),
701 expandAll: (keys, open) => set((u) => ({ ...u, open: { ...u.open, ...Object.fromEntries(keys.map((k) => [k, open])) } })),
702 hideDone: () => set((u) => ({ ...u, hideDone: !u.hideDone })),
703 reread: () => safely(rereadPlan($)),
704 close: () => safely(closePane($)),
705 }
706}
707
708async function drawPane($, e, next) {
709 if (e.requestId !== PANE_ID) return next(e)
710 const ui = $.ui.resolve(e)
711 const view = await paneView($, e.props.bodyColumns)
712 return paneTree(ui, view.theme, view.data, paneHandlers($))
713}
714
715// ---- M7: the model tool, the /headway command, persistence ------------------------------------
716// The tool's `track` and the command's `track` share `pin`; `status` and the tool's `state` share
717// `stateText`. Every write to filter, report, agents or log schedules a save (`scheduleSave`).
718
719const ARGUMENT_HINT = '[track <path> ["section"] [column=..|check=..|auto] [partial=<column>] [groups=<path>] | untrack | plan|agents|log|usage | close | status | band off|on]'
720const USAGE = 'usage: /headway ' + ARGUMENT_HINT
721
722// Pins a filter: refused unless the plan reads and parses. The filter already pinned only re-reads
723// the plan and logs `re-read`. Another filter replaces the old one, clears the focus and the compact
724// hint's boundary, keeps the agents still at work re-matched to the new plan, drops the finished
725// ones, and adds the pin entry to the log. The other sources keep their data under it (SPEC 4.1).
726// The same plan pinned again takes the new pin's bounds (SPEC 14): the user's pin lifts Claude's.
727async function pin($, filter: Filter, typed?: Typed): Promise<string> {
728 const r = await readPlan($, filter, typed)
729 if (!r.ok) return 'refused: ' + r.error
730 const at = await $.clock.now()
731 const before = await readInputs($)
732 if (sameFilter(before.filter, filter)) {
733 if (JSON.stringify(before.filter!.bounds) !== JSON.stringify(filter.bounds)) {
734 remember({ filter: await update($, filterAtom, () => filter) })
735 await safely(scheduleSave($))
736 }
737 // a new generation that keeps the last parse, so a changed plan still logs its flips
738 planGen++
739 planLoading = undefined
740 if (planCache) planCache = { ...planCache, mtime: -2 }
741 await addLog($, [rereadEntry(filter, at)])
742 await currentPlan($, { ...before, filter })
743 return trackLine(filter, r.plan)
744 }
745 boundary = START
746 remember({ filter: await update($, filterAtom, () => filter) })
747 await update($, focusAtom, () => null)
748 resetPlan()
749 remember({ agents: await update($, agentsAtom, (agents) => repinAgents(agents, { plan: r.plan }).agents) })
750 remember({ log: await update($, logAtom, (log) => appendLog(log, [pinEntry(filter, at)])) })
751 await safely(scheduleSave($))
752 await currentPlan($, { ...(await readInputs($)), filter })
753 return trackLine(filter, r.plan)
754}
755
756// Unpins the file only: a lower source with data shows next, its agents re-matched to it (SPEC 4.1).
757async function unpin($): Promise<string> {
758 remember({ filter: await update($, filterAtom, () => null) })
759 await update($, focusAtom, () => null)
760 resetPlan()
761 const inputs = await readInputs($)
762 await rematchAgents($, inputs)
763 await addLog($, [unpinEntry(await $.clock.now())])
764 await currentPlan($, inputs)
765 return 'untracked'
766}
767
768// The step ids of the plan-mode plan when it is the source drawn (and, with `open`, not finished); none otherwise.
769async function planModeIds($, open = false): Promise<string[]> {
770 const inputs = await readInputs($)
771 if (activeSource(inputs) !== 'plan' || !inputs.planMode || (open && inputs.planMode.doneAt !== undefined)) return []
772 return inputs.planMode.steps.map((s) => s.id)
773}
774
775async function reportTasks($, input: Record<string, unknown>): Promise<string> {
776 const parsed = parseReport(input)
777 if (!parsed.ok) return 'refused: ' + parsed.error
778 if (parsed.kind === 'updates') return reportUpdates($, parsed.updates)
779 const ids = handoff.fresh ? await planModeIds($, true) : []
780 if (ids.length > 0) return 'refused: a plan from plan mode is tracked; send updates with its ids: ' + idsText(ids)
781 const report = withDoneAt(parsed.report, parsed.report.tasks, await $.clock.now())
782 let fresh = true
783 remember({
784 report: await update($, reportAtom, (old) => {
785 fresh = isNewWork(old, report)
786 return report
787 }),
788 })
789 await startSource($, 'reported')
790 resetPlan()
791 const inputs = await readInputs($)
792 // under a pinned file the report waits beneath it; otherwise the agents follow the new list, and
793 // a list that is new work rather than a re-send retires the finished ones (4.1)
794 if (activeSource(inputs) !== 'file') await rematchAgents($, inputs, fresh)
795 await currentPlan($, inputs)
796 const text = 'reported ' + parsed.report.tasks.length + ' tasks'
797 await addLog($, [{ at: await $.clock.now(), kind: 'report', ref: '', detail: text + (parsed.report.title ? ' § ' + parsed.report.title : '') }])
798 await guarded($, 'invalidate', () => $.ui.invalidate('ui.render'))
799 return text
800}
801
802// `report` updates (SPEC 4.3): the tasks of the active reported or plan-mode list take their new
803// status. The agents are not re-matched (5.3: never when a status changes); the flips are logged.
804async function reportUpdates($, updates: StatusUpdate[]): Promise<string> {
805 handoff.fresh = false // Claude is sending updates: the handoff has reached it (4.4)
806 const before = await readInputs($)
807 const tag = activeSource(before)
808 const at = await $.clock.now()
809 let out: ReturnType<typeof applyReport> = applyReport(before, updates, at)
810 if (!out.ok) return 'refused: ' + out.error
811 // the flips are this call's own: from the list as the reducer found it to what it wrote, so an
812 // overlapping call's changes are not logged twice
813 let from: PlanInputs = before
814 let to: PlanInputs = before
815 if (tag === 'reported') {
816 const report = await update($, reportAtom, (cur) => {
817 from = { ...before, report: cur }
818 if (cur === null) return cur
819 out = applyReport(from, updates, at)
820 return out.ok ? out.sources.report : cur
821 })
822 remember({ report })
823 to = { ...before, report }
824 } else {
825 const kept = await update($, planAtom, (cur) => {
826 from = { ...before, planMode: cur }
827 if (cur === null) return cur
828 out = applyReport(from, updates, at)
829 return out.ok && out.sources.planMode ? { ...out.sources.planMode, ...(cur.text !== undefined ? { text: cur.text } : {}) } : cur
830 })
831 remember({ plan: withoutText(kept) })
832 to = { ...before, planMode: kept }
833 }
834 if (!out.ok) return 'refused: ' + out.error
835 resetPlan()
836 await currentPlan($, await readInputs($))
837 await noteSourceFlips($, from, to)
838 await guarded($, 'invalidate', () => $.ui.invalidate('ui.render'))
839 return updatedLine(out.applied, out.unknown)
840}
841
842async function focusTask($, taskId: unknown): Promise<string> {
843 const id = typeof taskId === 'string' ? taskId.trim() : ''
844 const plan = await currentPlan($)
845 const known = plan !== null && id !== '' && (plan.tasks.some((t) => t.id === id) || plan.groups.some((g) => g.id === id))
846 if (!known) return 'unknown task: ' + id
847 await update($, focusAtom, () => id)
848 return 'current task: ' + id
849}
850
851async function stateText($, logEntries: unknown): Promise<string> {
852 const inputs = await readInputs($)
853 const plan = await currentPlan($, inputs)
854 const agents = await read($, agentsAtom)
855 return stateBlock({
856 source: activeSource(inputs),
857 filter: inputs.filter,
858 goal: await read($, goalAtom),
859 current: bandCurrent(plan, agents, await read($, focusAtom), { partialSource: isTemporal(activeSource(inputs)) }).current,
860 ...(activeSource(inputs) === 'plan' && plan ? { steps: plan.tasks.map((t) => ({ id: t.id, title: t.title, status: t.status })) } : {}),
861 agents,
862 log: await read($, logAtom),
863 usage: await currentUsage($),
864 now: await $.clock.now(),
865 logEntries: typeof logEntries === 'number' && Number.isFinite(logEntries) ? Math.floor(logEntries) : 10,
866 })
867}
868
869// A pin made on Claude's behalf (SPEC 14), through the model tool, lands only inside the project: the
870// plan file and the group map must land, every link followed, under the session directory's own real
871// path, since what headway reads flows back to Claude through `state` outside Claude Code's
872// permissions. A path that does not stat keeps the plain refusal; one that stats without a real path
873// is outside. Answers the filter with its bounds, which every later read of the pin holds to
874// (readText), or the refusal.
875async function boundedFilter($, filter: Filter, typed: Typed): Promise<{ filter: Filter } | { refusal: string }> {
876 const root = await realPathOf($, await $.session.cwd())
877 const paths: Array<['file' | 'groupMap', string, string]> = [['file', filter.file, typed.file]]
878 if (filter.groupMap) paths.push(['groupMap', filter.groupMap, typed.groupMap ?? filter.groupMap])
879 const bounds: { file?: string; groupMap?: string } = {}
880 for (const [key, path, shown] of paths) {
881 let real: string | undefined
882 try {
883 real = (await $.fs.stat(path, { resolve: true })).realPath
884 } catch (err) {
885 return { refusal: 'refused: ' + readFailure(err, shown) }
886 }
887 const bound = real !== undefined && root !== undefined && insideDir(real, root) ? root : undefined
888 if (bound === undefined) {
889 const again = typed.file + (filter.groupMap ? ' groups=' + (typed.groupMap ?? filter.groupMap) : '')
890 return { refusal: 'refused: outside the project; ask the user to run /headway track ' + clean(again) }
891 }
892 bounds[key] = bound
893 }
894 return { filter: { ...filter, bounds: { file: bounds.file!, ...(bounds.groupMap !== undefined ? { groupMap: bounds.groupMap } : {}) } } }
895}
896
897async function runTool($, e): Promise<string> {
898 switch (e.action) {
899 case 'track': {
900 const parsed = filterFromInput(e, await $.session.cwd())
901 if (!parsed.ok) return 'refused: ' + parsed.error
902 // the approved plan-mode file (4.4) is not read: while its plan is drawn and unfinished the plan
903 // came with the approval; otherwise it is a path like any other, outside the project
904 const ids = await planModeIds($, true)
905 if (ids.length > 0 && isPlanModeFile(parsed.filter.file, handoff.file)) {
906 return 'already tracking this plan from plan mode; use report updates with ids: ' + idsText(ids)
907 }
908 const bounded = await boundedFilter($, parsed.filter, parsed.typed)
909 if ('refusal' in bounded) return bounded.refusal
910 return pin($, bounded.filter, parsed.typed)
911 }
912 case 'state':
913 return stateText($, e.logEntries)
914 case 'report':
915 return reportTasks($, e)
916 case 'focus':
917 return focusTask($, e.taskId)
918 case 'untrack':
919 return unpin($)
920 default:
921 return 'refused: unknown action: ' + String(e.action)
922 }
923}
924
925async function paneIsOpen($): Promise<boolean> {
926 try {
927 return (await $.ui.panes()).some((p) => p.id === PANE_ID)
928 } catch {
929 return (await read($, uiAtom)).opened
930 }
931}
932
933async function setBand($, enabled: boolean): Promise<void> {
934 bandOff = !enabled
935 await $.store.set(BAND_PREF, enabled)
936 $.ui.invalidate('ui.render')
937}
938
939// `typed`: the person ran it at the prompt (origin `composer`, SPEC 9.2). Any other run (a message a
940// model wrote, a relay, a notification, another plugin's `$.command.run`, a scheduled prompt, a run
941// with no stamp) may only read: `track`, `untrack` and `band on|off` are refused before they touch
942// anything; the pane, its tabs and `status` answer as usual.
943const FROM_PROMPT = 'refused: run this from the prompt'
944
945async function runCommand($, args: string, typed: boolean) {
946 const [word, ...more] = args.split(/\s+/)
947 const rest = args.slice(word.length).trim()
948 if (word === '') {
949 if (await paneIsOpen($)) await closePane($)
950 else await openPane($)
951 return {}
952 }
953 const mutating = word === 'track' || (word === 'untrack' && rest === '') || (word === 'band' && more.length === 1 && (rest === 'off' || rest === 'on'))
954 if (mutating && !typed) return { text: FROM_PROMPT }
955 if (word === 'track') {
956 const parsed = parsePinArgs(rest, await $.session.cwd())
957 if (!parsed.ok) return { text: 'refused: ' + parsed.error }
958 return { text: await pin($, parsed.filter, parsed.typed) }
959 }
960 if (word === 'untrack' && rest === '') return { text: await unpin($) }
961 if (word === 'status' && rest === '') return { text: await stateText($, undefined) }
962 if (word === 'close' && rest === '') {
963 await closePane($)
964 return {}
965 }
966 if (isTab(word) && rest === '') {
967 await openPane($, word)
968 return {}
969 }
970 if (word === 'band' && more.length === 1 && (rest === 'off' || rest === 'on')) {
971 await setBand($, rest === 'on')
972 return {}
973 }
974 return { text: USAGE }
975}
976
977// One save in flight at most: the first change arms a one-second timer, later changes replace the
978// pending record, the timer writes the newest. The record is read at the change, under the id of
979// that moment, so a save that fires after /clear or /resume reset `$.state` still writes the old
980// session's data under the old id.
981let saveTimer: { cancel: () => void } | undefined
982let pendingSave: { id: string; record: SavedSession; seq: number } | undefined
983let saveSeq = 0
984// What the store holds for the session last written, serialised without its save time: a record
985// equal to it is not written again.
986let lastWritten: { id: string; text: string } | undefined
987
988// The plan-mode plan as it is saved: its parsed steps, not its text (SPEC 4.4).
989function withoutText(p: PlanModeState | null): PlanMode | null {
990 if (!p) return null
991 const { text: _text, ...rest } = p
992 return rest
993}
994
995// The values of filter, report, plan, label, agents and log written since the session's state was last loaded,
996// newest per atom. Every get of one dispatch reads the moment it began, so a save scheduled right
997// after a write would read the value from before it; each write hands its result here instead, and
998// the save reads an atom only when nothing wrote it since.
999type Saved = Omit<SavedSession, 'savedAt'>
1000let written: Partial<Saved> = {}
1001
1002function remember(values: Partial<Saved>): void {
1003 written = { ...written, ...values }
1004}
1005
1006async function scheduleSave($): Promise<void> {
1007 const seq = ++saveSeq
1008 const known = written
1009 const id = await $.session.id()
1010 const record: SavedSession = {
1011 filter: 'filter' in known ? known.filter ?? null : await read($, filterAtom),
1012 report: 'report' in known ? known.report ?? null : await read($, reportAtom),
1013 plan: 'plan' in known ? known.plan ?? null : withoutText(await read($, planAtom)),
1014 label: 'label' in known ? known.label ?? {} : (await read($, labelAtom)) ?? {},
1015 prompt: 'prompt' in known ? known.prompt ?? null : await read($, promptAtom),
1016 tasks: 'tasks' in known ? known.tasks ?? null : await read($, tasksAtom),
1017 agents: known.agents ?? (await read($, agentsAtom)),
1018 log: known.log ?? (await read($, logAtom)),
1019 savedAt: 0,
1020 }
1021 if (pendingSave && pendingSave.seq > seq) return
1022 pendingSave = { id, record, seq }
1023 if (saveTimer) return
1024 saveTimer = $.clock.after(SAVE_MS, () => {
1025 saveTimer = undefined
1026 void safely(flushSave($))
1027 })
1028}
1029
1030async function flushSave($): Promise<void> {
1031 saveTimer?.cancel()
1032 saveTimer = undefined
1033 const p = pendingSave
1034 pendingSave = undefined
1035 if (!p) return
1036 const record = capSession(p.record)
1037 const text = recordText(record)
1038 if (lastWritten && lastWritten.id === p.id && lastWritten.text === text) return
1039 await $.store.set(sessionKey(p.id), { ...record, savedAt: await $.clock.now() })
1040 lastWritten = { id: p.id, text }
1041}
1042
1043// The session whose state the atoms hold: read at session start, moved by each reload. On /clear
1044// it still names the session being left, whose record the new id starts from.
1045let sessionNow: string | undefined
1046
1047// /clear, /resume and /branch reset `$.state`: copy the session's record back, keyed by
1048// `e.session_id` (inside this hook `$.session.id()` may still name the session left behind). With
1049// no record under that id, /clear and /branch start from the record of the session being left,
1050// copied under the new id; a resume starts empty. Never another session's record.
1051async function reloadSession($, sessionId: string, source: string): Promise<void> {
1052 await flushSave($)
1053 // the reset state starts from defaults (or the record below), not from the last session's writes
1054 written = {}
1055 handoff = { fresh: false }
1056 const leaving = sessionNow
1057 sessionNow = sessionId
1058 let saved = asSaved(await $.store.get(sessionKey(sessionId)))
1059 if (!saved) {
1060 if (source === 'resume') await resumeTasks($, sessionId)
1061 if (source === 'resume' || leaving === undefined || leaving === sessionId) return
1062 saved = asSaved(await $.store.get(sessionKey(leaving)))
1063 if (!saved) return
1064 if (source === 'clear') saved = cleared(saved)
1065 await $.store.set(sessionKey(sessionId), { ...saved, savedAt: await $.clock.now() })
1066 }
1067 const record = source === 'clear' ? cleared(saved) : saved
1068 // A resume rebuilds the list from the task files; an emptied directory (every task completed) keeps the saved one.
1069 const tasks = source === 'resume' ? (await taskFilesList($, sessionId)) ?? record.tasks : record.tasks
1070 // An agent whose last sign of life predates this process cannot be reached by any event now.
1071 const at = await $.clock.now()
1072 const settled = settleRestored(record.agents, { at, aliveSince: aliveSince ?? at })
1073 resetPlan()
1074 remember({
1075 filter: await update($, filterAtom, () => record.filter),
1076 report: await update($, reportAtom, () => record.report),
1077 plan: withoutText(await update($, planAtom, () => record.plan)),
1078 label: await update($, labelAtom, () => record.label),
1079 prompt: await update($, promptAtom, () => record.prompt),
1080 tasks: await update($, tasksAtom, () => tasks),
1081 agents: await update($, agentsAtom, () => settled.agents),
1082 log: await update($, logAtom, () => appendLog(record.log, settled.log)),
1083 })
1084 await currentPlan($, { filter: record.filter, report: record.report, planMode: record.plan, tasks, phases: phaseRun(settled.agents), labels: record.label, prompt: record.prompt })
1085 await refreshTheme($)
1086}
1087
1088// The plan dialog's "clear context" (SPEC 12): the plan approved in the turn just left, within
1089// CARRY_MS, starts the new conversation with its label, fresh through that conversation's first turn.
1090async function carryPlan($, carry: NonNullable<typeof planBeforeClear>): Promise<void> {
1091 if ((await $.clock.now()) - carry.at > CARRY_MS) return
1092 const labels = await update($, labelAtom, (cur) => startLabel(cur ?? {}, 'plan', carry.label))
1093 remember({ label: labels })
1094 const steps = await installPlanMode($, carry.text, carry.file, labels, !carry.approved)
1095 if (steps > 0) handoff.awaitStart = true
1096 await safely(scheduleSave($))
1097}
1098
1099// A new conversation after /clear (SPEC 12): the prompt-driven sources (the reported list, the
1100// plan-mode plan, the task list, their labels and the newest prompt) and the log belong to the one
1101// left; the pinned file and the agents carry over.
1102function cleared(record: SavedSession): SavedSession {
1103 return { ...record, report: null, plan: null, tasks: null, label: {}, prompt: null, log: [] }
1104}
1105
1106// The task list of `<configDir>/tasks/<session_id>/*.json` (SPEC 4.5); null when the directory has no task file.
1107async function taskFilesList($, sessionId: string): Promise<TaskItem[] | null> {
1108 const dir = (await configDir($)) + '/tasks/' + sessionId
1109 const all = (await tryList($, dir)).filter((f) => f.kind === 'file' && f.name.endsWith('.json')).map((f) => f.name)
1110 if (all.length > TASK_FILES_MAX) fault($, dir, new Error(all.length + ' task files, over ' + TASK_FILES_MAX + '; the rest are not read'))
1111 const names = all.slice(0, TASK_FILES_MAX)
1112 const texts = await Promise.all(names.map((n) => tryReadBounded($, dir + '/' + n, TASK_FILE_MAX)))
1113 return rebuildFromFiles(texts.filter((t): t is string => t !== undefined))
1114}
1115
1116// A resume with no saved record: the task files alone give the list.
1117async function resumeTasks($, sessionId: string): Promise<void> {
1118 const tasks = await taskFilesList($, sessionId)
1119 if (tasks === null) return
1120 resetPlan()
1121 remember({ tasks: await update($, tasksAtom, () => tasks) })
1122 await currentPlan($, await readInputs($))
1123}
1124
1125// When this process (or this module, after a hot reload) started: a reloaded agent with no sign of
1126// life since then belongs to another process (settleRestored).
1127let aliveSince: number | undefined
1128
1129// At session start: drop records saved more than 14 days ago, and read the band pref.
1130async function startStore($): Promise<void> {
1131 const now = await $.clock.now()
1132 aliveSince ??= now
1133 try {
1134 sessionNow = await $.session.id()
1135 } catch {
1136 // unknown: a /clear before the first save then starts empty
1137 }
1138 for (const key of await $.store.keys()) {
1139 if (isSessionKey(key) && isStale(await $.store.get(key), now)) await $.store.delete(key)
1140 }
1141 const pref = await $.store.get(BAND_PREF)
1142 bandOff = pref === undefined || pref === null ? bandDefaultOff : pref === false
1143}
1144
1145async function drawBand($, e, next) {
1146 const goal = await read($, goalAtom)
1147 const theme = await read($, themeAtom)
1148 const agents = await read($, agentsAtom)
1149 const focus = await read($, focusAtom)
1150 const source = activeSource(await readInputs($))
1151 const pinned = source !== null
1152 const live = agents.filter((a) => isShown(a) && !isFinished(a.status)).length
1153 const draws = bandVisible({ hasSurvey: e.props.hasSurvey === true, pinned, agents: live, off: bandOff })
1154 // pinned but not yet parsed: nothing to draw until the first load writes the goal
1155 if (!draws || (pinned && goal === null) || theme === null) return next(e)
1156 const ui = $.ui.resolve(e)
1157 const below = await next(e)
1158 // Line 2 from the agents atom and the poll's parse (module memory; a re-parse that changes the
1159 // counts writes `goal`, and every agent event writes `agents`; both redraw this site).
1160 const line2 = bandCurrent(planCache?.plan ?? null, agents, focus, { partialSource: isTemporal(source) })
1161 const lines = bandLines({
1162 columns: e.props.bodyColumns,
1163 ascii: theme.icons === 'ascii',
1164 goal: goal === null ? null : { ...goal, note: goal.error !== undefined ? 'unreadable' : undefined },
1165 current: line2.current,
1166 allDone: line2.allDone,
1167 marks: line2.marks,
1168 line3: line3Data({ usage: await read($, usageAtom), threshold: compactAt, log: await read($, logAtom) }),
1169 })
1170 if (!(await paneWaiting($))) return bandTree(ui, theme, lines, below)
1171 // The pane is open but unplaced: the band carries its body, under the lines (8.1).
1172 const view = await paneView($, e.props.bodyColumns)
1173 const body = bandBody(ui, theme, view.data, paneHandlers($), Math.max(0, e.props.maxRows - 1 - lines.length))
1174 return bandTree(ui, theme, lines, ui.Box({ flexDirection: 'column', children: [body, below] }))
1175}
1176
1177// headway's own tool in the transcript (SPEC 10.4): ToolUse is one dim line with the result folded in,
1178// ToolResult draws nothing. Any other tool, or quietToolRows off, keeps the stock row.
1179async function drawQuietRow($, e, next) {
1180 if (!quietOn || e.props.tool !== TOOL_NAME) return next(e)
1181 const theme = (await read($, themeAtom)) ?? FALLBACK_THEME
1182 const ui = $.ui.resolve(e)
1183 if (e.component === 'ToolResult') return ui.Box({})
1184 const segs = quietSegs({ input: e.props.input, output: e.props.output, isErrored: e.props.isErrored === true, isRunning: e.props.isRunning === true, ascii: theme.icons === 'ascii' })
1185 return quietTree(ui, theme, segs)
1186}
1187
1188// The band above the prompt and the pane: one ui.render hook for both sites.
1189function registerRender(on) {
1190 on('ui.render', { component: ['AbovePrompt', 'Pane'] }, async ($, e, next) => {
1191 try {
1192 if (e.component === 'Pane') return await drawPane($, e, next)
1193 return await drawBand($, e, next)
1194 } catch (err) {
1195 fault($, 'ui.render', err)
1196 return next(e)
1197 }
1198 })
1199}
1200hooks/agents.ts 1436 lines1// Agent tracking (pure): the agent record, its status transitions, ID matching against the pinned
2// plan, the stall rule, log entries, and the Plan-tree data. SPEC.md sections 2, 4 and 8.2.
3// Every reducer takes the agent list and one event and returns a new list; nothing is mutated,
4// so register.tsx can run a reducer inside `update`, which may call it more than once.
5
6import { candidateIds } from './plan.ts'
7import type { Plan, Task } from './plan.ts'
8import type { Filter } from './pin.ts'
9import { familyOf, roleOf, ruleR3, ruleR4, warningDetail, warningString } from './router.ts'
10import type { Roles } from './router.ts'
11import type { Warning } from './router.ts'
12import { costOf } from './prices.ts'
13import { basename, escapeRegex } from './format.ts'
14
15// `lost`: left queued, running or stalled by an earlier process, so no event can reach it again.
16export type AgentStatus = 'queued' | 'running' | 'stalled' | 'blocked' | 'failed' | 'done' | 'lost'
17
18export interface Tokens {
19 in: number
20 out: number
21 cacheRead: number
22 cacheWrite: number
23}
24
25export interface AgentRecord {
26 id: string // agentId when known; a workflow agent without one `wf:<runId>:<agentIndex>`; else tool_use_id
27 toolUseId?: string
28 agentId?: string
29 runId?: string
30 agentIndex?: number
31 label?: string
32 phase?: string
33 runTitle?: string // a workflow agent's run title (meta.description, else meta.name); the group title of the run (4.3)
34 runPhases?: string[] // the run's `meta.phases`, in order: the tasks of the phases source (4.6)
35 name?: string
36 description: string
37 subagentType: string
38 taskIds: string[] // the matched task ID, at most one (first match wins)
39 groupId?: string // the group the match resolved to, or the group the agent names
40 role: string
41 requestedModel?: string // the alias the call passed
42 model?: string // resolved: from each turn.step (the spawn's answer is not read)
43 effort?: string
44 status: AgentStatus
45 reason?: string // why it failed: error, refusal, aborted, failed, killed
46 question?: string // a blocked answer's text after BLOCKED
47 spawnedAt: number
48 firstStepAt?: number
49 lastStepAt?: number
50 stalledAt?: number
51 endedAt?: number
52 durationMs?: number
53 steps: number
54 tokens: Tokens
55 estUsd: number
56 warnings: string[] // router warnings (router.ts warningString): the rule, a space, the one-line text
57 planned?: true // a Workflow script's agent() call not yet spawned (addPlanned)
58 skipped?: true // a planned call its run ended without spawning (tick): kept for a late spawn, never drawn or counted
59 unordered?: true // a planned call of a script whose agentIndex order is not its source order: no join by ordinal
60 provisional?: true // a step or completion seen before its agent.spawn wrote the record
61 previous?: true // finished when a plan-mode approval or a new report list began new work: matched to nothing while it stays finished (4.1)
62}
63
64export type LogKind = 'done' | 'failed' | 'blocked' | 'stalled' | 'lost' | 'resumed' | 'task-done' | 'task-partial' | 'task-reopened' | 'pin' | 'unpin' | 'report' | 'plan' | 'warn'
65
66export interface LogEntry {
67 at: number
68 kind: LogKind
69 ref: string // task or group ID, `—` for an unassigned agent, '' for pin and unpin, the rule (R1-R4) for a warning
70 role?: string
71 label?: string // a workflow agent's call label, shown where the role would read `workflow-subagent`
72 agentId?: string // the record's id
73 detail?: string
74 durationMs?: number
75 estUsd?: number
76}
77
78export type { Filter }
79
80export interface Outcome {
81 agents: AgentRecord[]
82 log: LogEntry[]
83 changed: boolean
84 repoint?: string[] // record ids whose step data moved: their log entries take the record's ref and role (repointLog)
85}
86
87export interface Usage {
88 input_tokens: number
89 output_tokens: number
90 cache_read_input_tokens: number
91 cache_creation_input_tokens: number
92 cache_creation?: { ephemeral_5m_input_tokens?: number | null; ephemeral_1h_input_tokens?: number | null } | null
93 speed?: string | null
94 model?: string
95}
96
97export interface SpawnEvent {
98 at: number
99 toolUseId: string
100 description: string
101 subagentType: string
102 prompt?: string
103 name?: string
104 requestedModel?: string // agent.spawn's e.model
105 workflow?: { runId: string; agentIndex: number }
106 // The spawn hook passes none of these (it does not read what next(e) answers; the agentId joins
107 // later, joinSpawn); a caller that knows them may.
108 agentId?: string
109 model?: string
110 deny?: string // a refused spawn: no record
111 warnings?: string[] // router warnings found at the dispatch (tool.call), stored on the record
112}
113
114export interface MatchContext {
115 plan: Plan | null
116 temporal?: boolean // the active source is reported, plan-mode or task tools: an agent naming nothing joins the one partial task (5.3)
117 roles?: Roles // the role table R3/R4 judge by; absent when no role file is in force
118}
119
120export interface Match {
121 taskIds: string[]
122 groupId?: string
123}
124
125const MIN = 60_000
126const PROVISIONAL_TTL = 2 * MIN
127// An early record that finished and that no spawn claimed this long after: not an agent whose
128// spawn hook is about to write (that lands within the dispatch), but one the engine never raised
129// agent.spawn for, such as a stalled workflow agent's retry (`agent.spawn`'s `workflow` docs).
130const SURFACE_MS = 15_000
131// A run whose spawned agents are all terminal and that saw no spawn, step or end for this long is
132// over: its planned calls that never spawned are skipped. A sequential script spawns its next call
133// within seconds of the last one ending.
134export const RUN_QUIET_MS = MIN
135const PROMPT_HEAD = 2000
136const BLOCKED_HEAD = 4096
137
138// The text after BLOCKED on the first line, in the first BLOCKED_HEAD characters of an answer, whose
139// first word is BLOCKED (SPEC 3), an optional `:` dropped; undefined when there is none. String
140// operations only: no pattern runs over the whole answer.
141function blockedReason(answer: string): string | undefined {
142 for (const line of answer.slice(0, BLOCKED_HEAD).split(/[\n\r\u2028\u2029]/)) {
143 const t = line.trimStart()
144 if (!t.startsWith('BLOCKED') || /[A-Za-z0-9_]/.test(t.charAt(7))) continue
145 let rest = t.slice(7)
146 let k = 0
147 while (rest[k] === ' ' || rest[k] === '\t') k++
148 if (rest[k] === ':') k++
149 rest = rest.slice(k)
150 return rest.trim()
151 }
152 return undefined
153}
154const LOG_MAX = 200
155
156const ZERO: Tokens = { in: 0, out: 0, cacheRead: 0, cacheWrite: 0 }
157
158export function isFinished(s: AgentStatus): boolean {
159 return s === 'done' || s === 'failed' || s === 'blocked' || s === 'lost'
160}
161
162// Drawn and counted: not a provisional record waiting for its spawn, not a skipped planned call.
163export function isShown(a: AgentRecord): boolean {
164 return !a.provisional && !a.skipped
165}
166
167// Over for good: a blocked agent still waits on an answer, so it is not terminal.
168export function isTerminal(s: AgentStatus): boolean {
169 return s === 'done' || s === 'failed' || s === 'lost'
170}
171
172// When a finished agent last moved: the newest of these wins a cap.
173export function agentRecency(a: AgentRecord): number {
174 return a.endedAt ?? a.lastStepAt ?? a.spawnedAt
175}
176
177// Drops keys whose value is undefined, so records stay plain JSON.
178function clean<T extends object>(o: T): T {
179 const out = {} as T
180 for (const k of Object.keys(o) as Array<keyof T>) if (o[k] !== undefined) out[k] = o[k]
181 return out
182}
183
184function without<T extends object>(o: T, ...keys: Array<keyof T>): T {
185 const out = { ...o }
186 for (const k of keys) delete out[k]
187 return out
188}
189
190// ---------------------------------------------------------------- role and model (4.4)
191
192export { familyOf, roleOf }
193
194// ---------------------------------------------------------------- ID matching (4.3)
195
196const wordCache = new Map<string, RegExp>()
197
198// A text an agent is matched by: the name, description and workflow label are words a dispatcher
199// chose, where a heading-text ID may appear in any case; the prompt and the phase are not.
200export interface AnyCase {
201 text: string | undefined
202 anyCase: true
203}
204export type MatchText = string | undefined | AnyCase
205export const anyCase = (text: string | undefined): AnyCase => ({ text, anyCase: true })
206
207const NEVER = /(?!)/
208
209// An ID as a whole word. A bare number or a qualified one (a plan-mode or reported step `3`, `2.1`,
210// SPEC 5.3) only in the step forms: `Step n` / `step n`, `#n`, or `n:` / `n.` opening the text; a
211// loose number is no match. An ID is case-sensitive, except that one without a digit (a heading's
212// text, as in a plan-mode plan) matches in any case in an `anyCase` text. An empty ID matches nothing.
213function wordRe(id: string, loose = false): RegExp {
214 if (id === '') return NEVER
215 const fold = loose && !/\d/.test(id)
216 const key = (fold ? 'i:' : 's:') + id
217 let re = wordCache.get(key)
218 if (!re) {
219 const end = '(?![A-Za-z0-9-])'
220 const num = escapeRegex(id)
221 re = /^\d+(\.\d+)?$/.test(id)
222 ? new RegExp('(?<![A-Za-z0-9-])(?:[Ss]tep |#)' + num + '(?![A-Za-z0-9-]|\\.\\d)|^\\s*' + num + '[:.](?!\\d)')
223 : new RegExp('(?<![A-Za-z0-9-])' + escapeRegex(id) + end, fold ? 'i' : '')
224 wordCache.set(key, re)
225 }
226 return re
227}
228
229function groupOfTask(plan: Plan, id: string, text: string, loose = false): Match {
230 const occurrences = plan.tasks.filter((t) => t.id === id)
231 const groupIds: string[] = []
232 for (const t of occurrences) for (const gi of t.groups) if (groupIds.indexOf(plan.groups[gi].id) < 0) groupIds.push(plan.groups[gi].id)
233 const named = groupIds.slice().sort((a, b) => b.length - a.length).find((g) => wordRe(g, loose).test(text))
234 if (named !== undefined) return { taskIds: [id], groupId: named }
235 const first = occurrences[0]
236 if (first && first.groups.length > 0) return { taskIds: [id], groupId: plan.groups[first.groups[0]].id }
237 return { taskIds: [id] }
238}
239
240// The first text with a match wins; within a text, task IDs (longest first) before group IDs. The
241// candidates are the IDs the plan has, each matched as escaped literal text. With `temporal`
242// (SPEC 5.3: the active source is reported, plan-mode or task tools) an agent that names nothing
243// attaches to the plan's one partial task.
244export function matchAgent(plan: Plan | null, texts: MatchText[], opts: { temporal?: boolean } = {}): Match {
245 if (!plan) return { taskIds: [] }
246 const { taskIds, groupIds } = candidateIds(plan)
247 for (const entry of texts) {
248 const loose = typeof entry === 'object'
249 const text = typeof entry === 'object' ? entry.text : entry
250 if (!text) continue
251 for (const id of taskIds) if (wordRe(id, loose).test(text)) return groupOfTask(plan, id, text, loose)
252 for (const id of groupIds) if (wordRe(id, loose).test(text)) return { taskIds: [], groupId: id }
253 }
254 return opts.temporal ? partialMatch(plan) : { taskIds: [] }
255}
256
257// 5.3 step 2: exactly one task partial; with none or several, no association.
258function partialMatch(plan: Plan): Match {
259 const partial = plan.tasks.filter((t) => t.status === 'partial')
260 return partial.length === 1 ? groupOfTask(plan, partial[0].id, '') : { taskIds: [] }
261}
262
263// Under a phases plan (4.6) a record is placed by its run and its phase, never by the text of its
264// description: an agent of the plan's run goes to the task named by its phase (else to the run's
265// group), any other agent matches nothing. Undefined for every other plan.
266export function phaseMatch(plan: Plan | null, run: { runId?: string; toolUseId?: string }, phase: string | undefined): Match | undefined {
267 const pr = plan?.phaseRun
268 if (!plan || !pr) return undefined
269 const mine = (run.toolUseId !== undefined && run.toolUseId === pr.toolUseId) || (run.runId !== undefined && run.runId === pr.runId)
270 if (!mine) return { taskIds: [] }
271 const groupId = plan.groups[0]?.id
272 if (phase !== undefined && plan.tasks.some((t) => t.id === phase)) return { taskIds: [phase], groupId }
273 return { taskIds: [], groupId }
274}
275
276// ---------------------------------------------------------------- log entries
277
278// What the role column reads: a workflow agent's call label (every workflow agent has the same role),
279// else the role.
280export function roleText(r: { role: string; label?: string }): string {
281 return r.role === 'workflow-subagent' && r.label ? r.label : r.role
282}
283
284// The role (colours the cell) and, for a workflow agent, its label (the text) for a log entry.
285function roleFields(r: AgentRecord): { role: string; label?: string } {
286 return r.role === 'workflow-subagent' && r.label ? { role: r.role, label: r.label } : { role: r.role }
287}
288
289function refOf(r: AgentRecord): string {
290 return r.taskIds[0] ?? r.groupId ?? '—'
291}
292
293function finishEntry(r: AgentRecord, at: number): LogEntry {
294 const base = { at, ref: refOf(r), ...roleFields(r), agentId: r.id }
295 if (r.status === 'blocked') return clean({ ...base, kind: 'blocked' as const, detail: r.question })
296 if (r.status === 'failed') return clean({ ...base, kind: 'failed' as const, detail: r.reason })
297 return clean({ ...base, kind: 'done' as const, durationMs: r.durationMs, estUsd: r.estUsd })
298}
299
300export function appendLog(log: LogEntry[], entries: LogEntry[], max: number = LOG_MAX): LogEntry[] {
301 if (entries.length === 0) return log
302 return [...entries.slice().reverse(), ...log].slice(0, max)
303}
304
305export function pinEntry(filter: Filter, at: number): LogEntry {
306 let detail = 'tracking ' + basename(filter.file)
307 if (filter.section) detail += ' § ' + filter.section
308 const rules: string[] = []
309 if (filter.doneRule && filter.doneRule !== 'auto') rules.push(filter.doneRule)
310 if (filter.partial) rules.push('partial=' + filter.partial)
311 if (filter.groupMap) rules.push('groups=' + basename(filter.groupMap))
312 if (rules.length > 0) detail += ' ' + rules.join(' ')
313 return { at, kind: 'pin', ref: '', detail }
314}
315
316// The same filter pinned again: only a re-read, logged as one.
317export function rereadEntry(filter: Filter, at: number): LogEntry {
318 const e = pinEntry(filter, at)
319 return { ...e, detail: 're-read' + (e.detail ?? '').slice('tracking'.length) }
320}
321
322export function unpinEntry(at: number): LogEntry {
323 return { at, kind: 'unpin', ref: '', detail: 'untracked' }
324}
325
326// Tasks whose status changed between two parses of the same pin, in plan order. A repeated ID is
327// compared occurrence by occurrence; a task present in only one parse is not a flip.
328// `suffix` follows each detail: the file's flips say " (plan changed)", an in-memory source's say nothing.
329export function planFlips(prev: Plan, next: Plan, at: number, suffix: string = ' (plan changed)'): LogEntry[] {
330 const keyed = (tasks: Task[]) => {
331 const seen = new Map<string, number>()
332 const out = new Map<string, Task>()
333 for (const t of tasks) {
334 const n = seen.get(t.id) ?? 0
335 seen.set(t.id, n + 1)
336 out.set(t.id + '\u0000' + n, t)
337 }
338 return out
339 }
340 const before = keyed(prev.tasks)
341 const out: LogEntry[] = []
342 keyed(next.tasks).forEach((t, key) => {
343 const p = before.get(key)
344 if (!p || p.status === t.status) return
345 if (t.status === 'done') out.push({ at, kind: 'task-done', ref: t.id, detail: next.doneWord + suffix })
346 else if (t.status === 'partial') out.push({ at, kind: 'task-partial', ref: t.id, detail: (next.partialWord ?? 'partial') + suffix })
347 else out.push({ at, kind: 'task-reopened', ref: t.id, detail: 'reopened' + suffix })
348 })
349 return out
350}
351
352// Another filter pinned: the agents still at work (planned calls and early records included) stay,
353// re-matched to the new plan; finished and skipped ones go. Any other change of the active source
354// (SPEC 4.1) passes `keepFinished`: every agent stays and is re-matched. Only name, description, label and phase
355// are kept, not the prompt, so when they name nothing the old task or group stays if the new plan
356// still has that ID (the prompt named it as a whole word); else the agent is Unassigned.
357// `retire` (a plan-mode approval, a new report list: new work begins) marks every finished agent
358// `previous`; a previous agent that is still finished matches nothing, so it sits in its origin group
359// (the workflow run or `Agents`) and never in the new plan's bars. One that resumed re-matches.
360export function repinAgents(agents: AgentRecord[], ctx: MatchContext, opts: { keepFinished?: boolean; retire?: boolean } = {}): Outcome {
361 const ids = ctx.plan ? candidateIds(ctx.plan) : { taskIds: [], groupIds: [] }
362 const out: AgentRecord[] = []
363 for (const a of agents) {
364 if (a.skipped || (!opts.keepFinished && isFinished(a.status))) continue
365 if (a.provisional) {
366 out.push(a)
367 continue
368 }
369 if (isFinished(a.status) && (a.previous || opts.retire)) {
370 out.push({ ...without(a, 'groupId'), taskIds: [], previous: true })
371 continue
372 }
373 const byPhase = phaseMatch(ctx.plan, a, a.phase)
374 let m = byPhase ?? matchAgent(ctx.plan, [anyCase(a.name), anyCase(a.description), anyCase(a.label), a.phase])
375 if (byPhase === undefined && m.taskIds.length === 0 && m.groupId === undefined) {
376 const tid = a.taskIds[0]
377 if (tid !== undefined && ids.taskIds.indexOf(tid) >= 0 && ctx.plan) m = groupOfTask(ctx.plan, tid, a.description)
378 else if (tid === undefined && a.groupId !== undefined && ids.groupIds.indexOf(a.groupId) >= 0) m = { taskIds: [], groupId: a.groupId }
379 else if (ctx.temporal && ctx.plan) m = matchAgent(ctx.plan, [], { temporal: true })
380 }
381 out.push(clean({ ...without(a, 'groupId', 'previous'), taskIds: m.taskIds, groupId: m.groupId }))
382 }
383 return { agents: out, log: [], changed: true }
384}
385
386// ---------------------------------------------------------------- transitions (section 2)
387
388const SAME = (agents: AgentRecord[]): Outcome => ({ agents, log: [], changed: false })
389
390function replaceAt(agents: AgentRecord[], i: number, r: AgentRecord): AgentRecord[] {
391 const out = agents.slice()
392 out[i] = r
393 return out
394}
395
396function indexOfAgent(agents: AgentRecord[], agentId: string): number {
397 return agents.findIndex((a) => a.agentId === agentId)
398}
399
400function provisional(agentId: string, at: number): AgentRecord {
401 return {
402 id: agentId, agentId, provisional: true, description: '', subagentType: '', role: '', taskIds: [],
403 status: 'queued', spawnedAt: at, steps: 0, tokens: { ...ZERO }, estUsd: 0, warnings: [],
404 }
405}
406
407// A label pattern (`*` for each template substitution) against a whole description, with no
408// regex: the classic wildcard walk, which on a mismatch after a `*` resumes one character past
409// where that `*` last started. Time is O(label · description) at worst, never exponential.
410export function globMatch(label: string, text: string): boolean {
411 let p = 0
412 let t = 0
413 let star = -1
414 let resume = 0
415 while (t < text.length) {
416 if (p < label.length && label[p] !== '*' && label[p] === text[t]) {
417 p++
418 t++
419 } else if (p < label.length && label[p] === '*') {
420 star = p++
421 resume = t
422 } else if (star >= 0) {
423 p = star + 1
424 t = ++resume
425 } else return false
426 }
427 while (p < label.length && label[p] === '*') p++
428 return p === label.length
429}
430
431function labelMatches(label: string | undefined, description: string): boolean {
432 if (label === undefined) return false
433 if (label.indexOf('*') < 0) return label === description
434 return globMatch(label, description)
435}
436
437// The workflow join (4.2): the planned call of the same run whose label (or label pattern)
438// matches the spawn's description; `agentIndex` breaks ties.
439function plannedFor(agents: AgentRecord[], wf: { runId: string; agentIndex: number }, description: string): number {
440 let first = -1
441 for (let i = 0; i < agents.length; i++) {
442 const a = agents[i]
443 if (!a.planned || a.agentId || a.runId !== wf.runId) continue
444 // a call with no static label can only be told by its ordinal, and only in a script whose
445 // agentIndex order is its source order; elsewhere it stays unjoined rather than mis-joined
446 if (a.label === undefined) {
447 if (!a.unordered && a.agentIndex === wf.agentIndex) return i
448 continue
449 }
450 if (!labelMatches(a.label, description)) continue
451 if (a.agentIndex === wf.agentIndex) return i
452 if (first < 0) first = i
453 }
454 return first
455}
456
457export function spawnAgent(agents: AgentRecord[], ev: SpawnEvent, ctx: MatchContext): Outcome {
458 if (ev.deny !== undefined) return SAME(agents)
459 let list = agents
460 let planned: AgentRecord | undefined
461 let slot = -1
462 if (ev.workflow) {
463 slot = plannedFor(list, ev.workflow, ev.description)
464 if (slot >= 0) planned = list[slot]
465 }
466 let early: AgentRecord | undefined
467 if (ev.agentId) {
468 const pi = indexOfAgent(list, ev.agentId)
469 if (pi >= 0) {
470 early = list[pi]
471 list = list.filter((_, i) => i !== pi)
472 if (slot > pi) slot--
473 }
474 }
475 const label = ev.workflow ? ev.description : undefined
476 const phase = planned?.phase
477 const runTitle = ev.workflow ? planned?.runTitle ?? list.find((a) => a.runId === ev.workflow!.runId && a.runTitle !== undefined)?.runTitle : undefined
478 const runPhases = ev.workflow ? planned?.runPhases ?? list.find((a) => a.runId === ev.workflow!.runId && a.runPhases !== undefined)?.runPhases : undefined
479 const match =
480 phaseMatch(ctx.plan, { runId: ev.workflow?.runId, toolUseId: ev.toolUseId || undefined }, phase) ??
481 matchAgent(ctx.plan, [anyCase(ev.name), anyCase(ev.description), (ev.prompt ?? '').slice(0, PROMPT_HEAD), anyCase(label), phase], { temporal: ctx.temporal })
482 const id = ev.agentId ?? (ev.workflow ? 'wf:' + ev.workflow.runId + ':' + ev.workflow.agentIndex : ev.toolUseId)
483 const record: AgentRecord = clean({
484 id,
485 toolUseId: ev.toolUseId || undefined,
486 agentId: ev.agentId,
487 runId: ev.workflow?.runId,
488 agentIndex: ev.workflow?.agentIndex,
489 label,
490 phase,
491 runTitle,
492 runPhases,
493 name: ev.name,
494 description: ev.description,
495 subagentType: ev.subagentType,
496 taskIds: match.taskIds,
497 groupId: match.groupId,
498 role: roleOf(ev.subagentType),
499 requestedModel: ev.requestedModel ?? planned?.requestedModel,
500 model: early?.model ?? ev.model,
501 effort: early?.effort ?? planned?.effort,
502 status: early ? early.status : 'queued',
503 reason: early?.reason,
504 question: early?.question,
505 spawnedAt: Math.min(ev.at, early ? early.spawnedAt : ev.at),
506 firstStepAt: early?.firstStepAt,
507 lastStepAt: early?.lastStepAt,
508 endedAt: early?.endedAt,
509 durationMs: early?.durationMs,
510 steps: early ? early.steps : 0,
511 tokens: early ? early.tokens : { ...ZERO },
512 // a planned call never steps (it has no agentId), so the two sums are disjoint
513 estUsd: (planned?.estUsd ?? 0) + (early?.estUsd ?? 0),
514 warnings: [...(planned?.warnings ?? []), ...(ev.warnings ?? [])],
515 })
516 // A step that beat this spawn could not be routed (its record had no role): do it now.
517 const late = early && early.firstStepAt !== undefined ? firstStepWarnings(record, record.model, record.effort, ctx.roles) : []
518 const routed = late.length > 0 ? { ...record, warnings: [...record.warnings, ...late.map(warningString)] } : record
519 const next = slot >= 0 ? replaceAt(list, slot, routed) : [...list, routed]
520 const log = [
521 ...(early && isFinished(early.status) ? [finishEntry(routed, early.endedAt ?? ev.at)] : []),
522 ...late.map((w) => warnEntry(w, routed, early?.firstStepAt ?? ev.at)),
523 ]
524 return { agents: next, log, changed: true }
525}
526
527// ---------------------------------------------------------------- the spawn join (5.1)
528// The agent.spawn hook observes and passes the spawn on as it came (register.tsx), so its record is
529// written from the event, before the engine starts the agent, and has no agentId. The agent's first
530// step or its completion joins the agentId to it (joinSpawn); the Agent tool's result, which carries
531// the agentId, settles it, correcting a wrong join (bindAgent); a spawn the engine refused is dropped (dropUnjoined).
532
533// What tells a new agentId's spawn: the agent's transcript, which opens with the prompt it was
534// spawned with; its `$.agent.list()` row; the head of each waiting spawn's prompt, by record id,
535// kept in memory only; and the step's model and effort. A workflow agent has neither a readable
536// transcript nor a row at its first step (2.1.296), so only its model and effort tell it apart.
537export interface JoinHints {
538 text?: string
539 row?: { description: string; type: string }
540 prompts: Record<string, string>
541 model?: string
542 effort?: string
543}
544
545const JOIN_HEAD = 160
546// How much of a transcript's opening is searched for a head: the prompt may follow a preamble.
547export const JOIN_TEXT = 65536
548
549function squash(s: string): string {
550 return s.replace(/\s+/g, ' ').trim()
551}
552
553// The part of a prompt a join compares: its first JOIN_HEAD characters, white space folded.
554export function promptHead(prompt: string | undefined): string {
555 return squash((prompt ?? '').slice(0, JOIN_HEAD * 4)).slice(0, JOIN_HEAD)
556}
557
558// The id spawnAgent gives a spawn's record (the agentId is not known at the spawn).
559export function spawnRecordId(ev: { toolUseId: string; workflow?: { runId: string; agentIndex: number } }): string {
560 return ev.workflow ? 'wf:' + ev.workflow.runId + ':' + ev.workflow.agentIndex : ev.toolUseId
561}
562
563function waitingSpawn(a: AgentRecord): boolean {
564 return a.agentId === undefined && !a.planned && !a.provisional && !a.skipped && a.steps === 0 && (a.status === 'queued' || a.status === 'stalled')
565}
566
567// Whether a waiting workflow spawn could be the agent of a step on `model`: the family the call
568// asked for, where both sides name one. Effort does not rule a spawn out (the engine may run a call
569// at another effort than the script set); it only breaks a tie (workflowSpawn).
570function fitsModel(a: AgentRecord, hints: JoinHints): boolean {
571 const asked = familyOf(a.requestedModel)
572 const ran = familyOf(hints.model)
573 return asked === 'unknown' || ran === 'unknown' || asked === ran
574}
575
576// The workflow spawn a step with no transcript and no row joins, or -1. Candidates: queued (never a
577// stalled one), of a run not over (endedRuns, when `at` is given). The most recently started run
578// goes first (a run starts at the earliest record it has); only when none of its spawns fits the
579// step's model does the next run get a turn. Within a run, a spawn that set the step's effort
580// before one that did not, then the lowest agentIndex.
581function workflowSpawn(agents: AgentRecord[], waiting: Array<[AgentRecord, number]>, hints: JoinHints, at: number | undefined): number {
582 const over = at === undefined ? new Set<string>() : endedRuns(agents, at)
583 const flows = waiting.filter(([a]) => a.runId !== undefined && a.status === 'queued' && !over.has(a.runId))
584 if (flows.length === 0) return -1
585 const starts = new Map<string, number>()
586 for (const a of agents) if (a.runId !== undefined) starts.set(a.runId, Math.min(starts.get(a.runId) ?? a.spawnedAt, a.spawnedAt))
587 const runs = [...new Set(flows.map(([a]) => a.runId as string))].sort((x, y) => (starts.get(y) ?? 0) - (starts.get(x) ?? 0))
588 for (const run of runs) {
589 const fit = flows.filter(([a]) => a.runId === run && fitsModel(a, hints))
590 if (fit.length === 0) continue
591 const same = fit.filter(([a]) => a.effort !== undefined && a.effort === hints.effort)
592 const pool = same.length > 0 ? same : fit
593 return pool.reduce((b, x) => ((x[0].agentIndex ?? 0) < (b[0].agentIndex ?? 0) ? x : b))[1]
594 }
595 return -1
596}
597
598// The waiting spawn a new agentId belongs to, or -1. A transcript decides when some waiting spawn has
599// a prompt head: the earliest whose head the transcript's first JOIN_TEXT characters hold. When it
600// holds none, only a list row decides (an engine fork, which no spawn announced, joins nothing).
601// Else a list row decides: the earliest with its description and type. With neither, the agent is a
602// workflow agent (or an engine fork): it joins a waiting workflow spawn (workflowSpawn), and never an
603// Agent-tool spawn, however alone it waits: that one waits for a hint or its Agent call's result.
604export function joinSpawn(agents: AgentRecord[], hints: JoinHints, at?: number): number {
605 const waiting: Array<[AgentRecord, number]> = []
606 agents.forEach((a, i) => {
607 if (waitingSpawn(a)) waiting.push([a, i])
608 })
609 if (waiting.length === 0) return -1
610 const earliest = (xs: Array<[AgentRecord, number]>) => (xs.length === 0 ? -1 : xs.reduce((b, x) => (x[0].spawnedAt < b[0].spawnedAt ? x : b))[1])
611 const row = hints.row
612 const byRow = (xs: Array<[AgentRecord, number]>) => (row ? xs.filter(([a]) => a.description === row.description && a.subagentType === row.type) : [])
613 if (hints.text !== undefined) {
614 const headed = waiting.filter(([a]) => (hints.prompts[a.id] ?? '') !== '')
615 if (headed.length > 0) {
616 const text = squash(hints.text.slice(0, JOIN_TEXT))
617 const found = headed.filter(([a]) => text.includes(hints.prompts[a.id]))
618 if (found.length > 0) {
619 // spawns with the same prompt are told apart by the row, where there is one
620 const told = found.length > 1 ? byRow(found) : []
621 return earliest(told.length > 0 ? told : found)
622 }
623 return row ? earliest(byRow(waiting)) : -1
624 }
625 }
626 if (row) return earliest(byRow(waiting))
627 return workflowSpawn(agents, waiting, hints, at)
628}
629
630// What an agent's own events wrote on a record: they belong to its agentId, not to the spawn, and
631// move with it when a join is corrected (bindAgent).
632const STEP_FIELDS = ['agentId', 'model', 'effort', 'status', 'reason', 'question', 'firstStepAt', 'lastStepAt', 'stalledAt', 'endedAt', 'durationMs', 'steps', 'tokens', 'estUsd'] as const
633type StepData = Pick<AgentRecord, (typeof STEP_FIELDS)[number]>
634
635function stepData(r: AgentRecord): StepData {
636 const out: Record<string, unknown> = {}
637 for (const k of STEP_FIELDS) out[k] = r[k]
638 return out as unknown as StepData
639}
640
641const NO_STEPS = { status: 'queued' as const, steps: 0, tokens: { ...ZERO }, estUsd: 0 }
642
643// R3 and R4 for step data a record took from elsewhere: those steps were routed on another record,
644// or on none (a provisional record has no role).
645function routeTaken(r: AgentRecord, roles: Roles | undefined): { record: AgentRecord; warnings: Warning[] } {
646 if (r.firstStepAt === undefined) return { record: r, warnings: [] }
647 const found = firstStepWarnings(r, r.model, r.effort, roles)
648 return { record: found.length > 0 ? { ...r, warnings: [...r.warnings, ...found.map(warningString)] } : r, warnings: found }
649}
650
651// The record for step data `held` of another agent: on `base`'s spawn fields, under its agentId.
652function holding(base: AgentRecord, held: StepData): AgentRecord {
653 return clean({ ...without(base, ...STEP_FIELDS), ...held, id: held.agentId as string })
654}
655
656// The Agent tool's result names the agentId of the call's spawn, and it outranks any join made from
657// hints. The call's record takes that agent's step data, from a provisional record or from another
658// record a join gave it to. Step data the call's record held of another agent go back: to the record
659// that agent's data came from when two joins crossed (a swap), else to a provisional record of that
660// agent, which the provisional rules then keep, surface or drop. A record that gives up its data and
661// gets none back waits again under its spawn id. Log entries of both agents are re-pointed.
662export function bindAgent(agents: AgentRecord[], toolUseId: string, agentId: string, at: number, roles?: Roles): Outcome {
663 const i = agents.findIndex((a) => a.toolUseId === toolUseId && a.runId === undefined && !a.planned && !a.provisional)
664 if (i < 0 || agents[i].agentId === agentId) return SAME(agents)
665 const r = agents[i]
666 const k = agents.findIndex((a, n) => n !== i && a.agentId === agentId)
667 const other = k >= 0 ? agents[k] : undefined
668 const held = r.agentId !== undefined ? stepData(r) : undefined
669 const log: LogEntry[] = []
670 let mine: AgentRecord
671 if (other) {
672 const taken = stepData(other)
673 const routed = routeTaken(
674 clean({
675 ...without(r, ...STEP_FIELDS),
676 ...taken,
677 id: agentId,
678 spawnedAt: other.provisional ? Math.min(r.spawnedAt, other.spawnedAt) : r.spawnedAt,
679 // a spawn that held nothing has only its planned cost, which a step never adds to
680 estUsd: (held ? 0 : r.estUsd) + taken.estUsd,
681 }),
682 roles,
683 )
684 mine = routed.record
685 // a provisional record logs no finish: the record that takes its data does
686 if (other.provisional && isFinished(mine.status)) log.push(finishEntry(mine, mine.endedAt ?? at))
687 log.push(...routed.warnings.map((w) => warnEntry(w, mine, mine.firstStepAt ?? at)))
688 } else if (held) mine = clean({ ...without(r, ...STEP_FIELDS), ...NO_STEPS, id: agentId, agentId })
689 else mine = clean({ ...r, id: agentId, agentId })
690 let out = replaceAt(agents, i, mine)
691 if (other && !other.provisional) {
692 let theirs: AgentRecord
693 if (held) {
694 const swapped = routeTaken(holding(other, held), roles)
695 theirs = swapped.record
696 log.push(...swapped.warnings.map((w) => warnEntry(w, theirs, theirs.firstStepAt ?? at)))
697 } else {
698 const wf = other.runId !== undefined && other.agentIndex !== undefined ? { runId: other.runId, agentIndex: other.agentIndex } : undefined
699 theirs = clean({ ...without(other, ...STEP_FIELDS), ...NO_STEPS, id: spawnRecordId({ toolUseId: other.toolUseId ?? other.id, workflow: wf }) })
700 }
701 out = replaceAt(out, k, theirs)
702 } else {
703 const loose = held ? holding(provisional(held.agentId as string, held.firstStepAt ?? at), held) : undefined
704 if (other) out = loose ? replaceAt(out, k, loose) : out.filter((_, n) => n !== k)
705 else if (loose) out = [...out, loose]
706 }
707 if (!other && !held) return { agents: out, log, changed: true }
708 return { agents: out, log, changed: true, repoint: held ? [agentId, held.agentId as string] : [agentId] }
709}
710
711// Log entries of the given record ids take the ref and role of the record that now holds the id
712// (its step data moved, bindAgent). A warning's ref is its rule, so only its role is taken.
713export function repointLog(log: LogEntry[], agents: AgentRecord[], ids: string[]): LogEntry[] {
714 let changed = false
715 const out = log.map((l) => {
716 if (l.agentId === undefined || ids.indexOf(l.agentId) < 0) return l
717 const r = agents.find((a) => a.id === l.agentId)
718 if (!r) return l
719 const next = clean({ ...without(l, 'role', 'label'), ...(l.kind === 'warn' ? {} : { ref: refOf(r) }), ...roleFields(r) })
720 if (JSON.stringify(next) === JSON.stringify(l)) return l
721 changed = true
722 return next
723 })
724 return changed ? out : log
725}
726
727// An Agent call that came back denied or as an error before its agent ever stepped: the engine
728// refused the spawn, so its record goes.
729export function dropUnjoined(agents: AgentRecord[], toolUseId: string): Outcome {
730 const out = agents.filter((a) => !(a.toolUseId === toolUseId && a.runId === undefined && !a.planned && a.agentId === undefined && a.steps === 0))
731 return out.length === agents.length ? SAME(agents) : { agents: out, log: [], changed: true }
732}
733
734// The record a first step or a completion of an unknown agentId lands on: the waiting spawn it joins
735// (given the agentId), else a new provisional record.
736function landing(list: AgentRecord[], agentId: string, at: number, join: JoinHints | undefined): { list: AgentRecord[]; i: number } {
737 const j = joinSpawn(list, join ?? { prompts: {} }, at)
738 if (j >= 0) return { list: replaceAt(list, j, { ...list[j], id: agentId, agentId }), i: j }
739 return { list: [...list, provisional(agentId, at)], i: list.length }
740}
741
742// ---------------------------------------------------------------- planned workflow calls (4.2)
743
744export interface PlannedInput {
745 ordinal: number // 1-based position of the call in the script
746 label?: string // a literal, or a template pattern with `*` for each substitution; absent when dynamic
747 phase?: string
748 requestedModel?: string
749 effort?: string
750 warnings: string[] // the call's router warnings (R2)
751 unordered?: boolean // the script has a loop, a nested or dynamic call, a function: agentIndex is not the ordinal
752}
753
754// The Workflow tool call's agent() calls become queued, planned records, written before the tool
755// runs (its agents spawn while it does). They carry the tool_use_id until the run id is known.
756export function addPlanned(agents: AgentRecord[], ev: { at: number; toolUseId: string; calls: PlannedInput[]; runTitle?: string; runPhases?: string[] }, ctx: MatchContext): Outcome {
757 const kept = agents.filter((a) => !(a.planned && a.toolUseId === ev.toolUseId))
758 const added = ev.calls.map((c): AgentRecord => {
759 const match = phaseMatch(ctx.plan, { toolUseId: ev.toolUseId }, c.phase) ?? matchAgent(ctx.plan, [anyCase(c.label), c.phase])
760 return clean({
761 id: 'wf:' + ev.toolUseId + ':' + c.ordinal,
762 planned: true as const,
763 toolUseId: ev.toolUseId,
764 agentIndex: c.ordinal,
765 label: c.label,
766 phase: c.phase,
767 runTitle: ev.runTitle,
768 runPhases: ev.runPhases && ev.runPhases.length > 0 ? ev.runPhases : undefined,
769 description: c.label ?? 'agent #' + c.ordinal,
770 subagentType: 'workflow-subagent',
771 taskIds: match.taskIds,
772 groupId: match.groupId,
773 role: 'workflow-subagent',
774 requestedModel: c.requestedModel,
775 effort: c.effort,
776 unordered: c.unordered === true ? (true as const) : undefined,
777 status: 'queued' as const,
778 spawnedAt: ev.at,
779 steps: 0,
780 tokens: { ...ZERO },
781 estUsd: 0,
782 warnings: c.warnings,
783 })
784 })
785 if (added.length === 0 && kept.length === agents.length) return SAME(agents)
786 return { agents: [...kept, ...added], log: [], changed: true }
787}
788
789// Gives a tool call's planned records the run id (the join key of plannedFor) once it is known. A
790// resumed run keeps its run id: the planned calls an earlier call of that run left go first, so
791// their `wf:<runId>:<n>` ids cannot collide with the new plan's.
792export function bindRun(agents: AgentRecord[], toolUseId: string, runId: string): Outcome {
793 const kept = agents.filter((a) => !(a.planned && a.runId === runId && a.toolUseId !== toolUseId))
794 let changed = kept.length !== agents.length
795 const out = kept.map((a) => {
796 if (!a.planned || a.toolUseId !== toolUseId || a.runId !== undefined) return a
797 changed = true
798 return { ...a, runId, id: 'wf:' + runId + ':' + a.agentIndex }
799 })
800 return changed ? { agents: out, log: [], changed } : SAME(agents)
801}
802
803// A Workflow call that was denied runs no agents: its planned records go.
804export function dropPlanned(agents: AgentRecord[], toolUseId: string): Outcome {
805 const out = agents.filter((a) => !(a.planned && a.toolUseId === toolUseId))
806 return out.length === agents.length ? SAME(agents) : { agents: out, log: [], changed: true }
807}
808
809// ---------------------------------------------------------------- router warnings after the fact (5)
810
811function warnEntry(w: Warning, r: AgentRecord, at: number): LogEntry {
812 return { at, kind: 'warn', ref: w.rule, ...roleFields(r), agentId: r.id, detail: warningDetail(w) }
813}
814
815// R3 (when R1 fired: the dispatch named no model) and R4 on an agent's first step; marks only, no
816// text to Claude. A rule already on the record is not added twice.
817function firstStepWarnings(r: AgentRecord, model: string | undefined, effort: string | number | undefined, roles: Roles | undefined): Warning[] {
818 if (r.provisional || r.planned) return []
819 const found: Warning[] = []
820 if (r.warnings.some((w) => w.startsWith('R1 '))) {
821 const w = ruleR3({ description: r.description, subagentType: r.subagentType, model }, roles)
822 if (w) found.push(w)
823 }
824 const w4 = ruleR4({ description: r.description, subagentType: r.subagentType, effort }, roles)
825 if (w4) found.push(w4)
826 return found.filter((w) => !r.warnings.some((x) => x.startsWith(w.rule + ' ')))
827}
828
829// `match`: the context a resumed `previous` agent is matched in afresh (5.3); it then drops the mark.
830export function stepStart(
831 agents: AgentRecord[],
832 ev: { at: number; agentId: string; model?: string; effort?: string | number; roles?: Roles; match?: MatchContext; join?: JoinHints },
833): Outcome {
834 let list = agents
835 let i = indexOfAgent(list, ev.agentId)
836 if (i < 0) ({ list, i } = landing(list, ev.agentId, ev.at, ev.join))
837 const r = list[i]
838 // A step for a finished agent is a SendMessage resume (no new agent.spawn fires): it reopens.
839 const resumed = isFinished(r.status)
840 const base = resumed ? without(r, 'stalledAt', 'endedAt', 'durationMs', 'reason', 'question') : without(r, 'stalledAt')
841 const ctx = ev.match
842 const rematch: Partial<AgentRecord> =
843 r.previous && ctx
844 ? (phaseMatch(ctx.plan, r, r.phase) ?? matchAgent(ctx.plan, [anyCase(r.name), anyCase(r.description), anyCase(r.label), r.phase], { temporal: ctx.temporal }))
845 : {}
846 const next: AgentRecord = clean({
847 ...(r.previous && ctx ? without(base, 'previous', 'groupId') : base),
848 ...rematch,
849 status: 'running' as const,
850 firstStepAt: r.firstStepAt ?? ev.at,
851 lastStepAt: ev.at,
852 steps: r.steps + 1,
853 model: ev.model ?? r.model,
854 effort: ev.effort !== undefined ? String(ev.effort) : r.effort,
855 })
856 const log: LogEntry[] = resumed ? [{ at: ev.at, kind: 'resumed', ref: refOf(r), ...roleFields(r), agentId: r.id, detail: 'was ' + r.status }] : []
857 let routed = next
858 if (r.firstStepAt === undefined) {
859 const found = firstStepWarnings(next, next.model, next.effort, ev.roles)
860 if (found.length > 0) {
861 routed = { ...next, warnings: [...next.warnings, ...found.map(warningString)] }
862 log.push(...found.map((w) => warnEntry(w, routed, ev.at)))
863 }
864 }
865 return { agents: replaceAt(list, i, routed), log, changed: true }
866}
867
868function addUsage(t: Tokens, u: Usage): Tokens {
869 return {
870 in: t.in + (u.input_tokens ?? 0),
871 out: t.out + (u.output_tokens ?? 0),
872 cacheRead: t.cacheRead + (u.cache_read_input_tokens ?? 0),
873 cacheWrite: t.cacheWrite + (u.cache_creation_input_tokens ?? 0),
874 }
875}
876
877// A step's response is in: its usage adds up and is priced (per step, as the haiku-5-5 long-prompt
878// tier is), and it counts as activity for the stall rule.
879export function stepEnd(agents: AgentRecord[], ev: { at: number; agentId: string; usage: Usage | null }): Outcome {
880 const i = indexOfAgent(agents, ev.agentId)
881 if (i < 0) return SAME(agents)
882 const r = agents[i]
883 let next: AgentRecord = ev.usage ? { ...r, tokens: addUsage(r.tokens, ev.usage), estUsd: r.estUsd + costOf(r.model, ev.usage).usd } : r
884 if (!isFinished(r.status)) next = { ...without(next, 'stalledAt'), status: 'running', lastStepAt: ev.at }
885 return { agents: replaceAt(agents, i, next), log: [], changed: true }
886}
887
888export function completeAgent(
889 agents: AgentRecord[],
890 ev: { at: number; agentId: string; reason: string; answer: string; durationMs: number; usage?: Usage; join?: JoinHints },
891): Outcome {
892 let list = agents
893 let i = indexOfAgent(list, ev.agentId)
894 if (i < 0) ({ list, i } = landing(list, ev.agentId, ev.at, ev.join))
895 const r = list[i]
896 if (isFinished(r.status)) return SAME(agents)
897 let status: AgentStatus = 'failed'
898 let question: string | undefined
899 let reason: string | undefined
900 if (ev.reason === 'answer') {
901 const why = blockedReason(ev.answer)
902 if (why !== undefined) {
903 status = 'blocked'
904 question = why.slice(0, 200)
905 } else status = 'done'
906 } else reason = ev.reason
907 const counted = r.tokens.in + r.tokens.out + r.tokens.cacheRead + r.tokens.cacheWrite > 0
908 const next: AgentRecord = clean({
909 ...without(r, 'stalledAt'),
910 status,
911 reason,
912 question,
913 endedAt: ev.at,
914 durationMs: ev.durationMs,
915 tokens: !counted && ev.usage ? addUsage(r.tokens, ev.usage) : r.tokens,
916 estUsd: !counted && ev.usage ? r.estUsd + costOf(r.model, ev.usage).usd : r.estUsd,
917 })
918 return { agents: replaceAt(list, i, next), log: next.provisional ? [] : [finishEntry(next, ev.at)], changed: true }
919}
920
921// The stall rule, run by the 15 s timer: queued past `startMs` since spawn, or running past
922// `stallMs` since the last step, is stalled. A provisional record nobody spawned is dropped once
923// quiet, unless it finished: then it is surfaced (surfaceRetries).
924export function tick(agents: AgentRecord[], ev: { at: number; stallMs: number; startMs: number }): Outcome {
925 const log: LogEntry[] = []
926 let changed = false
927 const out: AgentRecord[] = []
928 const over = endedRuns(agents, ev.at)
929 for (const r of agents) {
930 if (r.provisional && isFinished(r.status)) {
931 out.push(r)
932 continue
933 }
934 if (r.provisional) {
935 const last = Math.max(r.spawnedAt, r.lastStepAt ?? 0, r.endedAt ?? 0)
936 if (ev.at - last >= PROVISIONAL_TTL) {
937 changed = true
938 continue
939 }
940 out.push(r)
941 continue
942 }
943 if (r.planned) {
944 if (!r.skipped && r.runId !== undefined && over.has(r.runId)) {
945 out.push({ ...r, skipped: true })
946 changed = true
947 } else out.push(r)
948 continue
949 }
950 const since = r.status === 'queued' ? r.spawnedAt : r.status === 'running' ? r.lastStepAt ?? r.spawnedAt : undefined
951 const limit = r.status === 'queued' ? ev.startMs : ev.stallMs
952 if (since !== undefined && ev.at - since >= limit) {
953 const next: AgentRecord = { ...r, status: 'stalled', stalledAt: ev.at }
954 out.push(next)
955 log.push({ at: ev.at, kind: 'stalled', ref: refOf(r), ...roleFields(r), agentId: r.id, detail: 'no step for ' + Math.floor((ev.at - since) / MIN) + 'm' })
956 changed = true
957 continue
958 }
959 out.push(r)
960 }
961 const surfaced = surfaceRetries(out, ev.at)
962 if (surfaced.changed) return { agents: surfaced.agents, log: [...log, ...surfaced.log], changed: true }
963 return changed ? { agents: out, log, changed } : SAME(agents)
964}
965
966// A finished early record no spawn claimed within SURFACE_MS is shown, never dropped: its answer,
967// BLOCKED question and cost stay visible. The events carry no run, so it joins a workflow record
968// only when exactly one workflow agent is stalled (across every run) and went quiet before the
969// retry began: the retry then takes that record's place (label, phase, run, task, warnings) and the
970// original is settled as failed, reason `retried`. Otherwise it stands alone, Unassigned.
971function surfaceRetries(agents: AgentRecord[], at: number): Outcome {
972 const due = agents.filter((r) => r.provisional && isFinished(r.status) && r.endedAt !== undefined && at - r.endedAt >= SURFACE_MS)
973 if (due.length === 0) return SAME(agents)
974 let list = agents
975 const log: LogEntry[] = []
976 for (const p of due.slice().sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0))) {
977 const pi = list.indexOf(p)
978 const stalled = list.filter((a) => a.status === 'stalled' && a.runId !== undefined && !a.planned && !a.provisional)
979 const orig = stalled.length === 1 && (stalled[0].lastStepAt ?? stalled[0].spawnedAt) <= p.spawnedAt ? stalled[0] : undefined
980 let shown: AgentRecord = without(p, 'provisional')
981 if (orig) {
982 shown = clean({
983 ...shown,
984 toolUseId: orig.toolUseId,
985 runId: orig.runId,
986 agentIndex: orig.agentIndex,
987 label: orig.label,
988 phase: orig.phase,
989 runTitle: orig.runTitle,
990 runPhases: orig.runPhases,
991 name: orig.name,
992 description: orig.description,
993 subagentType: orig.subagentType,
994 taskIds: orig.taskIds,
995 groupId: orig.groupId,
996 role: orig.role,
997 requestedModel: orig.requestedModel,
998 warnings: orig.warnings,
999 })
1000 const settled: AgentRecord = { ...without(orig, 'stalledAt'), status: 'failed', reason: 'retried', endedAt: p.spawnedAt }
1001 list = replaceAt(list, list.indexOf(orig), settled)
1002 log.push(finishEntry(settled, p.spawnedAt))
1003 }
1004 list = replaceAt(list, pi, shown)
1005 log.push(finishEntry(shown, shown.endedAt ?? at))
1006 }
1007 return { agents: list, log, changed: true }
1008}
1009
1010// A record reloaded into a new process (claude --resume, an in-session /resume of a session another
1011// process saved): an agent left queued, running or stalled whose last sign of life predates this
1012// process can never receive an event, so it is lost, logged once. Blocked, done and failed stay; a
1013// provisional record from then is dropped. A step that does arrive reopens it (stepStart).
1014export function settleRestored(agents: AgentRecord[], ev: { at: number; aliveSince: number }): Outcome {
1015 const log: LogEntry[] = []
1016 let changed = false
1017 const out: AgentRecord[] = []
1018 for (const r of agents) {
1019 const last = Math.max(r.spawnedAt, r.firstStepAt ?? 0, r.lastStepAt ?? 0, r.endedAt ?? 0)
1020 if (last >= ev.aliveSince || r.planned) {
1021 out.push(r)
1022 continue
1023 }
1024 if (r.provisional) {
1025 changed = true
1026 continue
1027 }
1028 if (isFinished(r.status)) {
1029 out.push(r)
1030 continue
1031 }
1032 const next: AgentRecord = { ...without(r, 'stalledAt'), status: 'lost', endedAt: r.lastStepAt ?? r.spawnedAt }
1033 out.push(next)
1034 log.push({ at: ev.at, kind: 'lost', ref: refOf(r), ...roleFields(r), agentId: r.id, detail: '(session restarted)' })
1035 changed = true
1036 }
1037 return changed ? { agents: out, log, changed } : SAME(agents)
1038}
1039
1040// The runs that are over (RUN_QUIET_MS): every agent the run spawned is terminal (lost included),
1041// and nothing of the run (the call, a spawn, a step, an end) happened for RUN_QUIET_MS.
1042function endedRuns(agents: AgentRecord[], at: number): Set<string> {
1043 const last = new Map<string, number>()
1044 const busy = new Set<string>()
1045 for (const a of agents) {
1046 if (a.runId === undefined || a.provisional) continue
1047 if (!a.planned && !isFinished(a.status)) busy.add(a.runId)
1048 const t = Math.max(a.spawnedAt, a.lastStepAt ?? 0, a.endedAt ?? 0)
1049 last.set(a.runId, Math.max(last.get(a.runId) ?? 0, t))
1050 }
1051 const over = new Set<string>()
1052 last.forEach((t, runId) => {
1053 if (!busy.has(runId) && at - t >= RUN_QUIET_MS) over.add(runId)
1054 })
1055 return over
1056}
1057
1058// `$.agent.list()` on the timer: catch `failed`/`killed` the events missed, and fill `name`.
1059export function reconcileList(agents: AgentRecord[], list: Array<{ id: string; status: string; name?: string }>, at: number): Outcome {
1060 let out = agents
1061 const log: LogEntry[] = []
1062 for (const row of list) {
1063 const i = indexOfAgent(out, row.id)
1064 if (i < 0 || out[i].provisional) continue
1065 let r = out[i]
1066 let touched = false
1067 if (row.name && !r.name) {
1068 r = { ...r, name: row.name }
1069 touched = true
1070 }
1071 if ((row.status === 'failed' || row.status === 'killed') && !isFinished(r.status)) {
1072 r = { ...without(r, 'stalledAt'), status: 'failed', reason: row.status, endedAt: at }
1073 log.push(finishEntry(r, at))
1074 touched = true
1075 }
1076 if (touched) out = replaceAt(out, i, r)
1077 }
1078 return { agents: out, log, changed: out !== agents }
1079}
1080
1081// ---------------------------------------------------------------- Plan tree (8.2)
1082
1083export type NodeStatus = 'active' | 'partial' | 'queued' | 'done' | 'failed'
1084
1085export interface Loud {
1086 blocked: number
1087 stalled: number
1088}
1089
1090export interface TaskNode {
1091 id: string
1092 title: string
1093 line?: number
1094 status: NodeStatus
1095 partialWord?: string // shown after a partial task's title
1096 sub?: { done: number; total: number } // the task's own bar when it has a sub-checklist
1097 agents: AgentRecord[]
1098 finished: number
1099 dispatched: number
1100 loud: Loud
1101 recent: boolean // an agent of the task finished within RECENT_MS of the clock given to buildTree
1102}
1103
1104export interface GroupNode {
1105 id: string
1106 title: string
1107 kind: 'heading' | 'map' | 'run' | 'agents' // run, agents: the unmatched agents of one workflow run, or the plain Agent-tool ones (4.3)
1108 status: NodeStatus
1109 tasks: TaskNode[]
1110 agents: AgentRecord[] // agents that name the group, not a task; for a run or agents group, all of its agents
1111 finished: number // over the group's own agents and its tasks' agents
1112 dispatched: number
1113 tasksDone: number
1114 tasksTotal: number
1115 loud: Loud
1116 recent: boolean // an agent of the group finished within RECENT_MS of the clock given to buildTree
1117}
1118
1119export interface PlanTree {
1120 groups: GroupNode[]
1121 unassigned: { agents: AgentRecord[] }
1122 unmapped: number
1123}
1124
1125// ---------------------------------------------------------------- unmatched agents (4.3)
1126
1127export interface LooseGroup {
1128 id: string // `wf:<runId>` for a workflow run, `agents` for the plain Agent-tool ones
1129 kind: 'run' | 'agents'
1130 title: string
1131 agents: AgentRecord[]
1132}
1133
1134export const AGENTS_GROUP = 'agents'
1135export const RUN_TITLE_MAX = 120
1136
1137// The id a run's short title uses when its script gave none: the run id without its `wf_` prefix, cut to 6.
1138export function runShort(id: string): string {
1139 return id.replace(/^(wf_|toolu_)/, '').slice(0, 6)
1140}
1141
1142// Where an agent that matched no task of the plan goes: its workflow run, else the Agents group
1143// (an Agent-tool call). A record with neither stays Unassigned (undefined), the last resort.
1144function looseOf(a: AgentRecord): { id: string; kind: 'run' | 'agents' } | undefined {
1145 if (a.runId !== undefined) return { id: 'wf:' + a.runId, kind: 'run' }
1146 if (a.planned && a.toolUseId !== undefined) return { id: 'wf:' + a.toolUseId, kind: 'run' }
1147 if (a.provisional || a.planned) return undefined
1148 if (a.subagentType !== '' || a.toolUseId !== undefined) return { id: AGENTS_GROUP, kind: 'agents' }
1149 return undefined
1150}
1151
1152export const matchedToPlan = (a: AgentRecord): boolean => a.taskIds[0] !== undefined || a.groupId !== undefined
1153
1154// The groups of the given unmatched agents, in order of first appearance, and the agents with no
1155// group. A run's title is the first one any of its records carries, else `Workflow <short run id>`.
1156export function looseGroups(agents: AgentRecord[]): { groups: LooseGroup[]; rest: AgentRecord[] } {
1157 const groups = new Map<string, LooseGroup>()
1158 const rest: AgentRecord[] = []
1159 for (const a of agents) {
1160 const k = looseOf(a)
1161 if (!k) {
1162 rest.push(a)
1163 continue
1164 }
1165 let g = groups.get(k.id)
1166 if (!g) {
1167 g = { id: k.id, kind: k.kind, title: k.kind === 'agents' ? 'Agents' : 'Workflow ' + runShort(k.id.slice(3)), agents: [] }
1168 groups.set(k.id, g)
1169 }
1170 g.agents.push(a)
1171 if (k.kind === 'run' && a.runTitle !== undefined && g.title === 'Workflow ' + runShort(k.id.slice(3))) g.title = a.runTitle
1172 }
1173 return { groups: [...groups.values()], rest }
1174}
1175
1176// A workflow agent's run title, for the Agents tab's task column; undefined for any other agent.
1177export function runTitleOf(a: AgentRecord): string | undefined {
1178 if (a.runId === undefined && !(a.planned && a.toolUseId !== undefined)) return undefined
1179 return a.runTitle ?? 'Workflow ' + runShort(a.runId ?? a.toolUseId ?? '')
1180}
1181
1182const NODE_RANK: Record<NodeStatus, number> = { active: 0, failed: 1, partial: 2, queued: 3, done: 4 }
1183// A group whose agents finished this recently sorts right after the active groups (8.2).
1184export const RECENT_MS = 30 * 60_000
1185
1186const AGENT_RANK: Record<AgentStatus, number> = { blocked: 0, stalled: 1, running: 2, queued: 3, failed: 4, done: 4, lost: 4 }
1187
1188// Blocked, stalled, running, queued, then finished newest first; spawn order otherwise.
1189export function sortAgents(agents: AgentRecord[]): AgentRecord[] {
1190 return agents
1191 .map((a, i) => ({ a, i }))
1192 .sort((x, y) => {
1193 const d = AGENT_RANK[x.a.status] - AGENT_RANK[y.a.status]
1194 if (d !== 0) return d
1195 if (AGENT_RANK[x.a.status] === 4) {
1196 const e = (y.a.endedAt ?? 0) - (x.a.endedAt ?? 0)
1197 if (e !== 0) return e
1198 }
1199 return x.i - y.i
1200 })hooks/band.tsx 263 lines1// Band above the prompt (pure): the quiet rule, the line layout and the tree, from plain data and the
2// resolved elements. SPEC.md 8.1. `bandLines` makes lines of segments (text and the element that
3// decides its colour); `bandTree` draws them. Neither calls `$` or reads a file.
4import { logGlyph } from './display.ts'
5import { bar, formatCounts, stripControls, truncate } from './format.ts'
6import { GLYPHS, style } from './theme.ts'
7import type { Element, Theme } from './theme.ts'
8
9export interface Seg {
10 text: string
11 el: Element
12}
13
14export interface GoalData {
15 label: string // the section heading, or the file name when there is none
16 done: number
17 partial: number
18 open: number
19 total: number
20 doneWord: string
21 partialWord?: string
22 noDoneRule?: boolean
23 note?: 'unreadable'
24 source?: string // the active source's tag, one dim word after the count (SPEC 4.1)
25}
26
27export interface CurrentData {
28 id: string
29 title: string
30 done: number
31 total: number
32 unit: 'agents' | 'items' // agents finished / dispatched, or the task's own sub-checklist
33 status?: 'partial' | 'done' // partial: a task no agent works, drawn by its status, not `0 agents`; done: the task is done, its bar is drawn in the done colour
34}
35
36export interface Marks {
37 warned?: number
38 blocked?: number
39 running?: number
40 stalled?: number
41}
42
43export type LogKind = 'done' | 'failed' | 'blocked' | 'stalled' | 'warn' | 'info'
44
45export interface Line3Data {
46 ctx?: { percent: number; level: 'ok' | 'warn' | 'err' }
47 cost?: string // '$12.34' or 'cost n/a'
48 log?: { kind: LogKind; text: string }
49 hint?: string // the compact hint; replaces the log slot
50}
51
52export interface BandData {
53 columns: number // e.props.bodyColumns
54 ascii: boolean
55 goal: GoalData | null // null: nothing pinned (the band draws for agents still at work)
56 current?: CurrentData
57 allDone?: boolean // every task is done and no agent is at work: line 2 reads `all done`
58 marks?: Marks
59 line3?: Line3Data
60}
61
62// The band is quiet (8.1) with a survey up, with the band switched off, or when nothing is pinned
63// and no agent is still at work (`agents` counts the shown records not yet finished).
64export function bandVisible(o: { hasSurvey: boolean; pinned: boolean; agents: number; off?: boolean }): boolean {
65 if (o.hasSurvey || o.off === true) return false
66 return o.pinned || o.agents > 0
67}
68
69const LABEL = 30
70const BAR = 40
71const MIN = 12
72const FIXED = 1 + 2 + 2 + 2 // margin, glyph and space, two gaps
73
74// Widths: label 30 and bar 40; when the count does not fit beside them the bar shrinks first (to 12),
75// then the label (to 12), then the count is cut to what is left.
76export function bandLayout(columns: number, countLen: number): { label: number; bar: number; count: number } {
77 let label = LABEL
78 let barW = BAR
79 if (FIXED + label + barW + countLen > columns) {
80 barW = Math.max(MIN, columns - FIXED - label - countLen)
81 if (FIXED + label + barW + countLen > columns) label = Math.max(MIN, columns - FIXED - barW - countLen)
82 }
83 const room = Math.max(0, columns - FIXED - label - barW)
84 return { label, bar: barW, count: Math.min(countLen, room) }
85}
86
87const len = (s: string) => Array.from(s).length
88
89// Cut a line to `width` cells; the last kept cell becomes an ellipsis when anything was cut.
90function clip(segs: Seg[], width: number): Seg[] {
91 const total = segs.reduce((n, s) => n + len(s.text), 0)
92 if (total <= width) return segs
93 const out: Seg[] = []
94 let left = Math.max(0, width)
95 for (const s of segs) {
96 if (left <= 0) break
97 const n = len(s.text)
98 if (n <= left) {
99 out.push(s)
100 left -= n
101 } else {
102 out.push({ text: truncate(s.text, left), el: s.el })
103 left = 0
104 }
105 }
106 return out
107}
108
109function pad(s: string, width: number): string {
110 return s + ' '.repeat(Math.max(0, width - len(s)))
111}
112
113function barSegs(b: { done: string; partial: string; track: string }, fill: Element, mid: Element): Seg[] {
114 const out: Seg[] = []
115 if (b.done !== '') out.push({ text: b.done, el: fill })
116 if (b.partial !== '') out.push({ text: b.partial, el: mid })
117 if (b.track !== '') out.push({ text: b.track, el: 'track' })
118 return out
119}
120
121function goalLine(d: BandData, label: number, barW: number, countRoom: number): Seg[] {
122 const g = d.goal
123 const gl = GLYPHS[d.ascii ? 'ascii' : 'unicode']
124 if (g === null) {
125 return [
126 { text: ' ', el: 'queued' },
127 { text: gl.goal, el: 'queued' },
128 { text: ' ', el: 'queued' },
129 { text: 'no plan', el: 'queued' },
130 ]
131 }
132 const b = bar({ done: g.done, partial: g.partial, total: g.total, width: barW, ascii: d.ascii })
133 const counts = formatCounts({ ...g, ascii: d.ascii })
134 const segs: Seg[] = [
135 { text: ' ', el: 'text' },
136 { text: gl.goal, el: 'goalGlyph' },
137 { text: ' ', el: 'text' },
138 { text: pad(truncate(g.label, label), label), el: 'goalLabel' },
139 { text: ' ', el: 'text' },
140 ...barSegs(b, 'barFill', 'barPartial'),
141 { text: ' ', el: 'text' },
142 ]
143 const rest: Seg[] = [{ text: counts, el: 'counts' }]
144 if (g.source !== undefined) rest.push({ text: ' ', el: 'text' }, { text: g.source, el: 'counts' })
145 if (g.note === 'unreadable') rest.push({ text: ' ', el: 'text' }, { text: 'plan unreadable', el: 'warning' })
146 return [...segs, ...clip(rest, countRoom)]
147}
148
149function currentRest(d: BandData, c: CurrentData): Seg[] {
150 const gl = GLYPHS[d.ascii ? 'ascii' : 'unicode']
151 const progress = c.total > 0 ? `${c.done}/${c.total} ${c.unit}` : `0 ${c.unit}`
152 const rest: Seg[] = c.status === 'partial' ? [{ text: gl.partial + ' in progress', el: 'partial' }] : [{ text: progress, el: 'counts' }]
153 const m = d.marks ?? {}
154 if (m.warned) rest.push({ text: ' ', el: 'text' }, { text: `${gl.warning} ${m.warned}`, el: 'warning' })
155 if (m.blocked) rest.push({ text: ' ', el: 'text' }, { text: `${gl.blocked} ${m.blocked} blocked`, el: 'blocked' })
156 if (m.running) rest.push({ text: ' ', el: 'text' }, { text: `${gl.running} ${m.running}`, el: 'running' })
157 if (m.stalled) rest.push({ text: ' ', el: 'text' }, { text: `${gl.stalled} ${m.stalled} stalled`, el: 'stalled' })
158 return rest
159}
160
161function currentLine(d: BandData, label: number, barW: number, room: number): Seg[] {
162 const gl = GLYPHS[d.ascii ? 'ascii' : 'unicode']
163 const c = d.current
164 if (d.allDone === true) {
165 return [
166 { text: ' ', el: 'text' },
167 { text: gl.done, el: 'doneGlyph' },
168 { text: ' ', el: 'text' },
169 { text: 'all done', el: 'taskDone' },
170 ]
171 }
172 if (!c) {
173 return [
174 { text: ' ', el: 'queued' },
175 { text: gl.current, el: 'queued' },
176 { text: ' ', el: 'queued' },
177 { text: 'no current task', el: 'queued' },
178 ]
179 }
180 const whole = truncate(c.id + ' ' + c.title, label)
181 const idLen = Math.min(len(c.id), len(whole))
182 const chars = Array.from(whole)
183 const idText = chars.slice(0, idLen).join('')
184 const titleText = chars.slice(idLen).join('')
185 const b = bar({ done: c.done, total: c.total, width: barW, ascii: d.ascii })
186 const rest = currentRest(d, c)
187 return [
188 { text: ' ', el: 'text' },
189 { text: gl.current, el: 'currentGlyph' },
190 { text: ' ', el: 'text' },
191 { text: idText, el: 'id' },
192 { text: pad(titleText, label - idLen), el: 'title' },
193 { text: ' ', el: 'text' },
194 ...(c.status === 'done' ? barSegs(b, 'barFill', 'barFill') : barSegs(b, 'taskBar', 'taskBar')),
195 { text: ' ', el: 'text' },
196 ...clip(rest, room),
197 ]
198}
199
200function lastLine(d: BandData, l3: Line3Data): Seg[] {
201 const g = GLYPHS[d.ascii ? 'ascii' : 'unicode']
202 const segs: Seg[] = [{ text: ' ', el: 'text' }]
203 const head: Seg[] = []
204 if (l3.ctx) {
205 const el: Element = l3.ctx.level === 'err' ? 'ctxCritical' : l3.ctx.level === 'warn' ? 'ctxWarn' : 'counts'
206 head.push({ text: `ctx ${Math.round(l3.ctx.percent)}%`, el })
207 }
208 if (l3.cost !== undefined) {
209 if (head.length > 0) head.push({ text: ' ', el: 'text' })
210 head.push({ text: l3.cost, el: 'counts' })
211 }
212 segs.push(...head)
213 if (l3.hint !== undefined) {
214 if (head.length > 0) segs.push({ text: ' ', el: 'text' })
215 segs.push({ text: g.goal, el: 'compactHint' }, { text: ' ', el: 'compactHint' }, { text: l3.hint, el: 'compactHint' })
216 } else if (l3.log) {
217 if (head.length > 0) segs.push({ text: ' ', el: 'text' })
218 segs.push(logGlyph(g, l3.log.kind), { text: ' ' + l3.log.text, el: 'text' })
219 }
220 return segs
221}
222
223// Two lines, or three: line 3 appears only with data for it and is dropped under 60 columns.
224export function bandLines(d: BandData): Seg[][] {
225 const g = d.goal
226 const goalRest = g === null ? 0 : len(formatCounts({ ...g, ascii: d.ascii })) + (g.source !== undefined ? 2 + len(g.source) : 0) + (g.note === 'unreadable' ? 2 + 15 : 0)
227 // Both lines share one label and bar width so the bars line up; the longer text sets them.
228 const currentRestLen = d.current ? currentRest(d, d.current).reduce((n, s) => n + len(s.text), 0) : 0
229 const w = bandLayout(d.columns, Math.max(goalRest, currentRestLen))
230 const room = Math.max(0, d.columns - FIXED - w.label - w.bar)
231 const lines: Seg[][] = [
232 clip(goalLine(d, w.label, w.bar, room), d.columns),
233 clip(currentLine(d, w.label, w.bar, room), d.columns),
234 ]
235 const l3 = d.line3
236 if (l3 && d.columns >= 60 && (l3.ctx || l3.cost !== undefined || l3.log || l3.hint !== undefined)) {
237 lines.push(clip(lastLine(d, l3), d.columns))
238 }
239 // Labels and titles are what a model or a file wrote: no control character or escape reaches the terminal.
240 return lines.map((line) => line.map((s) => ({ ...s, text: stripControls(s.text) })))
241}
242
243type El = (props: Record<string, unknown>) => unknown
244
245// The tree: one `Text` per line (truncate-end, nothing wraps) holding one nested `Text` per segment.
246export function bandTree(ui: { Box: El; Text: El }, theme: Theme, lines: Seg[][], below: unknown) {
247 const { Box, Text } = ui
248 return (
249 <Box flexDirection="column">
250 {lines.map((line, i) => (
251 <Text key={'wp-' + i} wrap="truncate-end">
252 {line.map((s, j) => (
253 <Text key={j} {...style(theme, s.el)}>
254 {s.text}
255 </Text>
256 ))}
257 </Text>
258 ))}
259 {below}
260 </Box>
261 )
262}
263hooks/format.ts 175 lines1// Bars, counts, durations, money and truncation (pure). SPEC.md sections 2, 8.1 and 9.3.
2
3const FRACTION = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
4
5export interface Bar {
6 done: string // solid fill, plus the fractional cell (unicode only)
7 partial: string // whole cells, drawn in the `run` slot
8 track: string // drawn on the `surface` background
9 text: string // done + partial + track, exactly `width` cells
10}
11
12function clamp(n: number, lo: number, hi: number): number {
13 return Math.min(hi, Math.max(lo, n))
14}
15
16// One bar. Done cells are floored to an eighth with one fractional cell; partial cells are whole
17// cells rounded to nearest, after the done fill, and clipped so the bar never exceeds `width`.
18// ascii: `#` fill, no fractional cell, `=` partial, `-` track.
19export function bar(o: { done: number; partial?: number; total: number; width: number; ascii?: boolean }): Bar {
20 const width = Math.max(0, Math.floor(o.width))
21 const ascii = o.ascii === true
22 const fill = ascii ? '#' : '█'
23 const mid = ascii ? '=' : '▓'
24 const empty = ascii ? '-' : ' '
25 if (!(o.total > 0) || width === 0) {
26 const track = empty.repeat(width)
27 return { done: '', partial: '', track, text: track }
28 }
29 const done = clamp(o.done, 0, o.total)
30 const partial = clamp(o.partial ?? 0, 0, o.total - done)
31 const eighths = Math.floor((done * width * 8) / o.total + 1e-9)
32 const full = Math.floor(eighths / 8)
33 const rem = ascii ? 0 : eighths % 8
34 const doneText = fill.repeat(full) + FRACTION[rem]
35 const doneCells = full + (rem > 0 ? 1 : 0)
36 const partialCells = Math.min(Math.round((partial * width) / o.total), width - doneCells)
37 const track = empty.repeat(width - doneCells - partialCells)
38 const partialText = mid.repeat(partialCells)
39 return { done: doneText, partial: partialText, track, text: doneText + partialText + track }
40}
41
42// Goal count text: `landed 0 · published 141 · open 186 / 327` with a partial rule, else `163/327 done`.
43export function formatCounts(o: {
44 done: number
45 partial: number
46 open: number
47 total: number
48 doneWord: string
49 partialWord?: string
50 noDoneRule?: boolean
51 ascii?: boolean
52}): string {
53 const sep = o.ascii === true ? '-' : '·'
54 if (o.partialWord !== undefined) {
55 return `${o.doneWord} ${o.done} ${sep} ${o.partialWord} ${o.partial} ${sep} open ${o.open} / ${o.total}`
56 }
57 const base = `${o.done}/${o.total} done`
58 return o.noDoneRule === true ? `${base} ${sep} no done rule` : base
59}
60
61export function two(n: number): string {
62 return n < 10 ? '0' + n : String(n)
63}
64
65// A string as a literal inside a regular expression.
66export function escapeRegex(s: string): string {
67 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
68}
69
70function wholeSeconds(ms: number): number {
71 return Math.max(0, Math.floor(ms / 1000))
72}
73
74// 06:12, or 1:02:03 from an hour up.
75export function formatClock(ms: number): string {
76 const s = wholeSeconds(ms)
77 const h = Math.floor(s / 3600)
78 const m = Math.floor((s % 3600) / 60)
79 return h > 0 ? `${h}:${two(m)}:${two(s % 60)}` : `${two(m)}:${two(s % 60)}`
80}
81
82// 20s, 4m12s, 1h01m.
83export function formatDuration(ms: number): string {
84 const s = wholeSeconds(ms)
85 if (s < 60) return `${s}s`
86 if (s < 3600) return `${Math.floor(s / 60)}m${two(s % 60)}s`
87 return `${Math.floor(s / 3600)}h${two(Math.floor((s % 3600) / 60))}m`
88}
89
90// The coarse age shown after "step ... ago": 20s, 14m, 2h.
91export function formatAgo(ms: number): string {
92 const s = wholeSeconds(ms)
93 if (s < 60) return `${s}s`
94 if (s < 3600) return `${Math.floor(s / 60)}m`
95 return `${Math.floor(s / 3600)}h`
96}
97
98// $12.34; a positive amount under one cent is <$0.01, so a small spend never reads as $0.00.
99export function formatUsd(n: number): string {
100 return n > 0 && n < 0.01 ? '<$0.01' : '$' + n.toFixed(2)
101}
102
103export function formatEstUsd(n: number): string {
104 return 'est.' + formatUsd(n)
105}
106
107// 999, 42.1k, 310k, 1.3M.
108export function formatTokens(n: number): string {
109 const one = (v: number) => v.toFixed(1).replace(/\.0$/, '')
110 if (n < 1000) return String(Math.round(n))
111 if (n < 100_000) return one(n / 1000) + 'k'
112 if (n < 999_500) return Math.round(n / 1000) + 'k'
113 return one(n / 1_000_000) + 'M'
114}
115
116// Cut to `width` cells (code points), ending in an ellipsis when something was cut.
117export function truncate(s: string, width: number): string {
118 if (width <= 0) return ''
119 const cps = Array.from(s)
120 if (cps.length <= width) return s
121 return cps.slice(0, width - 1).join('') + '…'
122}
123
124// ---------------------------------------------------------------- text that a model or a file wrote
125
126// Caps on what a model's list may hold, at ingest, in the saved record and in the plan that is drawn.
127export const TASKS_MAX = 200
128export const TITLE_MAX = 200
129
130// Terminal escape sequences, whole: OSC and the other string sequences (DCS, SOS, PM, APC) to their
131// terminator or the end of the text, CSI, then any other ESC with the one character after it. The
132// 8-bit introducers (C1) read the same way.
133const STRINGS = /(?:\u001b[\]PX^_]|[\u009d\u0090\u0098\u009e\u009f])[^\u0007\u001b\u009c]*(?:\u0007|\u001b\\|\u009c)?/g
134const CSI = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]?/g
135const ESC = /\u001b[ -~]?/g
136const BREAKS = /[\t\n\r\u2028\u2029]/g
137// The controls and format characters that go: every C0, C1 and DEL, and every format character except
138// the ones writing needs, which stay: the joiner and non-joiner (U+200C, U+200D: emoji sequences,
139// Persian and Indic words) and the direction marks (U+200E, U+200F, U+061C: they reorder only
140// neutral characters, and RTL text uses them). That drops the bidi embeddings, overrides and
141// isolates, the Unicode tag characters, the byte order mark, the zero-width space and the rest.
142// Variation selectors are not format characters and are never touched.
143const KEPT = String.raw`(?![\u200c-\u200f\u061c])`
144const CONTROLS = new RegExp(KEPT + String.raw`[\p{Cc}\p{Cf}]`, 'gu')
145const NEEDS_WORK = new RegExp(KEPT + String.raw`[\p{Cc}\p{Cf}]|[\u2028\u2029]`, 'u')
146
147// A string with its escape sequences and control characters removed (C0, C1, DEL, and the format
148// characters such as bidi overrides, but not the joiners or direction marks); a tab, a line break or
149// a line separator becomes one space.
150// Runs of spaces stay, since a row is padded with them.
151export function stripControls(s: string): string {
152 if (!NEEDS_WORK.test(s)) return s
153 return s.replace(STRINGS, '').replace(CSI, '').replace(ESC, '').replace(BREAKS, ' ').replace(CONTROLS, '')
154}
155
156// Text a model or a file wrote, made safe for one line of a band, a pane, a log or a reply:
157// stripControls, then every run of whitespace is one space, trimmed. `max` cuts to that many
158// code points, ending in an ellipsis.
159export function clean(s: string, max?: number): string {
160 const one = stripControls(s).replace(/\s+/g, ' ').trim()
161 return max === undefined ? one : truncate(one, max)
162}
163
164// The last segment of a path, with either separator.
165export function basename(path: string): string {
166 return path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
167}
168
169// A name that is safe as one segment of a path: letters, digits, dot, dash and underscore, and
170// neither `.` nor anything holding `..`. The names a model, a settings file or a directory listing
171// supplies pass through this before they are spliced into a path.
172export function plainName(s: string): boolean {
173 return /^[A-Za-z0-9._-]+$/.test(s) && s !== '.' && !s.includes('..')
174}
175hooks/quiet.tsx 128 lines1// Transcript rows for headway's own tool (pure): one dim line per call, the result folded in.
2// SPEC.md 10.4. `quietSegs` makes the line from the call's input and stored result; `quietTree`
3// draws it with the band's palette and glyphs. Neither calls `$`.
4import type { Seg } from './band.tsx'
5import { clean, stripControls, truncate } from './format.ts'
6import { GLYPHS, style } from './theme.ts'
7import type { Glyphs, Theme } from './theme.ts'
8
9export interface QuietCall {
10 input: unknown
11 output?: unknown
12 isErrored: boolean
13 isRunning: boolean
14 ascii: boolean
15}
16
17const TITLE_MAX = 48
18
19function obj(v: unknown): Record<string, unknown> {
20 return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
21}
22
23function str(v: unknown): string | undefined {
24 if (typeof v === 'string') return clean(v) === '' ? undefined : clean(v)
25 return typeof v === 'number' ? String(v) : undefined
26}
27
28// The stored result as text: the tool answers a string; an object with a text or result field is read too.
29function resultText(output: unknown): string | undefined {
30 if (typeof output === 'string') return clean(output) === '' ? undefined : clean(output)
31 const o = obj(output)
32 return str(o.result) ?? str(o.text)
33}
34
35function mark(g: Glyphs, status: unknown): Seg {
36 if (status === 'done') return { text: g.done, el: 'doneGlyph' }
37 if (status === 'partial') return { text: g.partial, el: 'partial' }
38 return { text: g.queued, el: 'queued' }
39}
40
41const dim = (text: string): Seg => ({ text, el: 'counts' })
42
43function steps(g: Glyphs, items: Array<{ id: string; status: unknown }>): Seg[] {
44 const out: Seg[] = []
45 items.forEach((it, i) => {
46 if (i > 0) out.push(dim(' ' + g.sep + ' '))
47 out.push(mark(g, it.status), dim(' step ' + it.id))
48 })
49 return out
50}
51
52function head(glyph: string, rest: string): Seg[] {
53 return [dim(glyph), dim(' ' + rest)]
54}
55
56function callLine(g: Glyphs, input: Record<string, unknown>): Seg[] {
57 const action = str(input.action)
58 if (action === 'report') {
59 const tasks = Array.isArray(input.tasks) ? input.tasks : []
60 const items: Array<{ id: string; status: unknown }> = []
61 if (Array.isArray(input.updates)) {
62 for (const u of input.updates) {
63 const id = str(obj(u).id)
64 if (id !== undefined) items.push({ id, status: obj(u).status })
65 }
66 }
67 if (Array.isArray(input.done)) {
68 for (const d of input.done) {
69 const id = str(d)
70 if (id !== undefined) items.push({ id, status: 'done' })
71 }
72 }
73 if (tasks.length > 0) {
74 const titles = tasks.map((t) => truncate(str(obj(t).title) ?? '?', TITLE_MAX))
75 return head(g.goal, 'headway ' + g.sep + ' ' + tasks.length + (tasks.length === 1 ? ' step: ' : ' steps: ') + titles.join(' ' + g.sep + ' '))
76 }
77 if (items.length > 0) return steps(g, items)
78 return head(g.goal, 'headway ' + g.sep + ' report')
79 }
80 if (action === 'track') {
81 const file = str(input.file)
82 const section = str(input.section)
83 return head(g.goal, 'tracking ' + (file ?? '?') + (section !== undefined ? ' § ' + section : ''))
84 }
85 if (action === 'state') return head(g.goal, 'headway state')
86 if (action === 'focus') return head(g.current, 'focus ' + (str(input.taskId) ?? '?'))
87 if (action === 'untrack') return head(g.goal, 'untracked')
88 return head(g.goal, 'headway')
89}
90
91// The line for one call. A refusal or an error draws in the error colour with its text; a focus on an
92// unknown task draws as a warning; unknown ids in an update's reply follow the line as a warning.
93export function quietSegs(c: QuietCall): Seg[] {
94 return lineSegs(c).map((s) => ({ ...s, text: stripControls(s.text) }))
95}
96
97function lineSegs(c: QuietCall): Seg[] {
98 const g = GLYPHS[c.ascii ? 'ascii' : 'unicode']
99 const reply = resultText(c.output)
100 if (c.isErrored || (reply !== undefined && reply.startsWith('refused:'))) {
101 const why = reply ?? 'failed'
102 return [{ text: g.failed + ' headway ' + g.sep + ' ' + why, el: 'failed' }]
103 }
104 if (reply !== undefined && reply.startsWith('unknown task:')) {
105 return [{ text: g.warning + ' headway ' + g.sep + ' ' + reply, el: 'warning' }]
106 }
107 const line = callLine(g, obj(c.input))
108 const unknown = reply === undefined ? null : /;\s*(unknown task:.*)$/.exec(reply)
109 if (unknown) line.push({ text: ' ' + g.warning + ' ' + unknown[1], el: 'warning' })
110 return line
111}
112
113type El = (props: Record<string, unknown>) => unknown
114
115// One `Text` (truncate-end, nothing wraps) holding one nested `Text` per segment.
116export function quietTree(ui: { Text: El }, theme: Theme, segs: Seg[]) {
117 const { Text } = ui
118 return (
119 <Text wrap="truncate-end">
120 {segs.map((s, i) => (
121 <Text key={i} {...style(theme, s.el)}>
122 {s.text}
123 </Text>
124 ))}
125 </Text>
126 )
127}
128hooks/pane.tsx 681 lines1// Pane (pure): the four tabs of SPEC.md 8.2 as rows of segments, and the trees that draw them, from
2// plain data and the resolved elements. `paneTree` is the pane body; `bandBody` is the same body in
3// the expanded band (8.1) with letter hotkeys. Neither calls `$` or reads a file.
4import { isShown, roleText, runTitleOf, sortAgents } from './agents.ts'
5import type { AgentRecord, AgentStatus, GroupNode, LogEntry, NodeStatus, PlanTree, TaskNode } from './agents.ts'
6import { bandLines } from './band.tsx'
7import type { GoalData, Seg } from './band.tsx'
8import { cell, elapsed, estCell, glyphsOf, len, logRows, lostGlyph, mkRow, modelEffort, modelName, pad, rowText, rowWidth, t } from './display.ts'
9import type { PaneRow, PSeg, UsageData } from './display.ts'
10import { bar, formatAgo, formatClock, formatDuration, formatTokens, formatUsd, truncate } from './format.ts'
11import { PRICES_NOTE, priceFamily } from './prices.ts'
12import { roleSlot, style } from './theme.ts'
13import type { Element, Glyphs, Theme } from './theme.ts'
14
15// The pieces the Log tab and the `state` block share live in display.ts.
16export { logRows, rowText, rowWidth }
17export type { PaneRow, PSeg, UsageData }
18
19export type Tab = 'plan' | 'agents' | 'log' | 'usage'
20
21export const TABS: Array<{ tab: Tab; label: string; digit: string; letter: string }> = [
22 { tab: 'plan', label: 'Plan', digit: '1', letter: 'p' },
23 { tab: 'agents', label: 'Agents', digit: '2', letter: 'a' },
24 { tab: 'log', label: 'Log', digit: '3', letter: 'l' },
25 { tab: 'usage', label: 'Usage', digit: '4', letter: 'u' },
26]
27
28export function isTab(s: string): s is Tab {
29 return TABS.some((t) => t.tab === s)
30}
31
32// The `ui` atom: the open tab, hide-done, and the expand state that differs from the default.
33export interface UiState {
34 tab: Tab
35 hideDone: boolean
36 open: Record<string, boolean> // row key -> expanded, only where the person (or `e`) set it
37 opened: boolean // the pane is open (set on open, cleared at ui.close)
38}
39
40export const DEFAULT_UI: UiState = { tab: 'plan', hideDone: false, open: {}, opened: false }
41
42export interface PaneData {
43 columns: number // e.props.bodyColumns
44 ascii: boolean
45 now: number
46 ui: UiState
47 goal: GoalData | null
48 tree: PlanTree
49 agents: AgentRecord[]
50 log: LogEntry[]
51 usage: UsageData | null
52 compactAt?: number
53}
54
55
56function agentGlyph(g: Glyphs, s: AgentStatus): PSeg {
57 switch (s) {
58 case 'running':
59 return t(g.running, 'running')
60 case 'queued':
61 return t(g.queued, 'queued')
62 case 'stalled':
63 return t(g.stalled, 'stalled')
64 case 'blocked':
65 return t(g.blocked, 'blocked')
66 case 'failed':
67 return t(g.failed, 'failed')
68 case 'lost':
69 return t(lostGlyph(g), 'queued')
70 default:
71 return t(g.done, 'doneGlyph')
72 }
73}
74
75function nodeGlyph(g: Glyphs, s: NodeStatus): PSeg {
76 switch (s) {
77 case 'done':
78 return t(g.done, 'doneGlyph')
79 case 'active':
80 return t(g.running, 'running')
81 case 'partial':
82 return t(g.partial, 'partial')
83 case 'failed':
84 return t(g.failed, 'failed')
85 default:
86 return t(g.queued, 'queued')
87 }
88}
89
90// ---------------------------------------------------------------- column fitting
91
92interface Col {
93 key: string
94 w: number
95 drop?: number // lower drops first when the pane is too narrow; absent never drops
96 flex?: number // how many cells of spare room it may take
97}
98
99function colsTotal(cols: Col[], gap: number, leading: boolean): number {
100 return cols.reduce((n, c) => n + c.w, 0) + gap * (leading ? cols.length : Math.max(0, cols.length - 1))
101}
102
103// Drops detail columns (lowest `drop` first) until the row fits; spare room goes to `flex` columns.
104function fitCols(cols: Col[], avail: number, gaps: number[], leading = false): { cols: Col[]; gap: number } {
105 let gap = gaps[0]
106 let kept = cols
107 for (const g of gaps) {
108 gap = g
109 if (colsTotal(kept, g, leading) <= avail) break
110 }
111 while (colsTotal(kept, gap, leading) > avail) {
112 const drops = kept.filter((c) => c.drop !== undefined)
113 if (drops.length === 0) break
114 const first = drops.reduce((a, b) => ((b.drop as number) < (a.drop as number) ? b : a))
115 kept = kept.filter((c) => c !== first)
116 }
117 let spare = avail - colsTotal(kept, gap, leading)
118 kept = kept.map((c) => {
119 if (spare <= 0 || !c.flex) return c
120 const add = Math.min(spare, c.flex)
121 spare -= add
122 return { ...c, w: c.w + add }
123 })
124 return { cols: kept, gap }
125}
126
127// ---------------------------------------------------------------- agent cells
128
129function shortId(a: AgentRecord): string {
130 if (a.planned) return 'wf:' + (a.agentIndex ?? '?')
131 return Array.from(a.agentId ?? a.id).slice(0, 4).join('')
132}
133
134function taskOf(a: AgentRecord): string {
135 return a.taskIds[0] ?? a.groupId ?? runTitleOf(a) ?? '—'
136}
137
138function lastActivity(a: AgentRecord, now: number): { text: string; el: Element } {
139 switch (a.status) {
140 case 'running':
141 return { text: 'step ' + formatAgo(now - (a.lastStepAt ?? a.spawnedAt)) + ' ago', el: 'counts' }
142 case 'stalled':
143 return { text: 'step ' + formatAgo(now - (a.lastStepAt ?? a.spawnedAt)) + ' ago', el: 'stalled' }
144 case 'queued':
145 return { text: 'queued', el: 'counts' }
146 case 'blocked':
147 return { text: 'answered ' + formatAgo(now - (a.endedAt ?? now)), el: 'blocked' }
148 case 'failed':
149 return { text: a.reason ?? 'failed', el: 'failed' }
150 case 'lost':
151 return { text: 'lost', el: 'queued' }
152 default:
153 return { text: 'done', el: 'counts' }
154 }
155}
156
157// An agent's figure is an estimate priced in prices.ts; n/a until a step reported usage. A model the
158// price table lacks was priced at the sonnet row and is marked `est.?` (a trailing `?` where the
159// column already says est.).
160const priced = (a: AgentRecord) => a.model === undefined || priceFamily(a.model) !== undefined
161const costCell = (a: AgentRecord) => (a.estUsd > 0 ? formatUsd(a.estUsd) + (priced(a) ? '' : '?') : 'n/a')
162
163const realAgents = (agents: AgentRecord[]) => agents.filter(isShown)
164
165// ---------------------------------------------------------------- Plan tab
166
167const GROUP_BAR = 4
168const TASK_PRE = 3
169const AGENT_PRE = 5
170
171function plural(n: number, one: string, many: string): string {
172 return n + ' ' + (n === 1 ? one : many)
173}
174
175// The sources whose group is the plan itself (a reported list, a plan-mode plan, the task tools): their
176// group rows count tasks, whatever agents work them, and start expanded.
177const OWN_SOURCES = ['reported', 'plan', 'tasks']
178const isOwn = (d: { goal: GoalData | null }, gn: GroupNode) => d.goal?.source !== undefined && OWN_SOURCES.includes(d.goal.source) && (gn.kind === 'heading' || gn.kind === 'map') && gn.tasksTotal > 0
179
180function groupCounts(gn: GroupNode, own: boolean): string {
181 if (own) return gn.tasksDone + '/' + gn.tasksTotal + ' tasks'
182 if (gn.dispatched > 0) return gn.finished + '/' + gn.dispatched + ' agents'
183 if (gn.tasksTotal > 0) return gn.tasksDone + '/' + gn.tasksTotal + ' tasks'
184 return ''
185}
186
187function taskCounts(tn: TaskNode): string {
188 if (tn.sub) return tn.sub.done + '/' + tn.sub.total + ' items'
189 if (tn.dispatched > 0) return tn.finished + '/' + tn.dispatched + ' agents'
190 return ''
191}
192
193function marksText(g: Glyphs, loud: { blocked: number; stalled: number }): PSeg[] {
194 const out: PSeg[] = []
195 if (loud.blocked > 0) out.push(t(' '), t(g.blocked + ' ' + loud.blocked + ' blocked', 'blocked'))
196 if (loud.stalled > 0) out.push(t(' '), t(g.stalled + ' ' + loud.stalled + ' stalled', 'stalled'))
197 return out
198}
199
200const segsLen = (segs: PSeg[]) => segs.reduce((n, s) => n + len(s.text), 0)
201
202// `id title` (the id alone when there is no title or it is the id's own text), cut to `width`: the id keeps its colour, the title is plain, the rest padded.
203function idTitle(id: string, title: string, width: number): PSeg[] {
204 const whole = truncate(title === '' || title === id ? id : id + ' ' + title, width)
205 const chars = Array.from(whole)
206 const idLen = Math.min(len(id), chars.length)
207 const head = chars.slice(0, idLen).join('')
208 const tail = pad(chars.slice(idLen).join(''), width - idLen)
209 return [t(head, 'id'), t(tail, 'title')]
210}
211
212const PLAN_AGENT_COLS: Col[] = [
213 { key: 'role', w: 11, drop: 1 },
214 { key: 'model', w: 12, drop: 2 },
215 { key: 'status', w: 8, drop: 0 },
216 { key: 'elapsed', w: 7 },
217 { key: 'last', w: 12, drop: 3 },
218 { key: 'est', w: 10 },
219]
220
221function planAgentRow(d: PaneData, g: Glyphs, a: AgentRecord, cols: Col[]): PaneRow {
222 const segs: PSeg[] = [
223 t(a.warnings.length > 0 ? g.warning + ' ' : ' ', 'warning'),
224 agentGlyph(g, a.status),
225 t(' '),
226 t(shortId(a), 'id'),
227 ]
228 const last = lastActivity(a, d.now)
229 for (const c of cols) {
230 segs.push(t(' '))
231 if (c.key === 'role') segs.push(t(cell(roleText(a) === '' ? '—' : roleText(a), c.w), 'text', a.role))
232 else if (c.key === 'model') segs.push(t(cell(modelEffort(a), c.w), 'modelEffort'))
233 else if (c.key === 'status') segs.push(t(cell(a.status, c.w), last.el === 'counts' ? 'queued' : last.el))
234 else if (c.key === 'elapsed') segs.push(t(cell(formatClock(elapsed(a, d.now)), c.w), 'timestamp'))
235 else if (c.key === 'last') segs.push(t(cell(last.text, c.w), last.el))
236 else segs.push(t(cell(estCell(a.estUsd, priced(a)), c.w), 'counts'))
237 }
238 return mkRow(d.columns, ' '.repeat(AGENT_PRE), segs)
239}
240
241export function planView(d: PaneData): { rows: PaneRow[]; keys: string[]; allOpen: boolean } {
242 const g = glyphsOf(d)
243 const W = d.columns
244 const rows: PaneRow[] = []
245 const keys: string[] = []
246 const used = new Map<string, number>()
247 const keyFor = (base: string) => {
248 const n = (used.get(base) ?? 0) + 1
249 used.set(base, n)
250 return n === 1 ? base : base + '#' + n
251 }
252 const isOpen = (key: string, def: boolean) => d.ui.open[key] ?? def
253 const tree = d.tree
254 const sep = g.sep
255
256 if (d.goal) rows.push(mkRow(W, '', bandLines({ columns: W, ascii: d.ascii, goal: d.goal })[0]))
257 else rows.push(mkRow(W, ' ', [t(g.goal, 'goalGlyph'), t(' '), t('no plan', 'queued')]))
258
259 // the summary counts the plan's groups, not the workflow and Agents groups
260 const planGroups = tree.groups.filter((x) => x.kind === 'heading' || x.kind === 'map')
261 if (planGroups.length > 0 || tree.unmapped > 0) {
262 const active = planGroups.filter((x) => x.status === 'active').length
263 const parts = [plural(planGroups.length, 'group', 'groups')]
264 if (active > 0) parts.push(active + ' active')
265 if (tree.unmapped > 0) parts.push(plural(tree.unmapped, 'unmapped row', 'unmapped rows'))
266 rows.push(mkRow(W, ' ', [t(parts.join(' ' + sep + ' '), 'counts')]))
267 }
268
269 const finishedAll = (gn: GroupNode) => gn.status === 'done' && gn.loud.blocked + gn.loud.stalled === 0 && gn.finished === gn.dispatched
270 const groups = d.ui.hideDone ? tree.groups.filter((gn) => !finishedAll(gn)) : tree.groups
271 const taskShown = (tn: TaskNode) => !(d.ui.hideDone && tn.status === 'done' && tn.finished === tn.dispatched)
272
273 // Widths shared by every row of a kind, from the rows the tree could draw.
274 const allTasks = groups.flatMap((gn) => gn.tasks.filter(taskShown))
275 const countsW = Math.max(0, ...groups.map((gn) => len(groupCounts(gn, isOwn(d, gn)))))
276 const marksW = Math.max(0, ...groups.map((gn) => segsLen(marksText(g, gn.loud))))
277 const groupRest = GROUP_BAR + 2 + countsW + marksW
278 const groupLabelW = Math.max(8, W - 3 - groupRest - 2)
279 const idW = Math.min(12, Math.max(4, ...allTasks.map((tn) => len(tn.id))))
280 const rightOf = (tn: TaskNode) => {
281 const parts: string[] = []
282 if (tn.status === 'partial' && tn.partialWord) parts.push(tn.partialWord)
283 const c = taskCounts(tn)
284 if (c !== '') parts.push(c)
285 return parts.join(' ')
286 }
287 const rightW = Math.min(26, Math.max(0, ...allTasks.map((tn) => len(rightOf(tn)))))
288 const taskTitleW = Math.max(6, W - (TASK_PRE + 1 + 1 + 1 + 1 + idW + 2) - (rightW > 0 ? rightW + 2 : 0))
289 const agentCols = fitCols(PLAN_AGENT_COLS, W - (AGENT_PRE + 2 + 1 + 1 + 4), [1], true).cols
290
291 const agentRows = (list: AgentRecord[]) => sortAgents(list).map((a) => planAgentRow(d, g, a, agentCols))
292
293 const taskRows = (key: string, tn: TaskNode) => {
294 const has = tn.agents.length > 0
295 const open = has && isOpen(key, tn.status === 'active' || tn.recent)
296 const right = rightOf(tn)
297 const segs: PSeg[] = [t(' '), nodeGlyph(g, tn.status), t(' '), t(cell(tn.id, idW), 'id'), t(' '), t(cell(tn.title, taskTitleW), 'title')]
298 if (rightW > 0) {
299 segs.push(t(' '))
300 if (tn.status === 'partial' && tn.partialWord) {
301 segs.push(t(tn.partialWord, 'counts'))
302 const c = taskCounts(tn)
303 if (c !== '') segs.push(t(' ' + c, 'counts'))
304 } else segs.push(t(right, 'counts'))
305 }
306 const press = has ? { key, label: open ? g.expanded : g.collapsed, open } : undefined
307 const out = [mkRow(W, press ? ' '.repeat(TASK_PRE) : ' '.repeat(TASK_PRE + 1), press ? segs : segs.slice(1), press)]
308 if (open) out.push(...agentRows(tn.agents))
309 return out
310 }
311
312 for (const gn of groups) {
313 const key = keyFor('g:' + gn.id)
314 const children = gn.tasks.length + gn.agents.length
315 if (children > 0) keys.push(key)
316 // Every expandable task key is collected whether or not its group is open: `e` acts on all of them.
317 const tasks = gn.tasks.filter(taskShown).map((tn) => ({ tn, key: keyFor('t:' + gn.id + '/' + tn.id + (tn.line !== undefined ? '@' + tn.line : '')) }))
318 for (const x of tasks) if (x.tn.agents.length > 0) keys.push(x.key)
319 const own = isOwn(d, gn)
320 const open = children > 0 && isOpen(key, groupOpensByDefault(gn, own))
321 // the plan's own group draws its tasks' progress; any other group draws its agents' (finished / dispatched) or its tasks'
322 const b = own
323 ? bar({ done: gn.tasksDone, partial: gn.tasks.filter((x) => x.status === 'partial' || x.status === 'active').length, total: gn.tasksTotal, width: GROUP_BAR, ascii: d.ascii })
324 : bar({ done: gn.dispatched > 0 ? gn.finished : gn.tasksDone, total: gn.dispatched > 0 ? gn.dispatched : gn.tasksTotal, width: GROUP_BAR, ascii: d.ascii })
325 const fill: Element = own || gn.status === 'done' ? 'barFill' : 'taskBar'
326 const mid: Element = own ? 'barPartial' : 'taskBar'
327 const segs: PSeg[] = [
328 ...(gn.kind === 'run' || gn.kind === 'agents' ? [t(cell(gn.title, groupLabelW), 'title')] : idTitle(gn.id, gn.title, groupLabelW)),
329 t(' '),
330 ...(b.done !== '' ? [t(b.done, fill)] : []),
331 ...(b.partial !== '' ? [t(b.partial, mid)] : []),
332 ...(b.track !== '' ? [t(b.track, 'track')] : []),
333 t(' '),
334 t(pad(groupCounts(gn, own), countsW), 'counts'),
335 ...marksText(g, gn.loud),
336 ]
337 const press = children > 0 ? { key, label: open ? g.expanded : g.collapsed, open } : undefined
338 rows.push(mkRow(W, ' ', press ? [t(' '), ...segs] : [t(' '), ...segs], press))
339 if (open) {
340 rows.push(...agentRows(gn.agents))
341 for (const x of tasks) rows.push(...taskRows(x.key, x.tn))
342 }
343 }
344
345 const un = tree.unassigned
346 const unAgents = un.agents.length
347 if (unAgents > 0) {
348 const loud = {
349 blocked: un.agents.filter((a) => a.status === 'blocked').length,
350 stalled: un.agents.filter((a) => a.status === 'stalled').length,
351 }
352 keys.push('u')
353 const open = isOpen('u', true)
354 const segs: PSeg[] = [t(' '), t('Unassigned', 'unassigned'), t(' '), t(plural(unAgents, 'agent', 'agents'), 'counts'), ...marksText(g, loud)]
355 rows.push(mkRow(W, ' ', segs, { key: 'u', label: open ? g.expanded : g.collapsed, open }))
356 if (open) rows.push(...agentRows(un.agents))
357 }
358
359 const allOpen = keys.length > 0 && keys.every((k) => isOpen(k, defaultOpenOf(k, d, tree)))
360 return { rows, keys, allOpen }
361}
362
363// A group opens by default when it has an agent at work, one that finished lately, a loud one, or is the plan's own.
364function groupOpensByDefault(gn: GroupNode, own: boolean): boolean {
365 return own || gn.status === 'active' || gn.recent || gn.loud.blocked + gn.loud.stalled > 0
366}
367
368// Whether a key opens by default: the rule planView applied when it drew the row.
369function defaultOpenOf(key: string, d: PaneData, tree: PlanTree): boolean {
370 const bare = key.replace(/#\d+$/, '')
371 if (bare === 'u') return true
372 if (bare.startsWith('g:')) {
373 const gn = tree.groups.find((x) => 'g:' + x.id === bare)
374 return gn !== undefined && groupOpensByDefault(gn, isOwn(d, gn))
375 }
376 const m = /^t:(.*)\/(.*?)(@\d+)?$/.exec(bare)
377 if (m) {
378 const gn = tree.groups.find((x) => x.id === m[1])
379 const tn = gn?.tasks.find((x) => x.id === m[2] && (m[3] === undefined || '@' + x.line === m[3]))
380 return tn !== undefined && (tn.status === 'active' || tn.recent)
381 }
382 return false
383}
384
385// ---------------------------------------------------------------- Agents tab
386
387const AGENT_COLS: Col[] = [
388 { key: 'st', w: 2 },
389 { key: 'id', w: 4 },
390 { key: 'task', w: 6, flex: 8 },
391 { key: 'role', w: 11, drop: 1 },
392 { key: 'model', w: 12, drop: 2 },
393 { key: 'elapsed', w: 7 },
394 { key: 'last', w: 13, flex: 10, drop: 3 },
395 { key: 'cost', w: 9 },
396]
397
398const AGENT_HEAD: Record<string, string> = {
399 st: 'st',
400 id: 'id',
401 task: 'task',
402 role: 'role',
403 model: 'model/effort',
404 elapsed: 'elapsed',
405 last: 'last activity',
406 cost: 'est. cost',
407}
408
409function headRow(columns: number, cols: Col[], gap: number, head: Record<string, string>): PaneRow {
410 const segs: PSeg[] = []
411 cols.forEach((c, i) => {
412 if (i > 0) segs.push(t(' '.repeat(gap)))
413 segs.push(t(cell(head[c.key], c.w), 'counts'))
414 })
415 return mkRow(columns, ' ', segs)
416}
417
418export function agentRows(d: PaneData): PaneRow[] {
419 const g = glyphsOf(d)
420 const W = d.columns
421 const { cols, gap } = fitCols(AGENT_COLS, W - 1, [2, 1])
422 const rows: PaneRow[] = [headRow(W, cols, gap, AGENT_HEAD)]
423 const list = sortAgents(realAgents(d.agents))
424 if (list.length === 0) {
425 rows.push(mkRow(W, ' ', [t('no agents dispatched', 'queued')]))
426 return rows
427 }
428 list.forEach((a, i) => {
429 const zebra = i % 2 === 1
430 const segs: PSeg[] = []
431 const last = lastActivity(a, d.now)
432 cols.forEach((c, ci) => {
433 if (ci > 0) segs.push(t(' '.repeat(gap)))
434 if (c.key === 'st') segs.push(t(a.warnings.length > 0 ? g.warning : ' ', 'warning'), agentGlyph(g, a.status))
435 else if (c.key === 'id') segs.push(t(cell(shortId(a), c.w), 'id'))
436 else if (c.key === 'task') segs.push(t(cell(taskOf(a), c.w), taskOf(a) === '—' ? 'queued' : 'text'))
437 else if (c.key === 'role') segs.push(t(cell(roleText(a) === '' ? '—' : roleText(a), c.w), 'text', a.role))
438 else if (c.key === 'model') segs.push(t(cell(modelEffort(a), c.w), 'modelEffort'))
439 else if (c.key === 'elapsed') segs.push(t(cell(formatClock(elapsed(a, d.now)), c.w), 'timestamp'))
440 else if (c.key === 'last') segs.push(t(cell(last.text, c.w), last.el))
441 else segs.push(t(cell(costCell(a), c.w), 'counts'))
442 })
443 if (a.planned) {
444 const label = [a.groupId ?? '—', a.phase !== undefined ? '·phase ' + a.phase : '', '(workflow, queued)'].filter((x) => x !== '').join(' ')
445 rows.push(mkRow(W, ' ', [t(' '), agentGlyph(g, a.status), t(' '), t(cell(shortId(a), 4), 'id'), t(' '.repeat(gap)), t(label, 'queued')], undefined, zebra))
446 return
447 }
448 if (a.warnings.length > 0) {
449 segs.push(t(' '.repeat(gap)), t(g.warning + ' ' + a.warnings.map((w) => w.split(' ')[0]).join(','), 'warning'))
450 }
451 rows.push(mkRow(W, ' ', segs, undefined, zebra))
452 if (a.status === 'blocked' && a.question !== undefined) {
453 const q = Array.from(a.question).slice(0, 60).join('')
454 rows.push(mkRow(W, ' ', [t(' '.repeat(cols.slice(0, 2).reduce((n, c) => n + c.w + gap, 0))), t('BLOCKED: ' + q, 'blocked')], undefined, zebra))
455 }
456 })
457 return rows
458}
459
460// ---------------------------------------------------------------- Log tab
461
462// ---------------------------------------------------------------- Usage tab
463
464const USAGE_COLS: Col[] = [
465 { key: 'id', w: 4 },
466 { key: 'role', w: 11, drop: 1 },
467 { key: 'model', w: 8 },
468 { key: 'in', w: 7 },
469 { key: 'out', w: 7 },
470 { key: 'cache', w: 13 },
471 { key: 'cost', w: 9 },
472]
473
474const USAGE_HEAD: Record<string, string> = { id: 'id', role: 'role', model: 'model', in: 'in', out: 'out', cache: 'cache r/w', cost: 'est. cost' }
475
476export function usageRows(d: PaneData): PaneRow[] {
477 const W = d.columns
478 const u = d.usage
479 const rows: PaneRow[] = []
480 const label = (s: string) => t(pad(s, 10), 'counts')
481
482 const ctx: PSeg[] = [label('context')]
483 if (u && u.percent !== undefined) {
484 const critical = u.percent >= 90
485 const warn = u.percent >= (d.compactAt ?? 70)
486 const el: Element = critical ? 'ctxCritical' : warn ? 'ctxWarn' : 'barFill'
487 const width = Math.max(8, Math.min(24, W - 11 - 2 - 4))
488 const b = bar({ done: u.percent, total: 100, width, ascii: d.ascii })
489 ctx.push(t(b.done, el), t(b.track, 'track'), t(' ' + Math.round(u.percent) + '%', critical ? 'ctxCritical' : warn ? 'ctxWarn' : 'text'))
490 if (u.tokens !== undefined && u.window !== undefined) ctx.push(t(' ' + formatTokens(u.tokens) + ' / ' + formatTokens(u.window), 'counts'))
491 ctx.push(t(' compact hint: ' + (u.hint === 'compact-now' ? 'now' : u.hint === 'compact-at-next-boundary' ? 'armed' : 'off'), 'counts'))
492 } else ctx.push(t('n/a', 'queued'))
493 rows.push(mkRow(W, ' ', ctx))
494
495 const session: PSeg[] = [label('session')]
496 if (u && u.usd !== undefined) session.push(t(formatUsd(u.usd), 'text'), t(' exact', 'counts'))
497 else session.push(t('n/a', 'queued'))
498 rows.push(mkRow(W, ' ', session))
499
500 const list = sortAgents(realAgents(d.agents).filter((a) => !a.planned))
501 const total = list.reduce((n, a) => n + a.estUsd, 0)
502 const est = list.some((a) => a.estUsd > 0 && !priced(a)) ? 'est.? ' : 'est. '
503 const figure = list.length === 0 ? 'no agents' : (total > 0 ? est + formatUsd(total) : 'est. n/a') + ' over ' + plural(list.length, 'agent', 'agents') + (total > 0 ? ' (' + PRICES_NOTE + ')' : '')
504 const agentsLine: PSeg[] = [label('agents'), t(figure, 'text')]
505 rows.push(mkRow(W, ' ', agentsLine))
506
507 const { cols, gap } = fitCols(USAGE_COLS, W - 1, [2, 1])
508 rows.push(headRow(W, cols, gap, USAGE_HEAD))
509 list.forEach((a, i) => {
510 const segs: PSeg[] = []
511 cols.forEach((c, ci) => {
512 if (ci > 0) segs.push(t(' '.repeat(gap)))
513 if (c.key === 'id') segs.push(t(cell(shortId(a), c.w), 'id'))
514 else if (c.key === 'role') segs.push(t(cell(roleText(a) === '' ? '—' : roleText(a), c.w), 'text', a.role))
515 else if (c.key === 'model') segs.push(t(cell(modelName(a), c.w), 'modelEffort'))
516 else if (c.key === 'in') segs.push(t(cell(formatTokens(a.tokens.in), c.w), 'counts'))
517 else if (c.key === 'out') segs.push(t(cell(formatTokens(a.tokens.out), c.w), 'counts'))
518 else if (c.key === 'cache') segs.push(t(cell(formatTokens(a.tokens.cacheRead) + ' / ' + formatTokens(a.tokens.cacheWrite), c.w), 'counts'))
519 else segs.push(t(cell(costCell(a), c.w), 'counts'))
520 })
521 rows.push(mkRow(W, ' ', segs, undefined, i % 2 === 1))
522 })
523 return rows
524}
525
526// ---------------------------------------------------------------- the body of the open tab
527
528export function bodyRows(d: PaneData): PaneRow[] {
529 switch (d.ui.tab) {
530 case 'agents':
531 return agentRows(d)
532 case 'log':
533 return logRows(d)
534 case 'usage':
535 return usageRows(d)
536 default:
537 return planView(d).rows
538 }
539}
540
541// ---------------------------------------------------------------- trees
542
543type El = (props: Record<string, unknown>) => unknown
544
545export interface PaneEls {
546 Box: El
547 Text: El
548 Button: El
549}
550
551// What a press runs. They return a promise so the press settles once the write and the redraw are done.
552export interface Handlers {
553 tab: (tab: Tab) => Promise<void>
554 toggle: (key: string, open: boolean) => Promise<void>
555 expandAll: (keys: string[], open: boolean) => Promise<void>
556 hideDone: () => Promise<void>
557 reread: () => Promise<void>
558 close: () => Promise<void>
559}
560
561function segStyle(theme: Theme, s: PSeg) {
562 if (s.role !== undefined && s.role !== '') {
563 const c = theme.colors[roleSlot(s.role)]
564 return c === null ? {} : { color: c }
565 }
566 return style(theme, s.el)
567}
568
569function rowNode(ui: PaneEls, theme: Theme, row: PaneRow, i: number, act: Handlers) {
570 const { Box, Text, Button } = ui
571 const bg = row.zebra ? style(theme, 'zebra') : {}
572 const text = (
573 <Text key={'t' + i} wrap="truncate-end">
574 {row.segs.length === 0
575 ? ' '
576 : row.segs.map((s, j) => (
577 <Text key={j} {...segStyle(theme, s)} {...bg}>
578 {s.text}
579 </Text>
580 ))}
581 </Text>
582 )
583 const press = row.press
584 if (!press) {
585 if (row.pre === '') return text
586 return (
587 <Box key={'r' + i} flexDirection="row">
588 <Text key="pre">{row.pre}</Text>
589 {text}
590 </Box>
591 )
592 }
593 const kids = []
594 if (row.pre !== '') kids.push(<Text key="pre">{row.pre}</Text>)
595 kids.push(<Button key={'row:' + press.key} label={press.label} plain onPress={async () => act.toggle(press.key, !press.open)} />)
596 kids.push(text)
597 return (
598 <Box key={'r' + i} flexDirection="row">
599 {kids}
600 </Box>
601 )
602}
603
604function tabButtons(ui: PaneEls, d: PaneData, act: Handlers, letters: boolean) {
605 const { Box, Text, Button } = ui
606 const tail = letters ? [<Button key="close" label="close" hotkey="x" plain dimColor={true} onPress={async () => act.close()} />] : []
607 return (
608 <Box key="tabs" flexDirection="row" columnGap={2}>
609 <Text key="m"> </Text>
610 {TABS.map((x) => (
611 <Button key={'tab-' + x.tab} label={x.label} hotkey={letters ? x.letter : x.digit} plain dimColor={d.ui.tab !== x.tab} onPress={async () => act.tab(x.tab)} />
612 ))}
613 {tail}
614 </Box>
615 )
616}
617
618function actionButtons(ui: PaneEls, d: PaneData, act: Handlers, view: { keys: string[]; allOpen: boolean } | undefined) {
619 const { Box, Text, Button } = ui
620 const kids = [<Text key="m"> </Text>]
621 if (view) {
622 kids.push(<Button key="expand" label={view.allOpen ? 'collapse all' : 'expand all'} hotkey="e" plain dimColor={true} onPress={async () => act.expandAll(view.keys, !view.allOpen)} />)
623 kids.push(<Button key="hide" label={d.ui.hideDone ? 'show done' : 'hide done'} hotkey="h" plain dimColor={!d.ui.hideDone} onPress={async () => act.hideDone()} />)
624 }
625 kids.push(<Button key="reread" label="re-read" hotkey="r" plain dimColor={true} onPress={async () => act.reread()} />)
626 kids.push(<Button key="quit" label="close" hotkey="q" plain dimColor={true} onPress={async () => act.close()} />)
627 return (
628 <Box key="actions" flexDirection="row" columnGap={2}>
629 {kids}
630 </Box>
631 )
632}
633
634// The pane body: tab row, action row, then the open tab. A tree taller than the pane scrolls.
635export function paneTree(ui: PaneEls, theme: Theme, d: PaneData, act: Handlers) {
636 const { Box } = ui
637 let rows: PaneRow[]
638 let view: { keys: string[]; allOpen: boolean } | undefined
639 if (d.ui.tab === 'plan') {
640 const v = planView(d)
641 rows = v.rows
642 view = v
643 } else rows = bodyRows(d)
644 return (
645 <Box flexDirection="column">
646 {tabButtons(ui, d, act, false)}
647 {actionButtons(ui, d, act, view)}
648 {rows.map((r, i) => rowNode(ui, theme, r, i, act))}
649 </Box>
650 )
651}
652
653// The same body in the expanded band: letter hotkeys and `x` to close, at most `budget` rows
654// (the tab row included); what does not fit is replaced by a `n more` row.
655export function bandBody(ui: PaneEls, theme: Theme, d: PaneData, act: Handlers, budget: number) {
656 const { Box, Text } = ui
657 const rows = bodyRows(d)
658 const room = Math.max(0, budget - 1)
659 let shown = rows
660 let more = 0
661 if (rows.length > room) {
662 shown = rows.slice(0, Math.max(0, room - 1))
663 more = rows.length - shown.length
664 }
665 const tailRows =
666 more > 0
667 ? [
668 <Text key="more" wrap="truncate-end" {...style(theme, 'counts')}>
669 {' ' + more + ' more'}
670 </Text>,
671 ]
672 : []
673 return (
674 <Box flexDirection="column">
675 {tabButtons(ui, d, act, true)}
676 {shown.map((r, i) => rowNode(ui, theme, r, i, act))}
677 {tailRows}
678 </Box>
679 )
680}
681hooks/plan.ts 493 lines1// Plan parser (pure): checklist and table shapes, sections, task and group IDs, done and partial
2// rules, the group map. SPEC.md section 3. Takes text, returns data; reads no file and calls no `$`.
3
4export type Status = 'done' | 'partial' | 'open'
5
6export interface ParseOptions {
7 section?: string
8 doneRule?: string // 'auto' | 'column=<name>' | 'check=<name>'
9 partial?: string // column name
10}
11
12export interface GroupMapEntry {
13 id: string
14 title?: string
15 rows: string[]
16}
17
18export interface Task {
19 id: string
20 title: string
21 ordinal: number // 1-based within the section, across both shapes
22 line: number // 1-based line in the parsed text
23 shape: 'checklist' | 'table'
24 status: Status
25 sub?: { done: number; total: number } // a checklist item's nested items, any depth
26 groups: number[] // indexes into Plan.groups; a task named by several map groups has several
27 failed?: true // a workflow phase whose agents all settled with one failed or lost (4.6): status open, drawn failed
28}
29
30export interface Group {
31 id: string
32 title: string
33 kind: 'heading' | 'map'
34 tasks: number[] // indexes into Plan.tasks
35 own?: true // a source's own group (its title, or a fallback word): drawn, never an ID to match
36}
37
38export interface Plan {
39 section: string // the matched heading text, '' for the whole file
40 tasks: Task[]
41 groups: Group[]
42 counts: { done: number; partial: number; open: number; total: number }
43 doneWord: string // the done column's header as written; 'done' for a checklist or no column
44 partialWord?: string // the partial column's header as written
45 noDoneRule: boolean // a table with neither a check column nor a done column was seen
46 unmapped: number // group-map rows that match no task, per occurrence
47 unmappedIds: string[]
48 phaseRun?: { runId?: string; toolUseId?: string } // the workflow run a phases plan stands for (4.6)
49}
50
51export type ParseResult = { ok: true; plan: Plan } | { ok: false; error: string }
52
53interface Heading {
54 level: number
55 text: string
56 line: number // 0-based
57}
58
59const HEADING_OPEN = /^(#{1,6})[ \t]+/
60export const ITEM = /^(\s*)[-*] \[( |x|X)\] (.+)$/
61const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/
62const FENCE_CLOSE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/
63const TWO_WORD = /^(?:step|task|phase|stage|part|milestone|item)$/i
64const TOKEN = /^[A-Za-z][A-Za-z0-9]*(?:[-.][A-Za-z0-9]+)*/
65export const LINE_MAX = 8192 // a line is read to its first 8 KiB
66const RULE_CELL = /^ *:?-+:? *$/
67const EMPTY_CELLS = ['', '-', '–', '—', '[ ]']
68
69function isEmptyCell(cell: string): boolean {
70 return EMPTY_CELLS.indexOf(cell.trim()) >= 0
71}
72
73function splitCells(line: string): string[] {
74 let s = line.trim()
75 if (s.startsWith('|')) s = s.slice(1)
76 if (s.endsWith('|') && !s.endsWith('\\|')) s = s.slice(0, -1)
77 return s.split(/(?<!\\)\|/).map((c) => c.replace(/\\\|/g, '|').trim())
78}
79
80// Default task ID rule (3.1). Returns null when the text holds no token with a digit.
81export function parseTaskId(text: string): { id: string; title: string } | null {
82 let s = text.trim()
83 const opens: string[] = []
84 for (;;) {
85 if (s.startsWith('**')) {
86 opens.push('**')
87 s = s.slice(2)
88 } else if (s.startsWith('`')) {
89 opens.push('`')
90 s = s.slice(1)
91 } else break
92 }
93 const m = TOKEN.exec(s)
94 if (!m) return null
95 let id = m[0]
96 let len = id.length
97 if (!/\d/.test(id)) {
98 if (!TWO_WORD.test(id)) return null
99 const d = /^\s+(\d+)/.exec(s.slice(len))
100 if (!d) return null
101 id = id + ' ' + d[1]
102 len += d[0].length
103 }
104 let rest = s.slice(len)
105 for (let i = opens.length - 1; i >= 0; i--) {
106 const at = rest.indexOf(opens[i])
107 if (at >= 0) rest = rest.slice(0, at) + rest.slice(at + opens[i].length)
108 }
109 rest = rest.trim().replace(/^[:.—-]\s*/, '').trim()
110 return { id, title: rest }
111}
112
113// An opener starts a line (after at most three spaces) with a run of ``` or ~~~ that the same
114// line does not close, so inline code such as ```x``` never opens a fence.
115function fenceOpener(line: string): string | null {
116 const m = FENCE.exec(line)
117 if (!m) return null
118 const rest = m[2]
119 if (m[1][0] === '`' ? rest.indexOf('`') >= 0 : rest.indexOf(m[1]) >= 0) return null
120 return m[1]
121}
122
123// Marks the lines of fenced code blocks (``` or ~~~), fence lines included. A fence closes on a
124// line of the same marker at least as long as its opener; an unclosed one runs to the end.
125export function fencedLines(lines: string[]): boolean[] {
126 const out: boolean[] = []
127 let open: string | null = null
128 for (const line of lines) {
129 if (open === null) {
130 open = fenceOpener(line)
131 out.push(open !== null)
132 } else {
133 const m = FENCE_CLOSE.exec(line)
134 if (m && m[1][0] === open[0] && m[1].length >= open.length) open = null
135 out.push(true)
136 }
137 }
138 return out
139}
140
141// An ATX heading: one to six #s, a space or tab, then text, which loses its trailing spaces and tabs.
142// The prefix is matched by an anchored regex and the end trimmed by hand, so no pattern backtracks
143// over a run of spaces inside the line.
144export function atxHeading(line: string): { level: number; raw: string } | null {
145 const m = HEADING_OPEN.exec(line)
146 if (!m) return null
147 let end = line.length
148 while (end > m[0].length && (line[end - 1] === ' ' || line[end - 1] === '\t')) end--
149 const raw = line.slice(m[0].length, end)
150 if (raw === '' || raw.indexOf('\u2028') >= 0 || raw.indexOf('\u2029') >= 0) return null
151 return { level: m[1].length, raw }
152}
153
154// ATX heading text without its closing run of #s (CommonMark): `## Phase A ##` is `Phase A`. The run
155// goes when it is the whole text or follows a space or tab, which go with it.
156export function headingText(raw: string): string {
157 let hashes = raw.length
158 while (hashes > 0 && raw[hashes - 1] === '#') hashes--
159 if (hashes === raw.length) return raw
160 if (hashes === 0) return ''
161 let cut = hashes
162 while (cut > 0 && (raw[cut - 1] === ' ' || raw[cut - 1] === '\t')) cut--
163 return cut === hashes ? raw : raw.slice(0, cut)
164}
165
166// A GFM delimiter row: cells of `-`s, each with an optional `:` either side and spaces around, split
167// by pipes; a leading pipe after at most three spaces, a trailing pipe and trailing spaces allowed.
168// Split by hand, each cell tested by an anchored regex with no two quantifiers over the same text.
169function isTableRule(line: string): boolean {
170 let lead = 0
171 while (line[lead] === ' ') lead++
172 let body = line
173 if (line[lead] === '|') {
174 if (lead > 3) return false
175 body = line.slice(lead + 1)
176 }
177 let end = body.length
178 while (end > 0 && body[end - 1] === ' ') end--
179 if (end > 0 && body[end - 1] === '|') end--
180 return body
181 .slice(0, end)
182 .split('|')
183 .every((cell) => RULE_CELL.test(cell))
184}
185
186// A table starts at a header row with a pipe, followed by a GFM delimiter row that has a pipe;
187// a bare `---` is a thematic break.
188function isTableStart(header: string, delim: string): boolean {
189 return header.indexOf('|') >= 0 && delim.indexOf('|') >= 0 && delim.indexOf('-') >= 0 && isTableRule(delim)
190}
191
192function lc(s: string): string {
193 return s.trim().toLowerCase()
194}
195
196function findColumn(headers: string[], name: string): number {
197 const want = lc(name)
198 for (let i = 0; i < headers.length; i++) if (lc(headers[i]) === want) return i
199 return -1
200}
201
202type Rule = { kind: 'column' | 'check'; col: number } | { kind: 'none' }
203
204function resolveRule(headers: string[], rule: string, firstRow: string[] | undefined): Rule | string {
205 const r = rule.trim()
206 if (r === '' || r === 'auto') {
207 if (firstRow) {
208 for (let i = 0; i < firstRow.length; i++) {
209 if (firstRow[i].indexOf('[ ]') >= 0 || /\[[xX]\]/.test(firstRow[i])) return { kind: 'check', col: i }
210 }
211 }
212 const d = findColumn(headers, 'done')
213 return d >= 0 ? { kind: 'column', col: d } : { kind: 'none' }
214 }
215 const m = /^(column|check)=(.+)$/.exec(r)
216 if (!m) return 'unknown done rule: ' + r
217 const col = findColumn(headers, m[2])
218 if (col < 0) return 'column not found: ' + m[2].trim()
219 return { kind: m[1] as 'column' | 'check', col }
220}
221
222// The lines of a plan's text: `\r\n` and lone `\r` read as `\n`, each line cut to its first LINE_MAX
223// characters, so no single line costs more than that to scan.
224export function planLines(text: string): string[] {
225 return text
226 .replace(/\r\n?/g, '\n')
227 .split('\n')
228 .map((l) => (l.length > LINE_MAX ? l.slice(0, LINE_MAX) : l))
229}
230
231export function parsePlan(text: string, opts: ParseOptions = {}, groupMap?: GroupMapEntry[]): ParseResult {
232 const lines = planLines(text)
233 const fenced = fencedLines(lines)
234 const headings: Heading[] = []
235 const isHeading: boolean[] = []
236 lines.forEach((l, i) => {
237 const h = fenced[i] ? null : atxHeading(l)
238 isHeading.push(h !== null)
239 if (h) headings.push({ level: h.level, text: headingText(h.raw), line: i })
240 })
241
242 // Section range [from, to) in 0-based lines; the heading itself is at `from`.
243 let from = 0
244 let to = lines.length
245 let sectionText = ''
246 const want = opts.section === undefined ? '' : lc(opts.section)
247 if (opts.section !== undefined && want !== '') {
248 const h = headings.find((x) => x.text.toLowerCase().startsWith(want))
249 if (!h) return { ok: false, error: 'section not found: ' + opts.section }
250 from = h.line
251 sectionText = h.text
252 const next = headings.find((x) => x.line > h.line && x.level <= h.level)
253 if (next) to = next.line
254 }
255 const inRange = headings.filter((h) => h.line >= from && h.line < to)
256
257 // Nearest ancestor heading with a real ID, else the nearest heading (3.2), for a task on each line of
258 // the range: one pass keeps the chain of ancestors (at most six, one per level) of the line.
259 const groupAt: Array<Heading | undefined> = []
260 {
261 const chain: Array<{ h: Heading; withId: Heading | undefined }> = []
262 let k = 0
263 for (let i = from; i < to; i++) {
264 const top = chain[chain.length - 1]
265 groupAt[i] = top ? top.withId ?? top.h : undefined
266 while (k < inRange.length && inRange[k].line < i) k++
267 const h = inRange[k]
268 if (h === undefined || h.line !== i) continue
269 while (chain.length > 0 && chain[chain.length - 1].h.level >= h.level) chain.pop()
270 const below = chain[chain.length - 1]
271 chain.push({ h, withId: parseTaskId(h.text) !== null ? h : below?.withId })
272 }
273 }
274 const groupFor = (line: number): Heading | undefined => groupAt[line]
275
276 const tasks: Task[] = []
277 const headingOf: Array<Heading | undefined> = []
278 let doneWord = ''
279 let partialWord: string | undefined
280 let noDoneRule = false
281 let sawChecklist = false
282 let sawTable = false
283 let parent: { task: Task; indent: number } | undefined
284
285 const makeId = (firstText: string, ordinal: number): { id: string; title: string | null } => {
286 const p = parseTaskId(firstText)
287 return p ? { id: p.id, title: p.title } : { id: '#' + ordinal, title: null }
288 }
289
290 // A table with no done rule is prose in a section that has checklist items (3.1).
291 let hasChecklist = false
292 for (let i = from; i < to; i++) if (!fenced[i] && ITEM.test(lines[i])) hasChecklist = true
293
294 for (let i = from; i < to; i++) {
295 if (fenced[i]) continue
296 const line = lines[i]
297 if (isHeading[i]) {
298 parent = undefined
299 continue
300 }
301
302 const item = ITEM.exec(line)
303 if (item) {
304 sawChecklist = true
305 const indent = item[1].length
306 const checked = item[2] !== ' '
307 if (parent && indent > parent.indent) {
308 const sub = parent.task.sub ?? (parent.task.sub = { done: 0, total: 0 })
309 sub.total++
310 if (checked) sub.done++
311 continue
312 }
313 const ordinal = tasks.length + 1
314 const body = item[3]
315 const got = makeId(body, ordinal)
316 const task: Task = {
317 id: got.id,
318 title: got.title === null ? body : got.title,
319 ordinal,
320 line: i + 1,
321 shape: 'checklist',
322 status: checked ? 'done' : 'open',
323 groups: [],
324 }
325 tasks.push(task)
326 headingOf.push(groupFor(i))
327 parent = { task, indent }
328 continue
329 }
330
331 if (i + 1 < to && !fenced[i + 1] && isTableStart(line, lines[i + 1])) {
332 parent = undefined
333 const headers = splitCells(line)
334 const rows: Array<{ cells: string[]; line: number }> = []
335 let j = i + 2
336 while (j < to && !fenced[j] && lines[j].trim() !== '' && lines[j].indexOf('|') >= 0) {
337 rows.push({ cells: splitCells(lines[j]), line: j })
338 j++
339 }
340 const rule = resolveRule(headers, opts.doneRule ?? 'auto', rows[0]?.cells)
341 if (typeof rule === 'string') return { ok: false, error: rule }
342 if (rule.kind === 'none' && hasChecklist) {
343 i = j - 1
344 continue
345 }
346 sawTable = true
347 let partialCol = -1
348 if (opts.partial !== undefined) {
349 partialCol = findColumn(headers, opts.partial)
350 if (partialCol < 0) return { ok: false, error: 'partial column not found: ' + opts.partial.trim() }
351 }
352 if (doneWord === '') {
353 doneWord = rule.kind === 'none' ? 'done' : headers[rule.col]
354 if (partialCol >= 0) partialWord = headers[partialCol]
355 }
356 if (rule.kind === 'none') noDoneRule = true
357 let titleCol = findColumn(headers, 'title')
358 if (titleCol < 0) titleCol = headers.length > 1 ? 1 : 0
359 for (const row of rows) {
360 const cell = (c: number) => row.cells[c] ?? ''
361 const ordinal = tasks.length + 1
362 const got = makeId(cell(0), ordinal)
363 let status: Status = 'open'
364 if (rule.kind === 'column' && !isEmptyCell(cell(rule.col))) status = 'done'
365 else if (rule.kind === 'check' && /\[[xX]\]/.test(cell(rule.col))) status = 'done'
366 else if (partialCol >= 0 && !isEmptyCell(cell(partialCol))) status = 'partial'
367 tasks.push({
368 id: got.id,
369 title: cell(titleCol) !== '' ? cell(titleCol) : cell(0),
370 ordinal,
371 line: row.line + 1,
372 shape: 'table',
373 status,
374 groups: [],
375 })
376 headingOf.push(groupFor(row.line))
377 }
378 i = j - 1
379 }
380 }
381
382 if (opts.partial !== undefined && (sawChecklist || !sawTable)) {
383 return { ok: false, error: 'partial column not found: ' + opts.partial.trim() }
384 }
385 if (doneWord === '') doneWord = 'done'
386
387 // Heading groups, in order of first use.
388 const groups: Group[] = []
389 const byLine = new Map<number, number>()
390 tasks.forEach((t, ti) => {
391 const h = headingOf[ti]
392 if (!h) return
393 let gi = byLine.get(h.line)
394 if (gi === undefined) {
395 const p = parseTaskId(h.text)
396 gi = groups.length
397 byLine.set(h.line, gi)
398 groups.push({ id: p ? p.id : h.text, title: p ? p.title : h.text, kind: 'heading', tasks: [] })
399 }
400 groups[gi].tasks.push(ti)
401 t.groups.push(gi)
402 })
403
404 let finalGroups = groups
405 let unmapped = 0
406 const unmappedIds: string[] = []
407 if (groupMap) {
408 const idToTasks = new Map<string, number[]>()
409 tasks.forEach((t, ti) => {
410 const l = idToTasks.get(t.id)
411 if (l) l.push(ti)
412 else idToTasks.set(t.id, [ti])
413 })
414 const named = new Set<number>()
415 const mapGroups: Group[] = groupMap.map((g) => {
416 const members: number[] = []
417 const seen = new Set<number>()
418 for (const r of g.rows) {
419 const hit = idToTasks.get(r)
420 if (!hit) {
421 unmapped++
422 unmappedIds.push(r)
423 continue
424 }
425 for (const ti of hit) {
426 if (seen.has(ti)) continue
427 seen.add(ti)
428 members.push(ti)
429 }
430 }
431 members.forEach((ti) => named.add(ti))
432 return { id: g.id, title: g.title ?? g.id, kind: 'map', tasks: members }
433 })
434 const keptHeading = groups
435 .map((g) => ({ ...g, tasks: g.tasks.filter((ti) => !named.has(ti)) }))
436 .filter((g) => g.tasks.length > 0)
437 finalGroups = [...mapGroups, ...keptHeading]
438 tasks.forEach((t) => (t.groups = []))
439 finalGroups.forEach((g, gi) => g.tasks.forEach((ti) => tasks[ti].groups.push(gi)))
440 }
441
442 const counts = { done: 0, partial: 0, open: 0, total: tasks.length }
443 for (const t of tasks) counts[t.status]++
444
445 return {
446 ok: true,
447 plan: { section: sectionText, tasks, groups: finalGroups, counts, doneWord, partialWord, noDoneRule, unmapped, unmappedIds },
448 }
449}
450
451export function parseGroupMap(text: string): { ok: true; groups: GroupMapEntry[] } | { ok: false; error: string } {
452 let data: unknown
453 try {
454 data = JSON.parse(text)
455 } catch (err) {
456 return { ok: false, error: 'group map is not JSON: ' + (err instanceof Error ? err.message : String(err)) }
457 }
458 const list = (data as { groups?: unknown } | null)?.groups
459 if (!Array.isArray(list)) return { ok: false, error: 'group map has no groups[]' }
460 const groups: GroupMapEntry[] = []
461 for (let i = 0; i < list.length; i++) {
462 const g = list[i] as { id?: unknown; rows?: unknown; title?: unknown } | null
463 if (!g || typeof g.id !== 'string') return { ok: false, error: 'group map: groups[' + i + '] has no string id' }
464 if (!Array.isArray(g.rows) || g.rows.some((r) => typeof r !== 'string')) {
465 return { ok: false, error: 'group map: groups[' + i + '] rows is not a string array' }
466 }
467 const entry: GroupMapEntry = { id: g.id, rows: g.rows as string[] }
468 if (typeof g.title === 'string') entry.title = g.title
469 groups.push(entry)
470 }
471 return { ok: true, groups }
472}
473
474function longestFirst(ids: string[]): string[] {
475 const seen = new Set<string>()
476 const out: string[] = []
477 for (const id of ids) {
478 if (!seen.has(id)) {
479 seen.add(id)
480 out.push(id)
481 }
482 }
483 return out.sort((a, b) => b.length - a.length)
484}
485
486// The IDs agent matching (4.3) may search for: the plan's own, never a regex.
487export function candidateIds(plan: Plan): { taskIds: string[]; groupIds: string[] } {
488 return {
489 taskIds: longestFirst(plan.tasks.map((t) => t.id)),
490 groupIds: longestFirst(plan.groups.filter((g) => !g.own).map((g) => g.id)),
491 }
492}
493hooks/pin.ts 138 lines1// Pin arguments (pure): `<path> ["Section text"] [column=<name> | check=<name> | auto] [partial=<column>]
2// [groups=<path>]`, the filter of SPEC.md 3.3. Paths are made absolute against the cwd.
3
4export interface Filter {
5 file: string
6 section?: string
7 doneRule?: string
8 partial?: string
9 groupMap?: string
10 // Set when the pin was made on Claude's behalf (SPEC 14): the real directory each path must land
11 // under, every link followed, at the pin and at every read after it. A pin of the user's has none.
12 bounds?: { file: string; groupMap?: string }
13}
14
15// Two filters pin the same plan when every field matches; no rule is the `auto` rule. The bounds say
16// who pinned it, not which plan, and are not compared.
17export function sameFilter(a: Filter | null, b: Filter | null): boolean {
18 if (a === null || b === null) return false
19 return (
20 a.file === b.file && a.section === b.section && (a.doneRule ?? 'auto') === (b.doneRule ?? 'auto') && a.partial === b.partial &&
21 a.groupMap === b.groupMap
22 )
23}
24
25const DRIVE = /^[A-Za-z]:(?=[\\/]|$)/
26
27// `path` made absolute against `cwd`, `.` and `..` resolved. A Windows-style path (a drive letter, or
28// a relative or `\`-rooted path under a drive-letter cwd) takes either separator and comes back as
29// `C:\...`; any other path is POSIX.
30export function absolutePath(cwd: string, path: string): string {
31 const drive = DRIVE.exec(path)?.[0] ?? (path.startsWith('/') ? undefined : DRIVE.exec(cwd)?.[0])
32 const full = drive === undefined ? (path.startsWith('/') ? path : cwd + '/' + path) : DRIVE.test(path) ? path : /^[\\/]/.test(path) ? drive + path : cwd + '\\' + path
33 const parts = (drive === undefined ? full : full.slice(2)).split(drive === undefined ? '/' : /[\\/]/)
34 const out: string[] = []
35 for (const p of parts) {
36 if (p === '' || p === '.') continue
37 if (p === '..') out.pop()
38 else out.push(p)
39 }
40 return drive === undefined ? '/' + out.join('/') : drive + '\\' + out.join('\\')
41}
42
43// The path without its trailing slashes and backslashes (a pattern for the run would try every start).
44function trimSeparators(p: string): string {
45 let end = p.length
46 while (end > 0 && (p[end - 1] === '/' || p[end - 1] === '\\')) end--
47 return p.slice(0, end)
48}
49
50// Whether a resolved path is the directory or under it (SPEC 14), both as `$.fs.stat(..., { resolve:
51// true })` answers them. A Windows directory (`C:\...` or a UNC share path) compares in any case and with
52// either slash; a sibling that only shares the prefix (`/wx` for `/w`) is outside.
53export function insideDir(path: string, dir: string): boolean {
54 const win = /^[A-Za-z]:([\\/]|$)/.test(dir) || dir.startsWith('\\\\')
55 const norm = (p: string) => trimSeparators(win ? p.replace(/\//g, '\\').toLowerCase() : p)
56 const d = norm(dir)
57 const p = norm(path)
58 return p === d || p.startsWith(d + (win ? '\\' : '/'))
59}
60
61// The paths as the user typed them, for the text of a refusal.
62export interface Typed {
63 file: string
64 groupMap?: string
65}
66
67// Why a file did not read, in a sentence that names no API: `file not found: p.md` (ENOENT) or
68// `cannot read p.md`; `shown` is the path as typed.
69export function readFailure(err: unknown, shown: string): string {
70 const code = (err as { code?: unknown } | null)?.code
71 const message = err instanceof Error ? err.message : String(err)
72 return code === 'ENOENT' || /\bENOENT\b/.test(message) ? 'file not found: ' + shown : 'cannot read ' + shown
73}
74
75export type PinParse = { ok: true; filter: Filter; typed: Typed } | { ok: false; error: string }
76
77export function parsePinArgs(args: string, cwd: string): PinParse {
78 const tokens: string[] = []
79 let section: string | undefined
80 let rest = args.trim()
81 while (rest !== '') {
82 if (rest.startsWith('"')) {
83 const end = rest.indexOf('"', 1)
84 if (end < 0) return { ok: false, error: 'unclosed quote' }
85 if (section !== undefined) return { ok: false, error: 'only one quoted section' }
86 section = rest.slice(1, end)
87 rest = rest.slice(end + 1).trim()
88 } else {
89 const m = /^\S+/.exec(rest)!
90 tokens.push(m[0])
91 rest = rest.slice(m[0].length).trim()
92 }
93 }
94 if (tokens.length === 0) return { ok: false, error: 'no plan file given' }
95 const filter: Filter = { file: absolutePath(cwd, tokens[0]) }
96 const typed: Typed = { file: tokens[0] }
97 if (section !== undefined) filter.section = section
98 for (const t of tokens.slice(1)) {
99 const error = applyToken(filter, t, cwd, typed)
100 if (error !== undefined) return { ok: false, error }
101 }
102 return { ok: true, filter, typed }
103}
104
105// One rule token into the filter; the refusal text when it is not one.
106function applyToken(filter: Filter, t: string, cwd: string, typed: Typed): string | undefined {
107 const m = /^(column|check|partial|groups)=(.*)$/.exec(t)
108 if (t === 'auto') filter.doneRule = 'auto'
109 else if (m && m[2] === '') return 'empty value: ' + t
110 else if (m && (m[1] === 'column' || m[1] === 'check')) filter.doneRule = t
111 else if (m && m[1] === 'partial') filter.partial = m[2]
112 else if (m && m[1] === 'groups') {
113 filter.groupMap = absolutePath(cwd, m[2])
114 typed.groupMap = m[2]
115 }
116 else return 'unknown argument: ' + t
117 return undefined
118}
119
120// The tool's `track` input: each field goes through the same token rules as the command's
121// arguments, so a refusal reads the same from either.
122export function filterFromInput(i: Record<string, unknown>, cwd: string): PinParse {
123 const fields: Array<[string, string]> = [['file', ''], ['section', ''], ['doneRule', ''], ['partial', 'partial='], ['groupMap', 'groups=']]
124 for (const [k] of fields) if (i[k] !== undefined && typeof i[k] !== 'string') return { ok: false, error: k + ' must be a string' }
125 const file = i.file as string | undefined
126 if (file === undefined || file.trim() === '') return { ok: false, error: 'no plan file given' }
127 const filter: Filter = { file: absolutePath(cwd, file.trim()) }
128 const typed: Typed = { file: file.trim() }
129 if (typeof i.section === 'string' && i.section !== '') filter.section = i.section
130 for (const [k, prefix] of fields.slice(2)) {
131 const v = i[k] as string | undefined
132 if (v === undefined) continue
133 const error = applyToken(filter, prefix + v, cwd, typed)
134 if (error !== undefined) return { ok: false, error }
135 }
136 return { ok: true, filter, typed }
137}
138hooks/persist.ts 143 lines1// What headway keeps in `$.store` (pure): one record per session under `session:<id>`, the band
2// pref, and the 14-day prune rule. SPEC.md 3.3 and 8.1.
3import { agentRecency, isTerminal } from './agents.ts'
4import { clean, TASKS_MAX, TITLE_MAX } from './format.ts'
5import type { AgentRecord, LogEntry } from './agents.ts'
6import type { Filter } from './pin.ts'
7import type { Reported } from './tool.ts'
8import type { PlanMode } from './planmode.ts'
9import type { TaskItem } from './tasks.ts'
10import type { SourceLabels } from './sources.ts'
11
12export interface SavedSession {
13 filter: Filter | null
14 report: Reported | null
15 plan: PlanMode | null // the approved plan-mode plan's parsed steps; its text stays in `$.state`
16 label: SourceLabels // each in-memory source's prompt label: the prompt of the turn that started it (SPEC 4.8)
17 prompt: string | null // the newest qualifying prompt line, the label of the next source to start
18 tasks: TaskItem[] | null // the task-tool list (SPEC 4.5); a finished list is kept once its task files are gone
19 agents: AgentRecord[]
20 log: LogEntry[]
21 savedAt: number
22}
23
24export const SAVE_MS = 1000
25export const AGENT_CAP = 300
26export const LOG_CAP = 500
27export const MAX_RECORD_BYTES = 3 * 1024 * 1024
28export const PRUNE_MS = 14 * 24 * 60 * 60_000
29export const BAND_PREF = 'band'
30const PREFIX = 'session:'
31
32export function sessionKey(id: string): string {
33 return PREFIX + id
34}
35
36export function isSessionKey(key: string): boolean {
37 return key.startsWith(PREFIX)
38}
39
40// A stored value read back as a record, or undefined when it is not one.
41export function asSaved(v: unknown): SavedSession | undefined {
42 if (typeof v !== 'object' || v === null) return undefined
43 const o = v as Record<string, unknown>
44 if (!Array.isArray(o.agents) || !Array.isArray(o.log) || typeof o.savedAt !== 'number') return undefined
45 return {
46 filter: (o.filter ?? null) as Filter | null,
47 report: (o.report ?? null) as Reported | null,
48 plan: (o.plan ?? null) as PlanMode | null,
49 label: typeof o.label === 'object' && o.label !== null && !Array.isArray(o.label) ? (o.label as SourceLabels) : {},
50 prompt: typeof o.prompt === 'string' ? o.prompt : null,
51 tasks: Array.isArray(o.tasks) ? (o.tasks as TaskItem[]) : null,
52 agents: o.agents as AgentRecord[],
53 log: o.log as LogEntry[],
54 savedAt: o.savedAt,
55 }
56}
57
58// Saved more than 14 days ago, or carrying no save time at all.
59export function isStale(v: unknown, now: number): boolean {
60 const at = typeof v === 'object' && v !== null ? (v as { savedAt?: unknown }).savedAt : undefined
61 return typeof at !== 'number' || now - at > PRUNE_MS
62}
63
64// A list a model wrote, as it is saved: at most TASKS_MAX items, each title one clean line of at most
65// TITLE_MAX characters. A list already inside both comes back as it is.
66function boundItems<T extends { title: string }>(items: T[]): T[] {
67 const kept = items.length > TASKS_MAX ? items.slice(0, TASKS_MAX) : items
68 if (kept === items && items.every((x) => clean(x.title, TITLE_MAX) === x.title)) return items
69 return kept.map((x) => ({ ...x, title: clean(x.title, TITLE_MAX) }))
70}
71
72function boundReport(r: Reported | null | undefined): Reported | null {
73 if (!r) return null
74 const goal = r.title === undefined ? undefined : clean(r.title, TITLE_MAX)
75 const { title: _title, ...rest } = r
76 return { ...rest, ...(goal !== undefined && goal !== '' ? { title: goal } : {}), tasks: boundItems(r.tasks) }
77}
78
79function boundMode(p: PlanMode | null | undefined): PlanMode | null {
80 if (!p) return null
81 const title = p.title === undefined ? undefined : clean(p.title, TITLE_MAX)
82 const { title: _title, ...rest } = p
83 return { ...rest, ...(title !== undefined && title !== '' ? { title } : {}), steps: boundItems(p.steps) }
84}
85
86// The record as it is written: the lists a model wrote (report, plan-mode steps, task-tool items) cut
87// to TASKS_MAX items of TITLE_MAX characters; at most AGENT_CAP agents (every one that is not over for
88// good, then the most recently moved finished ones); at most LOG_CAP log entries (newest first); and,
89// when the serialised record still exceeds MAX_RECORD_BYTES, the oldest finished agents, then the
90// oldest log entries, then the tail of the report, plan and task lists dropped until it fits.
91export function capSession(input: SavedSession): SavedSession {
92 const rec: SavedSession = { ...input, report: boundReport(input.report), plan: boundMode(input.plan), tasks: input.tasks ? boundItems(input.tasks) : input.tasks }
93 const live = rec.agents.filter((a) => !isTerminal(a.status)).length
94 const room = Math.max(0, AGENT_CAP - live)
95 const finished = rec.agents.filter((a) => isTerminal(a.status)).sort((x, y) => agentRecency(y) - agentRecency(x))
96 let keep = new Set(finished.slice(0, room))
97 let log = rec.log.length > LOG_CAP ? rec.log.slice(0, LOG_CAP) : rec.log
98 let report = rec.report
99 let plan = rec.plan
100 let tasks = rec.tasks
101 const build = (): SavedSession => ({
102 ...rec,
103 agents: rec.agents.length === live + keep.size ? rec.agents : rec.agents.filter((a) => !isTerminal(a.status) || keep.has(a)),
104 log,
105 report,
106 plan,
107 tasks,
108 })
109 let out = build()
110 let size = JSON.stringify(out).length
111 if (size <= MAX_RECORD_BYTES) return out
112 // Over the size guard: shed the oldest finished agents first, then the oldest log entries.
113 const byAge = [...keep].sort((x, y) => agentRecency(x) - agentRecency(y))
114 while (size > MAX_RECORD_BYTES && byAge.length > 0) {
115 keep = new Set(byAge.slice(Math.max(1, Math.ceil(byAge.length / 10))))
116 byAge.splice(0, byAge.length - keep.size)
117 out = build()
118 size = JSON.stringify(out).length
119 }
120 while (size > MAX_RECORD_BYTES && log.length > 0) {
121 log = log.slice(0, Math.floor(log.length * 0.9))
122 out = build()
123 size = JSON.stringify(out).length
124 }
125 // Then the sources: the tail of each list a tenth at a time, the longest first, until it fits.
126 const shed = <T,>(items: T[]): T[] => items.slice(0, Math.floor(items.length * 0.9))
127 while (size > MAX_RECORD_BYTES && ((report?.tasks.length ?? 0) > 0 || (plan?.steps.length ?? 0) > 0 || (tasks?.length ?? 0) > 0)) {
128 const sizes = [report?.tasks.length ?? 0, plan?.steps.length ?? 0, tasks?.length ?? 0]
129 const longest = sizes.indexOf(Math.max(...sizes))
130 if (longest === 0 && report) report = { ...report, tasks: shed(report.tasks) }
131 else if (longest === 1 && plan) plan = { ...plan, steps: shed(plan.steps) }
132 else if (tasks) tasks = shed(tasks)
133 out = build()
134 size = JSON.stringify(out).length
135 }
136 return out
137}
138
139// A record as the text it is compared by: a save whose text equals the last one written is skipped.
140export function recordText(rec: SavedSession): string {
141 return JSON.stringify({ ...rec, savedAt: 0 })
142}
143hooks/tool.ts 346 lines1// The model tool (pure): its registration, the `track` line, the `report` list and the plan it
2// stands for, and the `state` block Claude reads after `/compact`. SPEC.md 7.1.
3import { agentRecency, isShown, isTerminal, looseGroups, matchedToPlan, roleText, sortAgents } from './agents.ts'
4import type { AgentRecord, LogEntry } from './agents.ts'
5import type { CurrentData } from './band.tsx'
6import { clean, formatAgo, formatCounts, formatUsd, TASKS_MAX, TITLE_MAX } from './format.ts'
7import { elapsed, logRows, modelEffort, rowText } from './display.ts'
8import type { UsageData } from './display.ts'
9import type { Filter } from './pin.ts'
10import type { Plan, Task } from './plan.ts'
11import { buildPlan } from './planbuild.ts'
12import type { SourceTag } from './sources.ts'
13
14export const TOOL_SPEC = {
15 name: 'headway',
16 description:
17 'Plan progress tracker. One tool, pick an action. track: pin a plan FILE the user asked to track (file, optional section, ' +
18 'doneRule column=<name>|check=<name>|auto, partial=<column>, groupMap path); plan-mode plans are tracked automatically. ' +
19 'state: the goal counts, current task, blocked and stalled agents, every agent with its full id (for SendMessage), ' +
20 'the newest log entries and the context fill; call after /compact or when you need it. report: give the task list ' +
21 'yourself when the plan has no checklist or table (tasks), then mark steps with updates [{id, status}] or done [ids]; ' +
22 'status is open, partial (started) or done. focus: set the current task. untrack: unpin.',
23 inputSchema: {
24 type: 'object',
25 properties: {
26 action: { type: 'string', enum: ['track', 'state', 'report', 'focus', 'untrack'] },
27 file: { type: 'string', description: 'track: the plan file, relative to the session directory or absolute' },
28 section: { type: 'string', description: 'track: a heading prefix, case-insensitive' },
29 doneRule: { type: 'string', description: 'track: column=<name>, check=<name> or auto (default)' },
30 partial: { type: 'string', description: 'track: a column whose non-empty cell marks a row partly done' },
31 groupMap: { type: 'string', description: 'track: a JSON group map path' },
32 logEntries: { type: 'integer', description: 'state: how many log entries, default 10' },
33 tasks: {
34 type: 'array',
35 description: 'report: the task list; ids are optional (without them the tasks are 1..n), status defaults to open',
36 items: {
37 type: 'object',
38 properties: { id: { type: 'string' }, title: { type: 'string' }, status: { type: 'string', enum: ['open', 'partial', 'done'] } },
39 required: ['title'],
40 },
41 },
42 updates: {
43 type: 'array',
44 description: 'report: set the status of tasks already reported (not with tasks)',
45 items: {
46 type: 'object',
47 properties: { id: { type: 'string' }, status: { type: 'string', enum: ['open', 'partial', 'done'] } },
48 required: ['id', 'status'],
49 },
50 },
51 done: { type: 'array', description: 'report: ids of tasks that are done (shorthand for updates)', items: { type: 'string' } },
52 title: { type: 'string', description: 'report: the goal label' },
53 taskId: { type: 'string', description: 'focus: a task or group ID of the tracked plan' },
54 },
55 required: ['action'],
56 },
57 isDeferred: false as const,
58}
59
60export type ReportStatus = 'open' | 'partial' | 'done'
61
62// SPEC 6: the one section headway adds to the system prompt.
63export const GUIDE_ID = 'headway:guide'
64export const GUIDE_TEXT =
65 'headway tracks progress beside the prompt. When a request asks for two or more things (for example "rename the file, then ' +
66 'update its imports"), however few commands they take, call the `headway` tool once before any other work, with ' +
67 '`action: "report"`, a goal `title` of at most 6 words, and `tasks: [{ title }]`, one short title per thing asked (ids optional). ' +
68 'Mark each step as you go, not all at the end: before you start a step call `report` with `updates: [{ id, status: "partial" }]`; ' +
69 'when it is finished, `status: "done"`. ' +
70 'After plan-mode approval headway already tracks the plan: don\'t pin its file or report a new list; ' +
71 'send `updates` with the ids `state` shows. ' +
72 'Put the step\'s id in the description of any subagent you dispatch for it, as `step 3: ...`. For a request that asks for one ' +
73 'thing, call nothing. Do not describe this tracking in your reply.'
74
75// The tool's name as the engine offers it: a plugin tool is `mcp__<plugin>__<name>`.
76const TOOL_SPEC_NAME = 'mcp__headway__' + TOOL_SPEC.name
77
78// Renders that are not the main loop's prompt (a teammate's, a `/context` measure) or that draw
79// nowhere (a bare or print run). `lean` is not one: it marks the short body, which the main loop of
80// Opus 5.5 takes (seen live on 2.1.296: traits ['lean', 'skills']).
81const NOT_MAIN = ['bare', 'print', 'teammate', 'analysis']
82
83// The section is left out when guideClaude is off, when a file is pinned (the file is the record),
84// when the session draws nowhere (no surface), when the render is not the main loop's, and when the
85// request does not offer the tool. `e.tools` names a deferred tool too (seen live: TaskCreate listed
86// while the model had to load it through ToolSearch), so the name alone decides.
87export function guideWanted(o: { enabled: boolean; pinned: boolean; surfaces: readonly string[]; traits: readonly string[]; tools: readonly string[] }): boolean {
88 return o.enabled && !o.pinned && o.surfaces.length > 0 && o.tools.includes(TOOL_SPEC_NAME) && !o.traits.some((t) => NOT_MAIN.includes(t))
89}
90
91export interface Reported {
92 title?: string
93 tasks: Array<{ id: string; title: string; status: ReportStatus }>
94 doneAt?: number // when every task became done (SPEC 4.1); unset while one is not
95}
96
97export interface StatusUpdate {
98 id: string
99 status: ReportStatus
100}
101
102// The goal summary the band draws (the `goal` atom).
103export interface GoalSummary {
104 label: string
105 done: number
106 partial: number
107 open: number
108 total: number
109 doneWord: string
110 partialWord?: string
111 noDoneRule: boolean
112 error?: string
113 source?: SourceTag // the active source the goal is drawn from (SPEC 4.1)
114}
115
116function countText(g: { done: number; partial: number; open: number; total: number; doneWord: string; partialWord?: string }): string {
117 return g.partialWord !== undefined ? formatCounts(g) : g.done + '/' + g.total
118}
119
120export function trackLine(filter: Filter, plan: Plan): string {
121 const c = plan.counts
122 const counts = countText({ ...c, doneWord: plan.doneWord, partialWord: plan.partialWord })
123 return 'tracking ' + clean(filter.file) + (filter.section ? ' § ' + clean(filter.section) : '') + ': ' + counts + ' tasks, ' + plan.groups.length + ' groups'
124}
125
126const STATUSES: readonly string[] = ['open', 'partial', 'done']
127const isStatus = (v: unknown): v is ReportStatus => typeof v === 'string' && STATUSES.indexOf(v) >= 0
128
129// An id as given: a non-empty string, or a number (a model may send `3` for step 3).
130function idOf(v: unknown): string | undefined {
131 const text = typeof v === 'string' ? clean(v, TITLE_MAX) : typeof v === 'number' && Number.isFinite(v) ? String(v) : ''
132 return text === '' ? undefined : text
133}
134
135type Fail = { ok: false; error: string }
136
137function parseList(input: Record<string, unknown>): { ok: true; kind: 'list'; report: Reported } | Fail {
138 if (!Array.isArray(input.tasks)) return { ok: false, error: 'tasks must be an array' }
139 if (input.title !== undefined && typeof input.title !== 'string') return { ok: false, error: 'title must be a string' }
140 if (input.tasks.length > TASKS_MAX) return { ok: false, error: 'at most ' + TASKS_MAX + ' tasks' }
141 const tasks: Reported['tasks'] = []
142 const seen = new Set<string>()
143 for (const [i, raw] of (input.tasks as unknown[]).entries()) {
144 const t = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>
145 const title = typeof t.title === 'string' ? clean(t.title, TITLE_MAX) : ''
146 if (title === '') return { ok: false, error: 'task ' + (i + 1) + ': title must be a non-empty string' }
147 if (t.status !== undefined && !isStatus(t.status)) return { ok: false, error: 'task ' + (i + 1) + ': status must be open, partial or done' }
148 // an id given must be usable; one left out is the task's position
149 if (t.id !== undefined && idOf(t.id) === undefined) return { ok: false, error: 'task ' + (i + 1) + ': id must be a non-empty string' }
150 const id = idOf(t.id) ?? String(i + 1)
151 if (seen.has(id)) return { ok: false, error: 'duplicate id: ' + id }
152 seen.add(id)
153 tasks.push({ id, title, status: t.status ?? 'open' })
154 }
155 const report: Reported = { tasks }
156 const goal = typeof input.title === 'string' ? clean(input.title, TITLE_MAX) : ''
157 if (goal !== '') report.title = goal
158 return { ok: true, kind: 'list', report }
159}
160
161function parseUpdates(input: Record<string, unknown>): { ok: true; kind: 'updates'; updates: StatusUpdate[] } | Fail {
162 const updates: StatusUpdate[] = []
163 if (input.updates !== undefined) {
164 if (!Array.isArray(input.updates)) return { ok: false, error: 'updates must be an array' }
165 if (input.updates.length > TASKS_MAX) return { ok: false, error: 'at most ' + TASKS_MAX + ' updates' }
166 for (const [i, raw] of (input.updates as unknown[]).entries()) {
167 const u = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>
168 const id = idOf(u.id)
169 if (id === undefined) return { ok: false, error: 'update ' + (i + 1) + ': id must be a non-empty string' }
170 if (!isStatus(u.status)) return { ok: false, error: 'update ' + (i + 1) + ': status must be open, partial or done' }
171 updates.push({ id, status: u.status })
172 }
173 }
174 if (input.done !== undefined) {
175 if (!Array.isArray(input.done)) return { ok: false, error: 'done must be an array of ids' }
176 if (input.done.length > TASKS_MAX) return { ok: false, error: 'at most ' + TASKS_MAX + ' done ids' }
177 for (const [i, raw] of (input.done as unknown[]).entries()) {
178 const id = idOf(raw)
179 if (id === undefined) return { ok: false, error: 'done ' + (i + 1) + ': id must be a non-empty string' }
180 updates.push({ id, status: 'done' })
181 }
182 }
183 return { ok: true, kind: 'updates', updates }
184}
185
186// A `report` call (SPEC 4.3): a full list, or updates (with the `done` shorthand); never both.
187export function parseReport(input: Record<string, unknown>): { ok: true; kind: 'list'; report: Reported } | { ok: true; kind: 'updates'; updates: StatusUpdate[] } | Fail {
188 const hasList = input.tasks !== undefined
189 const hasUpdates = input.updates !== undefined || input.done !== undefined
190 if (hasList && hasUpdates) return { ok: false, error: 'tasks and updates or done cannot be combined in one call' }
191 if (hasList) return parseList(input)
192 if (hasUpdates) return parseUpdates(input)
193 return { ok: false, error: 'tasks, updates or done required' }
194}
195
196// Sets the status of each task named; an id no task has is named back and the rest still apply. A
197// repeated id (plan-mode headings) moves every task that has it. Answers a new list.
198export function applyUpdates<T extends { id: string; status: ReportStatus }>(
199 items: T[],
200 updates: StatusUpdate[],
201): { items: T[]; applied: number; unknown: string[] } {
202 const next = items.map((t) => ({ ...t }))
203 const unknown: string[] = []
204 let applied = 0
205 for (const u of updates) {
206 const hits = next.filter((t) => t.id === u.id)
207 if (hits.length === 0) {
208 if (unknown.indexOf(u.id) < 0) unknown.push(u.id)
209 continue
210 }
211 for (const t of hits) t.status = u.status
212 applied += hits.length
213 }
214 return { items: next, applied, unknown }
215}
216
217// A plan-mode plan's step ids for a reply: the first twelve, then how many more.
218const IDS_SHOWN = 12
219export function idsText(ids: readonly string[]): string {
220 const unique = ids.filter((id, i) => ids.indexOf(id) === i)
221 return unique.slice(0, IDS_SHOWN).join(', ') + (unique.length > IDS_SHOWN ? ', +' + (unique.length - IDS_SHOWN) : '')
222}
223
224// The note an approval adds to Claude's context (SPEC 4.4, 6): the plan's steps as headway holds
225// them and the two calls to make. Up to six steps show id and title (cut short), more show ids only.
226const NOTE_TITLES = 6
227const NOTE_TITLE_MAX = 24
228export function planNote(steps: ReadonlyArray<{ id: string; title: string }>): string {
229 const list =
230 steps.length <= NOTE_TITLES
231 ? steps.map((s) => s.id + ' ' + (s.title.length > NOTE_TITLE_MAX ? s.title.slice(0, NOTE_TITLE_MAX - 1).trimEnd() + '…' : s.title)).join(' · ')
232 : 'ids ' + idsText(steps.map((s) => s.id))
233 return 'headway is tracking this plan: ' + list + '. As you work, call headway report with updates [{id, status: "partial"}] when you start a step and "done" when it is finished.'
234}
235
236// The reply to an updates call: the count, then each unknown id.
237export function updatedLine(applied: number, unknown: string[]): string {
238 return 'updated ' + applied + ' tasks' + unknown.map((id) => '; unknown task: ' + id).join('')
239}
240
241// A reported list as a plan: one group named after the title, every task in it.
242export function reportPlan(r: Reported): Plan {
243 const tasks: Task[] = r.tasks.map((t, i) => ({ id: t.id, title: t.title, ordinal: i + 1, line: 0, shape: 'checklist', status: t.status, groups: [0] }))
244 return buildPlan({
245 section: r.title ?? '',
246 tasks,
247 groups: [{ id: r.title ?? 'reported', title: '', kind: 'heading', own: true, tasks: tasks.map((_, i) => i) }],
248 partialWord: true,
249 })
250}
251
252export function contextHint(usage: UsageData | null): 'compact-now' | 'compact-at-next-boundary' | null {
253 return usage?.hint ?? null
254}
255
256export interface StateData {
257 source?: SourceTag | null // the active source; absent reads as none
258 filter: Filter | null
259 goal: GoalSummary | null
260 current?: CurrentData
261 steps?: Array<{ id: string; title: string; status: ReportStatus }> // a plan-mode plan's steps: their ids are headway's (4.4)
262 agents: AgentRecord[]
263 log: LogEntry[]
264 usage: UsageData | null
265 now: number
266 logEntries: number
267}
268
269const STEP_TITLE = 60
270
271const STATE_FINISHED = 20
272
273function who(a: AgentRecord): string {
274 const role = clean(roleText(a))
275 const what = clean(a.label ?? a.description)
276 return clean(a.id) + ' ' + (role === '' ? '—' : role) + (role === what ? '' : ' "' + what + '"') + (a.name ? ' (name: ' + clean(a.name) + ')' : '')
277}
278
279// The goal line names the active source by its tag; a file also by its path and section.
280function goalLine(d: StateData): string {
281 const tag = d.source ?? null
282 if (tag === null) return 'goal: nothing tracked'
283 const f = tag === 'file' ? d.filter : null
284 const source = f ? 'file: ' + clean(f.file) + (f.section ? ' § ' + clean(f.section) : '') : tag
285 const g = d.goal
286 if (g === null) return 'goal: ' + clean(f ? f.section ?? f.file : tag) + ' — not read yet (' + source + ')'
287 return 'goal: ' + clean(g.label) + ' — ' + clean(formatCounts(g)) + ' (' + source + ')' + (g.error !== undefined ? ' — plan unreadable: ' + clean(g.error) : '')
288}
289
290function currentLine(c: CurrentData | undefined): string {
291 if (c === undefined) return 'current: none'
292 const done = c.status === 'partial' ? 'in progress' : c.unit === 'items' ? c.done + '/' + c.total + ' items done' : c.done + '/' + c.total + ' agents finished'
293 const title = clean(c.title)
294 return 'current: ' + clean(c.id) + (title !== '' ? ' ' + title : '') + ' — ' + done
295}
296
297function logText(log: LogEntry[], n: number): string {
298 const entries = log.slice(0, Math.max(0, n))
299 if (entries.length === 0) return 'log: none'
300 const rows = logRows({ columns: 10_000, ascii: false, log: entries })
301 return 'log: ' + rows.map((r) => clean(rowText(r))).join(' | ')
302}
303
304export function stateBlock(d: StateData): string {
305 const real = sortAgents(d.agents.filter(isShown))
306 const lines = [goalLine(d), currentLine(d.current)]
307 // a plan-mode plan's ids are headway's (`2.1` where numbering restarts): listed so `report` can name them
308 if (d.steps && d.steps.length > 0) lines.push('steps: ' + d.steps.map((s) => clean(s.id) + ' ' + clean(s.title, STEP_TITLE) + ' (' + s.status + ')').join(' · '))
309 const blocked = real.filter((a) => a.status === 'blocked')
310 if (blocked.length === 0) lines.push('blocked: none')
311 for (const a of blocked) lines.push('blocked: ' + who(a) + ' — BLOCKED' + (a.question ? ': ' + clean(a.question) : ''))
312 const stalled = real.filter((a) => a.status === 'stalled')
313 if (stalled.length === 0) lines.push('stalled: none')
314 for (const a of stalled) {
315 const quiet = a.firstStepAt === undefined ? 'no first step for ' + formatAgo(d.now - a.spawnedAt) : 'no step for ' + formatAgo(d.now - (a.lastStepAt ?? a.spawnedAt))
316 lines.push('stalled: ' + who(a) + ' — ' + quiet)
317 }
318 // Every agent that is not over for good, then the newest finished ones, then how many are left out.
319 const finished = real.filter((a) => isTerminal(a.status)).sort((x, y) => agentRecency(y) - agentRecency(x))
320 const shownFinished = new Set(finished.slice(0, STATE_FINISHED))
321 const listed = real.filter((a) => !isTerminal(a.status) || shownFinished.has(a))
322 const hidden = real.length - listed.length
323 // The unmatched agents of one workflow run are listed together, after the others, under the run's title.
324 const runs = looseGroups(real.filter((a) => !matchedToPlan(a))).groups.filter((g) => g.kind === 'run')
325 const inRun = new Set(runs.flatMap((g) => g.agents))
326 const entry = (a: AgentRecord) => '[' + a.status + '] ' + who(a) + ' ' + clean(modelEffort(a)) + ' ' + formatAgo(elapsed(a, d.now))
327 const blocks = [listed.filter((a) => !inRun.has(a)).map(entry).join(' · ')]
328 for (const g of runs) {
329 const mine = listed.filter((a) => g.agents.includes(a))
330 if (mine.length > 0) blocks.push('run "' + clean(g.title) + '": ' + mine.map(entry).join(' · '))
331 }
332 lines.push(
333 real.length === 0
334 ? 'agents: none'
335 : 'agents: ' + blocks.filter((b) => b !== '').join(' | ') + (hidden > 0 ? ' · +' + hidden + ' more' : '') + ' (ids are for SendMessage)',
336 )
337 lines.push(logText(d.log, d.logEntries))
338 const u = d.usage
339 let ctx = 'context: ' + (u?.percent !== undefined ? Math.round(u.percent) + '%' : 'n/a')
340 if (u?.percent !== undefined) ctx += u.usd !== undefined ? ' (' + formatUsd(u.usd) + ')' : ' (cost n/a)'
341 const hint = contextHint(u)
342 if (hint !== null) ctx += ' — contextHint: ' + hint
343 lines.push(ctx)
344 return lines.join('\n')
345}
346hooks/router.ts 260 lines1// Router warnings R1-R4 (pure, plain data in and out). SPEC.md section 7. A warning informs; this
2// module never denies or rewrites anything.
3import { clean, plainName } from './format.ts'
4
5export type Rule = 'R1' | 'R2' | 'R3' | 'R4'
6
7export interface Warning {
8 rule: Rule
9 text: string // one line, "headway: ..."
10}
11
12// Role rules (R3, R4) read a table the user writes in `<configDir>/headway-roles.json`; the code holds
13// none. A role the table does not name is not judged.
14export interface RoleRow {
15 models: string[]
16 efforts: string[]
17}
18export type Roles = Record<string, RoleRow>
19
20function names(v: unknown): string[] | undefined {
21 if (!Array.isArray(v) || v.length === 0) return undefined
22 const out: string[] = []
23 for (const x of v) {
24 if (typeof x !== 'string' || x.trim() === '') return undefined
25 out.push(x.trim().toLowerCase())
26 }
27 return out
28}
29
30// The role file: `{ "roles": { "<role>": { "models": [...], "efforts": [...] } } }`. Names and sets are
31// lower-cased. Anything else is an `error` saying why; this never throws.
32export function parseRoles(text: string): { roles: Roles } | { error: string } {
33 let data: unknown
34 try {
35 data = JSON.parse(text)
36 } catch (err) {
37 return { error: 'not JSON: ' + clean(err instanceof Error ? err.message : String(err)) }
38 }
39 const top = typeof data === 'object' && data !== null && !Array.isArray(data) ? (data as Record<string, unknown>) : undefined
40 const table = top && typeof top.roles === 'object' && top.roles !== null && !Array.isArray(top.roles) ? (top.roles as Record<string, unknown>) : undefined
41 if (!table) return { error: 'expected {"roles": {...}}' }
42 const roles: Roles = {}
43 for (const name of Object.keys(table)) {
44 const row = table[name]
45 const o = typeof row === 'object' && row !== null && !Array.isArray(row) ? (row as Record<string, unknown>) : undefined
46 const models = o ? names(o.models) : undefined
47 const efforts = o ? names(o.efforts) : undefined
48 if (!models || !efforts) return { error: 'role "' + clean(name) + '" needs "models" and "efforts", each a non-empty list of names' }
49 roles[name.trim().toLowerCase()] = { models, efforts }
50 }
51 return { roles }
52}
53
54const FAMILIES = ['fable', 'opus', 'sonnet', 'haiku']
55
56// Role = the last `:`-separated segment of `subagentType`, lower-cased (4.4).
57export function roleOf(subagentType: string | undefined): string {
58 const parts = (subagentType ?? '').split(':')
59 return (parts[parts.length - 1] ?? '').trim().toLowerCase()
60}
61
62// Model family = the first of fable, opus, sonnet, haiku found in the lower-cased alias or id.
63export function familyOf(model: string | undefined): string {
64 const m = (model ?? '').toLowerCase()
65 for (const f of FAMILIES) if (m.indexOf(f) >= 0) return f
66 return 'unknown'
67}
68
69// A set from the role file, each name cleaned (format.ts clean): the file is text on disk.
70function listOf(raw: string[]): string {
71 const items = raw.map((x) => clean(x))
72 if (items.length <= 1) return items.join('')
73 return items.slice(0, -1).join(', ') + ' or ' + items[items.length - 1]
74}
75
76export interface CallInput {
77 description: string
78 subagentType?: string
79 model?: string
80 definedModel?: string // the model the agent type's definition names, when it names one
81}
82
83// R1: an Agent call with no explicit model (a fork inherits and is exempt; so is a type whose
84// definition names a model: the dispatch routes it already).
85export function ruleR1(i: CallInput): Warning | undefined {
86 const role = roleOf(i.subagentType)
87 if (i.model || role === 'fork' || i.definedModel) return undefined
88 return {
89 rule: 'R1',
90 text: 'headway: Agent "' + clean(i.description) + '" (' + (clean(role) || 'general-purpose') + ') names no model; pass model on every dispatch.',
91 }
92}
93
94// The agent type's segments: `type`, or `plugin:type`. Every segment must be a plain name (format.ts
95// plainName): a type that is not is looked up nowhere, so R1 fires as for any type with no definition.
96function typeParts(subagentType: string | undefined): string[] | undefined {
97 const parts = (subagentType ?? '').split(':').map((p) => p.trim())
98 return parts.length <= 2 && parts.every(plainName) ? parts : undefined
99}
100
101// The file name an agent type is defined under: the last segment, case kept; '' for a type that is
102// not made of plain names.
103export function definitionName(subagentType: string | undefined): string {
104 const parts = typeParts(subagentType)
105 return parts ? parts[parts.length - 1] ?? '' : ''
106}
107
108// The plugin of a `plugin:type` name; undefined for a bare type or a name that is not plain.
109export function definitionPlugin(subagentType: string | undefined): string | undefined {
110 const parts = typeParts(subagentType)
111 return parts && parts.length === 2 ? parts[0] : undefined
112}
113
114// The directories a project definition may live in, nearest first: the session directory and each
115// parent up to the home directory (inclusive) when the session is inside it, else up to but never
116// including the filesystem root. Either separator is read; the paths are written with the session
117// directory's own (a `C:\...` or `\\host\share\...` directory is a Windows one).
118export function projectDirs(o: { cwd: string; home?: string }): { dirs: string[]; sep: string; atHome: boolean } {
119 const win = /^[A-Za-z]:([\\/]|$)/.test(o.cwd) || o.cwd.startsWith('\\\\')
120 const sep = win ? '\\' : '/'
121 const split = (p: string) => p.split(win ? /[\\/]+/ : /\/+/).filter((x) => x !== '')
122 const parts = split(o.cwd)
123 const unc = win && o.cwd.startsWith('\\\\')
124 const rootLen = win ? (unc ? 2 : 1) : 0
125 const root = win ? (unc ? '\\\\' + (parts[0] ?? '') + sep + (parts[1] ?? '') : (parts[0] ?? '')) : ''
126 const segs = parts.slice(rootLen)
127 const at = (n: number) => root + sep + segs.slice(0, n).join(sep)
128 const same = (a: string, b: string) => (win ? a.toLowerCase() === b.toLowerCase() : a === b)
129 const homeParts = o.home ? split(o.home) : []
130 const homeSegs = homeParts.slice(rootLen)
131 const inHome =
132 homeSegs.length > 0 &&
133 homeSegs.length <= segs.length &&
134 (!win || same(homeParts.slice(0, rootLen).join(sep), parts.slice(0, rootLen).join(sep))) &&
135 homeSegs.every((h, k) => same(h, segs[k] ?? ''))
136 const dirs: string[] = []
137 for (let n = segs.length; n >= (inHome ? homeSegs.length : 1); n--) dirs.push(at(n))
138 return { dirs, sep, atHome: inHome }
139}
140
141// The directories to read, from the chain above and where a `.git` was found in it (its index, or
142// none): the walk ends at the first `.git`, else at the home directory; a chain that reaches
143// neither is only the session directory.
144export function searchDirs(chain: { dirs: string[]; atHome: boolean }, gitAt: number | undefined): string[] {
145 if (gitAt !== undefined) return chain.dirs.slice(0, gitAt + 1)
146 return chain.atHome ? chain.dirs : chain.dirs.slice(0, 1)
147}
148
149// The `model:` of a definition's frontmatter (the block between the first two `---` lines), quotes
150// and a trailing comment removed. `inherit`, an empty value and no model line give undefined.
151// Read line by line with string operations, so no pattern backtracks over a long line.
152export function definitionModel(text: string): string | undefined {
153 const lines = (text.startsWith('\uFEFF') ? text.slice(1) : text).split('\n')
154 if (lines[0].trimEnd() !== '---') return undefined
155 let line: string | undefined
156 for (let i = 1; i < lines.length; i++) {
157 const l = lines[i].trimEnd()
158 if (l === '---') {
159 if (line === undefined) return undefined
160 let v = line.slice('model:'.length).trim()
161 const quote = v[0] === '"' || v[0] === "'" ? v.indexOf(v[0], 1) : -1
162 if (quote > 0) v = v.slice(1, quote)
163 else {
164 // a trailing comment: the first `#` after whitespace, and that whitespace, go
165 for (let k = 1; k < v.length; k++) {
166 if (v[k] === '#' && /\s/.test(v[k - 1])) {
167 v = v.slice(0, k)
168 break
169 }
170 }
171 }
172 v = v.trim()
173 return v === '' || v.toLowerCase() === 'inherit' ? undefined : v
174 }
175 if (line === undefined && l.startsWith('model:')) line = l
176 }
177 return undefined
178}
179
180// R3: a model family outside the role's set. At dispatch `model` is the call's, else the model the
181// type's definition names; after the fact it is the first step's resolved model (judged only when R1
182// fired: no model was named anywhere). An unknown role or family is not judged.
183export function ruleR3(i: CallInput, roles?: Roles): Warning | undefined {
184 const row = roles?.[roleOf(i.subagentType)]
185 const model = i.model || i.definedModel
186 const family = familyOf(model)
187 if (!row || !model || family === 'unknown' || row.models.indexOf(family) >= 0) return undefined
188 return {
189 rule: 'R3',
190 text: 'headway: ' + clean(roleOf(i.subagentType)) + ' "' + clean(i.description) + '" dispatched on ' + clean(family) + '; the role table expects ' + listOf(row.models) + '.',
191 }
192}
193
194// R4: the first step's effort outside the role's expected set. Log and row mark only.
195export function ruleR4(i: { description: string; subagentType?: string; effort?: string | number }, roles?: Roles): Warning | undefined {
196 const role = roleOf(i.subagentType)
197 const row = roles?.[role]
198 if (!row || i.effort === undefined || i.effort === '') return undefined
199 const effort = String(i.effort).toLowerCase()
200 if (row.efforts.indexOf(effort) >= 0) return undefined
201 return {
202 rule: 'R4',
203 text: 'headway: ' + clean(role) + ' "' + clean(i.description) + '" ran at effort ' + clean(effort) + '; the role table expects ' + listOf(row.efforts) + '.',
204 }
205}
206
207export interface CallShape {
208 ordinal: number
209 label?: string // a literal, or a template pattern with `*`; absent when dynamic or missing
210 hasModel: boolean
211 hasEffort: boolean
212 dynamic: boolean
213}
214
215const R2_BUDGET = 600
216const NAME_MAX = 80
217
218function callName(c: CallShape): string {
219 const label = c.label === undefined ? '' : clean(c.label, NAME_MAX)
220 if (label === '') return '#' + c.ordinal
221 return '"' + label + '"'
222}
223
224function r2Text(workflow: string | undefined, count: number, names: string[]): string {
225 const head = 'headway: Workflow "' + (workflow === undefined ? '(unnamed)' : clean(workflow, NAME_MAX)) + '": ' + count + ' agent() call(s) without model or effort: '
226 const tail = '. Pass both on every call.'
227 let shown = names.length
228 const text = () => head + names.slice(0, shown).join(', ') + (shown < names.length ? ', +' + (names.length - shown) + ' more' : '') + tail
229 while (shown > 1 && text().length > R2_BUDGET) shown--
230 return text()
231}
232
233// R2: agent() calls whose options lack `model:` or `effort:`. A call whose options are dynamic (a
234// variable, a spread) is not claimed to lack anything. Names the calls by label, else ordinal.
235// `warning` is the whole list, cut near 600 characters with `+n more`, for the log, the toast and
236// Claude; `perCall` is what each offending record stores, naming that call only.
237export function ruleR2(i: { name?: string; calls: CallShape[] }): {
238 warning: Warning
239 ordinals: number[]
240 perCall: Array<{ ordinal: number; warning: Warning }>
241} | undefined {
242 const missing = i.calls.filter((c) => !c.dynamic && (!c.hasModel || !c.hasEffort))
243 if (missing.length === 0) return undefined
244 return {
245 ordinals: missing.map((c) => c.ordinal),
246 warning: { rule: 'R2', text: r2Text(i.name, missing.length, missing.map(callName)) },
247 perCall: missing.map((c) => ({ ordinal: c.ordinal, warning: { rule: 'R2', text: r2Text(i.name, 1, [callName(c)]) } })),
248 }
249}
250
251// The text a warning is stored as on a record (warnings[]): the rule, a space, the one-line text.
252export function warningString(w: Warning): string {
253 return w.rule + ' ' + w.text
254}
255
256// "headway: " is dropped for the log and the pane, which already say what they are.
257export function warningDetail(w: Warning): string {
258 return w.text.replace(/^headway: /, '')
259}
260hooks/phases.ts 83 lines1// Workflow phases as a plan source (pure). SPEC.md 4.6. The source is derived from the agent
2// records, which carry the run's `meta.phases` (runPhases), title (runTitle) and each call's phase;
3// nothing of it is stored apart from them.
4import { isShown, isTerminal, runShort } from './agents.ts'
5import type { AgentRecord } from './agents.ts'
6import type { Plan, Task } from './plan.ts'
7import { buildPlan } from './planbuild.ts'
8
9// A phase's status: a task's, or `failed` (every call settled, one's latest attempt failed).
10export type PhaseStatus = Task['status'] | 'failed'
11
12export interface PhaseRun {
13 runId?: string
14 toolUseId?: string
15 title?: string // meta.description, else meta.name
16 phases: Array<{ name: string; status: PhaseStatus }>
17 at?: number // the newest call or spawn of the run (SPEC 4.1: newer data than a finished list above)
18}
19
20// The most recent run whose script declared phases, by the order its records first appear (a
21// call's planned records are written when it is made); null when no run did. The run's end changes
22// nothing: it stays the source until a newer run with phases replaces it or a higher source hides it.
23export function phaseRun(agents: AgentRecord[]): PhaseRun | null {
24 const runs = new Map<string, AgentRecord[]>()
25 for (const a of agents) {
26 const key = a.toolUseId ?? a.runId
27 if (a.provisional || key === undefined) continue
28 const list = runs.get(key)
29 if (list) list.push(a)
30 else runs.set(key, [a])
31 }
32 let latest: AgentRecord[] | undefined
33 runs.forEach((list) => {
34 if (list.some((a) => a.runPhases !== undefined)) latest = list
35 })
36 if (!latest) return null
37 const names = [...new Set(latest.find((a) => a.runPhases !== undefined)!.runPhases)]
38 const run: PhaseRun = {
39 phases: names.map((name) => ({ name, status: phaseStatus(latest!.filter((a) => a.phase === name && isShown(a))) })),
40 at: Math.max(...latest.map((a) => a.spawnedAt)),
41 }
42 const withRun = latest.find((a) => a.runId !== undefined)
43 if (withRun) run.runId = withRun.runId
44 const withCall = latest.find((a) => a.toolUseId !== undefined)
45 if (withCall) run.toolUseId = withCall.toolUseId
46 const title = latest.find((a) => a.runTitle !== undefined)?.runTitle
47 if (title !== undefined) run.title = title
48 return run
49}
50
51// Judged per planned call by its latest attempt (a retry surfaced in a stalled call's place shares its
52// agentIndex and label): partial while a call's latest attempt has spawned and is not over; open
53// while a call has not spawned, or with no call (a skipped one is not counted); once every call's
54// latest attempt is over, failed when one failed, else done when one is done, else (every one lost)
55// open. A lost attempt, settled by a restart, fails nothing.
56function phaseStatus(agents: AgentRecord[]): PhaseStatus {
57 const latest = new Map<string, AgentRecord>()
58 for (const a of agents) {
59 const key = a.planned ? 'p:' + a.id : a.agentIndex !== undefined ? 'i:' + a.agentIndex : 'l:' + (a.label ?? a.id)
60 const seen = latest.get(key)
61 if (!seen || a.spawnedAt >= seen.spawnedAt) latest.set(key, a)
62 }
63 const calls = [...latest.values()]
64 if (calls.some((a) => !a.planned && !isTerminal(a.status))) return 'partial'
65 if (calls.length === 0 || calls.some((a) => a.planned)) return 'open'
66 if (calls.some((a) => a.status === 'failed')) return 'failed'
67 return calls.some((a) => a.status === 'done') ? 'done' : 'open'
68}
69
70// The run as a plan: one group named after the run (never an ID to match), a task per phase.
71export function phasePlan(run: PhaseRun): Plan {
72 // a failed phase is not done: it counts as open toward the goal and is drawn failed
73 const tasks: Task[] = run.phases.map((p, i) => ({ id: p.name, title: '', ordinal: i + 1, line: 0, shape: 'checklist', status: p.status === 'failed' ? 'open' : p.status, groups: [0], ...(p.status === 'failed' ? { failed: true as const } : {}) }))
74 return {
75 ...buildPlan({
76 section: run.title ?? '',
77 tasks,
78 groups: [{ id: run.title ?? 'Workflow ' + runShort(run.runId ?? run.toolUseId ?? ''), title: '', kind: 'heading', own: true, tasks: tasks.map((_, i) => i) }],
79 }),
80 phaseRun: { ...(run.runId !== undefined ? { runId: run.runId } : {}), ...(run.toolUseId !== undefined ? { toolUseId: run.toolUseId } : {}) },
81 }
82}
83