SLOPSHOPPER

headway

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

newpanebandrowsguardcommand
★ 1v0.3.3MITupdated 2026-10-11atharv6f/headway/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · headway
│ ┃ Headway ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Plan 2: Agents 3: Log 4: Usage │ ┃ e: expand all h: hide done r: re-read ⏺ Read(src/auth.ts) │ ┃ ◆ no plan ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /headway │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Headway
1: Plan 2: Agents 3: Log 4: Usage e: expand all h: hide done r: re-read q: close ◆ no plan
README

headway

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.

Install

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.

What you get

  • A band above the prompt, always on. Line 1 the goal and its progress, line 2 the current step and its agents, line 3 context fill, the exact session cost and the newest event.
  • A Plan tab (/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.
  • An Agents tab: every subagent with its status, model and effort, elapsed time and estimated cost; blocked and stalled ones called out, each with the short id SendMessage takes.
  • Workflow phases as the plan. A Workflow script that declares meta.phases is tracked as one group, one task per phase, each agent() call under its phase.
  • Router warnings. A dispatch that names no model, a Workflow 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.
  • Quiet transcript rows and a small footprint. headway's own tool calls draw as one dim line each, not a JSON block. It never writes a file of yours, never denies or rewrites a tool call, makes no network request and runs no subprocess. Trust lists everything it reads, and What the hooks do every hook.

Quickstart (60 seconds)

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.

Plan sources

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 |

1. A pinned plan file

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.

2. A list Claude reports

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.

3. A plan-mode plan

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.

4. Claude's task tools

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.

5. A Workflow's phases

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.

6. Nothing: grouping by origin

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.

Matching an agent to a step

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.

Commands and keys

| 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.

Quiet transcript rows

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.

Settings

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.

Router warnings

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.

Cost and usage

  • Session cost ($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.
  • Per-agent cost is an estimate, always labelled 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.?.
  • Context fill (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.

Trust

What headway does with your machine, in full:

  • Network: none. No fetch, no telemetry, no update check.
  • Reads: the plan file and group map you pin, anywhere, or that Claude pins through the tool's 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).
  • Sizes: headway reads only a regular file, never a pipe or a device, and only up
Source 22 files
hooks/register.tsx 2012 lines
1// 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}
1200
hooks/agents.ts 1436 lines
1// 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 lines
1// 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}
263
hooks/format.ts 175 lines
1// 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}
175
hooks/quiet.tsx 128 lines
1// 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}
128
hooks/pane.tsx 681 lines
1// 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}
681
hooks/plan.ts 493 lines
1// 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}
493
hooks/pin.ts 138 lines
1// 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}
138
hooks/persist.ts 143 lines
1// 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}
143
hooks/tool.ts 346 lines
1// 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}
346
hooks/router.ts 260 lines
1// 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}
260
hooks/phases.ts 83 lines
1// 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