From 5fa429b3685587eb10763101a5f530aa52d29129 Mon Sep 17 00:00:00 2001 From: dot-agi Date: Mon, 12 Oct 2026 06:15:16 +0530 Subject: [PATCH] Release 1.1.0 --- .claude-plugin/plugin.json | 2 +- README.md | 12 ++++---- docs/usage.md | 17 ++++++----- hooks/jobs.ts | 61 +++++++++++++++++++++++++++++++------- hooks/notices.ts | 6 ++-- hooks/output.ts | 12 +++++++- hooks/register.tsx | 9 ++++-- hooks/state.ts | 1 + hooks/view.ts | 12 ++++++-- types/index.d.ts | 2 ++ 10 files changed, 99 insertions(+), 35 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b9c1727..a21f681 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "downrange", - "version": "1.0.1", + "version": "1.1.0", "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", diff --git a/README.md b/README.md index 9b43272..6b5a11a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Claude Code 2.1.288+ Claude Code mod License: MIT - Version: 1.0.1 + Version: 1.1.0

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

@@ -144,7 +144,7 @@ Only dynamic `/loop` wakeups appear, and other schedules can clear or keep one. - **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`, `3 of 7` inside a path or a version text such as `version 1.2 of 7.0`, or another ratio or percentage that is not progress, counts as progress and changes the bar and the rate ETA. Full dates such as `2026/10/11` and `10/11/2026` do not count. +- **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. - **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. @@ -155,12 +155,12 @@ Only dynamic `/loop` wakeups appear, and other schedules can clear or keep one. - **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. Downrange then drops the notification: the row keeps running until a later snapshot or agent list ends it. A shell row then shows `ended without a report` and no exit code. An input that Claude Code delivers late counts as new. For example, a Monitor event that arrives after you cleared its finished row brings the row back as running, with a `≥` age, until a later snapshot ends it. +- **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. 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 keeps `0 tools` and shows `quiet` from 2 minutes after launch, even while it works. A teammate in a terminal pane of its own can show the same. 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` or `quiet` with no end note. Check the teammate's own pane before you act on the row. Use **Hide** to dismiss the band. +- **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. diff --git a/docs/usage.md b/docs/usage.md index 4851701..a64d5a3 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -92,7 +92,7 @@ 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. These filters are heuristic: a short date such as `10/11` counts as a fraction, and `3 of 7` counts inside a path or a version text such as `version 1.2 of 7.0`. A subagent shows its tool count. 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 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. **ETA:** @@ -111,7 +111,7 @@ A drop from the previous progress sample starts a new phase. The rate estimate w **Flags:** -- `quiet 3m 00s`: a running shell command wrote no output for 2 minutes or more, or a subagent with no tool call in flight was silent that long. +- `quiet 3m 00s`: a running shell command wrote no output for 2 minutes or more, or a subagent with no tool call in flight was silent that long. A row with the note `remote` or `own pane` does not show it while it shows no tool count. - `3 failed calls in a row`: a subagent's last tool calls failed. **Notes:** @@ -121,7 +121,8 @@ A drop from the previous progress sample starts a new phase. The rate estimate w | `moved after timeout` | The command moved to the background at its timeout | | `backgrounded by you` | You moved the command to the background | | `ends with its agent` | The command belongs to a subagent that Claude waits for; it stops when that subagent answers | -| `remote` | A remote subagent | +| `remote` | A remote subagent. Its tool calls do not reach Downrange, so the row shows no tool count and no `quiet` flag | +| `own pane` | A teammate in a terminal pane of its own. As with `remote`, the row shows no tool count and no `quiet` flag | | `no output file` | The tool result named no output file, so the last line is unavailable | | `time limit`, `low memory`, `turn limit`, `previous session`, or a failure reason | Why the task ended, from its notification | | `ended without a report` | The session's task list no longer held the job, and no notification came. When a later list holds the job again, it shows as running again, and a launch note such as `backgrounded by you` returns | @@ -174,7 +175,7 @@ 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`, `3 of 7` inside a path or a version text such as `version 1.2 of 7.0`, or another ratio or percentage that is not progress, counts as progress and changes the bar and the rate ETA. Full dates such as `2026/10/11` and `10/11/2026` do not count. +- **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. - **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. @@ -185,12 +186,12 @@ Background work keeps running after these commands. Downrange carries running jo - **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. Downrange then drops the notification: the row keeps running until a later snapshot or agent list ends it. A shell row then shows `ended without a report` and no exit code. An input that Claude Code delivers late counts as new. For example, a Monitor event that arrives after you cleared its finished row brings the row back as running, with a `≥` age, until a later snapshot ends it. +- **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. 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 keeps `0 tools` and shows `quiet` from 2 minutes after launch, even while it works. A teammate in a terminal pane of its own can show the same. 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` or `quiet` with no end note. Check the teammate's own pane before you act on the row. Use **Hide** to dismiss the band. +- **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. diff --git a/hooks/jobs.ts b/hooks/jobs.ts index c94f208..961cf69 100644 --- a/hooks/jobs.ts +++ b/hooks/jobs.ts @@ -48,20 +48,44 @@ export function trimmed(jobs: readonly Job[]): Job[] { return jobs.filter(job => isRunning(job) || kept.has(job.id)) } +/** Kinds whose task id never runs again after its end; an agent can resume under its id. */ +const FINAL_KINDS: ReadonlySet = new Set(['shell', 'monitor', 'workflow']) + +/** + * Whether a notification states a final end: a known end word other than `blocked`, which a remote task can leave. + * A task of type `remote_agent`, such as a remote workflow, keeps the ordering rule of other inputs. + */ +function isFinalNotice(notice: Notice): boolean { + return notice.taskType !== 'remote_agent' && endStateOf(notice.status) !== undefined && notice.status?.toLowerCase() !== 'blocked' +} + +/** Whether a row takes a final end whatever the mod saw since the end's stamp: a running or `gone` shell, Monitor, or workflow. */ +const takesFinalEnd = (job: Job): boolean => FINAL_KINDS.has(job.kind) && (isRunning(job) || job.state === 'gone') + +/** Whether this notification's end applies to the job whatever the mod saw since its stamp. */ +function isFinalEnd(job: Job, notice: Notice): boolean { + return takesFinalEnd(job) && isFinalNotice(notice) +} + +/** The ids of a prompt's blocks that state a final end. */ +export function finalIdsOf(notices: readonly Notice[]): string[] { + return notices.filter(isFinalNotice).flatMap(notice => notice.ids) +} + /** Whether an end is at least as recent as the job's latest running observation and end. */ function canEnd(job: Job, now: number): boolean { return now >= Math.max(job.lastRunningAt ?? job.startedAt, job.lastEndedAt ?? job.startedAt) } -/** A current end preserves the first end time, advances the latest, replaces snapshot notes, and asks for a final shell read. */ -function endedJob(job: T, state: JobState, now: number, detail: { exitCode?: number; note?: string }): T { - if (!canEnd(job, now)) return job +/** A current or final end preserves the first end time, advances the latest, replaces snapshot notes, and asks for a final shell read. */ +function endedJob(job: T, state: JobState, now: number, detail: { exitCode?: number; note?: string }, isFinal = false): T { + if (!isFinal && !canEnd(job, now)) return job return { ...job, state, omittedAt: undefined, - endedAt: job.endedAt ?? now, - lastEndedAt: now, + endedAt: Math.min(job.endedAt ?? now, now), + lastEndedAt: Math.max(job.lastEndedAt ?? now, now), exitCode: detail.exitCode ?? job.exitCode, note: detail.note ?? (job.note !== undefined && GONE_NOTES.has(job.note) ? undefined : job.note), ...(job.kind === 'shell' ? { finalReadAskedAt: now } : {}), @@ -91,6 +115,7 @@ function withLaunch(known: Job, launch: Job): Job { launchNote: launch.launchNote ?? known.launchNote } if (known.kind === 'shell' && launch.kind === 'shell') return { ...known, ...identity, outputFile: launch.outputFile ?? known.outputFile, ownerAgentId: launch.ownerAgentId ?? known.ownerAgentId, endsWithAgent: launch.endsWithAgent ?? known.endsWithAgent } + if (known.kind === 'agent' && launch.kind === 'agent') return { ...known, ...identity, runsElsewhere: launch.runsElsewhere ?? known.runsElsewhere } return known.kind === 'monitor' && launch.kind === 'monitor' ? { ...known, ...identity, deadline: launch.deadline } : { ...known, ...identity } } @@ -150,7 +175,7 @@ export function monitorJob(launch: { id: string; description: string; timeoutMs: } /** A background or remote subagent. */ -export function agentJob(launch: { id: string; description: string; note?: string }, now: number): AgentJob { +export function agentJob(launch: { id: string; description: string; note?: string; runsElsewhere?: boolean }, now: number): AgentJob { return { kind: 'agent', id: launch.id, @@ -158,6 +183,7 @@ export function agentJob(launch: { id: string; description: string; note?: strin state: 'running', startedAt: now, isStartSeen: true, + runsElsewhere: launch.runsElsewhere, toolCount: 0, inFlight: 0, errorStreak: 0, @@ -286,7 +312,7 @@ function ended(jobs: readonly Job[], notice: Notice, state: JobState, now: numbe if (known.kind === 'shell' && !known.isStartSeen && kindOf(notice.summary ?? '') === 'monitor') known = asMonitor(known) const { words } = summaryParts(notice, known.title) const detail = { exitCode: exitCodeOf(words), note: endNoteOf(words) } - return withJob(list, endedJob(known, state, now, detail)) + return withJob(list, endedJob(known, state, now, detail, isFinalEnd(known, notice))) }, [...jobs]) } @@ -337,11 +363,14 @@ export function noticeProblemOf(notice: Notice): { cause: string; once: string } return { cause: `unknown notification status "${word}" for task ${id}`, once: `status|${word}` } } -/** The runs one block ends cleanly: completed, exit code 0 or none, a shell job still running, seen from launch, with a history key. */ +/** + * The runs one block ends cleanly: completed, exit code 0 or none, a shell job still running, seen from launch, with a + * history key, and an end no earlier than its start. + */ function cleanRunsOf(jobs: readonly Job[], notice: Notice, now: number): HistoryRun[] { if (endStateOf(notice.status) !== 'completed') return [] return jobs.flatMap(job => - job.kind === 'shell' && isRunning(job) && canEnd(job, now) && job.isStartSeen && job.historyKey !== undefined && notice.ids.includes(job.id) && + job.kind === 'shell' && isRunning(job) && (canEnd(job, now) || isFinalEnd(job, notice)) && now >= job.startedAt && job.isStartSeen && job.historyKey !== undefined && notice.ids.includes(job.id) && (exitCodeOf(summaryParts(notice, job.title).words) ?? 0) === 0 ? [{ key: job.historyKey, durationMs: now - job.startedAt }] : [], @@ -551,7 +580,9 @@ export function finishedCleared(jobs: readonly Job[]): Job[] { export type JobObservation = { at: number ids?: readonly string[] - kind?: 'snapshot' | 'restore' | 'activity' | 'remove' + kind?: 'snapshot' | 'restore' | 'activity' | 'remove' | 'notice' + /** Ids a notification ends for good; a running or `gone` shell, Monitor, or workflow row takes that end past a newer observation. */ + finalIds?: readonly string[] members?: readonly string[] noticeRow?: string } @@ -580,6 +611,7 @@ function capped(state: JobsState): JobsState { /** * One ordering rule for job inputs. Removal wins ties; other ties use write order. * Snapshots also reject older inputs for absent rows. Activity counts settle every call, with their own field ordering. + * A final end of a running or `gone` shell, Monitor, or workflow passes newer observations, and a notification never adds back a removed row. */ export function observedJobs(state: JobsState, input: JobObservation, change: (jobs: Job[]) => Job[]): JobsState { const next = ordered(state, input, change) @@ -594,10 +626,17 @@ function ordered(state: JobsState, input: JobObservation, change: (jobs: Job[]) const removed = new Map(state.removed) const known = new Set(state.items.map(job => job.id)) const targets = input.ids === undefined ? undefined : new Set(input.ids) + const rows = new Map(state.items.map(job => [job.id, job])) + const isFinal = (id: string): boolean => { + const row = rows.get(id) + return row !== undefined && input.finalIds?.includes(id) === true && takesFinalEnd(row) + } const accepts = (id: string): boolean => (targets === undefined || targets.has(id)) && input.at > (removed.get(id) ?? -Infinity) && - (input.kind === 'activity' || input.at >= (observed.get(id) ?? -Infinity)) && + // A notification never brings back a task whose row was removed. + !(input.kind === 'notice' && !known.has(id) && removed.has(id)) && + (input.kind === 'activity' || isFinal(id) || input.at >= (observed.get(id) ?? -Infinity)) && ((input.kind !== 'restore' && known.has(id)) || input.at >= (state.snapshotAt ?? -Infinity)) const eligible = state.items.filter(job => accepts(job.id)) const changed = change(eligible).filter(job => accepts(job.id)) diff --git a/hooks/notices.ts b/hooks/notices.ts index 0b18a2b..8690086 100644 --- a/hooks/notices.ts +++ b/hooks/notices.ts @@ -1,6 +1,8 @@ /** One `` block. */ export type Notice = { ids: string[] + /** The engine's task type, such as `remote_agent` for a task that runs in a cloud session. */ + taskType?: string outputFile?: string status?: string summary?: string @@ -19,7 +21,7 @@ const ID_SHAPE = /^\S{1,128}$/ const unescaped = (text: string): string => text.replaceAll('<', '<').replaceAll('>', '>').replaceAll('&', '&') /** - * One block's content: its header fields (every task id of a valid shape, and the first non-empty status and summary), + * One block's content: its header fields (every task id of a valid shape, and the first non-empty value of each other field), * then the text of the first `` in its body. The body's other parts (a note, a result, usage, plain text) are * not read. */ @@ -43,7 +45,7 @@ function noticeOf(block: string): Notice { const open = block.indexOf(EVENT_OPEN, end) const close = open === -1 ? -1 : block.indexOf(EVENT_CLOSE, open) const event = close === -1 ? undefined : unescaped(block.slice(open + EVENT_OPEN.length, close)) - return { ids, outputFile: first.get('output-file'), status: first.get('status'), summary: first.get('summary'), event } + return { ids, taskType: first.get('task-type'), outputFile: first.get('output-file'), status: first.get('status'), summary: first.get('summary'), event } } /** diff --git a/hooks/output.ts b/hooks/output.ts index 76f894a..47beca5 100644 --- a/hooks/output.ts +++ b/hooks/output.ts @@ -5,7 +5,9 @@ const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][\s\S]*?(?:\x07|\x1b\\)|\x1b[@-Z\\-_ const CONTROL = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g const PERCENT = /(?.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. */ @@ -50,11 +52,19 @@ function lastPercent(line: string): Found | undefined { return found } +/** Whether a `done/total` match is a short date followed by a clock. */ +function isDateBeforeClock(line: string, end: number, done: number, total: number): boolean { + if (done < 1 || done > 12 || total > 31) return false + CLOCK_AFTER.lastIndex = end + return CLOCK_AFTER.test(line) +} + function lastRatio(line: string, pattern: RegExp): 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}` } } return found diff --git a/hooks/register.tsx b/hooks/register.tsx index 446f5ff..b359eef 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -11,6 +11,7 @@ import { activityStarted, agentJob, agentsSynced, + finalIdsOf, finishedCleared, finishedRuns, historyAdded, @@ -260,7 +261,7 @@ async function applyNotices($: EngineInterface, text: string, noticeRow?: string } const now = observedAt ?? await $.clock.now() let runs: HistoryRun[] = [] - const changed = await writeJobs($, { at: now, ids: notices.flatMap(notice => notice.ids), noticeRow }, jobs => { + 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) }) @@ -519,12 +520,14 @@ export const register: Register = (on, options) => { const result = ran.result if (!('status' in result) || (result.status !== 'async_launched' && result.status !== 'remote_launched' && result.status !== 'teammate_spawned')) return ran const now = await $.clock.now() + // A teammate in a terminal pane of its own runs no loop here: its id is its address in its team. + const isOwnPane = result.status === 'teammate_spawned' && (result.agentId ?? result.agent_id) === result.teammate_id const job = result.status === 'teammate_spawned' - ? agentJob({ id: result.agentId ?? result.agent_id, description: e.description }, now) + ? agentJob({ id: result.agentId ?? result.agent_id, description: e.description, ...(isOwnPane ? { note: 'own pane', runsElsewhere: true } : {}) }, now) : result.status === 'async_launched' ? agentJob({ id: result.agentId, description: result.description }, now) - : agentJob({ id: result.taskId, description: result.description, note: 'remote' }, now) + : agentJob({ id: result.taskId, description: result.description, note: 'remote', runsElsewhere: true }, now) await writeJobs($, { at: now, ids: [job.id] }, jobs => launched(jobs, job)) return ran }).catch(($, e, next) => passOn($, 'tool.call{tool=Agent}', e, next)) diff --git a/hooks/state.ts b/hooks/state.ts index 71326bf..c683a81 100644 --- a/hooks/state.ts +++ b/hooks/state.ts @@ -27,6 +27,7 @@ function job(value: unknown): boolean { return number(value.toolCount) && number(value.inFlight) && number(value.errorStreak) && optionalNumbers(value, ['lastToolStartedAt', 'lastSuccessAt']) && (value.activityStatus === undefined || (typeof value.activityStatus === 'string' && activities.has(value.activityStatus))) && + (value.runsElsewhere === undefined || typeof value.runsElsewhere === 'boolean') && (value.concurrentFailureTimes === undefined || (Array.isArray(value.concurrentFailureTimes) && value.concurrentFailureTimes.every(number))) case 'workflow': return true case 'task': return typeof value.taskType === 'string' diff --git a/hooks/view.ts b/hooks/view.ts index 49e04ff..db65e59 100644 --- a/hooks/view.ts +++ b/hooks/view.ts @@ -1,4 +1,4 @@ -import type { Job, JobState, Wakeup } from '../types' +import type { AgentJob, Job, JobState, Wakeup } from '../types' import { isRunning } from './jobs' import { BAND_BUTTONS_COLUMNS, @@ -55,6 +55,9 @@ const endOf = (job: Job): number => job.endedAt ?? job.startedAt /** The tool of a subagent's activity summary, `: `. */ const toolOf = (activity: string): string => activity.split(': ', 1)[0] ?? activity +/** Whether none of a subagent's tool calls can reach Downrange: it runs elsewhere, and no call of it ran here. */ +const isUnseenAgent = (job: AgentJob): boolean => job.runsElsewhere === true && job.toolCount === 0 + function byAge(jobs: readonly Job[]): { running: Job[]; ended: Job[] } { return { running: jobs.filter(isRunning).sort((a, b) => a.startedAt - b.startedAt), @@ -89,7 +92,7 @@ function flagOf(job: Job, now: number): string | undefined { if (job.kind === 'agent' && job.activityStatus !== undefined && job.activityStatus !== 'running') return undefined if (job.kind === 'agent' && job.errorStreak >= FAILING_STREAK) return `${job.errorStreak} failed calls in a row` const quiet = now - (job.lastOutputAt ?? job.startedAt) - const canBeQuiet = (job.kind === 'shell' && job.successfulReadAt !== undefined && now - job.successfulReadAt <= LARGE_READ_EVERY_MS + TICK_MS) || (job.kind === 'agent' && job.inFlight === 0) + 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)) return canBeQuiet && quiet >= QUIET_MS ? `quiet ${durationText(quiet)}` : undefined } @@ -100,7 +103,10 @@ function progressCell(job: Job): { text?: string; percent?: number } { return { text: label.endsWith('%') ? label : `${label} (${Math.round(percent)}%)`, percent } } if (job.kind === 'monitor' && job.eventCount > 0) return { text: `${job.eventCount} event${job.eventCount === 1 ? '' : 's'}` } - if (job.kind === 'agent') return { text: [job.activityStatus === 'running' ? undefined : job.activityStatus, `${job.toolCount} tool${job.toolCount === 1 ? '' : 's'}`].filter(Boolean).join(' · ') } + if (job.kind === 'agent') { + const text = [job.activityStatus === 'running' ? undefined : job.activityStatus, isUnseenAgent(job) ? undefined : `${job.toolCount} tool${job.toolCount === 1 ? '' : 's'}`].filter(Boolean).join(' · ') + return { text: text === '' ? undefined : text } + } return {} } diff --git a/types/index.d.ts b/types/index.d.ts index cf2a17e..a48337e 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -55,6 +55,8 @@ export type MonitorJob = JobBase & { export type AgentJob = JobBase & { kind: 'agent' activityStatus?: 'pending' | 'running' | 'waiting' | 'idle' + /** 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. */ + runsElsewhere?: boolean toolCount: number inFlight: number errorStreak: number