A band, pane, and /downrange table: progress, ETA, and last output for Claude Code background shells and Monitor watches, tool activity for subagents, state…

<img src="https://img.shields.io/badge/Claude_Code-2.1.288%2B-D97757" alt="Claude Code 2.1.288+"> <img src="https://img.shields.io/badge/Claude_Code-mod-555555" alt="Claude Code mod"> <img src="https://img.shields.io/badge/license-MIT-78997A" alt="License: MIT"> <img src="https://img.shields.io/badge/version-1.1.2-555555" alt="Version: 1.1.2">
The band above the prompt shows a background shell with its progress and ETA.
Claude can run shell commands, Monitor watches, subagents, and workflows in the background while you keep working. To see how far they got, you ask Claude, and that question costs a model request that reads the whole conversation again. Users ask for progress and ETAs taken from the output itself (#39131, #82741). Downrange reads that output on your machine and shows where each job stands.
45%, [12/40], and Step 3 of 7 lines, and the progress lines of tools such as Docker, cargo, pytest, and git, become a progress label, and a bar in the pane. Shell output and Monitor events both count. A ratio in another place, such as loss 12/40, counts only with the genericRatios option on. A shell command that sends its output to a file, such as make > build.log 2>&1, shows progress from that file.run_in_background, ctrl+b, or a command moved at its timeout), Monitor watches, background and remote subagents, teammates, workflow runs, and the pending dynamic /loop wakeup. For background sessions, use agent view (claude agents).quiet 3m 00s for a shell command or subagent that stayed silent for 2 minutes or more, and 3 failed calls in a row for a struggling subagent./downrange answers at once, even while Claude works. With the answerStatusPrompts option on, a prompt such as status? gets the same table.ended without a report. Running jobs carry across /clear, /resume, and /branch./tasks stops tasks.The live pane lists a shell, a subagent, and a Monitor watch.
/downrange prints a status table at once, with no model request.
The usage guide explains every view, symbol, note, and message.
flowchart LR
L["Tool calls: launches, wakeups, stops, subagent activity"] --> S[("Jobs and wakeup in session state")]
N["Task notifications"] --> S
P["Stop and SubagentStop snapshots of tasks and crons"] --> S
T["Ticker, once a second while work is live"] -->|"output file ends, agent list"| S
N -->|"clean shell runs"| H[("Usual durations under hashed keys")]
H -->|"usual duration at launch"| S
S --> V["Band, pane, and /downrange table"]
S -->|"session end"| C["Carry for /clear, /resume, /branch"]
C --> S
tail above 4 MiB. Progress comes from the last 10 display lines of that end.> build.log or | tee build.log. The ticker then also reads the one file that the command's text names, its redirect file, in the same way. At each read, the row shows the file that changed last: Claude Code's output file or the redirect file. On equal times, it shows Claude Code's output file. While the row shows the redirect file, it notes output: build.log. The end and the exit code still come from Claude Code's notification. Downrange follows no redirect file on Windows, or in a command with code that may not run, such as an if or a function./downrange draw from the same state. The ticker stops when nothing is live. At session end, Downrange keeps the running jobs and the wakeup in memory, and restores them after /clear, /resume, or /branch.Works with Claude Code 2.1.288 and later. Tested with Claude Code 2.1.296.
Inside Claude Code:
/plugin marketplace add dot-agi/downrange
/plugin install downrange@downrange
/reload-plugins
From a shell:
claude plugin marketplace add dot-agi/downrange
claude plugin install downrange@downrange
From a clone, for one session:
git clone https://github.com/dot-agi/downrange
claude --plugin-dir ./downrange
Install from a shell with Claude Code 2.1.292 or later. The second part turns on auto-update. It runs only when the install succeeds and the marketplace entry exists:
claude plugin install downrange --marketplace dot-agi/downrange --scope user --json \
&& f="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json" \
&& jq -e --arg name downrange '.extraKnownMarketplaces[$name].source' "$f" > /dev/null \
&& tmp=$(mktemp "$f.XXXXXX") \
&& { jq --arg name downrange '.extraKnownMarketplaces[$name].autoUpdate = true' "$f" > "$tmp" && mv "$tmp" "$f" || { rm -f "$tmp"; false; }; }
Claude Code 2.1.288 to 2.1.291 has no --marketplace option. Add the marketplace first, then run the auto-update part above:
claude plugin marketplace add dot-agi/downrange --scope user --json \
&& claude plugin install downrange@downrange --scope user --json
Auto-update runs only in interactive sessions. A headless job updates at its start, before the session that uses the mod:
claude plugin update downrange@downrange --json
Check the result. The first command prints the installed version. The second prints the latest release:
claude plugin list --json | jq -r '.[] | select(.id == "downrange@downrange") | "\(.version) enabled=\(.enabled)"' \
&& git ls-remote --tags --refs https://github.com/dot-agi/downrange 'downrange--v*' | sed 's/.*--v//' | sort -V | tail -n 1
Notes:
CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 in the environment of every claude command and session./reload-plugins. An agent cannot type /reload-plugins, so it starts a new session.install again does not update. Use update.mv replaces the settings file, with mode 600. If settings.json is a symbolic link, edit its target instead.| Command or key | What it does |
|---|---|
/downrange | Prints the status table at once, mid-turn too |
/downrange pane | Opens or closes the live pane |
j on the focused band | Opens the pane |
h on the focused band | Hides the band until a new job starts |
c in the focused pane | Clears finished jobs |
x in the focused pane | Closes the pane |
Focus the band or the pane with Ctrl+X, then Tab, or a click. That chord is Claude Code's own abovePrompt:focus binding. Release Ctrl before you press Tab, because many terminals read Ctrl+Tab as next tab. In Emacs vterm, eat, or ansi-term, click the band, use /downrange pane, or rebind abovePrompt:focus with /keybindings.
Set options in /config, or under pluginConfigs["downrange@downrange"].options in ~/.claude/settings.json. For a --plugin-dir load, the key is downrange@inline.
| Option | Default | Effect |
|---|---|---|
band | true | Shows the band while background work is live |
answerStatusPrompts | false | When Claude is idle and a job runs, answers an exact status prompt (status?, what's the status?, give me a status update) with the table. Send the same prompt again within 60 seconds to ask Claude |
genericRatios | false | Reads progress from a ratio in any place in the output, such as loss 12/40. Off, only known formats count, such as a percentage, or a ratio in brackets, at the start of a line, after a step word such as Step, or after a progress bar. On shows progress for more jobs, and more false readings, such as a date like Released 10/11 |
| Where | Band | Pane | /downrange | Local status answer |
|---|---|---|---|---|
| Terminal, fullscreen | Yes | Docked from 110 columns, else above the prompt | Yes | When the option is on |
| Terminal, main screen | Yes | Above the prompt | Yes | When the option is on |
| Desktop app, Code tab | Yes | Yes | Yes | No |
| VS Code panel | No | Not verified; use /downrange | Yes | No |
| Agent SDK and stream-json sessions | No | No | Yes | No |
| Mobile app through Remote Control | No | Not verified; the terminal on your machine shows it | Yes | No |
| Feature or project | What it does | What Downrange adds |
|---|---|---|
Built-in /tasks | An interactive dialog that lists and stops background shells, subagents, Monitors, and MCP tasks | Live progress, ETA, last output, the pending /loop wakeup, and a text table that prints mid-turn and in SDK sessions. Downrange does not stop tasks |
| Built-in subagent panel | Rows of running background subagents below the prompt, with a key that stops them all | Shells, Monitors, wakeups, and progress |
Built-in /workflows | A progress line per workflow run, and a view of its phases | Each workflow run as one row beside the other jobs. Downrange shows no phases |
Built-in agent view (claude agents) | Background sessions with their state, and the /loop run count and countdown. A !cmd shell job shows its last output line | The background tasks inside one session |
| rabe | A band and pane with subagents, workflows, background shells, Monitors, cron jobs, and /loop wakeups with the next run time. It can stop jobs and message agents, and it estimates cost per agent | Progress and ETA parsed from job output, a text table, and running jobs kept across /clear, /resume, and /branch |
| Flotilla | Bash, PowerShell, and Monitor background tasks with command, run time, and end state. An agent tree with model and cost, workflow bars, a timeline, and a stuck-agent alert | The last output line, progress, and ETA for shells, the /loop wakeup, quiet flags from output silence, and the carry across /clear |
| claude-deck | Shells and agents with run time, a stop button per shell, job steps as a tree, recently ended commands, cost and usage meters, and failure toasts | Monitor watches, the /loop wakeup, progress and ETA from output, and running jobs kept across /clear |
| claude-flightdeck | A permission gate view, model vitals, and rich subagent cards and swimlanes | Progress and ETA read from the output of background shells and Monitors |
| sprout | Subagent cards with cost and a file tree. Its progress comes from a note it injects for Claude and a step tool that Claude calls | Progress read from real job output, with no tool or note for Claude |
[3/7], Step 3 of 7, and the progress lines of Docker, cargo, pytest, git, and similar tools./loop wakeup.loss 12/40, unless genericRatios is on. Dates such as [10/11] can still count./loop 10m, other cron schedules, jobs after /background until the next turn end, and work that goes on after its job ends. nohup alone does not end a job at once. A command that ends with & does. Downrange follows no file of a command with & or nohup.quiet flag.<1m and stays there.The list below holds the limits you can hit in daily use. docs/usage.md has every limit, also the rare ones: clashes with other schedules, size caps, races, failed reads, closed teammate panes, and unusual setups or task descriptions.
/loop wakeups. Only wakeups that ScheduleWakeup creates appear. Fixed-interval loops such as /loop 10m and other cron schedules do not.Step or Epoch, after a drawn bar, or in Docker Compose's [+] Running 3/7. A ratio in such a place that is not progress still counts. A short date counts, such as [10/11] or 10/11 deploy at the start of a line, unless a two-digit clock follows it, as in 10/11 14:03. So does N of M after a step word and before a space inside a path, such as Part 1 of 2 (live)/a.flac, and a percentage that is not progress, such as CPU 45%. Real progress in another place, such as loss 12/40 or [12:34:56] 3/15 done, does not count. With the genericRatios option on, a ratio counts in any place, so Released 10/11, Disc 1 of 2 (live)/a.flac, and other ratios that are not progress count too. These change the bar and the rate ETA. In both modes, full dates such as 2026/10/11 and 10/11/2026 do not count. Neither does N of M joined to a path or a number, such as /backups/3 of 7/, version 1.2 of 7.0, or 3 of 7.5 GB. The date rule also drops real progress of 1 to 12 out of at most 31 before a clock, such as 3/15 00:12.<1m while the job runs. This holds after quiet shows too, so a row can show 20%, <1m, and quiet 2m 00s together. A Monitor whose events stop behaves the same way.finishing, the deadline no longer shows. A rate ETA can run past the deadline: a 30-minute watch that goes from 1% to 2% in its first minute shows ~1h 38m, although its deadline is 29 minutes away.took time, and the run it adds to the duration history count only the time after the move. For example, a command moved after 2 minutes that ends 1 minute later shows took 1m 00s and records 1 minute. Runs of one command in one folder share one history, whatever the launch mode. The next run of that command that starts in the background then shows ~1m usual, even when it needs 3 minutes, and in the pane it can turn yellow early. A moved run can get a usual-duration ETA that is too long.quiet flag until its launch result or a notification names its output file. Only its launch result brings a usual-duration ETA. A task that started before Downrange loaded, such as one in a session moved to another process, gets no launch result. Claude Code's internal tasks also appear, under their engine labels, and a long-lived one can keep the band visible. Use Hide to dismiss the band.ends with its agent ends when its subagent answers. It then shows ended with its agent and no exit code, unless its own notification arrives later.quiet flag and no failed-calls flag, however long it stays in that state.remote, and a teammate in a terminal pane of its own shows own pane. Their rows show no tool count and no quiet flag, so a stuck or dead one looks the same as a busy one. A row whose launch result Downrange missed, or that an earlier version of Downrange saved, still keeps 0 tools and shows quiet from 2 minutes after launch, even while the agent works. Its notification or a snapshot still ends it./background and a hand-off to the desktop app start a fresh process that resumes the conversation. Downrange starts empty there. Running tasks return at the next turn end, with ≥ ages and no last line. A shell's notification can still name its output file, end it, and show its last line. The /loop wakeup returns only with the next ScheduleWakeup result.Downrange watches events and changes almost none of them. It makes no network request, writes no file of its own, and adds no tool for Claude.
/downrange table, the local status answer, and downrange: diagnostic lines in the transcript and the debug log. Two of these go further through Claude Code itself:/downrange table enters the conversation, so Claude reads it with its next request, as it reads any command output. The table cuts last output lines to 80 characters and removes control characters. A subagent row in the table shows only the tool it last used; the pane also shows that tool's input in short.tail -c 16384 <file>, with a 2-second time limit, to read the end of a large output. It runs only when the file it reads for a background shell is over 4 MiB, the largest file Claude Code's file read accepts. It prints the last 16,384 bytes of that file. Downrange passes it as an argument list, with no shell. The file path is the only part that varies. That path must be the output file of the job's own task id (absolute, in a tasks folder, and named <task id>.output), or the job's redirect file (see the next item). An organization policy that refuses mods which run host commands also refuses this mod.tasks folder, with its size and change time.build.log in make > build.log 2>&1. Downrange reads it only when it is a regular file, never a path under /dev, and never on Windows.It also reads the session's working folder, to hash it with a command and to place a relative redirect file, and the list of the session's agents and their states.
background_tasks and session_crons lists of Stop and SubagentStop hooks. The text of a prompt you type, only when answerStatusPrompts is on, to compare it with the status phrases. Downrange reads no transcript file.hooks/register.tsx 738 lines1import { atom, read, update } from 'claude-code'
2import type { Caught, CommandRunResult, EngineInterface, HookFailure, Register, Timer } from 'claude-code'
3
4import type { Job, JobsState, ShellJob, Wakeup, WakeupState } from '../types'
5import { bandTree, failedPaneTree, paneTree } from './drawing'
6import { durationsOf, historyKeyOf, recorded, usualOf } from './history'
7import type { Durations } from './history'
8import {
9 activityEnded,
10 activityOf,
11 activityStarted,
12 agentJob,
13 agentsSynced,
14 finalIdsOf,
15 finishedCleared,
16 finishedRuns,
17 historyAdded,
18 isLive,
19 isRunning,
20 launched,
21 monitorJob,
22 noticed,
23 noticeProblemOf,
24 observedJobs,
25 readFailed,
26 readsDue,
27 reconciled,
28 reloaded,
29 restoredJobs,
30 shellJob,
31 ownerAnswered,
32 shellLaunchNote,
33 stoppedJob,
34 tailRead,
35 wakeupAfterSnapshot,
36 wakeupAfterTick,
37 wakeupAfterTrigger,
38 wakeupOf,
39 workflowJob,
40} from './jobs'
41import type { HistoryRun, JobObservation, SnapshotTask, TailRead } from './jobs'
42import { CARRY_ATTEMPTS, STATE_WRITE_ATTEMPTS, FS_READ_MAX, REPEAT_WINDOW_MS, TAIL_TIMEOUT_MS, TICK_MS, TICKER_STALE_MS } from './limits'
43import { noticesOf } from './notices'
44import { displayLinesOf, isOutputFileOf, jobBytesOf, jobLinesOf, oneLine, outputEndOf, outputFileOf } from './output'
45import { jobsStateOf, wakeupStateOf } from './state'
46import { isStatusPrompt, normalizePrompt } from './prompt'
47import { redirectFileOf, redirectTargetOf } from './redirect'
48import { bandOf, isBandHidden, paneModelOf, tableOf } from './view'
49
50/** The slash command, without its slash; declared here so that `claude plugin validate` resolves the matcher. */
51const COMMAND = 'downrange'
52/** The pane's id, and the requestId its render hook matches. */
53const PANE_ID = 'downrange'
54/** The pane's tab title. */
55const PANE_TITLE = 'Jobs'
56
57const JOBS = atom({ plugin: 'downrange', key: 'jobs' } as const, { items: [], observed: [], removed: [], snapshotAt: null }, { shape: 'v8' })
58const WAKEUP = atom({ plugin: 'downrange', key: 'wakeup' } as const, { pending: null, updatedAt: null }, { shape: 'v4' })
59const BAND_HIDDEN_AT = atom({ plugin: 'downrange', key: 'bandHiddenAt' } as const, null)
60const LAST_ANSWERED = atom({ plugin: 'downrange', key: 'lastAnswered' } as const, null)
61
62/** The buttons of the band and the pane. */
63type Control = 'Open' | 'Hide' | 'Clear finished' | 'Close'
64
65/** Running jobs and the wakeup, as the carry across /clear, /resume, and /branch holds them. */
66type Carry = { jobs: JobsState; wakeup: WakeupState; destination?: string; attempts: number; restoring: boolean }
67type Committed = { jobs: JobsState; wakeup: WakeupState; sessionId?: string; jobsVersion: number; wakeupVersion: number }
68
69/** Keys of diagnostics already shown; a reload re-arms them on purpose. */
70const reported = new Set<string>()
71
72/** The ticker's timer; a reload drops it, and session.start starts a new one. */
73let ticker: Timer | null = null
74/** Guards against overlapping refreshes inside one module instance. */
75let isTicking = false
76/** When the ticker last ran a period, or started; an old time means a refused period ended it. */
77let lastTickAt = 0
78/** The newest committed state and versions; session.end saves running rows and ordering barriers. */
79let latest: Committed = { jobsVersion: -1, wakeupVersion: -1, jobs: { items: [], observed: [], removed: [], snapshotAt: null }, wakeup: { pending: null, updatedAt: null } }
80/** What session.end saved for the next classic.SessionStart. */
81let carry: Carry | null = null
82/**
83 * The last history record; each record waits for it, so two in one session never overwrite each other. Two sessions
84 * can still race, because the store has no versioned write; a lost record costs every run it held, a new key's first too.
85 */
86let historyWrites: Promise<void> = Promise.resolve()
87/** The `genericRatios` option, which register sets: any ratio in output counts as progress, not only a known format. */
88let isAnyRatio = false
89
90/** A `tail` that could not start, timed out, or exited non-zero. */
91class TailFailure extends Error {
92 constructor(message: string, readonly bytes: number) {
93 super(message)
94 }
95}
96
97function messageOf(error: unknown): string {
98 return error instanceof Error ? error.message : String(error)
99}
100
101/** A stat, read, or tail of a job's redirect file that failed, with that failure. */
102class RedirectFailure extends Error {
103 constructor(readonly failure: unknown) {
104 super(messageOf(failure))
105 }
106}
107
108/** A hook failure as one phrase: `hook throw: <message>`. */
109function failureText(failure: HookFailure): string {
110 return `hook ${failure.kind}: ${failure.message ?? 'no message'}`
111}
112
113/** A failed hook, named by its label, goes to the transcript the first time, then to the debug log. */
114function reportHook($: EngineInterface, hook: string, failure: HookFailure): void {
115 const key = `hook|${hook}`
116 const isFirst = !reported.has(key)
117 reported.add(key)
118 $.ui.log(`${isFirst ? 'downrange: ' : ''}${hook}: ${failureText(failure)}`, { to: isFirst ? 'transcript' : 'debug' })
119}
120
121/** What the `.catch` of a hook that only watches does: it reports the failure under the hook's label and passes the event on unchanged. */
122function passOn<I, R>($: EngineInterface, label: string, e: I, next: ((e: I) => R) & Caught): R {
123 if (next.error.kind !== 're-entry') reportHook($, label, next.error)
124 return next(e)
125}
126
127/** Logs the event and cause once per key; the debug sink supplies the plugin name. */
128function report($: EngineInterface, event: string, cause: string, once: string): void {
129 const key = `${event}|${once}`
130 if (reported.has(key)) return
131 reported.add(key)
132 $.ui.log(`${event}: ${cause}`, { to: 'debug' })
133}
134
135/** Starts the ticker, or starts it again (and logs once) when no period ran for TICKER_STALE_MS; a tick stops it when nothing is live. */
136async function ensureTicker($: EngineInterface): Promise<void> {
137 const now = await $.clock.now()
138 if (ticker !== null && now - lastTickAt < TICKER_STALE_MS) return
139 if (ticker !== null) {
140 ticker.cancel()
141 report($, 'clock.every', 'the ticker stopped (a period was refused); started it again', 'restart')
142 }
143 lastTickAt = now
144 ticker = $.clock.every(TICK_MS, () => {
145 void tick($)
146 })
147}
148
149/** Writes only to the destination captured before the read and retains committed versions alone. */
150async function writeJobs($: EngineInterface, input: JobObservation | JobsState | null, change: (jobs: Job[]) => Job[] = jobs => jobs, destination?: string): Promise<boolean> {
151 const sessionId = destination ?? await $.session.id()
152 for (let attempt = 0; attempt < STATE_WRITE_ATTEMPTS; attempt++) {
153 if (await $.session.id() !== sessionId) throw new Error('jobs destination changed')
154 const raw = await $.state.get({ plugin: 'downrange', key: 'jobs' })
155 const decoded = jobsStateOf(raw.value)
156 if (raw.value !== undefined && decoded === undefined) report($, 'state.get', 'unsupported or invalid jobs state; reset safely', 'jobs-shape')
157 const state = decoded ?? { items: [], observed: [], removed: [], snapshotAt: null }
158 const changed = input === null ? state : 'items' in input ? restoredJobs(state, input) : observedJobs(state, input, change)
159 if (await $.session.id() !== sessionId) throw new Error('jobs destination changed')
160 const written = input === null && (raw.value === undefined || (decoded !== undefined && raw.value.shape === 'v8'))
161 ? { isSet: true, version: raw.version }
162 : await $.state.set({ plugin: 'downrange', key: 'jobs' }, { shape: 'v8', value: changed }, { ifVersion: raw.version })
163 if (!written.isSet) continue
164 if (await $.session.id() !== sessionId) throw new Error('jobs destination changed')
165 if (latest.sessionId !== sessionId) latest = { sessionId, jobs: state, wakeup: { pending: null, updatedAt: null }, jobsVersion: -1, wakeupVersion: -1 }
166 if (written.version > latest.jobsVersion) latest = { ...latest, jobs: changed, jobsVersion: written.version }
167 await ensureTicker($)
168 return changed !== state
169 }
170 throw new Error('jobs state stayed busy')
171}
172
173/** Migrates before replacement and preserves the latest committed wakeup cancellation. */
174async function writeWakeup($: EngineInterface, now: number, change: (wakeup: Wakeup | null) => Wakeup | null, isReplacement = false, destination?: string): Promise<void> {
175 const sessionId = destination ?? await $.session.id()
176 for (let attempt = 0; attempt < STATE_WRITE_ATTEMPTS; attempt++) {
177 if (await $.session.id() !== sessionId) throw new Error('wakeup destination changed')
178 const raw = await $.state.get({ plugin: 'downrange', key: 'wakeup' })
179 const decoded = wakeupStateOf(raw.value)
180 if (raw.value !== undefined && decoded === undefined) report($, 'state.get', 'unsupported or invalid wakeup state; reset safely', 'wakeup-shape')
181 const state = decoded ?? { pending: null, updatedAt: null }
182 const accepts = state.updatedAt === null || now >= state.updatedAt
183 const pending = accepts ? change(state.pending) : state.pending
184 const changed = accepts && (isReplacement || pending !== state.pending) ? { pending, updatedAt: now } : state
185 if (await $.session.id() !== sessionId) throw new Error('wakeup destination changed')
186 const written = changed === state && (raw.value === undefined || (decoded !== undefined && raw.value.shape === 'v4'))
187 ? { isSet: true, version: raw.version }
188 : await $.state.set({ plugin: 'downrange', key: 'wakeup' }, { shape: 'v4', value: changed }, { ifVersion: raw.version })
189 if (!written.isSet) continue
190 if (await $.session.id() !== sessionId) throw new Error('wakeup destination changed')
191 if (latest.sessionId !== sessionId) latest = { sessionId, jobs: { items: [], observed: [], removed: [], snapshotAt: null }, wakeup: state, jobsVersion: -1, wakeupVersion: -1 }
192 if (written.version > latest.wakeupVersion) latest = { ...latest, wakeup: changed, wakeupVersion: written.version }
193 await ensureTicker($)
194 return
195 }
196 throw new Error('wakeup state stayed busy')
197}
198
199/** Keeps running rows and barriers for every terminal row omitted from the handoff. */
200function carriedJobs(state: JobsState): JobsState {
201 const removed = new Map(state.removed)
202 const observed = new Map(state.observed)
203 for (const job of state.items) {
204 if (!isRunning(job)) removed.set(job.id, Math.max(removed.get(job.id) ?? -Infinity, observed.get(job.id) ?? job.lastEndedAt ?? job.startedAt))
205 }
206 return { ...state, items: state.items.filter(isRunning), removed: [...removed] }
207}
208
209/** Retries a pending handoff only in its assigned destination; both writes must succeed before clearance. */
210async function restoreCarry($: EngineInterface): Promise<void> {
211 const carried = carry
212 if (carried === null || carried.destination === undefined || carried.restoring || carried.attempts >= CARRY_ATTEMPTS) return
213 if (await $.session.id() !== carried.destination || carry !== carried) return
214 carried.restoring = true
215 carried.attempts++
216 try {
217 await writeJobs($, carried.jobs, jobs => jobs, carried.destination)
218 if (carry !== carried || await $.session.id() !== carried.destination) return
219 if (carried.wakeup.updatedAt !== null) {
220 await writeWakeup($, carried.wakeup.updatedAt, () => carried.wakeup.pending, true, carried.destination)
221 }
222 if (carry === carried && await $.session.id() === carried.destination) carry = null
223 } finally {
224 carried.restoring = false
225 }
226}
227
228/** The stored durations; a value of the wrong shape counts as empty and logs once. */
229async function storedDurations($: EngineInterface, event: string): Promise<Durations> {
230 const durations = durationsOf(await $.store.get('durations'))
231 if (durations !== undefined) return durations
232 report($, event, 'duration history had the wrong shape; the next record replaces it', 'history-shape')
233 return {}
234}
235
236/** The history key and usual duration for a launch; a failure gives neither, logs once, and never stops the launch. */
237async function historyFor($: EngineInterface, command: string): Promise<{ historyKey: string; usualMs?: number } | undefined> {
238 try {
239 const historyKey = await historyKeyOf(await $.session.cwd(), command)
240 return { historyKey, usualMs: usualOf(await storedDurations($, 'tool.call'), historyKey) }
241 } catch (error) {
242 report($, 'tool.call', `duration history unavailable: ${messageOf(error)}`, 'history-get')
243 return undefined
244 }
245}
246
247/**
248 * The redirect file of a launch, absolute in the session's folder, or undefined. A subagent's command follows only an
249 * absolute path: a subagent can run in a folder of its own. A failure gives none, logs once, and never stops the launch.
250 */
251async function redirectFor($: EngineInterface, id: string, command: string, agentId: string | undefined): Promise<string | undefined> {
252 try {
253 const target = redirectTargetOf(command)
254 if (target === undefined || (agentId !== undefined && !target.startsWith('/'))) return undefined
255 return redirectFileOf(target, await $.session.cwd())
256 } catch (error) {
257 report($, 'tool.call', `cannot place the redirect file of task ${id}: ${messageOf(error)}`, `redirect|${id}`)
258 return undefined
259 }
260}
261
262/** Appends clean runs to the duration history, one record at a time; a failure costs the record only and logs once. */
263async function recordRuns($: EngineInterface, runs: readonly HistoryRun[], now: number, event: 'prompt.submit' | 'session.append'): Promise<void> {
264 if (runs.length === 0) return
265 const record = historyWrites.then(async () => {
266 try {
267 const durations = await storedDurations($, event)
268 await $.store.set('durations', runs.reduce((all, run) => recorded(all, run.key, run.durationMs, now), durations))
269 } catch (error) {
270 report($, event, `duration history unavailable: ${messageOf(error)}`, 'history-set')
271 }
272 })
273 historyWrites = record
274 await record
275}
276
277/** Applies every notification block in a delivered prompt, in order; history judges each block on the list the blocks before it left. */
278async function applyNotices($: EngineInterface, text: string, noticeRow?: string, observedAt?: number): Promise<void> {
279 const event = noticeRow === undefined ? 'prompt.submit' : 'session.append'
280 const notices = noticesOf(text)
281 if (notices.length === 0) {
282 if (text.includes('<task-notification>')) report($, event, 'a task notification held no complete <task-notification> block; jobs end at the next turn end', 'no-block')
283 return
284 }
285 for (const problem of notices.map(noticeProblemOf)) {
286 if (problem !== undefined) report($, event, problem.cause, problem.once)
287 }
288 const now = observedAt ?? await $.clock.now()
289 let runs: HistoryRun[] = []
290 const changed = await writeJobs($, { at: now, ids: notices.flatMap(notice => notice.ids), kind: 'notice', finalIds: finalIdsOf(notices), noticeRow }, jobs => {
291 runs = finishedRuns(jobs, notices, now)
292 return notices.reduce((list, notice) => noticed(list, notice, now, isAnyRatio), jobs)
293 })
294 if (changed) await recordRuns($, runs, now, event)
295}
296
297/**
298 * Reconciles with a Stop or SubagentStop snapshot: the wakeup with `session_crons`, the jobs with `background_tasks`;
299 * an absent field logs once. The wakeup rule runs again on the wakeup that stands at the write, so a wakeup set
300 * meanwhile is judged by its own time.
301 */
302async function reconcile(
303 $: EngineInterface,
304 event: string,
305 input: { background_tasks?: readonly SnapshotTask[]; session_crons?: readonly { recurring: boolean }[] },
306): Promise<void> {
307 const now = await $.clock.now()
308 const crons = input.session_crons
309 if (crons === undefined) {
310 report($, event, 'no session_crons in the hook input; a cancelled wakeup waits for its expiry', 'no-crons')
311 } else {
312 const { pending: wakeup } = await read($, WAKEUP)
313 if (wakeupAfterSnapshot(wakeup, crons, now) !== wakeup) await writeWakeup($, now, current => wakeupAfterSnapshot(current, crons, now))
314 }
315 const tasks = input.background_tasks
316 if (tasks === undefined) {
317 report($, event, 'no background_tasks in the hook input; reconciliation skipped', 'no-snapshot')
318 return
319 }
320 await writeJobs($, { at: now, kind: 'snapshot', members: tasks.map(task => task.id) }, jobs => reconciled(jobs, tasks, now))
321}
322
323/** Runs `tail -c 16384 <path>`: the last TAIL_BYTES bytes, written as text so the command reads in full; only the output file path varies. */
324async function tailOf($: EngineInterface, path: string, bytes: number): Promise<string> {
325 const ran = await $.process.run(['tail', '-c', '16384', path], { timeoutMs: TAIL_TIMEOUT_MS }).catch((error: unknown) => {
326 throw new TailFailure(messageOf(error), bytes)
327 })
328 if (ran.exitCode !== 0) throw new TailFailure(`exit ${ran.exitCode}: ${oneLine(ran.stderr)}`, bytes)
329 return ran.stdout
330}
331
332/** A file's end, by `$.fs.read` up to FS_READ_MAX and by `tail` above. */
333async function endOf($: EngineInterface, path: string, size: number): Promise<string> {
334 const isTail = size > FS_READ_MAX
335 return outputEndOf(isTail ? await tailOf($, path, size) : await $.fs.read(path), isTail)
336}
337
338/** What a job's output holds now: no new lines while its size stays, else its end. A job with a redirect file also looks at that file (see redirectRead). */
339async function readOutput($: EngineInterface, job: ShellJob & { outputFile: string }): Promise<TailRead> {
340 const stat = await $.fs.stat(job.outputFile)
341 const isAsked = job.failedReadBytes !== undefined || job.finalReadAskedAt !== undefined
342 const text = stat.size === job.outputBytes && !isAsked ? undefined : await endOf($, job.outputFile, stat.size)
343 const output: TailRead = { bytes: stat.size, mtimeMs: stat.mtimeMs, lines: text === undefined ? [] : displayLinesOf(text) }
344 return job.redirectFile === undefined ? output : await redirectRead($, job, job.redirectFile, output, text, isAsked)
345}
346
347/**
348 * The read of a job with a redirect file, after the read of its output (`text` when that read took its end). The row
349 * takes its lines from the file that changed last, by modification time, and from the output file when the two times
350 * are equal. The output file counts only while it holds a job line. Its time is its modification time once the job wrote
351 * to it since the last read, which the engine's closing line alone is not. Until then it keeps the time of the lines that
352 * the row shows from it, and it does not count while the row shows the redirect file. When the redirect file is missing
353 * or not a regular file, the output file shows if it holds a job line. The redirect file is read again when its size or
354 * its modification time changes. A read of it carries its size and time, which tailRead keeps as redirectBytes and
355 * lastOutputAt.
356 */
357async function redirectRead($: EngineInterface, job: ShellJob & { outputFile: string }, redirectFile: string, output: TailRead, text: string | undefined, isAsked: boolean): Promise<TailRead> {
358 const isRedirectShown = job.redirectBytes !== undefined
359 const isWritten = text !== undefined && output.bytes !== job.outputBytes && (job.outputBytes === undefined || jobBytesOf(text, output.bytes) > job.outputBytes)
360 const outputAt = isWritten ? output.mtimeMs : isRedirectShown ? undefined : job.lastOutputAt
361 /** Whether the output file holds a job line: in the lines read now, else in the line that the row shows from it. */
362 const hasJobLine = (lines: readonly string[]): boolean => (text === undefined && !isRedirectShown ? job.lastLine !== undefined : jobLinesOf(lines).length > 0)
363 let redirect: { size: number; mtimeMs: number }
364 try {
365 const stat = await $.fs.stat(redirectFile)
366 if (stat.kind !== 'file') throw new Error('not a regular file')
367 redirect = stat
368 } catch (error) {
369 // A row that shows the redirect file needs the output file's end to show it instead.
370 const lines = text === undefined && isRedirectShown ? displayLinesOf(await endOf($, job.outputFile, output.bytes)) : output.lines
371 if (hasJobLine(lines)) return { ...output, lines }
372 throw new RedirectFailure(error)
373 }
374 if (outputAt !== undefined && outputAt >= redirect.mtimeMs && hasJobLine(output.lines)) return output
375 try {
376 const isSame = isRedirectShown && redirect.size === job.redirectBytes && redirect.mtimeMs === job.lastOutputAt
377 const lines = isSame && !isAsked ? [] : displayLinesOf(await endOf($, redirectFile, redirect.size))
378 return { bytes: output.bytes, mtimeMs: redirect.mtimeMs, lines, redirectBytes: redirect.size }
379 } catch (error) {
380 throw new RedirectFailure(error)
381 }
382}
383
384/** Reads one shell job's output and applies it; only a failed stat, read, or tail sets the job's note and logs once per job and file. */
385async function readTail($: EngineInterface, job: ShellJob & { outputFile: string }, now: number): Promise<void> {
386 const path = job.outputFile
387 if (!isOutputFileOf(path, job.id)) {
388 report($, 'clock.every', `refused to read ${path}: it is not the output file of task ${job.id}`, `path|${job.id}`)
389 await writeJobs($, { at: now, ids: [job.id] }, jobs => readFailed(jobs, job.id, 'output unavailable', now))
390 return
391 }
392 let found: TailRead
393 try {
394 found = await readOutput($, job)
395 } catch (error) {
396 const isRedirect = error instanceof RedirectFailure
397 const failure = isRedirect ? error.failure : error
398 const bytes = failure instanceof TailFailure ? failure.bytes : undefined
399 const verb = bytes === undefined ? 'read' : 'tail'
400 report($, 'clock.every', `cannot ${verb} ${isRedirect ? 'the redirect file' : 'the output'} of task ${job.id}: ${messageOf(failure)}`, `${isRedirect ? 'redirect ' : ''}${verb}|${job.id}`)
401 await writeJobs($, { at: now, ids: [job.id] }, jobs => readFailed(jobs, job.id, bytes === undefined ? 'output unavailable' : 'tail failed', now, bytes))
402 return
403 }
404 await writeJobs($, { at: now, ids: [job.id] }, jobs => tailRead(jobs, job.id, found, now, isAnyRatio))
405}
406
407/** Applies `$.agent.list()` to agent jobs; a failed list logs once. */
408async function syncAgents($: EngineInterface, now: number, event = 'clock.every'): Promise<void> {
409 const agents = await $.agent.list().catch((error: unknown) => {
410 report($, event, `cannot list agents: ${messageOf(error)}`, 'agents')
411 return undefined
412 })
413 if (agents !== undefined) await writeJobs($, { at: now, ids: agents.map(agent => agent.id) }, jobs => agentsSynced(jobs, agents, now))
414}
415
416/** One ticker period: it marks the ticker alive, then refreshes unless a refresh still runs; a failure logs once per message. */
417async function tick($: EngineInterface): Promise<void> {
418 try {
419 const now = await $.clock.now()
420 lastTickAt = now
421 if (isTicking) return
422 isTicking = true
423 try {
424 await restoreCarry($)
425 await refresh($, now)
426 } finally {
427 isTicking = false
428 }
429 } catch (error) {
430 report($, 'clock.every', messageOf(error), `tick|${messageOf(error)}`)
431 }
432}
433
434/** The work of one tick: expire the wakeup, sync agents, read outputs (a pending final read too), redraw, and stop when nothing is live. */
435async function refresh($: EngineInterface, now: number): Promise<void> {
436 const seen = latest
437 const [{ items: jobs }, { pending: wakeup }] = await Promise.all([read($, JOBS), read($, WAKEUP)])
438 if (wakeupAfterTick(wakeup, now) !== wakeup) await writeWakeup($, now, current => wakeupAfterTick(current, now))
439 if (jobs.some(job => job.kind === 'agent' && isRunning(job))) await syncAgents($, now)
440 for (const job of readsDue(jobs, now)) await readTail($, job, now)
441 // Every write replaces `latest`: after a write during this tick, the next tick judges liveness on fresh state.
442 if (!isLive(jobs, wakeup, now) && latest === seen && (carry === null || carry.attempts >= CARRY_ATTEMPTS || carry.destination === undefined)) {
443 ticker?.cancel()
444 ticker = null
445 }
446 $.ui.invalidate('ui.render')
447}
448
449/** Runs a button of the band or the pane; a failure, and an Open whose pane waits unplaced, log to the transcript. */
450async function pressed($: EngineInterface, control: Control): Promise<void> {
451 try {
452 if (control === 'Open') {
453 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
454 if (!opened.isPlaced) $.ui.log(`downrange: ui.press: the Jobs pane waits: ${opened.reason}`)
455 } else if (control === 'Hide') {
456 const now = await $.clock.now()
457 await update($, BAND_HIDDEN_AT, () => now)
458 } else if (control === 'Clear finished') {
459 await writeJobs($, { at: await $.clock.now() }, finishedCleared)
460 } else {
461 await $.ui.close({ id: PANE_ID })
462 }
463 } catch (error) {
464 $.ui.log(`downrange: ui.press: ${control} failed: ${messageOf(error)}`)
465 }
466}
467
468/** The time, and the jobs and the wakeup in state; in a render hook, the reads subscribe the drawing. */
469async function stateNow($: EngineInterface): Promise<{ now: number; jobs: Job[]; wakeup: WakeupState }> {
470 const [now, { items: jobs }, wakeup] = await Promise.all([$.clock.now(), read($, JOBS), read($, WAKEUP)])
471 return { now, jobs, wakeup }
472}
473
474/** The /downrange table at the command's width. */
475async function tableNow($: EngineInterface, columns: number): Promise<string> {
476 const { now, jobs, wakeup: { pending: wakeup } } = await stateNow($)
477 return tableOf(jobs, wakeup, now, columns, false)
478}
479
480/** `/downrange pane`: close an open pane, else open it; where nothing draws, the pane is not placed, or a pane call fails, the reason and the table print. */
481async function togglePane($: EngineInterface, columns: number): Promise<CommandRunResult> {
482 try {
483 if ((await $.ui.panes()).some(pane => pane.id === PANE_ID)) {
484 await $.ui.close({ id: PANE_ID })
485 return {}
486 }
487 const hasSurface = (await $.session.surfaces()).length > 0
488 const opened = hasSurface ? await $.ui.open({ id: PANE_ID, title: PANE_TITLE }) : { isPlaced: false as const, reason: 'no surface draws in this session' }
489 if (opened.isPlaced) return {}
490 if (hasSurface) await $.ui.close({ id: PANE_ID })
491 $.ui.log(`command.run: the Jobs pane was not placed: ${opened.reason}`, { to: 'debug' })
492 return { text: `The Jobs pane is not shown here: ${opened.reason}.\n\n${await tableNow($, columns)}` }
493 } catch (error) {
494 $.ui.log(`command.run: the Jobs pane could not be toggled: ${messageOf(error)}`, { to: 'debug' })
495 return { text: `The Jobs pane could not be toggled: ${messageOf(error)}.\n\n${await tableNow($, columns)}` }
496 }
497}
498
499/** The local answer: the drop reason for an exact status prompt while a job runs, or undefined to pass it on. */
500async function statusAnswer($: EngineInterface, text: string): Promise<string | undefined> {
501 const normalized = normalizePrompt(text)
502 if (!isStatusPrompt(normalized)) return undefined
503 const [{ now, jobs, wakeup: { pending: wakeup } }, answered] = await Promise.all([stateNow($), read($, LAST_ANSWERED)])
504 if (!jobs.some(isRunning)) return undefined
505 if (answered !== null && answered.text === normalized && now - answered.at <= REPEAT_WINDOW_MS) {
506 await update($, LAST_ANSWERED, () => null)
507 return undefined
508 }
509 await update($, LAST_ANSWERED, () => ({ text: normalized, at: now }))
510 const seconds = REPEAT_WINDOW_MS / 1_000
511 return `Downrange answered this (not sent to Claude). Send it again within ${seconds} s to ask Claude.\n\n${tableOf(jobs, wakeup, now)}`
512}
513
514export const register: Register = (on, options) => {
515 isAnyRatio = options.genericRatios === true
516 on('session.start', async ($, e, next) => {
517 try {
518 await $.command.register({
519 name: COMMAND,
520 description: 'Background tasks of this session (not background sessions): progress, ETA, last output. "/downrange pane" toggles the live pane.',
521 argumentHint: '[pane]',
522 immediate: true,
523 })
524 } catch (error) {
525 $.ui.log(`downrange: session.start: /downrange was not registered: ${messageOf(error)}`)
526 }
527 const now = await $.clock.now()
528 await writeJobs($, null)
529 // After a reload, as a change of genericRatios or an update causes, rows follow the current option; with no row to change, nothing is written.
530 const { items } = await read($, JOBS)
531 if (reloaded(items, isAnyRatio, now).some((job, index) => job !== items[index])) await writeJobs($, { at: now }, jobs => reloaded(jobs, isAnyRatio, now))
532 await writeWakeup($, now, pending => pending)
533 return next(e)
534 }).catch(($, e, next) => passOn($, 'session.start', e, next))
535
536 on('session.end', ($, e, next) => {
537 const current = latest.sessionId === e.sessionId ? latest : undefined
538 if (current === undefined && carry?.destination !== e.sessionId) return next(e)
539 const jobs = current === undefined ? carry?.jobs : carry === null ? current.jobs : restoredJobs(carry.jobs, current.jobs)
540 const wakeup = current === undefined || (carry?.wakeup.updatedAt ?? -Infinity) > (current.wakeup.updatedAt ?? -Infinity) ? carry?.wakeup : current.wakeup
541 if (jobs !== undefined && wakeup !== undefined) carry = { jobs: carriedJobs(jobs), wakeup, attempts: 0, restoring: false }
542 return next(e)
543 }).catch(($, e, next) => passOn($, 'session.end', e, next))
544
545 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
546 const sessionId = await $.session.id()
547 if (e.session_id !== sessionId) return next(e)
548 if (carry !== null && carry.destination === undefined) carry.destination = sessionId
549 await ensureTicker($)
550 await restoreCarry($)
551 return next(e)
552 }).catch(($, e, next) => passOn($, 'classic.SessionStart{source=clear|resume|fork}', e, next))
553
554 on('command.run', { command: COMMAND }, async ($, e) => {
555 const argument = e.args.trim()
556 if (argument === '') return { text: await tableNow($, e.presentation.columns) }
557 if (argument === 'pane') return togglePane($, e.presentation.columns)
558 return { text: 'Usage: /downrange [pane]' }
559 }).catch(($, e, next) => {
560 if (next.error.kind === 're-entry') return { text: '/downrange is unavailable during a nested call.' }
561 reportHook($, 'command.run{command=downrange}', next.error)
562 return { text: `/downrange could not run: ${failureText(next.error)}.` }
563 })
564
565 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
566 const ran = await next(e)
567 if (ran.deny !== undefined || ran.isError === true) return ran
568 const id = ran.result.backgroundTaskId
569 if (id === undefined || id === '') return ran
570 const now = await $.clock.now()
571 const outputFile = outputFileOf(ran.text ?? '', id)
572 if (outputFile === undefined) {
573 report($, 'tool.call', `no output file in the result of task ${id}; its last line is unavailable`, `path|${id}`)
574 }
575 const note = shellLaunchNote(ran.result, outputFile !== undefined)
576 const redirectFile = await redirectFor($, id, e.command, e.agentId)
577 await writeJobs($, { at: now, ids: [id] }, jobs => launched(jobs, shellJob({ id, command: e.command, description: e.description, outputFile, redirectFile, note, ownerAgentId: e.agentId, endsWithAgent: ran.result.backgroundEndsWithFinalResponse === true }, now)))
578 // The history read comes after the launch, so an end or a clear that comes meanwhile is never undone.
579 const history = await historyFor($, e.command)
580 if (history !== undefined) await writeJobs($, { at: now, ids: [id] }, jobs => historyAdded(jobs, id, history))
581 return ran
582 }).catch(($, e, next) => passOn($, 'tool.call{tool=Bash}', e, next))
583
584 on('tool.call', { tool: 'Monitor' }, async ($, e, next) => {
585 const ran = await next(e)
586 if (ran.deny !== undefined || ran.isError === true) return ran
587 const now = await $.clock.now()
588 const job = monitorJob({ id: ran.result.taskId, description: e.description, timeoutMs: ran.result.timeoutMs, isPersistent: ran.result.persistent === true }, now)
589 await writeJobs($, { at: now, ids: [job.id] }, jobs => launched(jobs, job))
590 return ran
591 }).catch(($, e, next) => passOn($, 'tool.call{tool=Monitor}', e, next))
592
593 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
594 const ran = await next(e)
595 if (ran.deny !== undefined || ran.isError === true) return ran
596 // A foreground call answers `completed`, and a plugin's own call an AgentCallRecord (no status) once the agent settled: nothing runs.
597 const result = ran.result
598 if (!('status' in result) || (result.status !== 'async_launched' && result.status !== 'remote_launched' && result.status !== 'teammate_spawned')) return ran
599 const now = await $.clock.now()
600 // A teammate in a terminal pane of its own runs no loop here: its id is its address in its team.
601 const isOwnPane = result.status === 'teammate_spawned' && (result.agentId ?? result.agent_id) === result.teammate_id
602 const job =
603 result.status === 'teammate_spawned'
604 ? agentJob({ id: result.agentId ?? result.agent_id, description: e.description, ...(isOwnPane ? { note: 'own pane', runsElsewhere: true } : {}) }, now)
605 : result.status === 'async_launched'
606 ? agentJob({ id: result.agentId, description: result.description }, now)
607 : agentJob({ id: result.taskId, description: result.description, note: 'remote', runsElsewhere: true }, now)
608 await writeJobs($, { at: now, ids: [job.id] }, jobs => launched(jobs, job))
609 return ran
610 }).catch(($, e, next) => passOn($, 'tool.call{tool=Agent}', e, next))
611
612 on('tool.call', { tool: 'Workflow' }, async ($, e, next) => {
613 const ran = await next(e)
614 if (ran.deny !== undefined || ran.isError === true || ran.result.error !== undefined) return ran
615 const now = await $.clock.now()
616 const job = workflowJob({ id: ran.result.taskId, name: ran.result.workflowName }, now)
617 await writeJobs($, { at: now, ids: [job.id] }, jobs => launched(jobs, job))
618 return ran
619 }).catch(($, e, next) => passOn($, 'tool.call{tool=Workflow}', e, next))
620
621 on('tool.call', { tool: 'ScheduleWakeup' }, async ($, e, next) => {
622 const ran = await next(e)
623 if (ran.deny !== undefined || ran.isError === true) return ran
624 const now = await $.clock.now()
625 const wakeup = ran.result.stopped === true ? null : wakeupOf(ran.result.scheduledFor, e.reason, now)
626 await writeWakeup($, now, () => wakeup, true)
627 return ran
628 }).catch(($, e, next) => passOn($, 'tool.call{tool=ScheduleWakeup}', e, next))
629
630 on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
631 const ran = await next(e)
632 if (ran.deny !== undefined || ran.isError === true) return ran
633 const now = await $.clock.now()
634 const id = ran.result.task_id
635 await writeJobs($, { at: now, ids: [id] }, jobs => stoppedJob(jobs, id, now))
636 return ran
637 }).catch(($, e, next) => passOn($, 'tool.call{tool=TaskStop}', e, next))
638
639 on('tool.call', async ($, e, next) => {
640 const agentId = e.agentId
641 if (agentId === undefined) return next(e)
642 if (!(await read($, JOBS)).items.some(job => job.kind === 'agent' && job.id === agentId)) return next(e)
643 const started = await $.clock.now()
644 const summary = activityOf(String(e.tool), e)
645 await writeJobs($, { at: started, ids: [agentId], kind: 'activity' }, jobs => activityStarted(jobs, agentId, summary, started))
646 // A call that rejects counts as failed; the in-flight count drops whatever happens.
647 let isFailure = true
648 try {
649 const ran = await next(e)
650 isFailure = ran.deny !== undefined || ran.isError === true
651 return ran
652 } finally {
653 const settled = await $.clock.now()
654 await writeJobs($, { at: settled, ids: [agentId], kind: 'activity' }, jobs => activityEnded(jobs, agentId, isFailure, settled))
655 }
656 }).catch(($, e, next) => passOn($, 'tool.call', e, next))
657
658 on('session.append', async ($, e, next) => {
659 if (e.agentId === undefined || e.origin.kind !== 'task-notification') return next(e)
660 const now = await $.clock.now()
661 const stored = await next(e)
662 if (stored.deny !== undefined || stored.uuid !== e.uuid) return stored
663 const text = stored.message.content.flatMap(block => block.type === 'text' && 'text' in block && typeof block.text === 'string' ? [block.text] : []).join('\n')
664 await applyNotices($, text, JSON.stringify([e.agentId, stored.uuid]), now)
665 return stored
666 }).catch(($, e, next) => passOn($, 'session.append', e, next))
667
668 on('turn.complete', async ($, e, next) => {
669 const now = await $.clock.now()
670 const result = await next(e)
671 if (e.agentId !== undefined) {
672 if (e.reason === 'answer' && !e.isAborted) {
673 const agentId = e.agentId
674 await writeJobs($, { at: now }, jobs => ownerAnswered(jobs, agentId, now))
675 }
676 await syncAgents($, await $.clock.now(), 'turn.complete')
677 }
678 return result
679 }).catch(($, e, next) => passOn($, 'turn.complete', e, next))
680
681 on('prompt.submit', async ($, e, next) => {
682 if (e.origin.kind === 'task-notification') {
683 await applyNotices($, e.text)
684 } else if (e.origin.kind === 'scheduled-trigger') {
685 const now = await $.clock.now()
686 await writeWakeup($, now, wakeup => wakeupAfterTrigger(wakeup, now))
687 } else if (options.answerStatusPrompts === true && e.origin.kind === 'composer' && e.turnId === undefined && e.attachments === undefined) {
688 const answer = await statusAnswer($, e.text)
689 if (answer !== undefined) return { drop: answer }
690 }
691 return next(e)
692 }).catch(($, e, next) => passOn($, 'prompt.submit', e, next))
693
694 on('classic.Stop', async ($, e, next) => {
695 await reconcile($, 'classic.Stop', e)
696 if ((await read($, JOBS)).items.some(job => job.kind === 'agent')) await syncAgents($, await $.clock.now(), 'classic.Stop')
697 return next(e)
698 }).catch(($, e, next) => passOn($, 'classic.Stop', e, next))
699
700 on('classic.SubagentStop', async ($, e, next) => {
701 await reconcile($, 'classic.SubagentStop', e)
702 if ((await read($, JOBS)).items.some(job => job.kind === 'agent')) await syncAgents($, await $.clock.now(), 'classic.SubagentStop')
703 return next(e)
704 }).catch(($, e, next) => passOn($, 'classic.SubagentStop', e, next))
705
706 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
707 if (options.band === false || e.props.hasSurvey) return next(e)
708 const [{ now, jobs, wakeup: { pending: wakeup } }, hiddenAt] = await Promise.all([stateNow($), read($, BAND_HIDDEN_AT)])
709 if (!isLive(jobs, wakeup, now) || isBandHidden(jobs, hiddenAt)) return next(e)
710 const actions = {
711 open: () => {
712 void pressed($, 'Open')
713 },
714 hide: () => {
715 void pressed($, 'Hide')
716 },
717 }
718 return bandTree($.ui.resolve(e), bandOf(jobs, wakeup, now, e.props.bodyColumns), actions, await next(e))
719 }).catch(($, e, next) => passOn($, 'ui.render{component=AbovePrompt}', e, next))
720
721 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
722 const { now, jobs, wakeup: { pending: wakeup } } = await stateNow($)
723 const actions = {
724 clear: () => {
725 void pressed($, 'Clear finished')
726 },
727 close: () => {
728 void pressed($, 'Close')
729 },
730 }
731 return paneTree($.ui.resolve(e), paneModelOf(jobs, wakeup, now, e.props.bodyColumns), actions)
732 }).catch(($, e, next) => {
733 if (next.error.kind === 're-entry') return { type: 'Text', props: {}, children: ['The Jobs pane is unavailable during a nested render.'] }
734 reportHook($, 'ui.render{component=Pane, requestId=downrange}', next.error)
735 return failedPaneTree($.ui.resolve(e), `The Jobs pane could not draw: ${failureText(next.error)}`)
736 })
737}
738hooks/drawing.tsx 114 lines1import type { ElementTable, RenderElement } from 'claude-code'
2
3import type { BandPart, PaneModel, Row } from './view'
4
5type Ui = Pick<ElementTable, 'Box' | 'Text' | 'Button'>
6
7const PART_STYLES: Readonly<Record<BandPart['tone'], { color?: string; dimColor?: boolean }>> = {
8 plain: {},
9 failed: { color: 'red' },
10 done: { dimColor: true },
11}
12
13/** The band: one line of counts and snippets, Open and Hide, and what other mods drew beneath. */
14export function bandTree(ui: Ui, parts: readonly BandPart[], actions: { open: () => void; hide: () => void }, below: RenderElement): RenderElement {
15 const { Box, Text, Button } = ui
16 return (
17 <Box flexDirection="column">
18 <Box key="band" flexDirection="row" gap={1}>
19 <Box flexGrow={1}>
20 <Text wrap="truncate-end">
21 {parts.map(part => (
22 <Text {...PART_STYLES[part.tone]}>{part.text}</Text>
23 ))}
24 </Text>
25 </Box>
26 <Button key="open" label="Open" hotkey="j" onPress={actions.open} />
27 <Button key="hide" label="Hide" hotkey="h" dimColor onPress={actions.hide} />
28 </Box>
29 {below}
30 </Box>
31 )
32}
33
34const TONE_STYLES: Readonly<Record<Row['tone'], { color?: string; dimColor?: boolean }>> = {
35 running: { color: 'cyan' },
36 completed: { color: 'green' },
37 failed: { color: 'red' },
38 stopped: { color: 'yellow' },
39 gone: { dimColor: true },
40}
41
42const EMPTY = 'No background work yet. Jobs show here when Claude runs a command in the background, starts a Monitor, a background agent, or a workflow.'
43
44function rowTree(ui: Ui, row: Row): RenderElement {
45 const { Box, Text } = ui
46 return (
47 <Box key={`job:${row.id}`} flexDirection="column">
48 <Box flexDirection="row" gap={1}>
49 <Text {...TONE_STYLES[row.tone]}>{row.glyph}</Text>
50 <Text bold wrap="truncate-end">
51 {row.title}
52 </Text>
53 <Text dimColor>{row.kind}</Text>
54 <Text>{row.age}</Text>
55 {row.bar === undefined ? null : <Text color="cyan">{row.bar}</Text>}
56 {row.progress === undefined ? null : <Text>{row.progress}</Text>}
57 {row.eta === undefined ? null : <Text {...(row.isSlow ? { color: 'yellow' } : {})}>{row.eta}</Text>}
58 {row.flag === undefined ? null : <Text color="yellow">{row.flag}</Text>}
59 {row.note === undefined ? null : <Text dimColor>{row.note}</Text>}
60 </Box>
61 {row.last === undefined ? null : (
62 <Text dimColor wrap="truncate-end">
63 {` └ ${row.last}`}
64 </Text>
65 )}
66 </Box>
67 )
68}
69
70/** The pane: counts and buttons, then running and finished jobs; the empty text only when it has nothing to list. */
71export function paneTree(ui: Ui, model: PaneModel, actions: { clear: () => void; close: () => void }): RenderElement {
72 const { Box, Text, Button } = ui
73 const isEmpty = model.header === '' && model.running.length === 0 && model.ended.length === 0
74 return (
75 <Box flexDirection="column">
76 <Box key="header" flexDirection="row" gap={1}>
77 <Box flexGrow={1}>
78 {isEmpty ? (
79 <Text wrap="wrap">{EMPTY}</Text>
80 ) : (
81 <Text bold wrap="truncate-end">
82 {model.header}
83 </Text>
84 )}
85 </Box>
86 <Button key="clear" label="Clear finished" hotkey="c" dimColor onPress={actions.clear} />
87 <Button key="close" label="Close" hotkey="x" role="dismiss" onPress={actions.close} />
88 </Box>
89 {model.running.length === 0 ? null : (
90 <Text bold dimColor>
91 Running
92 </Text>
93 )}
94 {model.running.map(row => rowTree(ui, row))}
95 {model.ended.length === 0 ? null : (
96 <Text bold dimColor>
97 Finished
98 </Text>
99 )}
100 {model.ended.map(row => rowTree(ui, row))}
101 </Box>
102 )
103}
104
105/** What the pane draws when its hook failed: the cause, in red. */
106export function failedPaneTree(ui: Ui, text: string): RenderElement {
107 const { Text } = ui
108 return (
109 <Text color="red" wrap="wrap">
110 {text}
111 </Text>
112 )
113}
114hooks/history.ts 42 lines1import { MAX_KEYS, MAX_RUNS } from './limits'
2
3/** Past run durations by history key, as the store keeps them. */
4export type Durations = Record<string, { runs: number[]; at: number }>
5
6const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
7
8const isEntry = (value: unknown): boolean =>
9 isRecord(value) && typeof value.at === 'number' && Array.isArray(value.runs) && value.runs.every(run => typeof run === 'number')
10
11const isDurations = (value: unknown): value is Durations => isRecord(value) && Object.values(value).every(isEntry)
12
13/** The NUL character that separates folder and command in a history key, built from its code so that no string literal holds a control character. */
14const NUL = String.fromCharCode(0)
15
16/** The first 16 hex characters of SHA-256 over folder, NUL, and command: no command text is kept. */
17export async function historyKeyOf(cwd: string, command: string): Promise<string> {
18 const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`${cwd}${NUL}${command}`)))
19 return [...digest.slice(0, 8)].map(byte => byte.toString(16).padStart(2, '0')).join('')
20}
21
22/** The stored durations: {} when nothing is stored, undefined for a value of the wrong shape. */
23export function durationsOf(value: unknown): Durations | undefined {
24 if (value === undefined) return {}
25 return isDurations(value) ? value : undefined
26}
27
28/** The median of a key's runs, or undefined when it has none. */
29export function usualOf(durations: Durations, key: string): number | undefined {
30 const runs = [...(durations[key]?.runs ?? [])].sort((a, b) => a - b)
31 if (runs.length === 0) return undefined
32 const middle = runs.slice(Math.floor((runs.length - 1) / 2), Math.floor(runs.length / 2) + 1)
33 return middle.reduce((sum, run) => sum + run, 0) / middle.length
34}
35
36/** The durations with one more run for key: MAX_RUNS runs per key, the MAX_KEYS most recent keys. */
37export function recorded(durations: Durations, key: string, durationMs: number, now: number): Durations {
38 const runs = [...(durations[key]?.runs ?? []), durationMs].slice(-MAX_RUNS)
39 const entries = Object.entries({ ...durations, [key]: { runs, at: now } })
40 return Object.fromEntries(entries.sort(([, a], [, b]) => b.at - a.at).slice(0, MAX_KEYS))
41}
42hooks/jobs.ts 771 lines1import type { AgentInfo, ClassicEventOf } from 'claude-code'
2
3import type { AgentJob, Job, JobsState, JobState, MonitorJob, Progress, ShellJob, Wakeup, WorkflowJob } from '../types'
4import {
5 ACTIVITY_MAX,
6 LARGE_FILE_BYTES,
7 LARGE_READ_EVERY_MS,
8 LINE_MAX,
9 MAX_BARRIERS,
10 MAX_FINISHED,
11 NOTE_MAX,
12 NOTICE_ROWS_MAX,
13 PROGRESS_LINES,
14 READ_EVERY_MS,
15 RECENT_MS,
16 SNAPSHOT_SLACK_MS,
17 STATUS_WORD_MAX,
18 TITLE_MAX,
19 TRIGGER_EARLY_MS,
20 WAKEUP_STALE_MS,
21} from './limits'
22import type { Notice } from './notices'
23import { anchorOf, clip, displayLinesOf, isOutputFileOf, jobLinesOf, oneLine, progressOf } from './output'
24
25/** The launch note of a command that its synchronous subagent ends when it answers. */
26const ENDS_WITH_AGENT = 'ends with its agent'
27/** The note of a job that left the engine's list with no notification. */
28const GONE_NOTE = 'ended without a report'
29/** The same, for a command that ended with its synchronous subagent. */
30const AGENT_GONE_NOTE = 'ended with its agent'
31/** Snapshot notes, which a later notification replaces. */
32const GONE_NOTES: ReadonlySet<string> = new Set([GONE_NOTE, AGENT_GONE_NOTE])
33
34/** Whether the job still runs. */
35export const isRunning = (job: Job): boolean => job.state === 'running'
36
37const titled = (text: string): string => clip(oneLine(text), TITLE_MAX)
38
39/** Keeps every running job and the MAX_FINISHED newest ended jobs, in their order. */
40export function trimmed(jobs: readonly Job[]): Job[] {
41 const kept = new Set(
42 jobs
43 .filter(job => !isRunning(job))
44 .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
45 .slice(0, MAX_FINISHED)
46 .map(job => job.id),
47 )
48 return jobs.filter(job => isRunning(job) || kept.has(job.id))
49}
50
51/** Kinds whose task id never runs again after its end; an agent can resume under its id. */
52const FINAL_KINDS: ReadonlySet<Job['kind']> = new Set(['shell', 'monitor', 'workflow'])
53
54/**
55 * Whether a notification states a final end: a known end word other than `blocked`, which a remote task can leave.
56 * A task of type `remote_agent`, such as a remote workflow, keeps the ordering rule of other inputs.
57 */
58function isFinalNotice(notice: Notice): boolean {
59 return notice.taskType !== 'remote_agent' && endStateOf(notice.status) !== undefined && notice.status?.toLowerCase() !== 'blocked'
60}
61
62/** Whether a row takes a final end whatever the mod saw since the end's stamp: a running or `gone` shell, Monitor, or workflow. */
63const takesFinalEnd = (job: Job): boolean => FINAL_KINDS.has(job.kind) && (isRunning(job) || job.state === 'gone')
64
65/** Whether this notification's end applies to the job whatever the mod saw since its stamp. */
66function isFinalEnd(job: Job, notice: Notice): boolean {
67 return takesFinalEnd(job) && isFinalNotice(notice)
68}
69
70/** The ids of a prompt's blocks that state a final end. */
71export function finalIdsOf(notices: readonly Notice[]): string[] {
72 return notices.filter(isFinalNotice).flatMap(notice => notice.ids)
73}
74
75/** Whether an end is at least as recent as the job's latest running observation and end. */
76function canEnd(job: Job, now: number): boolean {
77 return now >= Math.max(job.lastRunningAt ?? job.startedAt, job.lastEndedAt ?? job.startedAt)
78}
79
80/** A current or final end preserves the first end time, advances the latest, replaces snapshot notes, and asks for a final shell read. */
81function endedJob<T extends Job>(job: T, state: JobState, now: number, detail: { exitCode?: number; note?: string }, isFinal = false): T {
82 if (!isFinal && !canEnd(job, now)) return job
83 return {
84 ...job,
85 state,
86 omittedAt: undefined,
87 endedAt: Math.min(job.endedAt ?? now, now),
88 lastEndedAt: Math.max(job.lastEndedAt ?? now, now),
89 exitCode: detail.exitCode ?? job.exitCode,
90 note: detail.note ?? (job.note !== undefined && GONE_NOTES.has(job.note) ? undefined : job.note),
91 ...(job.kind === 'shell' ? { finalReadAskedAt: now } : {}),
92 }
93}
94
95/** The list with this job added, or replacing the one with its id. */
96function withJob(jobs: readonly Job[], job: Job): Job[] {
97 return [...jobs.filter(one => one.id !== job.id), job]
98}
99
100/** Refines an inferred shell without carrying shell-only metadata into the Monitor. */
101function asMonitor(job: ShellJob): MonitorJob {
102 const { kind, outputFile, redirectFile, redirectBytes, outputBytes, failedReadBytes, lastReadAt, successfulReadAt, finalReadAskedAt, usualMs, historyKey, ownerAgentId, endsWithAgent, ...details } = job
103 return { ...details, kind: 'monitor', eventCount: 0, note: job.note === 'no output file' || READ_NOTES.has(job.note ?? '') ? undefined : job.note }
104}
105
106/** A launch's job laid over a job of its kind that a notification, a Monitor event, or a snapshot brought first: the launch adds what only it knows, and what the mod learned since stays. */
107function withLaunch(known: Job, launch: Job): Job {
108 if (known.kind === 'shell' && launch.kind === 'monitor') known = { ...launch, ...asMonitor(known) }
109 if (known.kind === 'task' && launch.kind !== 'task') {
110 const { kind, taskType, ...details } = known
111 known = { ...launch, ...details }
112 if (known.kind === 'shell' && !isRunning(known)) known.finalReadAskedAt = known.lastEndedAt ?? known.endedAt
113 }
114 const identity = { title: launch.title, startedAt: launch.startedAt, isStartSeen: true, omittedAt: undefined, note: known.note ?? launch.note,
115 launchNote: launch.launchNote ?? known.launchNote }
116 if (known.kind === 'shell' && launch.kind === 'shell') return { ...known, ...identity, outputFile: launch.outputFile ?? known.outputFile,
117 redirectFile: launch.redirectFile ?? known.redirectFile,
118 ownerAgentId: launch.ownerAgentId ?? known.ownerAgentId, endsWithAgent: launch.endsWithAgent ?? known.endsWithAgent }
119 if (known.kind === 'agent' && launch.kind === 'agent') return { ...known, ...identity, runsElsewhere: launch.runsElsewhere ?? known.runsElsewhere }
120 return known.kind === 'monitor' && launch.kind === 'monitor' ? { ...known, ...identity, deadline: launch.deadline } : { ...known, ...identity }
121}
122
123/** The list after a launch; observed state stays and kinds can be refined. */
124export function launched(jobs: readonly Job[], launch: Job): Job[] {
125 const known = jobs.find(job => job.id === launch.id)
126 if (known === undefined) return withJob(jobs, launch)
127 const refinesShell = known.kind === 'shell' && !known.isStartSeen && launch.kind === 'monitor'
128 return known.kind === launch.kind || known.kind === 'task' || refinesShell ? withJob(jobs, withLaunch(known, launch)) : [...jobs]
129}
130
131/** A background shell command, as its launch result describes it. */
132export function shellJob(launch: { id: string; command: string; description?: string; outputFile?: string; redirectFile?: string; note?: string; ownerAgentId?: string; endsWithAgent?: boolean }, now: number): ShellJob {
133 return {
134 kind: 'shell',
135 id: launch.id,
136 title: titled(launch.description || launch.command),
137 state: 'running',
138 startedAt: now,
139 isStartSeen: true,
140 outputFile: launch.outputFile,
141 redirectFile: launch.redirectFile,
142 ownerAgentId: launch.ownerAgentId,
143 endsWithAgent: launch.endsWithAgent,
144 note: launch.note,
145 launchNote: launch.note,
146 }
147}
148
149/** The list with a launch's history key and usual duration on the shell job that holds its id; a job the list no longer holds is not added back. */
150export function historyAdded(jobs: readonly Job[], id: string, history: { historyKey: string; usualMs?: number }): Job[] {
151 return jobs.map(job => (job.kind === 'shell' && job.id === id ? { ...job, historyKey: history.historyKey, usualMs: history.usualMs } : job))
152}
153
154/** The note a shell launch shows: a missing output file first, then an end tied to a subagent, then how the command went to the background. */
155export function shellLaunchNote(
156 result: { timedOutAfterMs?: number; backgroundedByUser?: boolean; backgroundEndsWithFinalResponse?: boolean },
157 hasOutputFile: boolean,
158): string | undefined {
159 if (!hasOutputFile) return 'no output file'
160 if (result.backgroundEndsWithFinalResponse === true) return ENDS_WITH_AGENT
161 if (result.timedOutAfterMs !== undefined) return 'moved after timeout'
162 return result.backgroundedByUser === true ? 'backgrounded by you' : undefined
163}
164
165/** A Monitor watch; a non-persistent one ends at its deadline. */
166export function monitorJob(launch: { id: string; description: string; timeoutMs: number; isPersistent: boolean }, now: number): MonitorJob {
167 return {
168 kind: 'monitor',
169 id: launch.id,
170 title: titled(launch.description),
171 state: 'running',
172 startedAt: now,
173 isStartSeen: true,
174 eventCount: 0,
175 deadline: launch.timeoutMs > 0 && !launch.isPersistent ? now + launch.timeoutMs : undefined,
176 }
177}
178
179/** A background or remote subagent. */
180export function agentJob(launch: { id: string; description: string; note?: string; runsElsewhere?: boolean }, now: number): AgentJob {
181 return {
182 kind: 'agent',
183 id: launch.id,
184 title: titled(launch.description),
185 state: 'running',
186 startedAt: now,
187 isStartSeen: true,
188 runsElsewhere: launch.runsElsewhere,
189 toolCount: 0,
190 inFlight: 0,
191 errorStreak: 0,
192 note: launch.note,
193 launchNote: launch.note,
194 }
195}
196
197/** A background workflow run. */
198export function workflowJob(launch: { id: string; name?: string }, now: number): WorkflowJob {
199 return { kind: 'workflow', id: launch.id, title: titled(launch.name || 'workflow'), state: 'running', startedAt: now, isStartSeen: true }
200}
201
202/** The pending wakeup of a dynamic /loop, set at `now`. */
203export function wakeupOf(dueAt: number, reason: string | undefined, now: number): Wakeup {
204 return { dueAt, reason: clip(oneLine(reason || 'wakeup'), NOTE_MAX), setAt: now }
205}
206
207/** The list after TaskStop stopped the job with this id; an ended job stays as it is. */
208export function stoppedJob(jobs: readonly Job[], id: string, now: number): Job[] {
209 return jobs.map(job => (job.id === id && isRunning(job) ? endedJob(job, 'stopped', now, {}) : job))
210}
211
212/** One in-flight task in a Stop or SubagentStop snapshot. */
213export type SnapshotTask = NonNullable<ClassicEventOf['classic.Stop']['background_tasks']>[number]
214
215/** One finished run for the duration history. */
216export type HistoryRun = { key: string; durationMs: number }
217
218/** The end states of the engine's task status words; a remote task blocked on input has ended for this session, and the engine marks it failed. */
219const NOTICE_STATES: ReadonlyMap<string, JobState> = new Map([
220 ['completed', 'completed'],
221 ['failed', 'failed'],
222 ['blocked', 'failed'],
223 ['killed', 'stopped'],
224 ['stopped', 'stopped'],
225 ['cancelled', 'stopped'],
226])
227const SNAPSHOT_KINDS: ReadonlyMap<string, Job['kind']> = new Map([
228 ['shell', 'shell'],
229 ['subagent', 'agent'],
230 ['monitor', 'monitor'],
231 ['workflow', 'workflow'],
232])
233
234/** The state a notification status ends a job in, in any letter case, or undefined for another word or none. */
235function endStateOf(status: string | undefined): JobState | undefined {
236 return status === undefined ? undefined : NOTICE_STATES.get(status.toLowerCase())
237}
238
239/** The quote that closes a description: the engine's own words follow it, as in `"build" completed (exit code 0)` or `"watch" script failed`. */
240const DESCRIPTION_END = /" (?:completed|failed|finished|stopped|was|is|script)\b/g
241
242/**
243 * A summary split at its quoted description, `<prefix> "<description>" <engine words>`, so the description's
244 * words cannot pose as the engine's own. A matching known title decides the boundary. Otherwise a reason matching
245 * the notification status starts after the description; without one, the last quote followed by an engine verb closes it.
246 */
247function summaryParts(notice: Notice, title?: string): { description?: string; words: string } {
248 const text = notice.summary ?? ''
249 const state = endStateOf(notice.status)
250 const open = text.indexOf('"')
251 const verbs = [...text.matchAll(DESCRIPTION_END)].filter(match => match.index > open)
252 const known = title === undefined ? undefined : verbs.find(match => text.slice(open + 1, match.index) === title)
253 const reason = verbs.find(match =>
254 state === 'failed' && /^" (?:failed|is blocked): /.test(text.slice(match.index)) ||
255 state === 'stopped' && /^" was stopped: /.test(text.slice(match.index)))
256 const close = (known ?? reason ?? verbs.at(-1))?.index ?? text.lastIndexOf('"')
257 return open === -1 || close <= open ? { words: text } : { description: text.slice(open + 1, close), words: text.slice(close + 1) }
258}
259
260/** The exit code a summary's engine words state, `exit code N` or `(exit N)`; the last one wins. */
261function exitCodeOf(words: string): number | undefined {
262 const code = [...words.matchAll(/exit code (\d+)|\(exit (\d+)\)/g)].at(-1)
263 return code === undefined ? undefined : Number(code[1] ?? code[2])
264}
265
266/**
267 * The end detail a notification's summary states: a known end cause or a failure reason. A block's `<note>` is not
268 * read: the engine puts its guidance for Claude there, not an end detail.
269 */
270function endNoteOf(summary: string): string | undefined {
271 if (summary.includes('background time limit')) return 'time limit'
272 if (summary.includes('low on memory')) return 'low memory'
273 if (summary.includes('-turn limit')) return 'turn limit'
274 const reason = /(?:failed|was stopped|is blocked): ([\s\S]+)$/.exec(summary)?.[1]
275 if (reason !== undefined) return clip(oneLine(reason), NOTE_MAX)
276 return summary.includes('previous session') ? 'previous session' : undefined
277}
278
279/** A job's kind from the summary's start, as the engine words its notices: a previous session's restored tasks start `Background shell command`, `Background agent`, or `Background workflow`. */
280function kindOf(summary: string): Job['kind'] {
281 if (/^Background (?:shell )?command/.test(summary)) return 'shell'
282 if (/^(?:Background agent|Agent)/.test(summary)) return 'agent'
283 if (summary.startsWith('Monitor')) return 'monitor'
284 return /^(?:Background workflow|Dynamic workflow|Workflow)/.test(summary) ? 'workflow' : 'task'
285}
286
287/** The fields of a job the mod first sees running or ended, with no launch result. */
288function unseen(id: string, title: string, now: number) {
289 return { id, title: titled(title), state: 'running' as const, startedAt: now, isStartSeen: false }
290}
291
292function freshMonitor(id: string, title: string, now: number): MonitorJob {
293 return { kind: 'monitor', ...unseen(id, title, now), eventCount: 0 }
294}
295
296function freshJob(kind: Job['kind'], id: string, title: string, now: number): Job {
297 switch (kind) {
298 case 'shell':
299 return { kind, ...unseen(id, title, now) }
300 case 'monitor':
301 return freshMonitor(id, title, now)
302 case 'agent':
303 return { kind, ...unseen(id, title, now), toolCount: 0, inFlight: 0, errorStreak: 0 }
304 case 'workflow':
305 return { kind, ...unseen(id, title, now) }
306 case 'task':
307 return { kind, ...unseen(id, title, now), taskType: 'task' }
308 }
309}
310
311function ended(jobs: readonly Job[], notice: Notice, state: JobState, now: number): Job[] {
312 return notice.ids.reduce<Job[]>((list, id) => {
313 let known = list.find(job => job.id === id) ?? freshJob(kindOf(notice.summary ?? ''), id, summaryParts(notice).description || id, now)
314 if (known.kind === 'shell' && !known.isStartSeen && kindOf(notice.summary ?? '') === 'monitor') known = asMonitor(known)
315 const { words } = summaryParts(notice, known.title)
316 const detail = { exitCode: exitCodeOf(words), note: endNoteOf(words) }
317 return withJob(list, endedJob(known, state, now, detail, isFinalEnd(known, notice)))
318 }, [...jobs])
319}
320
321function monitorEvent(jobs: readonly Job[], notice: Notice, event: string, now: number, isAnyRatio: boolean): Job[] {
322 const lines = displayLinesOf(event)
323 const progress = progressOf(lines.slice(-PROGRESS_LINES), now, isAnyRatio)
324 const lastLine = lines.at(-1)
325 return notice.ids.reduce<Job[]>((list, id) => {
326 let known = list.find(job => job.id === id)
327 if (known?.kind === 'shell' && !known.isStartSeen) {
328 known = asMonitor(known)
329 }
330 if (known !== undefined && known.kind !== 'monitor') return list
331 const current = known ?? freshMonitor(id, summaryParts(notice).description || id, now)
332 const monitor = { ...current, eventCount: current.eventCount + 1 }
333 return withJob(list, {
334 ...monitor,
335 omittedAt: undefined,
336 lastOutputAt: now,
337 lastLine: lastLine === undefined ? monitor.lastLine : clip(lastLine, LINE_MAX),
338 progress: progress ?? monitor.progress,
339 anchor: progress === undefined ? monitor.anchor : anchorOf(monitor.anchor, progress, monitor.progress),
340 })
341 }, [...jobs])
342}
343
344/** Applies the status and output in a notification; isAnyRatio (the `genericRatios` option) lets any ratio in a Monitor event count as progress. */
345export function noticed(jobs: readonly Job[], notice: Notice, now: number, isAnyRatio = false): Job[] {
346 const state = endStateOf(notice.status)
347 const updated = state === undefined ? [...jobs] : ended(jobs, notice, state, now)
348 const refined = notice.event === undefined ? updated : monitorEvent(updated, notice, notice.event, now, isAnyRatio)
349 return refined.map(job => {
350 if (job.kind !== 'shell' || !notice.ids.includes(job.id) || notice.outputFile === undefined || !isOutputFileOf(notice.outputFile, job.id)) return job
351 return { ...job, outputFile: notice.outputFile, note: job.note === 'no output file' ? undefined : job.note,
352 launchNote: job.launchNote === 'no output file' ? undefined : job.launchNote, finalReadAskedAt: isRunning(job) ? job.finalReadAskedAt : now }
353 })
354}
355
356/**
357 * Why a block that names a task changes no job: a status word the mod does not know. A block with no task id or no
358 * status is the engine's other news (an artifact's event, a stalled command, an agent resumed), which changes no job
359 * and is no problem.
360 */
361export function noticeProblemOf(notice: Notice): { cause: string; once: string } | undefined {
362 const id = notice.ids[0]
363 if (id === undefined || notice.status === undefined || endStateOf(notice.status) !== undefined) return undefined
364 const word = clip(oneLine(notice.status), STATUS_WORD_MAX)
365 return { cause: `unknown notification status "${word}" for task ${id}`, once: `status|${word}` }
366}
367
368/**
369 * The runs one block ends cleanly: completed, exit code 0 or none, a shell job still running, seen from launch, with a
370 * history key, and an end no earlier than its start.
371 */
372function cleanRunsOf(jobs: readonly Job[], notice: Notice, now: number): HistoryRun[] {
373 if (endStateOf(notice.status) !== 'completed') return []
374 return jobs.flatMap(job =>
375 job.kind === 'shell' && isRunning(job) && (canEnd(job, now) || isFinalEnd(job, notice)) && now >= job.startedAt && job.isStartSeen && job.historyKey !== undefined && notice.ids.includes(job.id) &&
376 (exitCodeOf(summaryParts(notice, job.title).words) ?? 0) === 0
377 ? [{ key: job.historyKey, durationMs: now - job.startedAt }]
378 : [],
379 )
380}
381
382/** The runs a prompt's blocks end cleanly; each block meets the list as the blocks before it left it. */
383export function finishedRuns(jobs: readonly Job[], notices: readonly Notice[], now: number): HistoryRun[] {
384 const runs: HistoryRun[] = []
385 let list = jobs
386 for (const notice of notices) {
387 runs.push(...cleanRunsOf(list, notice, now))
388 list = noticed(list, notice, now)
389 }
390 return runs
391}
392
393function discoveredJob(task: SnapshotTask, now: number): Job {
394 const job = { ...freshJob(SNAPSHOT_KINDS.get(task.type) ?? 'task', task.id, task.name || task.description || task.command || task.id, now), lastRunningAt: now }
395 return job.kind === 'task' ? { ...job, taskType: task.type } : job
396}
397
398/**
399 * A job that a snapshot marked gone and a later snapshot lists: it runs again, without its end time, and with the launch
400 * note its snapshot note replaced. A row with no launch note, such as one that an earlier version saved, turns
401 * `ended with its agent` back into `ends with its agent`, and shows no note after another end.
402 */
403function revived(job: Job, now: number): Job {
404 return { ...job, state: 'running', lastRunningAt: now, omittedAt: undefined, endedAt: undefined,
405 note: job.launchNote ?? (job.note === AGENT_GONE_NOTE ? ENDS_WITH_AGENT : undefined) }
406}
407
408/** Reconciles running tasks, allowing a grace period before an absence ends a job. */
409export function reconciled(jobs: readonly Job[], snapshot: readonly SnapshotTask[], now: number): Job[] {
410 const listed = new Set(snapshot.map(task => task.id))
411 const known = new Set(jobs.map(job => job.id))
412 const updated = jobs.map(job => {
413 if (isRunning(job) && !listed.has(job.id) && job.startedAt <= now - SNAPSHOT_SLACK_MS && (job.lastRunningAt === undefined || job.lastRunningAt < now)) {
414 return endedJob(job, 'gone', now, { note: job.note === ENDS_WITH_AGENT ? AGENT_GONE_NOTE : GONE_NOTE })
415 }
416 if (!listed.has(job.id)) return isRunning(job) && now > (job.lastRunningAt ?? job.startedAt) && now >= (job.omittedAt ?? -Infinity) ? { ...job, omittedAt: now } : job
417 if (isRunning(job)) return { ...job, omittedAt: undefined, lastRunningAt: Math.max(job.lastRunningAt ?? job.startedAt, now) }
418 return job.state === 'gone' && job.lastEndedAt !== undefined && job.lastEndedAt < now ? revived(job, now) : job
419 })
420 const discovered = snapshot.filter(task => !known.has(task.id)).map(task => discoveredJob(task, now))
421 return [...updated, ...discovered]
422}
423
424/** What one read of a job's output found. */
425export type TailRead = { bytes: number; mtimeMs: number; lines: readonly string[]; redirectBytes?: number }
426
427/** Notes a successful read clears. */
428const READ_NOTES: ReadonlySet<string> = new Set(['output unavailable', 'tail failed'])
429
430/** The states of the agent list's status words. */
431const AGENT_STATES: ReadonlyMap<string, JobState> = new Map([['pending', 'running'], ['running', 'running'], ['waiting', 'running'], ['idle', 'running'], ...NOTICE_STATES])
432
433type Detail = { field: string; shape: 'text' | 'name' | 'host' }
434
435/** The input field each tool's activity summary shows: a file name, a host, or the text itself. */
436const DETAILS: ReadonlyMap<string, Detail> = new Map([
437 ['Bash', { field: 'command', shape: 'text' }],
438 ['Monitor', { field: 'command', shape: 'text' }],
439 ['Read', { field: 'file_path', shape: 'name' }],
440 ['Edit', { field: 'file_path', shape: 'name' }],
441 ['Write', { field: 'file_path', shape: 'name' }],
442 ['NotebookEdit', { field: 'notebook_path', shape: 'name' }],
443 ['Grep', { field: 'pattern', shape: 'text' }],
444 ['Glob', { field: 'pattern', shape: 'text' }],
445 ['WebFetch', { field: 'url', shape: 'host' }],
446 ['WebSearch', { field: 'query', shape: 'text' }],
447 ['Agent', { field: 'description', shape: 'text' }],
448])
449
450function detailOf(value: string, shape: Detail['shape']): string {
451 if (shape === 'name') return value.split(/[\\/]/).pop() ?? value
452 if (shape === 'host') return URL.canParse(value) ? new URL(value).host : value
453 return value
454}
455
456/**
457 * The list after an agent list read in a tick that began at `now`: its status decides each agent job's state. A resumed
458 * agent runs again without its end time, and its launch note replaces its end note, but only when its latest end came
459 * before the tick: a later end may postdate the list.
460 */
461export function agentsSynced(jobs: readonly Job[], agents: readonly AgentInfo[], now: number): Job[] {
462 return jobs.map(job => {
463 if (job.kind !== 'agent') return job
464 const status = agents.find(agent => agent.id === job.id)?.status
465 const state = status === undefined ? undefined : AGENT_STATES.get(status)
466 if (state === undefined) return job
467 const activityStatus = status as AgentJob['activityStatus']
468 if (state === 'running' && isRunning(job)) return { ...job, activityStatus, omittedAt: undefined, lastRunningAt: Math.max(job.lastRunningAt ?? job.startedAt, now) }
469 if (state === job.state || now <= (job.lastRunningAt ?? job.startedAt)) return job
470 if (state !== 'running') return endedJob(job, state, now, {})
471 return job.lastEndedAt !== undefined && job.lastEndedAt < now ? { ...job, state, activityStatus, lastRunningAt: now, omittedAt: undefined, endedAt: undefined, note: job.launchNote } : job
472 })
473}
474
475/** `<tool>: <detail>`, or the tool alone, as one line of at most ACTIVITY_MAX characters. */
476export function activityOf(tool: string, input: object): string {
477 const detail = DETAILS.get(tool)
478 const value: unknown = detail === undefined ? undefined : Reflect.get(input, detail.field)
479 const text = detail !== undefined && typeof value === 'string' && value.trim() !== '' ? `${tool}: ${detailOf(value, detail.shape)}` : tool
480 return clip(oneLine(text), ACTIVITY_MAX)
481}
482
483/** The list after a tracked subagent started a tool call. */
484export function activityStarted(jobs: readonly Job[], agentId: string, summary: string, now: number): Job[] {
485 return jobs.map(job =>
486 job.kind === 'agent' && job.id === agentId
487 ? {
488 ...job,
489 toolCount: job.toolCount + 1,
490 inFlight: job.inFlight + 1,
491 lastLine: now >= (job.lastToolStartedAt ?? -Infinity) ? summary : job.lastLine,
492 lastToolStartedAt: Math.max(job.lastToolStartedAt ?? now, now),
493 lastOutputAt: Math.max(job.lastOutputAt ?? now, now),
494 }
495 : job,
496 )
497}
498
499/** Settled calls count in observation order. Concurrent failures retain their times until a success or no call remains. */
500export function activityEnded(jobs: readonly Job[], agentId: string, isFailure: boolean, now: number): Job[] {
501 return jobs.map(job => {
502 if (job.kind !== 'agent' || job.id !== agentId) return job
503 const inFlight = Math.max(0, job.inFlight - 1)
504 const previous = job.concurrentFailureTimes ?? []
505 const lastSuccessAt = isFailure ? job.lastSuccessAt : Math.max(job.lastSuccessAt ?? now, now)
506 const failures = isFailure
507 ? now >= (lastSuccessAt ?? -Infinity) ? [...previous, now] : previous
508 : previous.filter(at => at > lastSuccessAt!)
509 // The unrecorded part precedes these concurrent calls and every success resets it.
510 const errorStreak = (isFailure ? job.errorStreak - previous.length : 0) + failures.length
511 return {
512 ...job,
513 inFlight,
514 errorStreak,
515 concurrentFailureTimes: inFlight === 0 ? undefined : failures,
516 lastSuccessAt: inFlight === 0 ? undefined : lastSuccessAt,
517 lastOutputAt: Math.max(job.lastOutputAt ?? now, now),
518 }
519 })
520}
521
522/**
523 * Shell jobs whose output the ticker reads now: a pending final read, or a running job not read for READ_EVERY_MS
524 * (LARGE_READ_EVERY_MS once a file it reads, its output or the redirect file the row shows, passed LARGE_FILE_BYTES).
525 */
526export function readsDue(jobs: readonly Job[], now: number): (ShellJob & { outputFile: string })[] {
527 return jobs.filter(
528 (job): job is ShellJob & { outputFile: string } =>
529 job.kind === 'shell' &&
530 job.outputFile !== undefined &&
531 (job.finalReadAskedAt !== undefined ||
532 (isRunning(job) && now - (job.lastReadAt ?? 0) >= ((job.failedReadBytes ?? Math.max(job.redirectBytes ?? 0, job.outputBytes ?? 0)) > LARGE_FILE_BYTES ? LARGE_READ_EVERY_MS : READ_EVERY_MS))),
533 )
534}
535
536/** An end's final-read request that a read beginning at `readAt` leaves open: one made at or after that time. */
537const askedSince = (job: ShellJob, readAt: number): number | undefined =>
538 job.finalReadAskedAt !== undefined && job.finalReadAskedAt >= readAt ? job.finalReadAskedAt : undefined
539
540/** Whether two readings show the same value, both from a known format or both generic. A reading with no mark, which an earlier version saved, matches either. */
541const isSameReading = (a: Progress | undefined, b: Progress | undefined): boolean =>
542 a !== undefined && b !== undefined && a.label === b.label && a.percent === b.percent &&
543 (a.isGeneric === undefined || b.isGeneric === undefined || a.isGeneric === b.isGeneric)
544
545/** Whether a reading or an anchor stays with isAnyRatio off: it is absent, or marked as a known format. One with no mark came from an earlier version, which counted any ratio. */
546const isKnownOrAbsent = (value: { isGeneric?: boolean } | undefined): boolean => value === undefined || value.isGeneric === false
547
548/**
549 * The list after a reload of the mod at `now`, as a change of the `genericRatios` option or an update causes. With
550 * isAnyRatio off, a reading or an anchor that is generic or has no mark drops, and an anchor drops with its reading.
551 * Each running shell with an output file reads it once more in full, so its row follows the current option. A Monitor
552 * cannot read its old events again: its next event follows the option.
553 */
554export function reloaded(jobs: readonly Job[], isAnyRatio: boolean, now: number): Job[] {
555 return jobs.map(job => {
556 if (job.kind !== 'shell' && job.kind !== 'monitor') return job
557 const isGenericDropped = !isAnyRatio && (!isKnownOrAbsent(job.progress) || !isKnownOrAbsent(job.anchor))
558 const isReread = job.kind === 'shell' && isRunning(job) && job.outputFile !== undefined
559 if (!isGenericDropped && !isReread) return job
560 const ratios = isGenericDropped ? { progress: isKnownOrAbsent(job.progress) ? job.progress : undefined, anchor: undefined } : {}
561 return { ...job, ...ratios, ...(isReread ? { finalReadAskedAt: now } : {}) }
562 })
563}
564
565/**
566 * The list after a read that began at `now`: size, time, last line, and progress; a read-problem note clears, and a
567 * later end keeps its final read. A read of the redirect file carries that file's size and time. A read of the other
568 * file than the one the row shows replaces the row's last line, progress, and anchor with that file's. isAnyRatio (the
569 * `genericRatios` option) lets any ratio count as progress.
570 */
571export function tailRead(jobs: readonly Job[], id: string, read: TailRead, now: number, isAnyRatio = false): Job[] {
572 return jobs.map(original => {
573 if (original.kind !== 'shell' || original.id !== id) return original
574 // Only Claude Code's output file ends with its closing line; a redirect file holds only what the job wrote.
575 const lines = read.redirectBytes === undefined ? jobLinesOf(read.lines) : read.lines
576 const found = progressOf(lines.slice(-PROGRESS_LINES), now, isAnyRatio)
577 const isRedirect = read.redirectBytes !== undefined
578 const job: ShellJob = isRedirect === (original.redirectBytes !== undefined)
579 ? original
580 : { ...original, redirectBytes: undefined, lastLine: undefined, progress: undefined, anchor: undefined }
581 // A full read of output that kept its size, as a reload asks for, keeps a reading it finds again, with that reading's time.
582 const isUnchanged = (isRedirect ? read.redirectBytes === job.redirectBytes : read.bytes === job.outputBytes) && job.failedReadBytes === undefined
583 const progress = isUnchanged && isSameReading(found, job.progress) ? undefined : found
584 const lastLine = lines.at(-1)
585 return {
586 ...job,
587 outputBytes: read.bytes,
588 ...(isRedirect ? { redirectBytes: read.redirectBytes } : {}),
589 failedReadBytes: undefined,
590 lastOutputAt: read.mtimeMs,
591 lastReadAt: now,
592 successfulReadAt: now,
593 finalReadAskedAt: askedSince(job, now),
594 note: job.note !== undefined && READ_NOTES.has(job.note) ? undefined : job.note,
595 lastLine: lastLine === undefined ? job.lastLine : clip(lastLine, LINE_MAX),
596 progress: progress ?? job.progress,
597 anchor: progress === undefined ? job.anchor : anchorOf(job.anchor, progress, job.progress),
598 }
599 })
600}
601
602/** The list after a read that began at `now` failed: the next try waits its interval, a failed final read is not tried again unless a later end asked, and a launch or end note wins over a read note. */
603export function readFailed(jobs: readonly Job[], id: string, note: 'output unavailable' | 'tail failed', now: number, bytes?: number): Job[] {
604 return jobs.map(job =>
605 job.kind === 'shell' && job.id === id
606 ? {
607 ...job,
608 lastReadAt: now,
609 successfulReadAt: undefined,
610 failedReadBytes: bytes ?? job.failedReadBytes,
611 finalReadAskedAt: askedSince(job, now),
612 note: job.note === undefined || READ_NOTES.has(job.note) ? note : job.note,
613 }
614 : job,
615 )
616}
617
618/** The list with every ended job removed. */
619export function finishedCleared(jobs: readonly Job[]): Job[] {
620 return jobs.filter(isRunning)
621}
622
623/** The observation time belongs to the input, before any asynchronous work or state retry. */
624export type JobObservation = {
625 at: number
626 ids?: readonly string[]
627 kind?: 'snapshot' | 'restore' | 'activity' | 'remove' | 'notice'
628 /** Ids a notification ends for good; a running or `gone` shell, Monitor, or workflow row takes that end past a newer observation. */
629 finalIds?: readonly string[]
630 members?: readonly string[]
631 noticeRow?: string
632}
633
634/**
635 * The barriers: every barrier of a kept row, and the MAX_BARRIERS newest barriers of tasks with no row. A kept row's
636 * barrier does not count toward the cap. Only the count decides. A newer snapshot does not make the barrier of a task
637 * with no row redundant: the session that a /resume returns to can still hold that task's row, and the barrier must
638 * remove it there.
639 */
640function bounded(barriers: Iterable<[string, number]>, kept: ReadonlySet<string>): [string, number][] {
641 const entries = [...barriers]
642 const rowless = entries.filter(([id]) => !kept.has(id))
643 const excess = rowless.length - MAX_BARRIERS
644 if (excess <= 0) return entries
645 const evicted = new Set(rowless.sort(([, a], [, b]) => a - b).slice(0, excess).map(([id]) => id))
646 return entries.filter(([id]) => !evicted.has(id))
647}
648
649/** The state with both time lists bounded against its own rows. */
650function capped(state: JobsState): JobsState {
651 const kept = new Set(state.items.map(job => job.id))
652 return { ...state, observed: bounded(state.observed, kept), removed: bounded(state.removed, kept) }
653}
654
655/**
656 * One ordering rule for job inputs. Removal wins ties; other ties use write order.
657 * Snapshots also reject older inputs for absent rows. Activity counts settle every call, with their own field ordering.
658 * A final end of a running or `gone` shell, Monitor, or workflow passes newer observations, and a notification never adds back a removed row.
659 */
660export function observedJobs(state: JobsState, input: JobObservation, change: (jobs: Job[]) => Job[]): JobsState {
661 const next = ordered(state, input, change)
662 return next === state ? state : capped(next)
663}
664
665/** observedJobs before the cap. restoredJobs applies the cap once, after the whole carry, so a carried row never counts. */
666function ordered(state: JobsState, input: JobObservation, change: (jobs: Job[]) => Job[]): JobsState {
667 if (input.noticeRow !== undefined && state.noticeRows?.includes(input.noticeRow)) return state
668 if (input.kind === 'snapshot' && input.at < (state.snapshotAt ?? -Infinity)) return state
669 const observed = new Map(state.observed)
670 const removed = new Map(state.removed)
671 const known = new Set(state.items.map(job => job.id))
672 const targets = input.ids === undefined ? undefined : new Set(input.ids)
673 const rows = new Map(state.items.map(job => [job.id, job]))
674 const isFinal = (id: string): boolean => {
675 const row = rows.get(id)
676 return row !== undefined && input.finalIds?.includes(id) === true && takesFinalEnd(row)
677 }
678 const accepts = (id: string): boolean =>
679 (targets === undefined || targets.has(id)) &&
680 input.at > (removed.get(id) ?? -Infinity) &&
681 // A notification never brings back a task whose row was removed.
682 !(input.kind === 'notice' && !known.has(id) && removed.has(id)) &&
683 (input.kind === 'activity' || isFinal(id) || input.at >= (observed.get(id) ?? -Infinity)) &&
684 ((input.kind !== 'restore' && known.has(id)) || input.at >= (state.snapshotAt ?? -Infinity))
685 const eligible = state.items.filter(job => accepts(job.id))
686 const changed = change(eligible).filter(job => accepts(job.id))
687 const replacements = new Map(changed.map(job => [job.id, job]))
688 const candidates = [
689 ...state.items.flatMap(job => accepts(job.id) ? replacements.get(job.id) ?? [] : job),
690 ...changed.filter(job => !known.has(job.id)),
691 ]
692 const items = trimmed(candidates)
693 const kept = new Set(items.map(job => job.id))
694 const affected = input.ids ?? [
695 ...eligible.filter(job => input.kind === 'snapshot' || replacements.get(job.id) !== job).map(job => job.id),
696 ...changed.filter(job => !known.has(job.id)).map(job => job.id),
697 ]
698 const hadRow = new Set([...eligible, ...changed].map(job => job.id))
699 for (const id of new Set(affected)) {
700 if (!accepts(id)) continue
701 observed.set(id, Math.max(observed.get(id) ?? input.at, input.at))
702 if (!kept.has(id) && (input.kind === 'remove' || hadRow.has(id))) removed.set(id, input.at)
703 }
704 // Retention is a removal too, including a finished row first discovered by this input.
705 for (const job of candidates) {
706 if (kept.has(job.id)) continue
707 const at = Math.max(input.at, observed.get(job.id) ?? input.at)
708 observed.set(job.id, at)
709 removed.set(job.id, Math.max(removed.get(job.id) ?? at, at))
710 }
711 return {
712 items,
713 observed: [...observed],
714 removed: [...removed],
715 snapshotAt: input.kind === 'snapshot' ? input.at : state.snapshotAt,
716 snapshotIds: input.kind === 'snapshot' ? input.members === undefined ? undefined : [...input.members] : state.snapshotIds,
717 noticeRows: input.noticeRow === undefined ? state.noticeRows : [...(state.noticeRows ?? []), input.noticeRow].slice(-NOTICE_ROWS_MAX),
718 }
719}
720
721/** Restores the carry through the same ordering rule, using original observation and removal times, then applies the cap once. */
722export function restoredJobs(state: JobsState, carried: JobsState): JobsState {
723 let restored = state
724 for (const [id, at] of carried.removed) {
725 restored = ordered(restored, { at, ids: [id], kind: 'remove' }, () => [])
726 }
727 const rows = new Map(carried.items.map(job => [job.id, job]))
728 for (const [id, at] of carried.observed) {
729 const row = rows.get(id)
730 restored = ordered(restored, { at, ids: [id], kind: 'restore' }, jobs => row === undefined ? jobs : [row])
731 }
732 const saved = (carried.snapshotAt ?? -Infinity) >= (state.snapshotAt ?? -Infinity) ? carried : state
733 if (saved.snapshotAt !== null && saved.snapshotIds !== undefined) {
734 const members = new Set(saved.snapshotIds)
735 const at = saved.snapshotAt
736 restored = ordered(restored, { at, ids: restored.items.filter(job => !members.has(job.id)).map(job => job.id) }, jobs => reconciled(jobs, [], at))
737 }
738 return capped({ ...restored, snapshotAt: saved.snapshotAt, snapshotIds: saved.snapshotIds,
739 noticeRows: [...new Set([...(state.noticeRows ?? []), ...(carried.noticeRows ?? [])])].slice(-NOTICE_ROWS_MAX) })
740}
741
742/** Live: a job runs, a wakeup is pending, or a job ended less than RECENT_MS ago. */
743export function isLive(jobs: readonly Job[], wakeup: Wakeup | null, now: number): boolean {
744 return wakeup !== null || jobs.some(job => isRunning(job) || now - (job.endedAt ?? 0) < RECENT_MS)
745}
746
747/** The wakeup after a scheduled trigger: cleared from TRIGGER_EARLY_MS before its due time on. */
748export function wakeupAfterTrigger(wakeup: Wakeup | null, now: number): Wakeup | null {
749 return wakeup !== null && now >= wakeup.dueAt - TRIGGER_EARLY_MS ? null : wakeup
750}
751
752/** The wakeup after a tick: dropped once it is more than WAKEUP_STALE_MS past due. */
753export function wakeupAfterTick(wakeup: Wakeup | null, now: number): Wakeup | null {
754 return wakeup !== null && now > wakeup.dueAt + WAKEUP_STALE_MS ? null : wakeup
755}
756
757/**
758 * The wakeup after the session crons of a snapshot whose hook began at `now`: a list with no one-shot entry holds no
759 * pending wakeup. The engine took the snapshot before the hook began, while hooks above it ran, so a wakeup set from
760 * SNAPSHOT_SLACK_MS before then on may be newer than the snapshot, and it stays.
761 */
762export function wakeupAfterSnapshot(wakeup: Wakeup | null, crons: readonly { recurring: boolean }[], now: number): Wakeup | null {
763 return wakeup === null || crons.some(cron => !cron.recurring) || wakeup.setAt > now - SNAPSHOT_SLACK_MS ? wakeup : null
764}
765
766/** Ends only commands whose launch tied their lifetime to this agent's final answer. */
767export function ownerAnswered(jobs: readonly Job[], agentId: string, now: number): Job[] {
768 return jobs.map(job => job.kind === 'shell' && job.ownerAgentId === agentId && job.endsWithAgent === true && isRunning(job)
769 ? endedJob(job, 'gone', now, { note: AGENT_GONE_NOTE }) : job)
770}
771hooks/limits.ts 81 lines1/** The ticker's period. */
2export const TICK_MS = 1_000
3/** A ticker with no period for this long has stopped (a refused period ends an interval); the next write starts it again. */
4export const TICKER_STALE_MS = 3_000
5/** The least time between two reads of one job's output. */
6export const READ_EVERY_MS = 2_000
7/** Outputs larger than this are read at most every LARGE_READ_EVERY_MS. */
8export const LARGE_FILE_BYTES = 262_144
9/** The least time between two reads of a large output. */
10export const LARGE_READ_EVERY_MS = 10_000
11/** The largest file $.fs.read reads (4 MiB); a larger output goes through `tail`. */
12export const FS_READ_MAX = 4_194_304
13/** Bytes `tail -c` returns, and characters kept from the end of a whole read. The `tail` call in register.tsx writes this number as the text '16384': change both together. */
14export const TAIL_BYTES = 16_384
15/** How long `tail` may run. */
16export const TAIL_TIMEOUT_MS = 2_000
17/** Display lines searched for progress, newest first. */
18export const PROGRESS_LINES = 10
19/** Characters before a ratio that decide whether it is a known progress counter, such as `Step` or `[builder `. */
20export const COUNTER_CONTEXT = 256
21/** The least time between anchor and sample before a rate ETA shows. */
22export const ETA_MIN_SPAN_MS = 10_000
23/** The least percentage-point gain before a rate ETA shows. */
24export const ETA_MIN_GAIN = 1
25/** Silence before a running shell or agent shows `quiet`. */
26export const QUIET_MS = 120_000
27/** Failed subagent calls in a row before the failing flag shows. */
28export const FAILING_STREAK = 3
29/** How long an ended job keeps the session live and shows in the band. */
30export const RECENT_MS = 60_000
31/** Ended jobs older than this leave the /downrange table. */
32export const TABLE_RECENT_MS = 1_800_000
33/** The most ended jobs in the /downrange table. */
34export const TABLE_FINISHED_ROWS = 5
35/** The most rows in the /downrange table. */
36export const TABLE_MAX_ROWS = 15
37/** A /downrange table narrower than this joins Kind into Job and ETA into Progress. */
38export const TABLE_NARROW_COLUMNS = 100
39/** Ended jobs kept in state. */
40export const MAX_FINISHED = 20
41/** Ordering barriers of tasks with no row kept in each of the jobs state's observed and removed lists. The oldest go first. A kept row's barriers stay and do not count. */
42export const MAX_BARRIERS = 2_000
43/** A wakeup this far past due is dropped. */
44export const WAKEUP_STALE_MS = 600_000
45/** A scheduled trigger this close before the due time clears the wakeup. */
46export const TRIGGER_EARLY_MS = 5_000
47/** A job or wakeup first seen this soon before a Stop or SubagentStop hook began may be newer than its snapshot, so absence does not end it. */
48export const SNAPSHOT_SLACK_MS = 10_000
49/** A repeated status prompt inside this window goes to Claude. */
50export const REPEAT_WINDOW_MS = 60_000
51/** Durations kept per history key. */
52export const MAX_RUNS = 5
53/** History keys kept in the store. */
54export const MAX_KEYS = 200
55/** Lengths kept in state. */
56export const TITLE_MAX = 120
57export const LINE_MAX = 200
58export const NOTE_MAX = 80
59export const ACTIVITY_MAX = 60
60/** The longest unknown status word a diagnostic quotes. */
61export const STATUS_WORD_MAX = 20
62/** Lengths in the /downrange table, wide and narrow. */
63export const CELL_TITLE_MAX = 40
64export const CELL_LINE_MAX = 80
65export const CELL_LINE_NARROW_MAX = 40
66/** Band layout: room for the two buttons, and the longest snippet title. */
67export const BAND_BUTTONS_COLUMNS = 20
68export const BAND_TITLE_MAX = 24
69/** Pane layout. */
70export const BAR_CELLS = 10
71export const BAR_MIN_COLUMNS = 60
72export const PANE_TITLE_MIN = 12
73export const PANE_FIXED_COLUMNS = 44
74
75/** Stored child rows retained for duplicate suppression. */
76export const NOTICE_ROWS_MAX = 256
77/** Compare-and-set attempts before reporting a busy state. */
78export const STATE_WRITE_ATTEMPTS = 8
79/** Restore attempts per destination session. */
80export const CARRY_ATTEMPTS = 3
81hooks/notices.ts 71 lines1/** One `<task-notification>` block. */
2export type Notice = {
3 ids: string[]
4 /** The engine's task type, such as `remote_agent` for a task that runs in a cloud session. */
5 taskType?: string
6 outputFile?: string
7 status?: string
8 summary?: string
9 event?: string
10}
11
12const OPEN = '<task-notification>'
13const CLOSE = '</task-notification>'
14/** One header field, as the engine writes each one on a line of its own, before the block's body. */
15const FIELD = /\s*<(task-id|tool-use-id|task-type|output-file|status|summary)>([\s\S]*?)<\/\1>/y
16const EVENT_OPEN = '<event>'
17const EVENT_CLOSE = '</event>'
18const ID_SHAPE = /^\S{1,128}$/
19
20/** Text as it stood before the engine escaped `&`, `<`, and `>` in it. */
21const unescaped = (text: string): string => text.replaceAll('<', '<').replaceAll('>', '>').replaceAll('&', '&')
22
23/**
24 * One block's content: its header fields (every task id of a valid shape, and the first non-empty value of each other field),
25 * then the text of the first `<event>` in its body. The body's other parts (a note, a result, usage, plain text) are
26 * not read.
27 */
28function noticeOf(block: string): Notice {
29 const ids: string[] = []
30 const first = new Map<string, string>()
31 let end = 0
32 for (;;) {
33 FIELD.lastIndex = end
34 const field = FIELD.exec(block)
35 if (field === null) break
36 end = FIELD.lastIndex
37 const [, name = '', text = ''] = field
38 const value = unescaped(text.trim())
39 if (name === 'task-id') {
40 if (ID_SHAPE.test(value)) ids.push(value)
41 } else if (value !== '' && !first.has(name)) {
42 first.set(name, value)
43 }
44 }
45 const open = block.indexOf(EVENT_OPEN, end)
46 const close = open === -1 ? -1 : block.indexOf(EVENT_CLOSE, open)
47 const event = close === -1 ? undefined : unescaped(block.slice(open + EVENT_OPEN.length, close))
48 return { ids, taskType: first.get('task-type'), outputFile: first.get('output-file'), status: first.get('status'), summary: first.get('summary'), event }
49}
50
51/**
52 * Every notification block in a delivered prompt, in order. The engine escapes every value inside a block, so a block
53 * runs to the first `</task-notification>` after its opening tag. Blocks follow one another with only white space
54 * between them. Other text after a block is that block's trailing text, which the engine does not escape (a stalled
55 * command's last output, for one), so nothing after it is read.
56 */
57export function noticesOf(text: string): Notice[] {
58 const notices: Notice[] = []
59 let at = text.indexOf(OPEN)
60 while (at !== -1) {
61 const start = at + OPEN.length
62 const close = text.indexOf(CLOSE, start)
63 if (close === -1) break
64 notices.push(noticeOf(text.slice(start, close)))
65 const end = close + CLOSE.length
66 const next = text.indexOf(OPEN, end)
67 at = next !== -1 && text.slice(end, next).trim() === '' ? next : -1
68 }
69 return notices
70}
71hooks/output.ts 194 lines1import type { Anchor, Progress } from '../types'
2import { COUNTER_CONTEXT, ETA_MIN_GAIN, ETA_MIN_SPAN_MS, TAIL_BYTES } from './limits'
3
4const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][\s\S]*?(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g
5const CONTROL = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g
6const PERCENT = /(?<![\d.])(\d{1,3}(?:\.\d+)?)\s?%/g
7const FRACTION = /(?<![\w./:-])(\d{1,7})\s?\/\s?(\d{1,7})(?![\w./-])/g
8const OF = /(?<![\w./\\])(\d{1,7}) of (\d{1,7})(?![\w/\\]|\.\w)/gi
9/** A two-digit clock right after a `done/total` match: with done 1 to 12 and total up to 31, the match is a short date, as in `10/11 14:03`. */
10const CLOCK_AFTER = /\s+\d{2}:\d{2}/y
11/** Words that name the count right after them, as in `Step 3/7`, `Epoch 3 of 10`, or pip's `Progress 12 of 40`. */
12const STEP_WORDS: ReadonlySet<string> = new Set(['step', 'stage', 'phase', 'epoch', 'iteration', 'batch', 'chunk', 'part', 'file', 'item', 'task', 'test', 'progress'])
13/** The last word before a count, with an optional colon, as in `Step 3/7` or `batch: 9/30`. */
14const WORD_BEFORE = /\W([A-Za-z]+):?\s*$/
15/** An opening bracket right before a count, after up to two labels, as in `[3/7]`, `( 3/150)`, or Docker's `[builder 3/7]`. */
16const OPENED_BEFORE = /([[(])\s*(?:[A-Za-z][\w./-]*\s+){0,2}$/
17/** A drawn bar right before a count: `[=====> ]` (cargo), `|█████ |` (tqdm), or `━━━━━━` (rich). */
18const BAR_BEFORE = /(?:\[[=#>\-. ]{3,}\]|\|[█▉▊▋▌▍▎▏ ]{3,}\||[━╸╺█▉▊▋▌▍▎▏■▰]{3})\s*$/
19/** Docker Compose's count line, as in `[+] Running 3/7`. */
20const COMPOSE_BEFORE = /\[\+\]\s+[A-Za-z]+\s+$/
21/** The bracket that closes a count, after optional spaces. */
22const CLOSER_AFTER = /\s*([\])])/y
23/** An output file path as the engine names it: absolute, in a `tasks` folder, named `<task id>.output`. Its folders may hold spaces (a temp folder set by CLAUDE_CODE_TMPDIR). */
24const OUTPUT_PATH = /^(?:[A-Za-z]:)?[\\/][^\n]*[\\/]tasks[\\/]([A-Za-z0-9_-]+)\.output$/
25/** The engine's words before the path in a background command's result. */
26const OUTPUT_NOTICE = 'Output is being written to: '
27/** The line the engine adds to a shell's output when the command ends. */
28const EXIT_LINE = /^\[exited with code -?\d+\]$/
29/** That line at the end of an output's text, with the line break the engine writes before it and the one after it. */
30const EXIT_END = /\n?\[exited with code -?\d+\]\n?$/
31/** A carriage return, built from its code so that no string literal holds a control character. */
32const CR = String.fromCharCode(13)
33
34type Found = { percent: number; label: string; isGeneric: boolean }
35
36/** Cuts text to max characters, ending with an ellipsis when it cut. */
37export function clip(text: string, max: number): string {
38 return text.length <= max ? text : `${text.slice(0, max - 1)}…`
39}
40
41/** The lines a terminal would show for raw output: `\r` overwrites, escapes and controls removed, empty lines dropped. */
42export function displayLinesOf(text: string): string[] {
43 return text
44 .split('\n')
45 .map(raw => {
46 const line = raw.endsWith(CR) ? raw.slice(0, -1) : raw
47 return line
48 .slice(line.lastIndexOf(CR) + 1)
49 .replace(ANSI, '')
50 .replace(/\t/g, ' ')
51 .replace(CONTROL, '')
52 .trim()
53 })
54 .filter(line => line !== '')
55}
56
57/** Any text as one clean display line. */
58export function oneLine(text: string): string {
59 return displayLinesOf(text).join(' ').replace(/\s+/g, ' ')
60}
61
62function lastPercent(line: string): Found | undefined {
63 let found: Found | undefined
64 for (const match of line.matchAll(PERCENT)) {
65 const value = Number(match[1])
66 if (value <= 100) found = { percent: value, label: `${Math.round(value)}%`, isGeneric: false }
67 }
68 return found
69}
70
71/** Whether a `done/total` match is a short date followed by a clock. */
72function isDateBeforeClock(line: string, end: number, done: number, total: number): boolean {
73 if (done < 1 || done > 12 || total > 31) return false
74 CLOCK_AFTER.lastIndex = end
75 return CLOCK_AFTER.test(line)
76}
77
78/**
79 * Whether the count from start to end has a known progress format: it starts the line, follows a step word, sits in
80 * brackets, follows a drawn bar, or ends Docker Compose's `[+] Running`. Past the line-start test, only the
81 * COUNTER_CONTEXT characters before it decide, so a long line stays cheap.
82 */
83function isCounter(line: string, start: number, end: number): boolean {
84 if (!/\S/.test(line.slice(0, start))) return true
85 // A leading space stands for the line start, so that a word at the start has a boundary before it.
86 const before = start > COUNTER_CONTEXT ? line.slice(start - COUNTER_CONTEXT, start) : ` ${line.slice(0, start)}`
87 const word = WORD_BEFORE.exec(before)?.[1]
88 if (word !== undefined && STEP_WORDS.has(word.toLowerCase())) return true
89 const opener = OPENED_BEFORE.exec(before)?.[1]
90 CLOSER_AFTER.lastIndex = end
91 if (opener !== undefined && CLOSER_AFTER.exec(line)?.[1] === (opener === '[' ? ']' : ')')) return true
92 return BAR_BEFORE.test(before) || COMPOSE_BEFORE.test(before)
93}
94
95/** The last valid ratio in the line; unless isAnyRatio, only one in a known progress format counts. isGeneric marks one in no known format. */
96function lastRatio(line: string, pattern: RegExp, isAnyRatio: boolean): Found | undefined {
97 let found: Found | undefined
98 for (const match of line.matchAll(pattern)) {
99 const done = Number(match[1])
100 const total = Number(match[2])
101 const end = match.index + match[0].length
102 if (pattern === FRACTION && isDateBeforeClock(line, end, done, total)) continue
103 if (total < 1 || done > total) continue
104 const isKnown = isCounter(line, match.index, end)
105 if (isKnown || isAnyRatio) found = { percent: (done / total) * 100, label: `${done}/${total}`, isGeneric: !isKnown }
106 }
107 return found
108}
109
110/**
111 * The newest progress reading in the lines: percent, then `done/total`, then `N of M`. A ratio counts only in a known
112 * progress format, unless isAnyRatio (the `genericRatios` option) lets any ratio count.
113 */
114export function progressOf(lines: readonly string[], at: number, isAnyRatio: boolean): Progress | undefined {
115 for (const line of [...lines].reverse()) {
116 const found = lastPercent(line) ?? lastRatio(line, FRACTION, isAnyRatio) ?? lastRatio(line, OF, isAnyRatio)
117 if (found !== undefined) return { ...found, at }
118 }
119 return undefined
120}
121
122/** The start of the current rising run of readings, which the rate ETA measures from; a drop starts a new run. A new anchor keeps the format mark of its reading. */
123export function anchorOf(anchor: Anchor | undefined, progress: Progress, previous: Progress | undefined): Anchor {
124 return anchor === undefined || previous === undefined || progress.percent < previous.percent
125 ? { percent: progress.percent, at: progress.at, isGeneric: progress.isGeneric === true }
126 : anchor
127}
128
129/** Milliseconds left at the observed rate, or undefined before a rate exists. */
130export function remainingByRate(progress: Progress, anchor: Anchor, now: number): number | undefined {
131 const span = progress.at - anchor.at
132 const gain = progress.percent - anchor.percent
133 if (span < ETA_MIN_SPAN_MS || gain < ETA_MIN_GAIN) return undefined
134 return Math.max(0, ((100 - progress.percent) * span) / gain - (now - progress.at))
135}
136
137/** 42s, 4m 05s, 1h 05m. */
138export function durationText(ms: number): string {
139 const seconds = Math.max(0, Math.floor(ms / 1_000))
140 if (seconds < 60) return `${seconds}s`
141 const minutes = Math.floor(seconds / 60)
142 if (minutes < 60) return `${minutes}m ${String(seconds % 60).padStart(2, '0')}s`
143 return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`
144}
145
146/** <1m, ~3m, ~1h 05m: minutes rounded up. */
147export function etaText(ms: number): string {
148 if (ms < 60_000) return '<1m'
149 const minutes = Math.ceil(ms / 60_000)
150 if (minutes < 60) return `~${minutes}m`
151 return `~${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`
152}
153
154/** Whether a path is the output file of this task: absolute, in a `tasks` folder, and named after the task id. */
155export function isOutputFileOf(path: string, id: string): boolean {
156 return OUTPUT_PATH.exec(path)?.[1] === id
157}
158
159/**
160 * The output file named by the last valid engine notice. Its terminal filename ends the path; directories may
161 * contain the notice's words or the same filename.
162 */
163export function outputFileOf(text: string, id: string): string | undefined {
164 const name = `${id}.output`
165 const end = text.lastIndexOf(name)
166 if (end === -1) return undefined
167 if (!/^(?:\.(?:\s|$)|$)/.test(text.slice(end + name.length))) return undefined
168 let notice = text.lastIndexOf(OUTPUT_NOTICE, end)
169 while (notice !== -1) {
170 const path = text.slice(notice + OUTPUT_NOTICE.length, end + name.length)
171 if (isOutputFileOf(path, id)) return path
172 notice = notice === 0 ? -1 : text.lastIndexOf(OUTPUT_NOTICE, notice - 1)
173 }
174 return undefined
175}
176
177/** The lines of an output without the engine's closing `[exited with code N]` line: the lines the job wrote. */
178export function jobLinesOf(lines: readonly string[]): readonly string[] {
179 return EXIT_LINE.test(lines.at(-1) ?? '') ? lines.slice(0, -1) : lines
180}
181
182/** The size of an output without the engine's closing line, from its size and the text of its end: the bytes the job wrote. */
183export function jobBytesOf(text: string, size: number): number {
184 return size - (EXIT_END.exec(text)?.[0].length ?? 0)
185}
186
187/** The last TAIL_BYTES of an output, from a line start: a tail, or a read cut short, drops its partial first line; text with no line break stays whole, one line that `\r` redraws. */
188export function outputEndOf(text: string, isTail: boolean): string {
189 const end = text.slice(-TAIL_BYTES)
190 if (!isTail && end.length === text.length) return end
191 const lineStart = end.indexOf('\n') + 1
192 return lineStart === 0 ? end : end.slice(lineStart)
193}
194hooks/state.ts 60 lines1import type { JobsState, WakeupState } from '../types'
2
3const record = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
4const number = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
5const optionalNumbers = (value: Record<string, unknown>, keys: readonly string[]): boolean => keys.every(key => value[key] === undefined || number(value[key]))
6const optionalStrings = (value: Record<string, unknown>, keys: readonly string[]): boolean => keys.every(key => value[key] === undefined || typeof value[key] === 'string')
7const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every(item => typeof item === 'string')
8const times = (value: unknown): boolean => Array.isArray(value) && value.every(item => Array.isArray(item) && item.length === 2 && typeof item[0] === 'string' && number(item[1]))
9const generic = (value: Record<string, unknown>): boolean => value.isGeneric === undefined || typeof value.isGeneric === 'boolean'
10const progress = (value: unknown): boolean => value === undefined || (record(value) && number(value.percent) && number(value.at) && typeof value.label === 'string' && generic(value))
11const anchor = (value: unknown): boolean => value === undefined || (record(value) && number(value.percent) && number(value.at) && generic(value))
12const states = new Set(['running', 'completed', 'failed', 'stopped', 'gone'])
13const activities = new Map([['pending', true], ['running', true], ['waiting', true], ['idle', true]])
14
15function job(value: unknown): boolean {
16 if (!record(value) || typeof value.id !== 'string' || typeof value.title !== 'string' || (typeof value.state !== 'string' || !states.has(value.state)) ||
17 !number(value.startedAt) || typeof value.isStartSeen !== 'boolean' ||
18 !optionalNumbers(value, ['lastRunningAt', 'omittedAt', 'endedAt', 'lastEndedAt', 'exitCode', 'lastOutputAt']) ||
19 !optionalStrings(value, ['note', 'launchNote', 'lastLine'])) return false
20 switch (value.kind) {
21 case 'shell':
22 return optionalStrings(value, ['outputFile', 'redirectFile', 'historyKey', 'ownerAgentId']) &&
23 (value.endsWithAgent === undefined || typeof value.endsWithAgent === 'boolean') &&
24 optionalNumbers(value, ['outputBytes', 'redirectBytes', 'failedReadBytes', 'lastReadAt', 'successfulReadAt', 'finalReadAskedAt', 'usualMs']) && progress(value.progress) && anchor(value.anchor)
25 case 'monitor':
26 return number(value.eventCount) && optionalNumbers(value, ['deadline']) && progress(value.progress) && anchor(value.anchor)
27 case 'agent':
28 return number(value.toolCount) && number(value.inFlight) && number(value.errorStreak) &&
29 optionalNumbers(value, ['lastToolStartedAt', 'lastSuccessAt']) &&
30 (value.activityStatus === undefined || (typeof value.activityStatus === 'string' && activities.has(value.activityStatus))) &&
31 (value.runsElsewhere === undefined || typeof value.runsElsewhere === 'boolean') &&
32 (value.concurrentFailureTimes === undefined || (Array.isArray(value.concurrentFailureTimes) && value.concurrentFailureTimes.every(number)))
33 case 'workflow': return true
34 case 'task': return typeof value.taskType === 'string'
35 default: return false
36 }
37}
38
39/** Reads the supported jobs shapes without discarding their ordering barriers. */
40export function jobsStateOf(raw: unknown): JobsState | undefined {
41 if (!record(raw) || (raw.shape !== 'v7' && raw.shape !== 'v8') || !record(raw.value)) return undefined
42 const value = raw.value
43 if (!Array.isArray(value.items) || !value.items.every(job) || !times(value.observed) || !times(value.removed) ||
44 (value.snapshotAt !== null && !number(value.snapshotAt)) ||
45 (value.snapshotIds !== undefined && !strings(value.snapshotIds)) || (value.noticeRows !== undefined && !strings(value.noticeRows))) return undefined
46 const state = value as JobsState
47 const observations = new Map(state.observed)
48 if (state.items.some(item => !observations.has(item.id))) return undefined
49 return state
50}
51
52/** Reads the previous wakeup shape, including a cancellation with no pending wakeup. */
53export function wakeupStateOf(raw: unknown): WakeupState | undefined {
54 if (!record(raw) || (raw.shape !== 'v3' && raw.shape !== 'v4') || !record(raw.value)) return undefined
55 const { pending, updatedAt } = raw.value
56 if (updatedAt !== null && !number(updatedAt)) return undefined
57 if (pending !== null && (!record(pending) || !number(pending.dueAt) || !number(pending.setAt) || typeof pending.reason !== 'string' || !number(updatedAt))) return undefined
58 return raw.value as WakeupState
59}
60hooks/prompt.ts 14 lines1/** A whole prompt that asks only for status, such as `status?`, `what's the eta`, or `give me a progress update`. */
2const STATUS =
3 /^(?:(?:what(?:'?s| is) (?:the )?|give me (?:the |a |an )?)?(?:latest |live |current )?(?:status|eta|progress)(?: now| update| so far)?)[?.!]*$/
4
5/** Trimmed, lowercased, single-spaced, with the typographic apostrophe made plain. */
6export function normalizePrompt(text: string): string {
7 return text.trim().toLowerCase().replace(/\s+/g, ' ').replace(/’/g, "'")
8}
9
10/** Whether a normalized prompt is an exact status question. */
11export function isStatusPrompt(normalized: string): boolean {
12 return STATUS.test(normalized)
13}
14hooks/redirect.ts 313 lines1/**
2 * The redirect rule: the one file that a background command's own text sends its output to, its redirect file. A
3 * small reader of shell text finds it. It gives up on text it cannot read for sure, and the command then follows no file.
4 */
5
6/** A word after quote removal; isLiteral is false when the shell can expand it: a variable, command text, a pattern, or a leading `~`. */
7type Word = { kind: 'word'; text: string; isLiteral: boolean }
8/** A redirection, with the file descriptor written right before it: digits or `{name}`, or empty. */
9type Redirect = { kind: 'redirect'; op: string; fd: string }
10/** An operator between commands. */
11type Separator = { kind: 'separator'; op: string }
12type Token = Word | Redirect | Separator
13
14/** Redirection operators, longest first. */
15const REDIRECTS = ['&>>', '&>', '>>', '>&', '>|', '>', '<<<', '<<-', '<<', '<>', '<&', '<']
16/** A here-document, whose lines this reader does not follow. */
17const HERE_DOCUMENTS: ReadonlySet<string> = new Set(['<<', '<<-'])
18/** Operators between commands, longest first. */
19const SEPARATORS = ['&&', '&', '||', '|&', '|', ';;&', ';;', ';&', ';', '(', ')', '\n']
20/** Characters that end a word outside quotes. */
21const METACHARACTERS: ReadonlySet<string> = new Set([' ', '\t', '\n', '|', '&', ';', '(', ')', '<', '>'])
22/** Unquoted characters that make a word a pattern or a brace list. */
23const PATTERN_CHARACTERS: ReadonlySet<string> = new Set(['*', '?', '[', '{'])
24/** Commands that change the folder, after which a relative path can name another file. */
25const FOLDER_CHANGES: ReadonlySet<string> = new Set(['cd', 'chdir', 'pushd', 'popd'])
26/** Words that run other shell text in this shell, or a builtin behind a prefix, which can change the folder out of sight: anywhere in the command. */
27const HIDDEN_CODE: ReadonlySet<string> = new Set(['eval', 'source', 'command', 'builtin'])
28/** The operators of a plain command list; every other operator, such as `||`, `(`, or `&`, makes the text one this reader does not follow. */
29const LIST_SEPARATORS: ReadonlySet<string> = new Set(['&&', ';', '\n', '|', '|&'])
30/** Words that, as a command, start or belong to code that may not run, or that runs later or in the background: a function, a condition, a loop, or a group. */
31const COMPOUND_WORDS: ReadonlySet<string> = new Set(['function', 'if', 'then', 'elif', 'else', 'fi', 'case', 'esac', 'while', 'until', 'for', 'select', 'do', 'done', 'repeat', 'foreach', 'coproc', '{', '}'])
32/** Words before a command that keep the next word a command: `!`, `time`, and `time -p`. */
33const PIPELINE_PREFIXES: ReadonlySet<string> = new Set(['!', 'time', '-p'])
34/** A variable assignment before a command, such as `FLAGS=-j4`. */
35const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*\+?=/
36/** The redirections that send standard output to a file, with the file descriptors each may carry: `>`, `>>`, `1>`, `1>>`, `&>`, `&>>`, and `>&`. */
37const OUTPUT_REDIRECTS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
38 ['>', new Set(['', '1'])],
39 ['>>', new Set(['', '1'])],
40 ['&>', new Set([''])],
41 ['&>>', new Set([''])],
42 ['>&', new Set([''])],
43])
44/** A file descriptor written right before a redirection. */
45const FD = /^(?:\d+|\{[A-Za-z_][A-Za-z0-9_]*\})$/
46/** The target of `>&` that copies, moves (`3-`), or closes a file descriptor instead of naming a file. */
47const DUPLICATE = /^(?:\d+-?|-)$/
48/** A variable name or a special parameter after `$`. */
49const PARAMETER = /[A-Za-z_][A-Za-z0-9_]*|[0-9?$!#*@-]/y
50/** A Windows drive path, such as `C:/b.log`. */
51const DRIVE = /^[A-Za-z]:/
52
53/** The end of a `'…'` or `` `…` `` part that starts at `at`, after its closing mark; a backslash escapes the next character when isEscaped. */
54function closingMarkOf(command: string, at: number, mark: string, isEscaped: boolean): number | undefined {
55 for (let end = at + 1; end < command.length; end += 1) {
56 if (isEscaped && command[end] === '\\') end += 1
57 else if (command[end] === mark) return end + 1
58 }
59 return undefined
60}
61
62/** The end of the bracketed part that opens at `open`, after its closing bracket; quotes and expansions inside it are skipped. */
63function closingBracketOf(command: string, open: number, opener: string, closer: string): number | undefined {
64 let depth = 0
65 let at = open
66 while (at < command.length) {
67 const char = command[at]
68 if (char === '\\') {
69 at += 2
70 continue
71 }
72 if (char === "'" || char === '"' || char === '$' || char === '`') {
73 const end = char === "'" ? closingMarkOf(command, at, "'", false) : char === '"' ? doubleQuotedAt(command, at)?.end : expansionEnd(command, at)
74 if (end === undefined) return undefined
75 at = end
76 continue
77 }
78 if (char === opener) depth += 1
79 else if (char === closer && --depth === 0) return at + 1
80 at += 1
81 }
82 return undefined
83}
84
85/** The end of the expansion at `at`: `$(…)`, `$((…))`, `${…}`, `$'…'`, `$name`, a lone `$`, or `` `…` ``. */
86function expansionEnd(command: string, at: number): number | undefined {
87 if (command[at] === '`') return closingMarkOf(command, at, '`', true)
88 const next = command[at + 1]
89 if (next === '(') return closingBracketOf(command, at + 1, '(', ')')
90 if (next === '{') return closingBracketOf(command, at + 1, '{', '}')
91 if (next === "'") return closingMarkOf(command, at + 1, "'", true)
92 PARAMETER.lastIndex = at + 1
93 return PARAMETER.test(command) ? PARAMETER.lastIndex : at + 1
94}
95
96/** The `"…"` part at `at`: its text after quote removal, its end, and whether it holds no expansion. */
97function doubleQuotedAt(command: string, at: number): { text: string; end: number; isLiteral: boolean } | undefined {
98 let text = ''
99 let isLiteral = true
100 let index = at + 1
101 while (index < command.length) {
102 const char = command[index]!
103 if (char === '"') return { text, end: index + 1, isLiteral }
104 if (char === '\\' && index + 1 < command.length) {
105 const next = command[index + 1]!
106 if (next !== '\n') text += '$`"\\'.includes(next) ? next : `\\${next}`
107 index += 2
108 } else if (char === '$' || char === '`') {
109 const end = expansionEnd(command, index)
110 if (end === undefined) return undefined
111 text += command.slice(index, end)
112 isLiteral = false
113 index = end
114 } else {
115 text += char
116 index += 1
117 }
118 }
119 return undefined
120}
121
122/** The word at `start`: its text after quote removal, its end, whether it is literal, and whether any of it was quoted. */
123function wordAt(command: string, start: number): { text: string; end: number; isLiteral: boolean; isQuoted: boolean } | undefined {
124 let text = ''
125 let isLiteral = command[start] !== '~'
126 let isQuoted = false
127 let at = start
128 while (at < command.length && !METACHARACTERS.has(command[at]!)) {
129 const char = command[at]!
130 if (char === '\\') {
131 if (command[at + 1] !== '\n') text += command[at + 1] ?? ''
132 isQuoted = true
133 at += 2
134 } else if (char === "'") {
135 const end = closingMarkOf(command, at, "'", false)
136 if (end === undefined) return undefined
137 text += command.slice(at + 1, end - 1)
138 isQuoted = true
139 at = end
140 } else if (char === '"') {
141 const quoted = doubleQuotedAt(command, at)
142 if (quoted === undefined) return undefined
143 text += quoted.text
144 isLiteral &&= quoted.isLiteral
145 isQuoted = true
146 at = quoted.end
147 } else if (char === '$' || char === '`') {
148 const end = expansionEnd(command, at)
149 if (end === undefined) return undefined
150 text += command.slice(at, end)
151 isLiteral = false
152 at = end
153 } else {
154 if (PATTERN_CHARACTERS.has(char)) isLiteral = false
155 text += char
156 at += 1
157 }
158 }
159 return { text, end: at, isLiteral, isQuoted }
160}
161
162/**
163 * The tokens of a command, or undefined for text this reader does not follow: an open quote, a here-document,
164 * arithmetic in `((`, or a word right before `(`, such as a function or an array.
165 */
166function tokensOf(command: string): Token[] | undefined {
167 const tokens: Token[] = []
168 let fd = ''
169 let at = 0
170 while (at < command.length) {
171 const char = command[at]!
172 if (char === ' ' || char === '\t') {
173 at += 1
174 continue
175 }
176 if (char === '\\' && command[at + 1] === '\n') {
177 at += 2
178 continue
179 }
180 if (char === '#') {
181 const end = command.indexOf('\n', at)
182 at = end === -1 ? command.length : end
183 continue
184 }
185 if (command.startsWith('((', at)) return undefined
186 if ((char === '<' || char === '>') && command[at + 1] === '(') {
187 // Process substitution: command text in place of a path.
188 const end = closingBracketOf(command, at + 1, '(', ')')
189 if (end === undefined) return undefined
190 tokens.push({ kind: 'word', text: command.slice(at, end), isLiteral: false })
191 fd = ''
192 at = end
193 continue
194 }
195 const redirect = REDIRECTS.find(op => command.startsWith(op, at))
196 if (redirect !== undefined) {
197 if (HERE_DOCUMENTS.has(redirect)) return undefined
198 tokens.push({ kind: 'redirect', op: redirect, fd })
199 fd = ''
200 at += redirect.length
201 continue
202 }
203 const separator = SEPARATORS.find(op => command.startsWith(op, at))
204 if (separator !== undefined) {
205 tokens.push({ kind: 'separator', op: separator })
206 at += separator.length
207 continue
208 }
209 const word = wordAt(command, at)
210 if (word === undefined || command[word.end] === '(') return undefined
211 at = word.end
212 if ((command[at] === '<' || command[at] === '>') && !word.isQuoted && FD.test(word.text)) {
213 fd = word.text
214 } else {
215 tokens.push({ kind: 'word', text: word.text, isLiteral: word.isLiteral })
216 }
217 }
218 return tokens
219}
220
221/** Whether a redirection sends standard output to the file its target names. */
222function isOutputRedirect(redirect: Redirect, target: Word): boolean {
223 const fds = OUTPUT_REDIRECTS.get(redirect.op)
224 return fds !== undefined && fds.has(redirect.fd) && (redirect.op !== '>&' || !DUPLICATE.test(target.text))
225}
226
227/** The file of `tee FILE` or `tee -a FILE`; `other` for another tee, whose files are unknown; undefined when the command is no tee. */
228function teeFileOf(words: readonly Word[]): Word | 'other' | undefined {
229 if (words[0]?.text !== 'tee') return undefined
230 const files = words[1]?.text === '-a' ? words.slice(2) : words.slice(1)
231 const [file] = files
232 return files.length === 1 && file !== undefined && !file.text.startsWith('-') ? file : 'other'
233}
234
235/**
236 * The one file that a command's own text sends its output to, as the text names it: the target of `>`, `>>`, `1>`,
237 * `1>>`, `&>`, `&>>`, or `>&`, or the file of `tee FILE` or `tee -a FILE` at the end of a pipeline. Only a plain command
238 * list counts: commands joined by `;`, `&&`, a new line, or a pipe. Undefined when the text names no such file, names two
239 * different ones, names one that is not literal, holds `&` or `nohup`, holds code that may not run (a function, a
240 * condition, a loop, `||`, a group, or a subshell), or names a relative path after a folder change.
241 */
242export function redirectTargetOf(command: string): string | undefined {
243 const tokens = tokensOf(command)
244 if (tokens === undefined) return undefined
245 const targets: Word[] = []
246 let pipeline: Word[][] = [[]]
247 let isCondition = false
248 let changesFolder = false
249 /** Whether the next word is a command, where a word such as `if` or `{` starts compound code. */
250 let isCommandStart = true
251 /** Ends a pipeline: its last command's tee file counts, and an unknown tee follows nothing. */
252 const isEnded = (): boolean => {
253 const tee = pipeline.length > 1 ? teeFileOf(pipeline.at(-1) ?? []) : undefined
254 if (tee !== undefined && tee !== 'other') targets.push(tee)
255 pipeline = [[]]
256 return tee !== 'other'
257 }
258 for (let index = 0; index < tokens.length; index += 1) {
259 const token = tokens[index]!
260 if (token.kind === 'word') {
261 if (token.text === 'nohup' || (isCommandStart && (COMPOUND_WORDS.has(token.text) || token.text.startsWith('{')))) return undefined
262 if (HIDDEN_CODE.has(token.text) || (isCommandStart && token.text === '.')) changesFolder = true
263 isCommandStart &&= PIPELINE_PREFIXES.has(token.text) || ASSIGNMENT.test(token.text)
264 if (FOLDER_CHANGES.has(token.text)) changesFolder = true
265 // Inside `[[ … ]]`, `<` and `>` compare strings.
266 if (token.text === '[[') isCondition = true
267 else if (token.text === ']]') isCondition = false
268 pipeline.at(-1)?.push(token)
269 } else if (token.kind === 'redirect') {
270 if (isCondition) continue
271 const target = tokens[index + 1]
272 // zsh `>!`, `>>!`, and `>&!` force the redirection, so the word after `!` names the file: this reader follows none.
273 if (target?.kind !== 'word' || target.text.startsWith('!')) return undefined
274 index += 1
275 if (isOutputRedirect(token, target)) targets.push(target)
276 } else if (!LIST_SEPARATORS.has(token.op)) {
277 return undefined
278 } else if (token.op === '|' || token.op === '|&') {
279 isCommandStart = true
280 pipeline.push([])
281 } else if (isEnded()) {
282 isCommandStart = true
283 } else {
284 return undefined
285 }
286 }
287 if (!isEnded()) return undefined
288 const names = new Set(targets.map(target => target.text))
289 const [name] = names
290 if (names.size !== 1 || name === undefined || name === '' || targets.some(target => !target.isLiteral)) return undefined
291 return changesFolder && !name.startsWith('/') ? undefined : name
292}
293
294/** Whether a path, with `.` and `..` folded by their text, is `/dev` or under it. */
295function isDevicePath(path: string): boolean {
296 const parts: string[] = []
297 for (const part of path.split('/')) {
298 if (part === '..') parts.pop()
299 else if (part !== '' && part !== '.') parts.push(part)
300 }
301 return parts[0] === 'dev'
302}
303
304/**
305 * A redirect target as an absolute path in the session folder `cwd`. Undefined on Windows, where `cwd` is no POSIX
306 * path and the shell can map a path to another folder, and for a drive path or a path under `/dev`.
307 */
308export function redirectFileOf(target: string, cwd: string): string | undefined {
309 if (!cwd.startsWith('/') || DRIVE.test(target)) return undefined
310 const path = target.startsWith('/') ? target : `${cwd.replace(/\/+$/, '')}/${target.replace(/^(?:\.\/+)+/, '')}`
311 return isDevicePath(path) ? undefined : path
312}
313hooks/view.ts 271 lines1import type { AgentJob, Job, JobState, Wakeup } from '../types'
2import { isRunning } from './jobs'
3import {
4 BAND_BUTTONS_COLUMNS,
5 BAND_TITLE_MAX,
6 BAR_CELLS,
7 BAR_MIN_COLUMNS,
8 CELL_LINE_MAX,
9 CELL_LINE_NARROW_MAX,
10 CELL_TITLE_MAX,
11 FAILING_STREAK,
12 NOTE_MAX,
13 PANE_FIXED_COLUMNS,
14 PANE_TITLE_MIN,
15 QUIET_MS,
16 LARGE_READ_EVERY_MS,
17 TICK_MS,
18 RECENT_MS,
19 TABLE_FINISHED_ROWS,
20 TABLE_MAX_ROWS,
21 TABLE_NARROW_COLUMNS,
22 TABLE_RECENT_MS,
23} from './limits'
24import { clip, durationText, etaText, oneLine, remainingByRate } from './output'
25
26/** One job as every view shows it. */
27export type Row = {
28 id: string
29 glyph: string
30 tone: JobState
31 title: string
32 kind: string
33 age: string
34 progress?: string
35 percent?: number
36 bar?: string
37 eta?: string
38 isSlow: boolean
39 last?: string
40 tableLast?: string
41 flag?: string
42 note?: string
43}
44
45/** One colored piece of the band line. */
46export type BandPart = { text: string; tone: 'plain' | 'failed' | 'done' }
47
48/** What the pane draws. */
49export type PaneModel = { header: string; running: Row[]; ended: Row[] }
50
51const GLYPHS: Readonly<Record<JobState, string>> = { running: '●', completed: '✓', failed: '✗', stopped: '■', gone: '◌' }
52
53/** The words before a redirect file's name in a row's note, as in `output: build.log`. */
54const REDIRECT_NOTE = 'output: '
55
56/** Where a shell row's lines come from when that is its redirect file: `output: <file name>`. */
57function redirectNoteOf(job: Job): string | undefined {
58 if (job.kind !== 'shell' || job.redirectFile === undefined || job.redirectBytes === undefined) return undefined
59 return clip(oneLine(`${REDIRECT_NOTE}${job.redirectFile.split(/[\\/]/).pop() ?? ''}`), NOTE_MAX)
60}
61
62const endOf = (job: Job): number => job.endedAt ?? job.startedAt
63
64/** The tool of a subagent's activity summary, `<tool>: <detail>`. */
65const toolOf = (activity: string): string => activity.split(': ', 1)[0] ?? activity
66
67/** Whether none of a subagent's tool calls can reach Downrange: it runs elsewhere, and no call of it ran here. */
68const isUnseenAgent = (job: AgentJob): boolean => job.runsElsewhere === true && job.toolCount === 0
69
70function byAge(jobs: readonly Job[]): { running: Job[]; ended: Job[] } {
71 return {
72 running: jobs.filter(isRunning).sort((a, b) => a.startedAt - b.startedAt),
73 ended: jobs.filter(job => !isRunning(job)).sort((a, b) => endOf(b) - endOf(a)),
74 }
75}
76
77/** The ETA text: rate, then usual duration, then the monitor deadline. */
78function etaOf(job: Job, now: number): { text: string; isSlow: boolean } | undefined {
79 if (!isRunning(job)) return undefined
80 if ((job.kind === 'monitor' || (job.kind === 'shell' && job.successfulReadAt !== undefined)) && job.progress !== undefined) {
81 if (job.progress.percent >= 100) return { text: 'finishing', isSlow: false }
82 const remaining = job.anchor === undefined ? undefined : remainingByRate(job.progress, job.anchor, now)
83 if (remaining !== undefined) return { text: etaText(remaining), isSlow: false }
84 }
85 if (job.kind === 'shell' && job.usualMs !== undefined) {
86 const elapsed = now - job.startedAt
87 return elapsed < job.usualMs
88 ? { text: `${etaText(job.usualMs - elapsed)} usual`, isSlow: false }
89 : { text: `usual ${etaText(job.usualMs)}`, isSlow: elapsed > 1.5 * job.usualMs }
90 }
91 if (job.kind === 'monitor' && job.deadline !== undefined) {
92 return { text: job.deadline > now ? `ends in ${etaText(job.deadline - now)}` : 'ending', isSlow: false }
93 }
94 return undefined
95}
96
97/** The flags of a running job: a subagent's failed calls in a row, else silence for QUIET_MS. */
98function flagOf(job: Job, now: number): string | undefined {
99 if (!isRunning(job)) return undefined
100 if (job.omittedAt !== undefined) return 'awaiting confirmation'
101 if (job.kind === 'agent' && job.activityStatus !== undefined && job.activityStatus !== 'running') return undefined
102 if (job.kind === 'agent' && job.errorStreak >= FAILING_STREAK) return `${job.errorStreak} failed calls in a row`
103 const quiet = now - (job.lastOutputAt ?? job.startedAt)
104 const canBeQuiet = (job.kind === 'shell' && job.successfulReadAt !== undefined && now - job.successfulReadAt <= LARGE_READ_EVERY_MS + TICK_MS) || (job.kind === 'agent' && job.inFlight === 0 && !isUnseenAgent(job))
105 return canBeQuiet && quiet >= QUIET_MS ? `quiet ${durationText(quiet)}` : undefined
106}
107
108function progressCell(job: Job): { text?: string; percent?: number } {
109 if (!isRunning(job)) return { text: job.exitCode === undefined ? undefined : `exit ${job.exitCode}` }
110 if ((job.kind === 'shell' || job.kind === 'monitor') && job.progress !== undefined) {
111 const { label, percent } = job.progress
112 return { text: label.endsWith('%') ? label : `${label} (${Math.round(percent)}%)`, percent }
113 }
114 if (job.kind === 'monitor' && job.eventCount > 0) return { text: `${job.eventCount} event${job.eventCount === 1 ? '' : 's'}` }
115 if (job.kind === 'agent') {
116 const text = [job.activityStatus === 'running' ? undefined : job.activityStatus, isUnseenAgent(job) ? undefined : `${job.toolCount} tool${job.toolCount === 1 ? '' : 's'}`].filter(Boolean).join(' · ')
117 return { text: text === '' ? undefined : text }
118 }
119 return {}
120}
121
122function ageOf(job: Job, now: number): string {
123 if (isRunning(job)) return `${job.isStartSeen ? '' : '≥'}${durationText(now - job.startedAt)}`
124 return job.isStartSeen ? `took ${durationText(endOf(job) - job.startedAt)}` : 'ended'
125}
126
127function rowOf(job: Job, now: number): Row {
128 const eta = etaOf(job, now)
129 const progress = progressCell(job)
130 const redirect = redirectNoteOf(job)
131 return {
132 id: job.id,
133 glyph: GLYPHS[job.state],
134 tone: job.state,
135 title: job.title,
136 kind: job.kind === 'task' ? job.taskType : job.kind,
137 age: ageOf(job, now),
138 progress: progress.text,
139 percent: progress.percent,
140 eta: eta?.text,
141 isSlow: eta?.isSlow ?? false,
142 last: job.lastLine,
143 tableLast: job.kind === 'agent' && job.lastLine !== undefined ? toolOf(job.lastLine) : job.lastLine,
144 flag: flagOf(job, now),
145 note: redirect === undefined || job.note === undefined ? job.note ?? redirect : `${job.note} · ${redirect}`,
146 }
147}
148
149function wakeupText(wakeup: Wakeup, now: number): string {
150 return wakeup.dueAt > now ? `in ${durationText(wakeup.dueAt - now)}` : 'due now'
151}
152
153function headerOf(jobs: readonly Job[], wakeup: Wakeup | null, now: number): string {
154 const running = jobs.filter(isRunning).length
155 const failed = jobs.filter(job => job.state === 'failed' && now - endOf(job) <= TABLE_RECENT_MS).length
156 return [
157 ...(running > 0 ? [`${running} running`] : []),
158 ...(failed > 0 ? [`${failed} failed`] : []),
159 ...(wakeup === null ? [] : [`wakeup ${wakeupText(wakeup, now)} (${oneLine(wakeup.reason)})`]),
160 ].join(' · ')
161}
162
163/** A table cell: one line, cut, with table syntax escaped; backslashes before a pipe double and one more escapes the pipe, so the pipe never ends the cell. */
164const cell = (text: string, max: number): string =>
165 clip(oneLine(text), max)
166 .replace(/(\\*)\|/g, (_, slashes: string) => `${slashes}${slashes}\\|`)
167 .replace(/`/g, "'")
168
169/** Code spans keep backslashes literally. Put escaped table pipes between spans so their escapes cannot change the output. */
170const codeCell = (text: string, max: number): string =>
171 clip(oneLine(text), max)
172 .replace(/`/g, "'")
173 .split('|')
174 .map(part => part === '' ? '' : `\`${part.trim() !== '' && part.startsWith(' ') && part.endsWith(' ') ? ` ${part} ` : part}\``)
175 .join('\\|')
176
177/**
178 * One table row: the last output in backticks, then the flag and the note; a narrow table joins Kind into Job and ETA
179 * into Progress. A subagent row shows its tool alone: the table enters the conversation, and a tool's input can hold
180 * text that the main model never saw.
181 */
182function tableRow(row: Row, isNarrow: boolean): string {
183 const last =
184 [
185 ...(row.tableLast === undefined ? [] : [codeCell(row.tableLast, isNarrow ? CELL_LINE_NARROW_MAX : CELL_LINE_MAX)]),
186 ...(row.flag === undefined ? [] : [row.flag]),
187 ...(row.note === undefined ? [] : [cell(row.note, NOTE_MAX)]),
188 ].join(' · ') || '—'
189 const title = cell(row.title, CELL_TITLE_MAX)
190 const kind = cell(row.kind, CELL_TITLE_MAX)
191 if (!isNarrow) return `| ${row.glyph} | ${title} | ${kind} | ${row.age} | ${row.progress ?? '—'} | ${row.eta ?? '—'} | ${last} |`
192 const progress = [row.progress, row.eta].filter(part => part !== undefined).join(' · ') || '—'
193 return `| ${row.glyph} | ${title} (${kind}) | ${row.age} | ${progress} | ${last} |`
194}
195
196/** The /downrange table: running jobs, then up to 5 jobs that ended in the last 30 minutes; below TABLE_NARROW_COLUMNS, five columns. */
197export function tableOf(jobs: readonly Job[], wakeup: Wakeup | null, now: number, columns = TABLE_NARROW_COLUMNS, named = true): string {
198 const { running, ended } = byAge(jobs)
199 const shown = [...running, ...ended.filter(job => now - endOf(job) <= TABLE_RECENT_MS).slice(0, TABLE_FINISHED_ROWS)]
200 if (shown.length === 0 && wakeup === null) return named ? '**Downrange**: no background work right now.' : 'No background work right now.'
201 const header = [named ? '**Downrange**' : '', headerOf(jobs, wakeup, now)].filter(part => part !== '').join(' · ')
202 if (shown.length === 0) return header
203 const isNarrow = columns < TABLE_NARROW_COLUMNS
204 const more = shown.length > TABLE_MAX_ROWS ? ['', `+${shown.length - TABLE_MAX_ROWS} more: /downrange pane shows all.`] : []
205 return [
206 header,
207 '',
208 isNarrow ? '| | Job | Age | Progress | Last output |' : '| | Job | Kind | Age | Progress | ETA | Last output |',
209 isNarrow ? '| --- | --- | --- | --- | --- |' : '| --- | --- | --- | --- | --- | --- | --- |',
210 ...shown.slice(0, TABLE_MAX_ROWS).map(job => tableRow(rowOf(job, now), isNarrow)),
211 ...more,
212 ].join('\n')
213}
214
215function snippetOf(job: Job, now: number): BandPart {
216 const title = clip(job.title, BAND_TITLE_MAX)
217 if (job.state === 'failed') return { text: `✗ ${title} ${job.exitCode === undefined ? 'failed' : `exit ${job.exitCode}`}`, tone: 'failed' }
218 if (!isRunning(job)) return { text: `${GLYPHS[job.state]} ${title}`, tone: 'done' }
219 const row = rowOf(job, now)
220 const detail = [row.progress, row.eta].filter(part => part !== undefined).join(' ')
221 return { text: `${title} ${detail === '' ? row.age : detail}`, tone: 'plain' }
222}
223
224/** The band line: counts first, then as many job snippets as the width holds. */
225export function bandOf(jobs: readonly Job[], wakeup: Wakeup | null, now: number, columns: number): BandPart[] {
226 const { running, ended } = byAge(jobs)
227 const recent = ended.filter(job => now - endOf(job) < RECENT_MS)
228 const failed = recent.filter(job => job.state === 'failed')
229 const counts: BandPart[] = [
230 ...(running.length > 0 ? [{ text: `● ${running.length} running`, tone: 'plain' as const }] : []),
231 ...(failed.length > 0 ? [{ text: `✗ ${failed.length} failed`, tone: 'failed' as const }] : []),
232 ...(wakeup === null ? [] : [{ text: `◷ ${wakeupText(wakeup, now)}`, tone: 'plain' as const }]),
233 ]
234 const hasProgress = (job: Job) => (job.kind === 'shell' || job.kind === 'monitor') && job.progress !== undefined
235 const queue = [...failed, ...running.filter(hasProgress), ...running.filter(job => !hasProgress(job)), ...recent.filter(job => job.state !== 'failed')]
236 const budget = columns - BAND_BUTTONS_COLUMNS
237 const snippets: BandPart[] = []
238 let used = counts.reduce((sum, part, index) => sum + part.text.length + (index === 0 ? 0 : 2), 0)
239 for (const job of queue) {
240 const snippet = snippetOf(job, now)
241 if (used + snippet.text.length + 3 > budget) break
242 snippets.push(snippet)
243 used += snippet.text.length + 3
244 }
245 const joined = (parts: readonly BandPart[], separator: string): BandPart[] =>
246 parts.flatMap((part, index) => (index === 0 ? [part] : [{ text: separator, tone: 'plain' as const }, part]))
247 const divider: BandPart[] = counts.length > 0 && snippets.length > 0 ? [{ text: ' │ ', tone: 'plain' }] : []
248 return [...joined(counts, ' '), ...divider, ...joined(snippets, ' · ')]
249}
250
251/** Hidden: the user pressed Hide, and no job started after that. */
252export function isBandHidden(jobs: readonly Job[], hiddenAt: number | null): boolean {
253 return hiddenAt !== null && jobs.every(job => job.startedAt <= hiddenAt)
254}
255
256function barOf(percent: number): string {
257 const filled = Math.min(BAR_CELLS, Math.max(0, Math.round((percent / 100) * BAR_CELLS)))
258 return '▰'.repeat(filled) + '▱'.repeat(BAR_CELLS - filled)
259}
260
261/** The pane's model: titles fit the width; bars show from BAR_MIN_COLUMNS. */
262export function paneModelOf(jobs: readonly Job[], wakeup: Wakeup | null, now: number, columns: number): PaneModel {
263 const { running, ended } = byAge(jobs)
264 const titleWidth = Math.max(PANE_TITLE_MIN, columns - PANE_FIXED_COLUMNS)
265 const shape = (job: Job): Row => {
266 const row = rowOf(job, now)
267 return { ...row, title: clip(row.title, titleWidth), bar: columns >= BAR_MIN_COLUMNS && row.percent !== undefined ? barOf(row.percent) : undefined }
268 }
269 return { header: headerOf(jobs, wakeup, now), running: running.map(shape), ended: ended.map(shape) }
270}
271types/index.d.ts 114 lines1export type JobState = 'running' | 'completed' | 'failed' | 'stopped' | 'gone'
2
3/**
4 * A progress reading; isGeneric is true for a ratio in no known progress format, which counts only with the
5 * `genericRatios` option on, and false for a known format. A reading that an earlier version saved has no mark.
6 */
7export type Progress = { percent: number; label: string; at: number; isGeneric?: boolean }
8
9/** The start of a rising run of readings; isGeneric is the mark of the reading that started it, absent when an earlier version saved it. */
10export type Anchor = { percent: number; at: number; isGeneric?: boolean }
11
12export type JobBase = {
13 id: string
14 title: string
15 state: JobState
16 startedAt: number
17 /** When a snapshot or agent list most recently confirmed the job running. */
18 lastRunningAt?: number
19 /** Snapshot omission protected by the grace period. */
20 omittedAt?: number
21 isStartSeen: boolean
22 /** When the job first ended. */
23 endedAt?: number
24 /** When its latest end came: a later notification can end a job that already shows ended. */
25 lastEndedAt?: number
26 exitCode?: number
27 note?: string
28 /** The note the launch result gave, which the job shows again when it runs again after an end. */
29 launchNote?: string
30 lastLine?: string
31 lastOutputAt?: number
32}
33
34export type ShellJob = JobBase & {
35 kind: 'shell'
36 ownerAgentId?: string
37 endsWithAgent?: boolean
38 successfulReadAt?: number
39 outputFile?: string
40 /** The file that the command's own text sends its output to, absolute. Each read looks at it and at the output file, and the row shows the one that changed last. */
41 redirectFile?: string
42 /** The size of redirectFile at its last successful read. While it is set, the row's lines and progress come from that file. */
43 redirectBytes?: number
44 outputBytes?: number
45 /** The size at a failed tail, used for its retry interval without marking that output as read. */
46 failedReadBytes?: number
47 lastReadAt?: number
48 /** When the latest end, or a reload of the mod, asked for one more full read of the output; a read that began after it clears it. */
49 finalReadAskedAt?: number
50 progress?: Progress
51 anchor?: Anchor
52 usualMs?: number
53 historyKey?: string
54}
55
56export type MonitorJob = JobBase & {
57 kind: 'monitor'
58 eventCount: number
59 deadline?: number
60 progress?: Progress
61 anchor?: Anchor
62}
63
64export type AgentJob = JobBase & {
65 kind: 'agent'
66 activityStatus?: 'pending' | 'running' | 'waiting' | 'idle'
67 /** Its launch said it runs outside this process, as a remote subagent or a teammate in a terminal pane of its own, so its tool calls do not reach Downrange. */
68 runsElsewhere?: boolean
69 toolCount: number
70 inFlight: number
71 errorStreak: number
72 /** When the newest tool call started, independently of completion times. */
73 lastToolStartedAt?: number
74 /** Failure times in the streak while other tool calls can still settle. */
75 concurrentFailureTimes?: number[]
76 /** Latest success among those concurrent completions; cleared when no call remains. */
77 lastSuccessAt?: number
78}
79
80export type WorkflowJob = JobBase & { kind: 'workflow' }
81
82export type TaskJob = JobBase & { kind: 'task'; taskType: string }
83
84export type Job = ShellJob | MonitorJob | AgentJob | WorkflowJob | TaskJob
85
86/** Rows and observation times survive together, including empty snapshots and removed task IDs. Each time list keeps every entry of a kept row, and the MAX_BARRIERS newest entries (hooks/limits.ts) of tasks with no row. */
87export type JobsState = {
88 items: Job[]
89 observed: [id: string, at: number][]
90 removed: [id: string, at: number][]
91 snapshotAt: number | null
92 /** Membership at snapshotAt, used only to reconcile absences. */
93 snapshotIds?: string[]
94 /** Stored child notification identities already applied. */
95 noticeRows?: string[]
96}
97
98/** The pending wakeup: when it fires, why, and when ScheduleWakeup set it. */
99export type Wakeup = { dueAt: number; reason: string; setAt: number }
100
101/** The latest wakeup change, including cancellation when no wakeup remains. */
102export type WakeupState = { pending: Wakeup | null; updatedAt: number | null }
103
104declare module 'claude-code' {
105 interface PluginState {
106 'downrange': {
107 jobs: Shaped<JobsState>
108 wakeup: Shaped<WakeupState>
109 bandHiddenAt: number | null
110 lastAnswered: { text: string; at: number } | null
111 }
112 }
113}
114