From 0612bb4c58e9483a9751fe632930a98c26e1d1a9 Mon Sep 17 00:00:00 2001 From: dot-agi Date: Mon, 12 Oct 2026 15:28:01 +0530 Subject: [PATCH] Release 1.1.1 --- .claude-plugin/plugin.json | 8 ++++- AGENTS.md | 2 +- README.md | 40 ++++++++++++------------- docs/usage.md | 22 +++++++++++--- hooks/jobs.ts | 49 +++++++++++++++++++++++++------ hooks/limits.ts | 2 ++ hooks/output.ts | 60 +++++++++++++++++++++++++++++++------- hooks/register.tsx | 11 +++++-- hooks/state.ts | 5 ++-- types/index.d.ts | 11 +++++-- 10 files changed, 157 insertions(+), 53 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index a21f681..05beae4 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "downrange", - "version": "1.1.0", + "version": "1.1.1", "description": "A band, pane, and /downrange table: progress, ETA, and last output for Claude Code background shells and Monitor watches, tool activity for subagents, state for workflows, and the next /loop wakeup.", "author": { "name": "dot-agi" }, "license": "MIT", @@ -24,6 +24,12 @@ "title": "Answer status prompts locally", "description": "When Claude is idle and background work runs, answer an exact status prompt such as \"status?\" with the /downrange table instead of sending it to Claude. Send the same prompt again within 60 seconds to ask Claude.", "default": false + }, + "genericRatios": { + "type": "boolean", + "title": "Progress from any ratio", + "description": "Also read progress from a ratio in any place in the output, such as \"loss 12/40\" or \"Retry 1 of 3\". Off: only known formats count, such as \"45%\", \"[3/7]\", \"Step 3 of 7\", or a count after a progress bar. On shows progress for more jobs, and more false readings, such as a date in \"Released 10/11\".", + "default": false } } } diff --git a/AGENTS.md b/AGENTS.md index 00fb793..b736930 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ Run all of them after each change. All must pass. - Write event names in `on(...)` as string literals. Write each matcher value as a literal or as a constant of `hooks/register.tsx`: `claude plugin validate` shows an imported constant as `?`. Use static imports only. Hooks run with no DOM and no Node. - Export every type in `types/index.d.ts`. `claude plugin validate` refuses a contract type without `export`. - Keep the other modules pure: no `$`, no I/O, and no clock reads. Pass `now` in. -- Keep session values in `$.state` through the atoms. The only module variables are `ticker`, `isTicking`, `lastTickAt`, `latest`, `carry`, `historyWrites`, and `reported`. +- Keep session values in `$.state` through the atoms. The only module variables are `ticker`, `isTicking`, `lastTickAt`, `latest`, `carry`, `historyWrites`, `reported`, and `isAnyRatio`, the `genericRatios` option that `register` sets. - Write `jobs` only through `writeJobs` and `wakeup` only through `writeWakeup`. They keep the carry's copy and the ticker current. - Render hooks never write state. Buttons write from their press handlers. - Give every `on(...)` registration a `.catch` that logs the failure under the hook's label, such as `tool.call{tool=Bash}`, and passes the event on with `next(e)`: write it as a function literal that calls `passOn`. `claude plugin validate` refuses a `.catch` that is not a function literal or the name of one. The `/downrange` command and the pane answer with the cause instead. diff --git a/README.md b/README.md index fb42683..2c1558a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Claude Code 2.1.288+ Claude Code mod License: MIT - Version: 1.1.0 + Version: 1.1.1

Progress and an ETA for background shells and Monitor watches, read from their real output or past runs, with no model request.

