SLOPSHOPPER

downrange

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

newpanebandguardcommandprompt
v1.1.2MITupdated 2026-10-14dot-agi/downrange
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · downrange
│ ┃ downrange ✕ › fix the failing auth test and add an audit log call │ ┃ No background work yet. Jobs show here when │ ┃ Claude runs a command in the background, ● downrange: classic.Stop: no session_crons in the hook input; a canc │ ┃ starts a Monitor, a background agent, or a ● downrange: classic.Stop: no background_tasks in the hook input; rec │ ┃ workflow. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /downrange │ ⎿ downrange: No background work right now. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · downrange
No background work yet. Jobs show here when Claude runs a [ command in the background, starts a Monitor, a background agent, or a workflow.
README

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

Claude Code terminal where a line above the prompt reads: 1 running, count to 90, 21/90 (23%), about 3 minutes left.

The band above the prompt shows a background shell with its progress and ETA.

Why

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.

Features

  • Progress from real output. 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.
  • ETA. From the progress rate. With no rate yet, from the usual duration of the same command in the same folder, or from a Monitor watch's deadline. In the pane, a usual-duration ETA turns yellow once the run passes 1.5 times its usual duration.
  • The background tasks of your session. Shell commands (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).
  • Liveness flags. 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.
  • A table that prints mid-turn. /downrange answers at once, even while Claude works. With the answerStatusPrompts option on, a prompt such as status? gets the same table.
  • In step with Claude Code. At each turn end, Downrange checks its rows against Claude Code's task list. It adds tasks it missed, and marks a job that left the list ended without a report. Running jobs carry across /clear, /resume, and /branch.
  • Read-only by design. Downrange never stops or changes a task. Built-in /tasks stops tasks.

Pane above the prompt shows three running jobs: count to 90 with a progress bar at 15/90, a wait 40s subagent, and a Monitor watch.

The live pane lists a shell, a subagent, and a Monitor watch.

The /downrange command prints a table of a running shell at 65/90, a running Monitor watch, and a finished subagent, with ages and ETAs.

/downrange prints a status table at once, with no model request.

The usage guide explains every view, symbol, note, and message.

How it works

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
  • Launches. Hooks on the Bash, Monitor, Agent, and Workflow tools record each background launch with its id and title, and a shell's output file. A ScheduleWakeup result sets the wakeup. A TaskStop result marks its job stopped.
  • Output. The ticker reads each running shell's output file every 2 seconds, or every 10 seconds above 256 KiB. It keeps the end of the file: the last 16,384 characters, or the last 16,384 bytes through tail above 4 MiB. Progress comes from the last 10 display lines of that end.
  • Redirect files. Some commands send their output to a file, such as > 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.
  • Ends. Task notifications end jobs and carry Monitor events. The agent list updates subagent states. Stop and SubagentStop snapshots add tasks that Downrange missed, and end tasks that left the list after a 10-second grace period. A scheduled prompt that fires from 5 seconds before the wakeup's due time clears the wakeup. So does a snapshot with no one-shot schedule, after the same grace period.
  • Usual durations. A clean end (exit code 0 or none) of a shell that Downrange saw start adds its run time to a history keyed by a hash of folder and command. The usual-duration ETA is the median of the last 5 runs.
  • Views and carry. The band, the pane, and /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.

Install

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

Agents and scripts

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:

  • On a host with no SSH key for GitHub, set CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 in the environment of every claude command and session.
  • New code loads in the next session, or after /reload-plugins. An agent cannot type /reload-plugins, so it starts a new session.
  • Running install again does not update. Use update.
  • The mv replaces the settings file, with mode 600. If settings.json is a symbolic link, edit its target instead.

Commands

Command or keyWhat it does
/downrangePrints the status table at once, mid-turn too
/downrange paneOpens or closes the live pane
j on the focused bandOpens the pane
h on the focused bandHides the band until a new job starts
c in the focused paneClears finished jobs
x in the focused paneCloses 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.

Options

Set options in /config, or under pluginConfigs["downrange@downrange"].options in ~/.claude/settings.json. For a --plugin-dir load, the key is downrange@inline.

OptionDefaultEffect
bandtrueShows the band while background work is live
answerStatusPromptsfalseWhen 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
genericRatiosfalseReads 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 it runs

WhereBandPane/downrangeLocal status answer
Terminal, fullscreenYesDocked from 110 columns, else above the promptYesWhen the option is on
Terminal, main screenYesAbove the promptYesWhen the option is on
Desktop app, Code tabYesYesYesNo
VS Code panelNoNot verified; use /downrangeYesNo
Agent SDK and stream-json sessionsNoNoYesNo
Mobile app through Remote ControlNoNot verified; the terminal on your machine shows itYesNo

How it compares

Feature or projectWhat it doesWhat Downrange adds
Built-in /tasksAn interactive dialog that lists and stops background shells, subagents, Monitors, and MCP tasksLive 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 panelRows of running background subagents below the prompt, with a key that stops them allShells, Monitors, wakeups, and progress
Built-in /workflowsA progress line per workflow run, and a view of its phasesEach 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 lineThe background tasks inside one session
rabeA 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 agentProgress and ETA parsed from job output, a text table, and running jobs kept across /clear, /resume, and /branch
FlotillaBash, 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 alertThe last output line, progress, and ETA for shells, the /loop wakeup, quiet flags from output silence, and the carry across /clear
claude-deckShells and agents with run time, a stop button per shell, job steps as a tree, recently ended commands, cost and usage meters, and failure toastsMonitor watches, the /loop wakeup, progress and ETA from output, and running jobs kept across /clear
claude-flightdeckA permission gate view, model vitals, and rich subagent cards and swimlanesProgress and ETA read from the output of background shells and Monitors
sproutSubagent cards with cost and a file tree. Its progress comes from a note it injects for Claude and a step tool that Claude callsProgress read from real job output, with no tool or note for Claude

Known limits

What it catches

  • Progress. Percentages, [3/7], Step 3 of 7, and the progress lines of Docker, cargo, pytest, git, and similar tools.
  • Jobs. Background shells, Monitor watches, subagents, teammates, workflow runs, and the dynamic /loop wakeup.
  • Stalls. A shell or subagent that stays silent for 2 minutes, and a subagent with 3 failed calls in a row.
  • ETAs. From the progress rate, the usual run time of the same command in the same folder, or a Monitor watch's deadline.

What it does not catch

  • Progress. Ratios outside known formats, such as loss 12/40, unless genericRatios is on. Dates such as [10/11] can still count.
  • Jobs. Fixed-interval loops such as /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.
  • Stalls. Stuck remote subagents, teammates in their own panes, and waiting agents. They show no quiet flag.
  • ETAs. Run time in the foreground before ctrl+b or a timeout move. In output silence, a rate ETA runs down to <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.

  • Only dynamic /loop wakeups. Only wakeups that ScheduleWakeup creates appear. Fixed-interval loops such as /loop 10m and other cron schedules do not.
  • Progress read from output. Progress is a guess from the text. By default, a ratio counts only in a known progress format: in brackets, at the start of a line, after a step word such as 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.
  • ETAs during output silence. When output stops, the bar keeps its last reading, and the rate ETA keeps counting down from the time of that reading. Once the estimate runs out, the ETA stays at <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.
  • Monitor deadlines and rate ETAs. A Monitor watch shows its deadline only when it has no progress-based ETA. Once its progress gives a rate ETA, or reaches 100% and shows 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.
  • Time in the foreground before a move to the background. A shell job's clock starts when the Bash call returns its background result. A command that first ran in the foreground, such as one moved at its timeout or with ctrl+b, loses that time. Its age, its 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.
  • Tasks first seen in a snapshot. Claude Code's turn-end snapshot can list a Monitor as a shell. A launch result is the result of the Bash or Monitor call that started a task. A Monitor first seen in a snapshot shows as a shell until its launch result, its first event, or its end notification arrives. Only its launch result gives it a deadline. A shell first seen in a snapshot shows no last line, progress, ETA, or 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.
  • Commands that subagents start. A command with the note 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.
  • Agents that wait. A subagent that the agent list reports as pending, waiting, or idle shows that word, beside its tool count when it shows one. It shows no quiet flag and no failed-calls flag, however long it stays in that state.
  • Agents that run elsewhere. Downrange sees the tool calls only of agents that run in this Claude Code process. A remote subagent shows 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.
  • A session moved to another process. /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.

Privacy

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.

  • What it sends, and where. Downrange itself sends nothing off your machine. Its output goes to your screen: the band, the pane, the /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:
  • The /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.
  • Where a remote surface draws the band or the pane, Claude Code sends that drawing to the surface.
  • The program it starts. Only 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.
  • Local data it reads. Downrange reads these files, and no other file:
  • The output file of each background shell, which Claude Code writes in its tasks folder, with its size and change time.
  • The redirect file of a background shell, with its size and change time, at each read of its output file. Downrange reads the redirect file's text when that file changed last, or while the output file holds no line of the job. That is the one file that the command's own text sends its output to, such as 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.
  • Its own duration store, a JSON file that Claude Code keeps for the mod in your Claude Code configuration folder.

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.

  • Conversation data it reads. The input and result of the tool calls in the hook table below: a command or description, the task id, the output file path, and the wakeup time and reason. Downrange reads a background command's text to find its redirect file. One input field of each tool call a tracked subagent makes (see the last item). Task notifications. The 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.
  • What it keeps. Session state, which Claude Code holds for the session and which other mods in the session can read. It holds job titles, last output lines, notes, tool summaries, and the wakeup's reason, each cut to 200 c
Source 12 files
hooks/register.tsx 738 lines
1import { 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}
738
hooks/drawing.tsx 114 lines
1import 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}
114
hooks/history.ts 42 lines
1import { 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}
42
hooks/jobs.ts 771 lines
1import 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}
771
hooks/limits.ts 81 lines
1/** 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
81
hooks/notices.ts 71 lines
1/** 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('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&amp;', '&')
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}
71
hooks/output.ts 194 lines
1import 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}
194
hooks/state.ts 60 lines
1import 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}
60
hooks/prompt.ts 14 lines
1/** 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}
14
hooks/redirect.ts 313 lines
1/**
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}
313
hooks/view.ts 271 lines
1import 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}
271
types/index.d.ts 114 lines
1export 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