@@ -23,7 +23,7 @@ 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 become a progress label, and a bar in the pane. Shell output and Monitor events both count. +- **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. - **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. @@ -148,6 +148,7 @@ Set options in `/config`, or under `pluginConfigs["downrange@downrange"].options | --- | --- | --- | | `band` | `true` | Shows the band while background work is live | | `answerStatusPrompts` | `false` | When Claude is idle and a job runs, answers an exact status prompt (`status?`, `what's the status?`, `give me a status update`) with the table. Send the same prompt again within 60 seconds to ask Claude | +| `genericRatios` | `false` | Reads progress from a ratio in any place in the output, such as `loss 12/40`. Off, only known formats count, such as a percentage, or a ratio in brackets, at the start of a line, after a step word such as `Step`, or after a progress bar. On shows progress for more jobs, and more false readings, such as a date like `Released 10/11` | ## Where it runs @@ -176,36 +177,35 @@ Set options in `/config`, or under `pluginConfigs["downrange@downrange"].options ## Known limits -Only dynamic `/loop` wakeups appear, and other schedules can clear or keep one. Progress and ETAs are guesses from output and past runs, and output silence, failed reads, late inputs, remote agents, closed teammate panes, and session changes can make a row wrong or stale. +### 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, and jobs after `/background` until the next turn end. +- **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](docs/usage.md#known-limits) 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.
-Read all known limits +Read the limits you can hit in daily use - **Only dynamic `/loop` wakeups.** Only wakeups that `ScheduleWakeup` creates appear. Fixed-interval loops such as `/loop 10m` and other cron schedules do not. -- **Other schedules and the wakeup.** Downrange cannot tell which schedule fired a scheduled prompt, or which one-shot schedule in a turn-end snapshot is the wakeup. A prompt of a scheduled task, a routine, or a fixed-interval loop that fires from 5 seconds before the wakeup's due time on clears the wakeup, even before the wakeup's own prompt fires. A cancelled wakeup that only a snapshot can clear stays while any other one-shot schedule remains. It then shows until a scheduled prompt fires near its due time, a new `ScheduleWakeup` result replaces it, or it is 10 minutes past due. -- **Progress read from output.** Progress is a guess from the text. A short date such as `10/11` counts as progress, unless a two-digit clock follows it, as in `10/11 14:03`. `N of M` followed by a space inside a path, such as `Disc 1 of 2 (live)/a.flac`, counts too, and so does another ratio or percentage that is not progress. These change the bar and the rate ETA. 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`. -- **Only the end of an output.** A direct read of a file up to 4 MiB keeps the last 16,384 characters (UTF-16 code units), which can be up to 48 KiB of non-ASCII text. A tail read above 4 MiB keeps the last 16,384 bytes. Progress and the last line come from that end. Long lines after a progress line can push it out. +- **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. -- **Duration history after a snapshot end.** A run adds to the duration history only when its completion notice arrives while its row still runs. When a turn-end snapshot already ended the row as `ended without a report`, the later notice marks it completed but adds no duration. -- **Simultaneous duration records.** Two sessions recording durations together can lose new runs, because the duration store has no versioned write. One lost write can hold several runs. When it held a command's first runs, that command keeps no history and shows no usual-duration estimate. Runs stored before both writes began remain, with their estimates. -- **Failed output reads.** While reads of a shell's output fail, its row shows no `quiet` flag and no rate ETA. When a shell job ends, Downrange reads its output one more time. If that read fails, Downrange does not try it again. The job can keep `output unavailable` or `tail failed` and the last line of an earlier read. Only a later ending, or a later notification that names the output file, asks for a new read. -- **Large outputs on Windows.** Outputs over 4 MiB need `tail` on PATH. Without it, such a job shows `tail failed`. A launch or end note that the job already shows, such as `backgrounded by you`, stays in its place; `claude --debug` still shows `cannot tail the output of task `. - **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. -- **`awaiting confirmation`.** A turn-end snapshot that leaves out a job younger than 10 seconds does not end it. The job keeps running with `awaiting confirmation` until newer input confirms or ends it, such as a later snapshot or a notification. The label does not expire on a timer. -- **Inputs that reach Downrange late.** Claude Code gives its events no shared sequence number. Downrange stamps each input with the time it receives it, and drops an input older than one it already applied to the same job. Hooks can overlap, so an end notification can land after a newer output read, agent list, or snapshot of its job. A completed, failed, or stopped notification of a shell, Monitor, or workflow still applies then, with its exit code, because such a task never runs again. Downrange drops a late end of an agent or a teammate, a `blocked` end, and the end of a remote task such as a remote workflow, because such a task can run again under its id. That row keeps running until a later agent list ends it, or a snapshot ends it as `ended without a report`, with no exit code or end note. A teammate can leave the agent list as it ends, so its row ends at the next turn-end snapshot. A notification does not add back a job that left the list, such as one you cleared. It still can when it lands during `/clear`, `/resume`, or `/branch`, before Downrange restores what it remembers, or when Downrange forgot the task (see the 2,000 tasks below). The row then comes back as running, with a `≥` age, until a later snapshot ends it. -- **Launch details that arrive late.** The same rule applies to launch results. A newer notification, Monitor event, snapshot, or output read of a task can land while Downrange still handles its launch result. Downrange then drops what the launch result adds. A Monitor then shows no deadline, and its age starts with `≥`. A shell can lose its usual-duration ETA, or its output file and launch note too, and that run adds nothing to the duration history. A notification that later names the shell's output file still brings back its last line. - **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 teammate whose pane closed.** A teammate in a terminal pane of its own writes `running` or `idle` in its team's roster. When that pane closes or dies, the roster keeps the last word. Its notification or a snapshot ends its row, but a later agent list read shows it running again. That read comes at each turn end, and every second while another agent runs. The row can then switch between ended and running, keep the band visible, and show `idle` with no end note. Check the teammate's own pane before you act on the row. Use **Hide** to dismiss the band. - **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. -- **Changes during `/clear`, `/resume`, or `/branch`.** Downrange carries the jobs and the wakeup as it last wrote them before the old session ended. An update that lands during the change, such as a launch or a notification, can be lost. A later turn-end snapshot then adds a lost running task, with a `≥` age, and ends a task that left its list, as `ended without a report`. Downrange remembers the last 2,000 tasks that left its list, and forgets the oldest first. A task does not count toward the 2,000 while it is back in the list. These commands carry that memory to the next session. A finished job leaves the list through **Clear finished**, through the 20-job limit of **Finished**, or when you leave its session with one of these commands, which carry only running jobs. After `/resume`, a job that the resumed session last showed running can return, if 2,000 other tasks left the list after it, counted across all later sessions. The job shows `ended without a report`, or shows as running until the next turn end. -- **Another identity for the mod.** Claude Code keeps the duration history per plugin identity, `@`. Installed from the `downrange` marketplace, Downrange is `downrange@downrange`. Loaded with `--plugin-dir`, it is `downrange@inline`. A copy under another identity, such as one installed from another marketplace, keeps a separate history, which starts empty. It shows no usual-duration ETA until it records runs of its own. The jobs and the wakeup live in session state under the plugin name `downrange`. A build under another plugin name cannot read them, so it starts with no jobs and no wakeup. A later turn-end snapshot brings running tasks back, with `≥` ages. The `/loop` wakeup returns only with the next `ScheduleWakeup` result. -- **Quoted failure text in task descriptions.** A failed or stopped task's description can contain quoted text followed by `failed:` or `was stopped:`, such as `Investigate "build" failed: exit code 2`. If Downrange first sees the task at its end, it can mistake description text for the end details. This also happens when an already tracked task's title was shortened or cleaned. The band, pane, and `/downrange` can show an incorrect title or exit code; end notes can hide the actual cause. Check the full task notification in the transcript before acting on those details. Use short task descriptions without quoted failure phrases to avoid this ambiguity. - -The usage guide holds the same items under [Known limits](docs/usage.md#known-limits).
diff --git a/docs/usage.md b/docs/usage.md index a64d5a3..35d41ee 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -92,7 +92,19 @@ When nothing draws in the session, or the surface cannot show the pane, `/downra **Age:** `4m 12s` for a running job. `≥4m 12s` when Downrange first saw the job already running. `took 1m 12s` for an ended job seen from its start. `ended` for other ended jobs. A shell command's age starts when it went to the background, so it leaves out time in the foreground before ctrl+b or a timeout move. -**Progress** is a guess from the last 10 display lines, newest first: a percentage (`45%`), a fraction (`12/40`), or `3 of 7`. A shell's lines come from the end of its output: the last 16,384 characters (UTF-16 code units) of a file up to 4 MiB, or the last 16,384 bytes above that. Values over 100% do not count. A fraction joined to a letter, a dot, a slash, or a dash does not count, so full dates such as `2026/10/11` and most paths and version numbers do not count. A fraction from 1 to 12 out of at most 31 reads as a date when a two-digit clock follows it, as in `10/11 14:03`, and does not count. `3 of 7` joined to a path or a number, such as `/backups/3 of 7/` or `version 1.2 of 7.0`, does not count. These filters are heuristic: a short date with no clock after it, such as `10/11`, counts as a fraction, and so does `3 of 7` followed by a space inside a path. A subagent shows its tool count. A remote subagent or a teammate in a terminal pane of its own shows none until one of its tool calls reaches Downrange. A Monitor with no progress shows its event count. An ended job shows its exit code. +**Progress** is a guess from the last 10 display lines, newest first: a percentage (`45%`), a fraction (`12/40`), or `3 of 7`. A shell's lines come from the end of its output: the last 16,384 characters (UTF-16 code units) of a file up to 4 MiB, or the last 16,384 bytes above that. Values over 100% do not count. A percentage counts in any place. By default, a fraction or `3 of 7` counts only in a known progress format: + +- In brackets, alone or after one or two labels: `[3/7]`, `[ 5/8]`, `(3/15)`, `(3 of 7)`, or Docker's `[builder 3/7]`. +- At the start of a line: `3/7 compiling` or `3 of 7 done`. +- Right after a step word, with an optional colon: `Step 3/7`, `Epoch 3/10`, `batch: 9/30`, or pip's `Progress 12 of 40`. The step words are step, stage, phase, epoch, iteration, batch, chunk, part, file, item, task, test, and progress, in any letter case. +- Right after a drawn bar: cargo's `[=====> ] 120/300`, tqdm's `|█████ | 450/1000`, or rich's `━━━━━━ 12/40`. +- In Docker Compose's `[+] Running 3/7`. + +These formats cover the progress lines of Yarn (`[1/4]`), pip (`--progress-bar raw`, for downloads under 10 MB), cargo and cargo-nextest, pytest (`[ 62%]` or `[ 5/8]`), Docker (`#7 [3/7]`, `Step 3/7`, and Compose), and git (`45% (450/1000)` or `Rebasing (3/7)`). A fraction or `3 of 7` in another place, such as `loss 12/40` or `[12:34:56] 3/15 done`, does not count. With the option `genericRatios` on, a fraction or `3 of 7` counts in any place. + +In both modes, a fraction joined to a letter, a dot, a slash, or a dash does not count, so full dates such as `2026/10/11` and most paths and version numbers do not count. A fraction from 1 to 12 out of at most 31 reads as a date when a two-digit clock follows it, as in `10/11 14:03`, and does not count. `3 of 7` joined to a path or a number, such as `/backups/3 of 7/` or `version 1.2 of 7.0`, does not count. These filters are heuristic. A short date with no clock after it counts where a fraction counts: `[10/11]`, or `10/11 deploy` at the start of a line, or with `genericRatios` on, `Released 10/11`. So does `3 of 7` followed by a space inside a path: `Part 1 of 2 (live)/a.flac` after the step word part, or with `genericRatios` on, `Disc 1 of 2 (live)/a.flac`. + +A subagent shows its tool count. A remote subagent or a teammate in a terminal pane of its own shows none until one of its tool calls reaches Downrange. A Monitor with no progress shows its event count. An ended job shows its exit code. **ETA:** @@ -153,8 +165,9 @@ Set options in `/config`, or under `pluginConfigs["downrange@downrange"].options | --- | --- | --- | | `band` | `true` | Shows the band while work is live | | `answerStatusPrompts` | `false` | Answers exact status prompts locally, as described above | +| `genericRatios` | `false` | Reads progress from a fraction or `3 of 7` in any place in the output, not only in a known progress format (see [Reading a row](#reading-a-row)). It shows progress for more jobs, and more false readings, such as a date like `Released 10/11` | -A change takes effect at once: Claude Code reloads the mod with the new values. +A change takes effect at once: Claude Code reloads the mod with the new values. After a reload, Downrange reads the output of each running shell again, so its row follows the current `genericRatios` value. A Monitor cannot read its past events again. With `genericRatios` off, a Monitor row drops progress and a rate ETA that came from another ratio. Without progress, it shows its event count until an event in a known format arrives. With `genericRatios` on, another ratio counts from the Monitor's next event. ## Where it runs @@ -175,9 +188,10 @@ Background work keeps running after these commands. Downrange carries running jo - **Only dynamic `/loop` wakeups.** Only wakeups that `ScheduleWakeup` creates appear. Fixed-interval loops such as `/loop 10m` and other cron schedules do not. - **Other schedules and the wakeup.** Downrange cannot tell which schedule fired a scheduled prompt, or which one-shot schedule in a turn-end snapshot is the wakeup. A prompt of a scheduled task, a routine, or a fixed-interval loop that fires from 5 seconds before the wakeup's due time on clears the wakeup, even before the wakeup's own prompt fires. A cancelled wakeup that only a snapshot can clear stays while any other one-shot schedule remains. It then shows until a scheduled prompt fires near its due time, a new `ScheduleWakeup` result replaces it, or it is 10 minutes past due. -- **Progress read from output.** Progress is a guess from the text. A short date such as `10/11` counts as progress, unless a two-digit clock follows it, as in `10/11 14:03`. `N of M` followed by a space inside a path, such as `Disc 1 of 2 (live)/a.flac`, counts too, and so does another ratio or percentage that is not progress. These change the bar and the rate ETA. 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`. +- **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`. - **Only the end of an output.** A direct read of a file up to 4 MiB keeps the last 16,384 characters (UTF-16 code units), which can be up to 48 KiB of non-ASCII text. A tail read above 4 MiB keeps the last 16,384 bytes. Progress and the last line come from that end. Long lines after a progress line can push it out. - **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 progress after an update.** An earlier version of Downrange saved progress with no mark of its format, and counted any ratio. With `genericRatios` off, the first load of this version drops that progress and its rate ETA. A shell reads its output again at once, and its rate ETA starts again from the new reading. A Monitor shows its event count until its next event in a known format. - **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. - **Duration history after a snapshot end.** A run adds to the duration history only when its completion notice arrives while its row still runs. When a turn-end snapshot already ended the row as `ended without a report`, the later notice marks it completed but adds no duration. @@ -187,7 +201,7 @@ Background work keeps running after these commands. Downrange carries running jo - **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. - **`awaiting confirmation`.** A turn-end snapshot that leaves out a job younger than 10 seconds does not end it. The job keeps running with `awaiting confirmation` until newer input confirms or ends it, such as a later snapshot or a notification. The label does not expire on a timer. - **Inputs that reach Downrange late.** Claude Code gives its events no shared sequence number. Downrange stamps each input with the time it receives it, and drops an input older than one it already applied to the same job. Hooks can overlap, so an end notification can land after a newer output read, agent list, or snapshot of its job. A completed, failed, or stopped notification of a shell, Monitor, or workflow still applies then, with its exit code, because such a task never runs again. Downrange drops a late end of an agent or a teammate, a `blocked` end, and the end of a remote task such as a remote workflow, because such a task can run again under its id. That row keeps running until a later agent list ends it, or a snapshot ends it as `ended without a report`, with no exit code or end note. A teammate can leave the agent list as it ends, so its row ends at the next turn-end snapshot. A notification does not add back a job that left the list, such as one you cleared. It still can when it lands during `/clear`, `/resume`, or `/branch`, before Downrange restores what it remembers, or when Downrange forgot the task (see the 2,000 tasks below). The row then comes back as running, with a `≥` age, until a later snapshot ends it. -- **Launch details that arrive late.** The same rule applies to launch results. A newer notification, Monitor event, snapshot, or output read of a task can land while Downrange still handles its launch result. Downrange then drops what the launch result adds. A Monitor then shows no deadline, and its age starts with `≥`. A shell can lose its usual-duration ETA, or its output file and launch note too, and that run adds nothing to the duration history. A notification that later names the shell's output file still brings back its last line. +- **Launch details that arrive late.** The same rule applies to launch results. A newer notification, Monitor event, snapshot, or output read of a task, or a reload of Downrange, can land while Downrange still handles its launch result. Downrange then drops what the launch result adds. A Monitor then shows no deadline, and its age starts with `≥`. A shell can lose its usual-duration ETA, or its output file and launch note too, and that run adds nothing to the duration history. A notification that later names the shell's output file still brings back its last line. - **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. diff --git a/hooks/jobs.ts b/hooks/jobs.ts index 961cf69..e9ea51c 100644 --- a/hooks/jobs.ts +++ b/hooks/jobs.ts @@ -1,6 +1,6 @@ import type { AgentInfo, ClassicEventOf } from 'claude-code' -import type { AgentJob, Job, JobsState, JobState, MonitorJob, ShellJob, Wakeup, WorkflowJob } from '../types' +import type { AgentJob, Job, JobsState, JobState, MonitorJob, Progress, ShellJob, Wakeup, WorkflowJob } from '../types' import { ACTIVITY_MAX, LARGE_FILE_BYTES, @@ -316,9 +316,9 @@ function ended(jobs: readonly Job[], notice: Notice, state: JobState, now: numbe }, [...jobs]) } -function monitorEvent(jobs: readonly Job[], notice: Notice, event: string, now: number): Job[] { +function monitorEvent(jobs: readonly Job[], notice: Notice, event: string, now: number, isAnyRatio: boolean): Job[] { const lines = displayLinesOf(event) - const progress = progressOf(lines.slice(-PROGRESS_LINES), now) + const progress = progressOf(lines.slice(-PROGRESS_LINES), now, isAnyRatio) const lastLine = lines.at(-1) return notice.ids.reduce((list, id) => { let known = list.find(job => job.id === id) @@ -339,11 +339,11 @@ function monitorEvent(jobs: readonly Job[], notice: Notice, event: string, now: }, [...jobs]) } -/** Applies the status and output in a notification. */ -export function noticed(jobs: readonly Job[], notice: Notice, now: number): Job[] { +/** Applies the status and output in a notification; isAnyRatio (the `genericRatios` option) lets any ratio in a Monitor event count as progress. */ +export function noticed(jobs: readonly Job[], notice: Notice, now: number, isAnyRatio = false): Job[] { const state = endStateOf(notice.status) const updated = state === undefined ? [...jobs] : ended(jobs, notice, state, now) - const refined = notice.event === undefined ? updated : monitorEvent(updated, notice, notice.event, now) + const refined = notice.event === undefined ? updated : monitorEvent(updated, notice, notice.event, now, isAnyRatio) return refined.map(job => { if (job.kind !== 'shell' || !notice.ids.includes(job.id) || notice.outputFile === undefined || !isOutputFileOf(notice.outputFile, job.id)) return job return { ...job, outputFile: notice.outputFile, note: job.note === 'no output file' ? undefined : job.note, @@ -532,12 +532,43 @@ export function readsDue(jobs: readonly Job[], now: number): (ShellJob & { outpu const askedSince = (job: ShellJob, readAt: number): number | undefined => job.finalReadAskedAt !== undefined && job.finalReadAskedAt >= readAt ? job.finalReadAskedAt : undefined -/** The list after a read that began at `now`: size, time, last line, and progress; a read-problem note clears, and a later end keeps its final read. */ -export function tailRead(jobs: readonly Job[], id: string, read: TailRead, now: number): Job[] { +/** 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. */ +const isSameReading = (a: Progress | undefined, b: Progress | undefined): boolean => + a !== undefined && b !== undefined && a.label === b.label && a.percent === b.percent && + (a.isGeneric === undefined || b.isGeneric === undefined || a.isGeneric === b.isGeneric) + +/** 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. */ +const isKnownOrAbsent = (value: { isGeneric?: boolean } | undefined): boolean => value === undefined || value.isGeneric === false + +/** + * The list after a reload of the mod at `now`, as a change of the `genericRatios` option or an update causes. With + * isAnyRatio off, a reading or an anchor that is generic or has no mark drops, and an anchor drops with its reading. + * Each running shell with an output file reads it once more in full, so its row follows the current option. A Monitor + * cannot read its old events again: its next event follows the option. + */ +export function reloaded(jobs: readonly Job[], isAnyRatio: boolean, now: number): Job[] { + return jobs.map(job => { + if (job.kind !== 'shell' && job.kind !== 'monitor') return job + const isGenericDropped = !isAnyRatio && (!isKnownOrAbsent(job.progress) || !isKnownOrAbsent(job.anchor)) + const isReread = job.kind === 'shell' && isRunning(job) && job.outputFile !== undefined + if (!isGenericDropped && !isReread) return job + const ratios = isGenericDropped ? { progress: isKnownOrAbsent(job.progress) ? job.progress : undefined, anchor: undefined } : {} + return { ...job, ...ratios, ...(isReread ? { finalReadAskedAt: now } : {}) } + }) +} + +/** + * The list after a read that began at `now`: size, time, last line, and progress; a read-problem note clears, and a + * later end keeps its final read. isAnyRatio (the `genericRatios` option) lets any ratio count as progress. + */ +export function tailRead(jobs: readonly Job[], id: string, read: TailRead, now: number, isAnyRatio = false): Job[] { return jobs.map(job => { if (job.kind !== 'shell' || job.id !== id) return job const lines = /^\[exited with code -?\d+\]$/.test(read.lines.at(-1) ?? '') ? read.lines.slice(0, -1) : read.lines - const progress = progressOf(lines.slice(-PROGRESS_LINES), now) + const found = progressOf(lines.slice(-PROGRESS_LINES), now, isAnyRatio) + // 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. + const isUnchanged = read.bytes === job.outputBytes && job.failedReadBytes === undefined + const progress = isUnchanged && isSameReading(found, job.progress) ? undefined : found const lastLine = lines.at(-1) return { ...job, diff --git a/hooks/limits.ts b/hooks/limits.ts index 4d0ff02..6b9021f 100644 --- a/hooks/limits.ts +++ b/hooks/limits.ts @@ -16,6 +16,8 @@ export const TAIL_BYTES = 16_384 export const TAIL_TIMEOUT_MS = 2_000 /** Display lines searched for progress, newest first. */ export const PROGRESS_LINES = 10 +/** Characters before a ratio that decide whether it is a known progress counter, such as `Step` or `[builder `. */ +export const COUNTER_CONTEXT = 256 /** The least time between anchor and sample before a rate ETA shows. */ export const ETA_MIN_SPAN_MS = 10_000 /** The least percentage-point gain before a rate ETA shows. */ diff --git a/hooks/output.ts b/hooks/output.ts index 47beca5..ed30110 100644 --- a/hooks/output.ts +++ b/hooks/output.ts @@ -1,5 +1,5 @@ import type { Anchor, Progress } from '../types' -import { ETA_MIN_GAIN, ETA_MIN_SPAN_MS, TAIL_BYTES } from './limits' +import { COUNTER_CONTEXT, ETA_MIN_GAIN, ETA_MIN_SPAN_MS, TAIL_BYTES } from './limits' const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][\s\S]*?(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g const CONTROL = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g @@ -8,6 +8,18 @@ const FRACTION = /(? = new Set(['step', 'stage', 'phase', 'epoch', 'iteration', 'batch', 'chunk', 'part', 'file', 'item', 'task', 'test', 'progress']) +/** The last word before a count, with an optional colon, as in `Step 3/7` or `batch: 9/30`. */ +const WORD_BEFORE = /\W([A-Za-z]+):?\s*$/ +/** An opening bracket right before a count, after up to two labels, as in `[3/7]`, `( 3/150)`, or Docker's `[builder 3/7]`. */ +const OPENED_BEFORE = /([[(])\s*(?:[A-Za-z][\w./-]*\s+){0,2}$/ +/** A drawn bar right before a count: `[=====> ]` (cargo), `|█████ |` (tqdm), or `━━━━━━` (rich). */ +const BAR_BEFORE = /(?:\[[=#>\-. ]{3,}\]|\|[█▉▊▋▌▍▎▏ ]{3,}\||[━╸╺█▉▊▋▌▍▎▏■▰]{3})\s*$/ +/** Docker Compose's count line, as in `[+] Running 3/7`. */ +const COMPOSE_BEFORE = /\[\+\]\s+[A-Za-z]+\s+$/ +/** The bracket that closes a count, after optional spaces. */ +const CLOSER_AFTER = /\s*([\])])/y /** An output file path as the engine names it: absolute, in a `tasks` folder, named `.output`. Its folders may hold spaces (a temp folder set by CLAUDE_CODE_TMPDIR). */ const OUTPUT_PATH = /^(?:[A-Za-z]:)?[\\/][^\n]*[\\/]tasks[\\/]([A-Za-z0-9_-]+)\.output$/ /** The engine's words before the path in a background command's result. */ @@ -15,7 +27,7 @@ const OUTPUT_NOTICE = 'Output is being written to: ' /** A carriage return, built from its code so that no string literal holds a control character. */ const CR = String.fromCharCode(13) -type Found = { percent: number; label: string } +type Found = { percent: number; label: string; isGeneric: boolean } /** Cuts text to max characters, ending with an ellipsis when it cut. */ export function clip(text: string, max: number): string { @@ -47,7 +59,7 @@ function lastPercent(line: string): Found | undefined { let found: Found | undefined for (const match of line.matchAll(PERCENT)) { const value = Number(match[1]) - if (value <= 100) found = { percent: value, label: `${Math.round(value)}%` } + if (value <= 100) found = { percent: value, label: `${Math.round(value)}%`, isGeneric: false } } return found } @@ -59,29 +71,55 @@ function isDateBeforeClock(line: string, end: number, done: number, total: numbe return CLOCK_AFTER.test(line) } -function lastRatio(line: string, pattern: RegExp): Found | undefined { +/** + * Whether the count from start to end has a known progress format: it starts the line, follows a step word, sits in + * brackets, follows a drawn bar, or ends Docker Compose's `[+] Running`. Past the line-start test, only the + * COUNTER_CONTEXT characters before it decide, so a long line stays cheap. + */ +function isCounter(line: string, start: number, end: number): boolean { + if (!/\S/.test(line.slice(0, start))) return true + // A leading space stands for the line start, so that a word at the start has a boundary before it. + const before = start > COUNTER_CONTEXT ? line.slice(start - COUNTER_CONTEXT, start) : ` ${line.slice(0, start)}` + const word = WORD_BEFORE.exec(before)?.[1] + if (word !== undefined && STEP_WORDS.has(word.toLowerCase())) return true + const opener = OPENED_BEFORE.exec(before)?.[1] + CLOSER_AFTER.lastIndex = end + if (opener !== undefined && CLOSER_AFTER.exec(line)?.[1] === (opener === '[' ? ']' : ')')) return true + return BAR_BEFORE.test(before) || COMPOSE_BEFORE.test(before) +} + +/** The last valid ratio in the line; unless isAnyRatio, only one in a known progress format counts. isGeneric marks one in no known format. */ +function lastRatio(line: string, pattern: RegExp, isAnyRatio: boolean): Found | undefined { let found: Found | undefined for (const match of line.matchAll(pattern)) { const done = Number(match[1]) const total = Number(match[2]) - if (pattern === FRACTION && isDateBeforeClock(line, match.index + match[0].length, done, total)) continue - if (total >= 1 && done <= total) found = { percent: (done / total) * 100, label: `${done}/${total}` } + const end = match.index + match[0].length + if (pattern === FRACTION && isDateBeforeClock(line, end, done, total)) continue + if (total < 1 || done > total) continue + const isKnown = isCounter(line, match.index, end) + if (isKnown || isAnyRatio) found = { percent: (done / total) * 100, label: `${done}/${total}`, isGeneric: !isKnown } } return found } -/** The newest progress reading in the lines: percent, then `done/total`, then `N of M`. */ -export function progressOf(lines: readonly string[], at: number): Progress | undefined { +/** + * The newest progress reading in the lines: percent, then `done/total`, then `N of M`. A ratio counts only in a known + * progress format, unless isAnyRatio (the `genericRatios` option) lets any ratio count. + */ +export function progressOf(lines: readonly string[], at: number, isAnyRatio: boolean): Progress | undefined { for (const line of [...lines].reverse()) { - const found = lastPercent(line) ?? lastRatio(line, FRACTION) ?? lastRatio(line, OF) + const found = lastPercent(line) ?? lastRatio(line, FRACTION, isAnyRatio) ?? lastRatio(line, OF, isAnyRatio) if (found !== undefined) return { ...found, at } } return undefined } -/** The start of the current rising run of readings, which the rate ETA measures from; a drop starts a new run. */ +/** 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. */ export function anchorOf(anchor: Anchor | undefined, progress: Progress, previous: Progress | undefined): Anchor { - return anchor === undefined || previous === undefined || progress.percent < previous.percent ? { percent: progress.percent, at: progress.at } : anchor + return anchor === undefined || previous === undefined || progress.percent < previous.percent + ? { percent: progress.percent, at: progress.at, isGeneric: progress.isGeneric === true } + : anchor } /** Milliseconds left at the observed rate, or undefined before a rate exists. */ diff --git a/hooks/register.tsx b/hooks/register.tsx index b359eef..ea3833f 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -25,6 +25,7 @@ import { readFailed, readsDue, reconciled, + reloaded, restoredJobs, shellJob, ownerAnswered, @@ -82,6 +83,8 @@ let carry: Carry | null = null * can still race, because the store has no versioned write; a lost record costs every run it held, a new key's first too. */ let historyWrites: Promise = Promise.resolve() +/** The `genericRatios` option, which register sets: any ratio in output counts as progress, not only a known format. */ +let isAnyRatio = false /** A `tail` that could not start, timed out, or exited non-zero. */ class TailFailure extends Error { @@ -263,7 +266,7 @@ async function applyNotices($: EngineInterface, text: string, noticeRow?: string let runs: HistoryRun[] = [] const changed = await writeJobs($, { at: now, ids: notices.flatMap(notice => notice.ids), kind: 'notice', finalIds: finalIdsOf(notices), noticeRow }, jobs => { runs = finishedRuns(jobs, notices, now) - return notices.reduce((list, notice) => noticed(list, notice, now), jobs) + return notices.reduce((list, notice) => noticed(list, notice, now, isAnyRatio), jobs) }) if (changed) await recordRuns($, runs, now, event) } @@ -329,7 +332,7 @@ async function readTail($: EngineInterface, job: ShellJob & { outputFile: string await writeJobs($, { at: now, ids: [job.id] }, jobs => readFailed(jobs, job.id, isTail ? 'tail failed' : 'output unavailable', now, isTail ? error.bytes : undefined)) return } - await writeJobs($, { at: now, ids: [job.id] }, jobs => tailRead(jobs, job.id, found, now)) + await writeJobs($, { at: now, ids: [job.id] }, jobs => tailRead(jobs, job.id, found, now, isAnyRatio)) } /** Applies `$.agent.list()` to agent jobs; a failed list logs once. */ @@ -440,6 +443,7 @@ async function statusAnswer($: EngineInterface, text: string): Promise { + isAnyRatio = options.genericRatios === true on('session.start', async ($, e, next) => { try { await $.command.register({ @@ -453,6 +457,9 @@ export const register: Register = (on, options) => { } const now = await $.clock.now() await writeJobs($, null) + // 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. + const { items } = await read($, JOBS) + if (reloaded(items, isAnyRatio, now).some((job, index) => job !== items[index])) await writeJobs($, { at: now }, jobs => reloaded(jobs, isAnyRatio, now)) await writeWakeup($, now, pending => pending) return next(e) }).catch(($, e, next) => passOn($, 'session.start', e, next)) diff --git a/hooks/state.ts b/hooks/state.ts index c683a81..a503053 100644 --- a/hooks/state.ts +++ b/hooks/state.ts @@ -6,8 +6,9 @@ const optionalNumbers = (value: Record, keys: readonly string[] const optionalStrings = (value: Record, keys: readonly string[]): boolean => keys.every(key => value[key] === undefined || typeof value[key] === 'string') const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every(item => typeof item === 'string') const times = (value: unknown): boolean => Array.isArray(value) && value.every(item => Array.isArray(item) && item.length === 2 && typeof item[0] === 'string' && number(item[1])) -const progress = (value: unknown): boolean => value === undefined || (record(value) && number(value.percent) && number(value.at) && typeof value.label === 'string') -const anchor = (value: unknown): boolean => value === undefined || (record(value) && number(value.percent) && number(value.at)) +const generic = (value: Record): boolean => value.isGeneric === undefined || typeof value.isGeneric === 'boolean' +const progress = (value: unknown): boolean => value === undefined || (record(value) && number(value.percent) && number(value.at) && typeof value.label === 'string' && generic(value)) +const anchor = (value: unknown): boolean => value === undefined || (record(value) && number(value.percent) && number(value.at) && generic(value)) const states = new Set(['running', 'completed', 'failed', 'stopped', 'gone']) const activities = new Map([['pending', true], ['running', true], ['waiting', true], ['idle', true]]) diff --git a/types/index.d.ts b/types/index.d.ts index a48337e..6a761dd 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,8 +1,13 @@ export type JobState = 'running' | 'completed' | 'failed' | 'stopped' | 'gone' -export type Progress = { percent: number; label: string; at: number } +/** + * A progress reading; isGeneric is true for a ratio in no known progress format, which counts only with the + * `genericRatios` option on, and false for a known format. A reading that an earlier version saved has no mark. + */ +export type Progress = { percent: number; label: string; at: number; isGeneric?: boolean } -export type Anchor = { percent: number; at: number } +/** 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. */ +export type Anchor = { percent: number; at: number; isGeneric?: boolean } export type JobBase = { id: string @@ -36,7 +41,7 @@ export type ShellJob = JobBase & { /** The size at a failed tail, used for its retry interval without marking that output as read. */ failedReadBytes?: number lastReadAt?: number - /** When the latest end asked for one more read of the output; a read that began after it clears it. */ + /** 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. */ finalReadAskedAt?: number progress?: Progress anchor?: Anchor