From a1a67530c7c4d329734e818928eb2a8d05e2cd16 Mon Sep 17 00:00:00 2001
From: dot-agi
Date: Sun, 25 Oct 2026 06:25:19 +0530
Subject: [PATCH 1/2] Release 1.1.6
---
.claude-plugin/plugin.json | 2 +-
AGENTS.md | 6 +-
README.md | 21 +-
docs/usage.md | 18 +-
hooks/jobs.ts | 23 +-
hooks/limits.ts | 8 +-
hooks/register.tsx | 436 +++++++++++++++++++++++++------------
7 files changed, 346 insertions(+), 168 deletions(-)
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index 211afa9..7ad29f0 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "downrange",
- "version": "1.1.5",
+ "version": "1.1.6",
"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/AGENTS.md b/AGENTS.md
index a36c453..759c03a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -48,7 +48,7 @@ Notes:
| `.claude-plugin/plugin.json` | The manifest, the options, and the state contract path |
| `.claude-plugin/marketplace.json` | The one-plugin marketplace |
| `hooks/hooks.json` | The name of the hooks module, `./register.tsx` |
-| `hooks/register.tsx` | Every hook, every `$` call, the command name and the pane constants, the state atoms, the writers, the ticker, the carry, and the diagnostics |
+| `hooks/register.tsx` | Every hook, every `$` call, the command name and the pane constants, the state atoms, the memory and its mirror, the writers, the ticker, and the diagnostics |
| `hooks/limits.ts` | Every named limit |
| `hooks/output.ts` | Clipping, display lines, progress, ETA math, durations, the output path rule, and the end of an output |
| `hooks/redirect.ts` | The redirect rule: the one file that a background command's text sends its output to |
@@ -75,8 +75,8 @@ 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`, `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.
+- Keep the jobs and the wakeup in module memory, `memory`: it is the source of truth, and it survives `/clear`, `/resume`, and `/branch`. `$.state` holds a mirror of the current session's view for drawing, and **Hide** and the last answered status prompt as atoms. The only module variables are `ticker`, `isTicking`, `lastTickAt`, `memory`, `since`, `sessions`, `switchedAt`, `mirror`, `historyWrites`, `reported`, and `isAnyRatio`, the `genericRatios` option that `register` sets.
+- Change `memory.jobs` only through `writeJobs` and `memory.wakeup` only through `writeWakeup`: they change memory at once, then write the mirror through `mirrored`, wait until a write that holds the change has landed, and keep the ticker current. A failed write is no failure of the hook (see `persisted`): memory took the change, so the rest of the hook runs. Each hook passes one `deadlineOf()` to all its writes: it waits `MIRROR_WAIT_MS` at most in all, and a wait that passes its deadline leaves the write's waiters. A reload of the mod empties module variables and keeps `$.state`: only `loaded`, at the first `session.start`, change of memory, or mirror write, reads the jobs and the wakeup from `$.state` into memory. Each change of memory waits for that read and applies on top of it, and no mirror write goes out before it. The mirror keeps a removal for each ended job that the session does not show (see `viewedJobs`). Read the mirror only to draw: in a render hook, `/downrange`, and the status answer.
- 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.
- Start every diagnostic with `downrange: : `, then the cause. Never log output text, prompt text, or a command line.
diff --git a/README.md b/README.md
index 075ca7d..6db2e3c 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
-
+
Progress and an ETA for background shells and Monitor watches, read from their real output or past runs, with no model request.
@@ -28,7 +28,7 @@ Downrange reads that output on your machine and shows where each job stands.
- **The background tasks of your session.** Shell commands (`run_in_background`, ctrl+b, or a command moved at its timeout), Monitor watches, background and remote subagents, teammates, workflow runs, and the pending dynamic `/loop` wakeup. For background sessions, use agent view (`claude agents`).
- **Liveness flags.** `quiet 3m 00s` for a shell command or subagent that stayed silent for 2 minutes or more, and `3 failed calls in a row` for a struggling subagent.
- **A table that prints mid-turn.** `/downrange` answers at once, even while Claude works. With the `answerStatusPrompts` option on, a prompt such as `status?` gets the same table.
-- **In step with Claude Code.** At each turn end, Downrange checks its rows against Claude Code's task list. It adds tasks it missed, and marks a job that left the list `ended without a report`. Running jobs carry across `/clear`, `/resume`, and `/branch`.
+- **In step with Claude Code.** At each turn end, Downrange checks its rows against Claude Code's task list. It adds tasks it missed, and marks a job that left the list `ended without a report`. Running jobs stay in view across `/clear`, `/resume`, and `/branch`.
- **Read-only by design.** Downrange never stops or changes a task. Built-in `/tasks` stops tasks.

@@ -45,15 +45,14 @@ The [usage guide](docs/usage.md) explains every view, symbol, note, and message.
```mermaid
flowchart LR
- L["Tool calls: launches, wakeups, stops, subagent activity"] --> S[("Jobs and wakeup in session state")]
+ L["Tool calls: launches, wakeups, stops, subagent activity"] --> S[("Jobs and wakeup in memory, one list per process")]
N["Task notifications"] --> S
P["Stop and SubagentStop snapshots of tasks and crons"] --> S
T["Ticker, once a second while work is live"] -->|"output file ends, agent list"| S
N -->|"clean shell runs"| H[("Usual durations under hashed keys")]
H -->|"usual duration at launch"| S
- S --> V["Band, pane, and /downrange table"]
- S -->|"session end"| C["Carry for /clear, /resume, /branch"]
- C --> S
+ S -->|"each change, and each new session"| M[("Copy of the session's view in session state")]
+ M --> V["Band, pane, and /downrange table"]
```
- **Launches.** Hooks on the Bash, Monitor, Agent, and Workflow tools record each background launch with its id and title, and a shell's output file. A ScheduleWakeup result sets the wakeup. A TaskStop result marks its job stopped.
@@ -61,7 +60,7 @@ flowchart LR
- **Redirect files.** Some commands send their output to a file, such as `> build.log` or `| tee build.log`. The ticker then also reads the one file that the command's text names, its redirect file, in the same way. At each read, the row shows the file that changed last: Claude Code's output file or the redirect file. On equal times, it shows Claude Code's output file. While the row shows the redirect file, it notes `output: build.log`. The end and the exit code still come from Claude Code's notification. Downrange follows no redirect file on Windows, or in a command with code that may not run, such as an `if` or a function.
- **Ends.** Task notifications end jobs and carry Monitor events. The agent list updates subagent states. Stop and SubagentStop snapshots add tasks that Downrange missed, and end tasks that left the list after a 10-second grace period. A scheduled prompt that fires from 5 seconds before the wakeup's due time clears the wakeup. So does a snapshot with no one-shot schedule, after the same grace period.
- **Usual durations.** A clean end (exit code 0 or none) of a shell that Downrange saw start adds its run time to a history keyed by a hash of folder and command. The usual-duration ETA is the median of the last 5 runs.
-- **Views and carry.** The band, the pane, and `/downrange` draw from the same state. The ticker stops when nothing is live. At session end, Downrange keeps the running jobs and the wakeup in memory, and restores them after `/clear`, `/resume`, or `/branch`.
+- **Views and session changes.** Downrange keeps the jobs and the wakeup in its own memory, which `/clear`, `/resume`, and `/branch` do not touch. After each change, it writes a copy of the session's view to session state, and the band, the pane, and `/downrange` draw from that copy. So the new session shows the running jobs and the wakeup at once, with their ages, last lines, progress, ETAs, and file reads. Each session shows the jobs that ended after it started. **Hide** does not carry. The ticker stops when nothing is live.
## Install
@@ -225,7 +224,7 @@ Downrange watches events and changes almost none of them. It makes no network re
It also reads the session's working folder, to hash it with a command and to place a relative redirect file, and the list of the session's agents and their states.
- **Conversation data it reads.** The input and result of the tool calls in the hook table below: a command or description, the task id, the output file path, and the wakeup time and reason. Downrange reads a background command's text to find its redirect file. One input field of each tool call a tracked subagent makes (see the last item). Task notifications. The `background_tasks` and `session_crons` lists of Stop and SubagentStop hooks. The text of a prompt you type, only when `answerStatusPrompts` is on, to compare it with the status phrases. Downrange reads no transcript file.
-- **What it keeps.** Session state, which Claude Code holds for the session and which other mods in the session can read. It holds job titles, last output lines, notes, tool summaries, and the wakeup's reason, each cut to 200 characters or fewer. It also holds task ids, output file paths, redirect file paths, progress, exit codes, times, and the last status prompt it answered. The duration store holds run times under a hash of folder and command, with no command text, no path, and no output.
+- **What it keeps.** Memory, while Claude Code runs, and a copy of the session's view in session state, which Claude Code holds for the session and which other mods in the session can read. They hold job titles, last output lines, notes, tool summaries, and the wakeup's reason, each cut to 200 characters or fewer. It also holds task ids, output file paths, redirect file paths, progress, exit codes, times, and the last status prompt it answered. The duration store holds run times under a hash of folder and command, with no command text, no path, and no output.
- **Permissions.** Downrange never changes a permission decision. No hook denies or approves a call, rewrites a tool's input, or answers a permission prompt. Claude Code and you decide as before. `claude plugin validate` lists most hooks as gating hooks with a `.catch`, because their events can block. Each such `.catch`, such as the one on `classic.SessionStart` in `hooks/register.tsx`, only logs the failure and passes the event on unchanged with `next(e)`. The `/downrange` command and the Jobs pane show the failure instead.
- **The `Reflect.get` call in `hooks/jobs.ts`.** It reads one named field of a tracked subagent's tool input, from a fixed list in that file. The fields are `command` for Bash and Monitor, the file name from `file_path` or `notebook_path`, the `pattern` of Grep and Glob, the host of a WebFetch `url`, the `query` of WebSearch, and the `description` of Agent. That field becomes a summary such as `Edit: view.ts`, cut to 60 characters. Each tool's input has its own shape, so the code reads the field by name. It calls no function and reads no other field. Other tools show only their name.
@@ -234,9 +233,9 @@ Downrange watches events and changes almost none of them. It makes no network re
| Event | What the hook does | What it changes |
| --- | --- | --- |
-| `session.start` | Registers `/downrange`, and loads the session's jobs and wakeup | Nothing |
-| `session.end` | Keeps the running jobs and the wakeup in memory for the next session | Nothing |
-| `classic.SessionStart` after `/clear`, `/resume`, or `/branch` | Puts the kept jobs and wakeup back, and starts the once-a-second timer | Nothing. It adds no context, instructions, or settings |
+| `session.start` | Registers `/downrange`, and loads the jobs and the wakeup from session state into memory, as after a reload | Nothing |
+| `session.end` | On `/clear`, `/resume`, or `/branch`: notes the time, where the next session's **Finished** starts | Nothing |
+| `classic.SessionStart` after `/clear`, `/resume`, or `/branch` | Takes the new session's id from the event, writes that session's view of the jobs and the wakeup to session state, and starts the once-a-second timer | Nothing. It adds no context, instructions, or settings |
| `command.run` for `downrange` only | Prints the table for `/downrange`, and opens or closes the pane for `/downrange pane` | Only its own command |
| `tool.call` for Bash, Monitor, Agent, Workflow, ScheduleWakeup, and TaskStop | Lets the call run, then reads its result to record a background launch, the wakeup, or a stop | Nothing. The call's input and result stay as they were |
| `tool.call` for any tool | For a subagent that Downrange tracks, counts the call and keeps a short summary of it | Nothing |
diff --git a/docs/usage.md b/docs/usage.md
index 62e0fac..0845a6a 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -211,7 +211,15 @@ A change takes effect at once: Claude Code reloads the mod with the new values.
## `/clear`, `/resume`, and `/branch`
-Background work keeps running after these commands. Downrange carries running jobs and the wakeup across them.
+Background work keeps running after these commands, in the same Claude Code process. The jobs and the wakeup belong to that process: Downrange keeps them in its own memory, which these commands do not touch. Every session of the process shows the same running jobs and the same wakeup. The band, the pane, and `/downrange` show them at once in the new session:
+
+- Each job keeps its real age, last line, progress, and ETA. Downrange goes on reading each shell's output file and redirect file, as before the command.
+- Each update applies at once, also during the change: an output read, a launch, a notification, a subagent's tool call, a turn-end snapshot, or a scheduled prompt. The rules are the same as in a session. So a late notification or Monitor event of a job that ended before the change does not bring the job back.
+- **Finished** shows the jobs that ended after the session started. A session that this process has not shown before starts when the old session ends, so it shows the jobs that end during the change and after it. An in-app `/resume` back to a session that this process showed keeps that session's start, for the last 32 sessions. **Hide** does not carry: a band that you hid shows again while work is live.
+
+Downrange draws from a copy of the session's view in session state, which Claude Code clears at each of these commands. Downrange writes the copy again after each change, and when the new session starts. It writes one copy at a time, in order, so the last copy holds the new session's view.
+
+Only a change inside one Claude Code process carries. `/fork` copies the conversation into a background session in a process of its own: Downrange starts empty there, and your session keeps its rows. `claude --resume` from a shell also starts a new process, where Downrange starts empty.
## Known limits
@@ -231,14 +239,14 @@ 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. 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, 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.
+- **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 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.
+- **Changes during `/clear`, `/resume`, or `/branch`.** No update is lost at these commands: it goes to Downrange's memory at once. The band and the pane can lag for a moment: when a copy that Downrange began before the command is slow to land, the new session's view shows when that copy lands. 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. A finished job leaves the list through **Clear finished** or through the 20-job limit of **Finished**. **Clear finished** clears the finished jobs of every session in the process. A reload of Downrange, as an update or a change of its options causes, starts its memory again from the copy of the current session's view, so the current session keeps its jobs, its wakeup, and **Hide**. An update that comes during the reload waits for that copy and applies on top of it. Each hook also waits until a copy that holds its update has landed, so a reload right after it keeps the update. One hook waits 2 seconds at most in all, from its first wait, also when it makes more than one update. So when Claude Code answers a write slowly, or updates come without a pause, a hook can go on with its update in memory only. A hook that waits for a tool call between two updates, such as the one that counts a subagent's tool call, can go on at once after the call. The ticker runs either way: it reads outputs, and it writes the copy again. A reload of Downrange before the next copy lands then loses that update; a later turn-end snapshot corrects the jobs, and the next `ScheduleWakeup` result corrects the wakeup. Ended jobs that this session did not show, and the starts of the other sessions, are then gone. Downrange keeps the removal of each such job, within the 2,000, so a late notification or Monitor event does not bring it back.
- **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.
@@ -249,6 +257,8 @@ Run `claude --debug`, or `claude --debug-file `, and search for `downrange
| Message | Meaning | What to do |
| --- | --- | --- |
| `: hook throw: …` or `: hook timeout: …` | A hook failed, such as `tool.call{tool=Bash}`. Handlers other than `/downrange` and the Jobs pane pass the event on unchanged | Report a bug with the line |
+| `state.set: cannot write the copy of the session's view: …; the next tick writes it again` | Claude Code refused or failed a write of the copy that the band and the pane draw. Downrange's memory took the update, and the hook went on | Nothing. The next tick writes the copy again. Report a bug with the line if it repeats |
+| `clock.every: …` with a state write's cause | A copy of the session's view that the band and the pane draw could not be written. Downrange's memory holds every update, and each tick tries the copy again | Nothing. Report a bug with the line if it repeats |
| `session.start: /downrange was not registered: …` | Claude Code rejected command registration; a built-in name conflict is one possible cause | Read the appended cause. Open the pane with the band's **Open** |
| `state.get: unsupported or invalid jobs state; reset safely` or `unsupported or invalid wakeup state; reset safely` | The stored format is unknown or invalid | Running tasks can return through snapshots; a new wakeup result restores its countdown |
| `tool.call: no output file in the result of task ; …` | The tool result named no output file | Nothing. The job shows without a last line |
diff --git a/hooks/jobs.ts b/hooks/jobs.ts
index b530a31..5164bde 100644
--- a/hooks/jobs.ts
+++ b/hooks/jobs.ts
@@ -662,7 +662,7 @@ export function observedJobs(state: JobsState, input: JobObservation, change: (j
return next === state ? state : capped(next)
}
-/** observedJobs before the cap. restoredJobs applies the cap once, after the whole carry, so a carried row never counts. */
+/** observedJobs before the cap. restoredJobs applies the cap once, after the whole state it lays over, so a row of it never counts. */
function ordered(state: JobsState, input: JobObservation, change: (jobs: Job[]) => Job[]): JobsState {
if (input.noticeRow !== undefined && state.noticeRows?.includes(input.noticeRow)) return state
if (input.kind === 'snapshot' && input.at < (state.snapshotAt ?? -Infinity)) return state
@@ -718,7 +718,10 @@ function ordered(state: JobsState, input: JobObservation, change: (jobs: Job[])
}
}
-/** Restores the carry through the same ordering rule, using original observation and removal times, then applies the cap once. */
+/**
+ * Lays `carried` over `state` through the same ordering rule, using its original observation and removal times, then
+ * applies the cap once. The hooks no longer use it: the ordering tests keep it as the reference for the rule.
+ */
export function restoredJobs(state: JobsState, carried: JobsState): JobsState {
let restored = state
for (const [id, at] of carried.removed) {
@@ -739,6 +742,22 @@ export function restoredJobs(state: JobsState, carried: JobsState): JobsState {
noticeRows: [...new Set([...(state.noticeRows ?? []), ...(carried.noticeRows ?? [])])].slice(-NOTICE_ROWS_MAX) })
}
+/**
+ * The jobs that a session shows: every running job, and the ended jobs from `since` on. Each ended job that it does not
+ * show keeps a removal, as a cleared row does, and the cap bounds them: a reload that starts from this view still
+ * refuses the late inputs of those jobs.
+ */
+export function viewedJobs(state: JobsState, since: number): JobsState {
+ const isShown = (job: Job): boolean => isRunning(job) || (job.endedAt ?? -Infinity) >= since
+ if (state.items.every(isShown)) return state
+ const observed = new Map(state.observed)
+ const removed = new Map(state.removed)
+ for (const job of state.items) {
+ if (!isShown(job)) removed.set(job.id, Math.max(removed.get(job.id) ?? -Infinity, observed.get(job.id) ?? job.lastEndedAt ?? job.startedAt))
+ }
+ return capped({ ...state, items: state.items.filter(isShown), removed: [...removed] })
+}
+
/** Live: a job runs, a wakeup is pending, or a job ended less than RECENT_MS ago. */
export function isLive(jobs: readonly Job[], wakeup: Wakeup | null, now: number): boolean {
return wakeup !== null || jobs.some(job => isRunning(job) || now - (job.endedAt ?? 0) < RECENT_MS)
diff --git a/hooks/limits.ts b/hooks/limits.ts
index 6b9021f..d88bf1b 100644
--- a/hooks/limits.ts
+++ b/hooks/limits.ts
@@ -74,7 +74,7 @@ export const PANE_FIXED_COLUMNS = 44
/** Stored child rows retained for duplicate suppression. */
export const NOTICE_ROWS_MAX = 256
-/** Compare-and-set attempts before reporting a busy state. */
-export const STATE_WRITE_ATTEMPTS = 8
-/** Restore attempts per destination session. */
-export const CARRY_ATTEMPTS = 3
+/** The longest wait of a hook for the mirror write that holds its change; past it, the hook goes on with the change in memory only. */
+export const MIRROR_WAIT_MS = 2_000
+/** Sessions of one process whose start time for Finished stays in memory, for a /resume back to them; the oldest goes first. */
+export const MAX_SESSIONS = 32
diff --git a/hooks/register.tsx b/hooks/register.tsx
index 64dd5e1..4b64ec1 100644
--- a/hooks/register.tsx
+++ b/hooks/register.tsx
@@ -26,12 +26,12 @@ import {
readsDue,
reconciled,
reloaded,
- restoredJobs,
shellJob,
ownerAnswered,
shellLaunchNote,
stoppedJob,
tailRead,
+ viewedJobs,
wakeupAfterSnapshot,
wakeupAfterTick,
wakeupAfterTrigger,
@@ -39,7 +39,7 @@ import {
workflowJob,
} from './jobs'
import type { HistoryRun, JobObservation, SnapshotTask, TailRead } from './jobs'
-import { CARRY_ATTEMPTS, STATE_WRITE_ATTEMPTS, FS_READ_MAX, REPEAT_WINDOW_MS, TAIL_TIMEOUT_MS, TICK_MS, TICKER_STALE_MS } from './limits'
+import { FS_READ_MAX, MAX_SESSIONS, MIRROR_WAIT_MS, REPEAT_WINDOW_MS, TAIL_TIMEOUT_MS, TICK_MS, TICKER_STALE_MS } from './limits'
import { noticesOf } from './notices'
import { displayLinesOf, isOutputFileOf, jobBytesOf, jobLinesOf, oneLine, outputEndOf, outputFileOf } from './output'
import { jobsStateOf, wakeupStateOf } from './state'
@@ -61,10 +61,15 @@ const LAST_ANSWERED = atom({ plugin: 'downrange', key: 'lastAnswered' } as const
/** The buttons of the band and the pane. */
type Control = 'Open' | 'Hide' | 'Clear finished' | 'Close'
+/** A hook that waits for a mirror write: told when the write lands, or why it failed. */
+type Waiter = { landed: () => void; failed: (error: unknown) => void }
+/** A mirror write, under way or next in line, and the hooks that wait for it. */
+type Write = { waiters: Set }
+/** One hook's bound on its waits for the mirror: its timer, whether it passed, and how each wait leaves at it. */
+type Deadline = { timer: Timer | null; isPassed: boolean; leaves: Set<() => void> }
-/** Running jobs and the wakeup, as the carry across /clear, /resume, and /branch holds them. */
-type Carry = { jobs: JobsState; wakeup: WakeupState; destination?: string; attempts: number; restoring: boolean }
-type Committed = { jobs: JobsState; wakeup: WakeupState; sessionId?: string; jobsVersion: number; wakeupVersion: number }
+const NO_JOBS: JobsState = { items: [], observed: [], removed: [], snapshotAt: null }
+const NO_WAKEUP: WakeupState = { pending: null, updatedAt: null }
/** Keys of diagnostics already shown; a reload re-arms them on purpose. */
const reported = new Set()
@@ -75,10 +80,24 @@ let ticker: Timer | null = null
let isTicking = false
/** When the ticker last ran a period, or started; an old time means a refused period ended it. */
let lastTickAt = 0
-/** The newest committed state and versions; session.end saves running rows and ordering barriers. */
-let latest: Committed = { jobsVersion: -1, wakeupVersion: -1, jobs: { items: [], observed: [], removed: [], snapshotAt: null }, wakeup: { pending: null, updatedAt: null } }
-/** What session.end saved for the next classic.SessionStart. */
-let carry: Carry | null = null
+/**
+ * The process's jobs and wakeup, the source of truth. Background work belongs to the process, so every session of the
+ * process shows it, and /clear, /resume, and /branch do not touch it. Each hook changes it at once; `$.state` holds a
+ * mirror of the current session's view of it, for drawing (see mirrored).
+ */
+const memory: { jobs: JobsState; wakeup: WakeupState } = { jobs: NO_JOBS, wakeup: NO_WAKEUP }
+/** The current session's `since`: its Finished shows the jobs that ended from then on (see viewNow). */
+let since = -Infinity
+/** The `since` of each session that this process showed, by session id: the MAX_SESSIONS most recent, oldest first. */
+const sessions = new Map()
+/** When the last /clear, /resume, or /branch ended a session. */
+let switchedAt: number | null = null
+/**
+ * The mirror: the read of it into memory at a load of the module (see loaded), the write under way, the one after it,
+ * which holds every change made meanwhile, and whether the last write failed.
+ */
+const mirror: { load: Promise | null; isLoaded: boolean; writing: Write | null; next: Write | null; isBehind: boolean } =
+ { load: null, isLoaded: false, writing: null, next: null, isBehind: false }
/**
* The last history record; each record waits for it, so two in one session never overwrite each other. Two sessions
* can still race, because the store has no versioned write; a lost record costs every run it held, a new key's first too.
@@ -118,9 +137,14 @@ function reportHook($: EngineInterface, hook: string, failure: HookFailure): voi
$.ui.log(`${isFirst ? 'downrange: ' : ''}${hook}: ${failureText(failure)}`, { to: isFirst ? 'transcript' : 'debug' })
}
+/** Whether a hook failed by re-entry. Claude Code 2.1.309 and earlier report this kind; the 2.1.310 types no longer name it. */
+function isReentry(failure: HookFailure): boolean {
+ return (failure.kind as string) === 're-entry'
+}
+
/** What the `.catch` of a hook that only watches does: it reports the failure under the hook's label and passes the event on unchanged. */
function passOn($: EngineInterface, label: string, e: I, next: ((e: I) => R) & Caught): R {
- if (next.error.kind !== 're-entry') reportHook($, label, next.error)
+ if (!isReentry(next.error)) reportHook($, label, next.error)
return next(e)
}
@@ -146,82 +170,203 @@ async function ensureTicker($: EngineInterface): Promise {
})
}
-/** Writes only to the destination captured before the read and retains committed versions alone. */
-async function writeJobs($: EngineInterface, input: JobObservation | JobsState | null, change: (jobs: Job[]) => Job[] = jobs => jobs, destination?: string): Promise {
- const sessionId = destination ?? await $.session.id()
- for (let attempt = 0; attempt < STATE_WRITE_ATTEMPTS; attempt++) {
- if (await $.session.id() !== sessionId) throw new Error('jobs destination changed')
- const raw = await $.state.get({ plugin: 'downrange', key: 'jobs' })
- const decoded = jobsStateOf(raw.value)
- if (raw.value !== undefined && decoded === undefined) report($, 'state.get', 'unsupported or invalid jobs state; reset safely', 'jobs-shape')
- const state = decoded ?? { items: [], observed: [], removed: [], snapshotAt: null }
- const changed = input === null ? state : 'items' in input ? restoredJobs(state, input) : observedJobs(state, input, change)
- if (await $.session.id() !== sessionId) throw new Error('jobs destination changed')
- const written = input === null && (raw.value === undefined || (decoded !== undefined && raw.value.shape === 'v8'))
- ? { isSet: true, version: raw.version }
- : await $.state.set({ plugin: 'downrange', key: 'jobs' }, { shape: 'v8', value: changed }, { ifVersion: raw.version })
- if (!written.isSet) continue
- if (await $.session.id() !== sessionId) throw new Error('jobs destination changed')
- if (latest.sessionId !== sessionId) latest = { sessionId, jobs: state, wakeup: { pending: null, updatedAt: null }, jobsVersion: -1, wakeupVersion: -1 }
- if (written.version > latest.jobsVersion) latest = { ...latest, jobs: changed, jobsVersion: written.version }
- await ensureTicker($)
- return changed !== state
- }
- throw new Error('jobs state stayed busy')
-}
-
-/** Migrates before replacement and preserves the latest committed wakeup cancellation. */
-async function writeWakeup($: EngineInterface, now: number, change: (wakeup: Wakeup | null) => Wakeup | null, isReplacement = false, destination?: string): Promise {
- const sessionId = destination ?? await $.session.id()
- for (let attempt = 0; attempt < STATE_WRITE_ATTEMPTS; attempt++) {
- if (await $.session.id() !== sessionId) throw new Error('wakeup destination changed')
- const raw = await $.state.get({ plugin: 'downrange', key: 'wakeup' })
- const decoded = wakeupStateOf(raw.value)
- if (raw.value !== undefined && decoded === undefined) report($, 'state.get', 'unsupported or invalid wakeup state; reset safely', 'wakeup-shape')
- const state = decoded ?? { pending: null, updatedAt: null }
- const accepts = state.updatedAt === null || now >= state.updatedAt
- const pending = accepts ? change(state.pending) : state.pending
- const changed = accepts && (isReplacement || pending !== state.pending) ? { pending, updatedAt: now } : state
- if (await $.session.id() !== sessionId) throw new Error('wakeup destination changed')
- const written = changed === state && (raw.value === undefined || (decoded !== undefined && raw.value.shape === 'v4'))
- ? { isSet: true, version: raw.version }
- : await $.state.set({ plugin: 'downrange', key: 'wakeup' }, { shape: 'v4', value: changed }, { ifVersion: raw.version })
- if (!written.isSet) continue
- if (await $.session.id() !== sessionId) throw new Error('wakeup destination changed')
- if (latest.sessionId !== sessionId) latest = { sessionId, jobs: { items: [], observed: [], removed: [], snapshotAt: null }, wakeup: state, jobsVersion: -1, wakeupVersion: -1 }
- if (written.version > latest.wakeupVersion) latest = { ...latest, wakeup: changed, wakeupVersion: written.version }
- await ensureTicker($)
- return
- }
- throw new Error('wakeup state stayed busy')
+/**
+ * The current session's view of the process's data: every running job, the ended jobs from its `since` on with a
+ * removal for each other one (see viewedJobs), and the wakeup.
+ */
+function viewNow(): { jobs: JobsState; wakeup: WakeupState } {
+ return { jobs: viewedJobs(memory.jobs, since), wakeup: memory.wakeup }
+}
+
+/** One write of the mirror: the view as it stands when the write begins, the jobs, then the wakeup. */
+async function writeMirror($: EngineInterface): Promise {
+ const shown = viewNow()
+ await $.state.set({ plugin: 'downrange', key: 'jobs' }, { shape: 'v8', value: shown.jobs })
+ await $.state.set({ plugin: 'downrange', key: 'wakeup' }, { shape: 'v4', value: shown.wakeup })
}
-/** Keeps running rows and barriers for every terminal row omitted from the handoff. */
-function carriedJobs(state: JobsState): JobsState {
- const removed = new Map(state.removed)
- const observed = new Map(state.observed)
- for (const job of state.items) {
- if (!isRunning(job)) removed.set(job.id, Math.max(removed.get(job.id) ?? -Infinity, observed.get(job.id) ?? job.lastEndedAt ?? job.startedAt))
+/**
+ * Begins a mirror write, for the hooks that wait for it. When it ends, one listener tells each of them, and the write
+ * after it, which holds every change made meanwhile, begins with its own waiters. A failed write leaves the mirror
+ * behind until a later write lands (see refresh).
+ */
+function begun($: EngineInterface, waiters: Set = new Set()): Write {
+ const write: Write = { waiters }
+ mirror.writing = write
+ const ended = (failure: { error: unknown } | null): void => {
+ mirror.writing = null
+ mirror.isBehind = failure !== null
+ for (const waiter of write.waiters) {
+ if (failure === null) waiter.landed()
+ else waiter.failed(failure.error)
+ }
+ write.waiters.clear()
+ const next = mirror.next
+ if (next !== null) {
+ mirror.next = null
+ begun($, next.waiters)
+ }
}
- return { ...state, items: state.items.filter(isRunning), removed: [...removed] }
+ writeMirror($).then(() => ended(null), (error: unknown) => ended({ error }))
+ return write
}
-/** Retries a pending handoff only in its assigned destination; both writes must succeed before clearance. */
-async function restoreCarry($: EngineInterface): Promise {
- const carried = carry
- if (carried === null || carried.destination === undefined || carried.restoring || carried.attempts >= CARRY_ATTEMPTS) return
- if (await $.session.id() !== carried.destination || carry !== carried) return
- carried.restoring = true
- carried.attempts++
- try {
- await writeJobs($, carried.jobs, jobs => jobs, carried.destination)
- if (carry !== carried || await $.session.id() !== carried.destination) return
- if (carried.wakeup.updatedAt !== null) {
- await writeWakeup($, carried.wakeup.updatedAt, () => carried.wakeup.pending, true, carried.destination)
+/** The write that holds the data as it stands now: a new write when none runs, else the one after the write under way. */
+function carrying($: EngineInterface): Write {
+ if (mirror.next !== null) return mirror.next
+ if (mirror.writing === null) return begun($)
+ mirror.next = { waiters: new Set() }
+ return mirror.next
+}
+
+/** A new bound for one hook's waits for the mirror (see waited). */
+function deadlineOf(): Deadline {
+ return { timer: null, isPassed: false, leaves: new Set() }
+}
+
+/**
+ * Waits until `write` lands, until the hook's deadline at most. The deadline starts at the hook's first wait and holds
+ * for every wait of that hook, so one hook waits MIRROR_WAIT_MS at most in all. At the deadline the hook leaves the
+ * write's waiters, so a write that does not answer keeps nothing of it; the deadline's timer ends by itself then.
+ */
+function waited($: EngineInterface, write: Write, deadline: Deadline): Promise {
+ if (deadline.isPassed) return Promise.resolve()
+ return new Promise((resolve, reject) => {
+ const leave = (): void => {
+ write.waiters.delete(waiter)
+ resolve()
}
- if (carry === carried && await $.session.id() === carried.destination) carry = null
- } finally {
- carried.restoring = false
+ const waiter: Waiter = {
+ landed: () => {
+ deadline.leaves.delete(leave)
+ resolve()
+ },
+ failed: error => {
+ deadline.leaves.delete(leave)
+ reject(error)
+ },
+ }
+ write.waiters.add(waiter)
+ deadline.leaves.add(leave)
+ deadline.timer ??= $.clock.after(MIRROR_WAIT_MS, () => {
+ deadline.isPassed = true
+ for (const left of deadline.leaves) left()
+ deadline.leaves.clear()
+ })
+ })
+}
+
+/**
+ * Writes the current session's view to the mirror after a change, and waits until a write that holds the change has
+ * landed: a reload right after the hook keeps the change. One write runs at a time, so the writes land in the order they
+ * began: a write that began before a switch lands before the write that classic.SessionStart asks for. A change while a
+ * write runs asks for one more write after it, which holds every change made meanwhile: a burst costs at most two
+ * writes, and the last write holds the current data. The hook's deadline bounds the wait (see waited): past it, the
+ * change stays in memory, and a later write or tick writes it.
+ */
+async function mirrored($: EngineInterface, deadline: Deadline): Promise {
+ if (!mirror.isLoaded) await loaded($)
+ await waited($, carrying($), deadline)
+}
+
+/**
+ * Mirrors a change that memory took (see mirrored). A failed write is no failure of the hook: memory holds the change,
+ * so what follows it in the hook still runs, the ticker writes the mirror again, and the failure reports to the
+ * transcript the first time, then to the debug log.
+ */
+async function persisted($: EngineInterface, deadline: Deadline): Promise {
+ try {
+ await mirrored($, deadline)
+ } catch (error) {
+ const isFirst = !reported.has('mirror')
+ reported.add('mirror')
+ $.ui.log(`${isFirst ? 'downrange: ' : ''}state.set: cannot write the copy of the session's view: ${messageOf(error)}; the next tick writes it again`, { to: isFirst ? 'transcript' : 'debug' })
+ }
+}
+
+/**
+ * Applies an observation to the process's jobs by the ordering rule, then mirrors them; the result tells whether they
+ * changed. In a module that has not read the mirror yet, the observation waits for that read (see loaded).
+ */
+async function writeJobs($: EngineInterface, deadline: Deadline, input: JobObservation, change: (jobs: Job[]) => Job[] = jobs => jobs): Promise {
+ if (!mirror.isLoaded) await loaded($)
+ const before = memory.jobs
+ memory.jobs = observedJobs(before, input, change)
+ const isChanged = memory.jobs !== before
+ // What follows the change runs whether or not the mirror write lands: the ticker, which reads outputs and writes a
+ // mirror that fell behind, and the rest of the hook.
+ if (isChanged) await persisted($, deadline)
+ await ensureTicker($)
+ return isChanged
+}
+
+/** The wakeup state after a write at `now`: an older write never replaces a newer one. */
+function wakeupWritten(state: WakeupState, now: number, change: (wakeup: Wakeup | null) => Wakeup | null, isReplacement: boolean): WakeupState {
+ if (state.updatedAt !== null && now < state.updatedAt) return state
+ const pending = change(state.pending)
+ return isReplacement || pending !== state.pending ? { pending, updatedAt: now } : state
+}
+
+/** Sets the process's wakeup, and preserves the latest wakeup cancellation, then mirrors it; it waits, reports, and starts the ticker as writeJobs does. */
+async function writeWakeup($: EngineInterface, deadline: Deadline, now: number, change: (wakeup: Wakeup | null) => Wakeup | null, isReplacement = false): Promise {
+ if (!mirror.isLoaded) await loaded($)
+ const before = memory.wakeup
+ memory.wakeup = wakeupWritten(before, now, change, isReplacement)
+ if (memory.wakeup !== before) await persisted($, deadline)
+ await ensureTicker($)
+}
+
+/**
+ * Memory after a load of the module. A reload of the mod starts module variables empty, keeps `$.state`, and fires
+ * session.start again. So the first session.start, the first change of memory, or the first mirror write, whichever
+ * comes first, reads the mirror into memory, once. Each change waits for that read and applies on top of it, and no
+ * mirror write goes out before it: the mirror is never written from empty memory over its own data. A failed read is
+ * tried again at the next change. A later session.start of the same module keeps memory.
+ */
+function loaded($: EngineInterface): Promise {
+ mirror.load ??= seeded($).catch((error: unknown) => {
+ mirror.load = null
+ throw error
+ })
+ return mirror.load
+}
+
+/**
+ * Starts memory from the mirror, which holds the current session's view: its running jobs, its ended jobs, the removals,
+ * and the wakeup (Hide stays in its own atom). No change of memory runs before it ends (see loaded). A stored shape to
+ * update, an invalid value, and rows that the current options change go back to the mirror at once; with none of
+ * them, nothing is written.
+ */
+async function seeded($: EngineInterface): Promise {
+ const now = await $.clock.now()
+ const [storedJobs, storedWakeup] = await Promise.all([$.state.get({ plugin: 'downrange', key: 'jobs' }), $.state.get({ plugin: 'downrange', key: 'wakeup' })])
+ const jobs = jobsStateOf(storedJobs.value)
+ if (storedJobs.value !== undefined && jobs === undefined) report($, 'state.get', 'unsupported or invalid jobs state; reset safely', 'jobs-shape')
+ const wakeup = wakeupStateOf(storedWakeup.value)
+ if (storedWakeup.value !== undefined && wakeup === undefined) report($, 'state.get', 'unsupported or invalid wakeup state; reset safely', 'wakeup-shape')
+ memory.jobs = jobs ?? NO_JOBS
+ memory.wakeup = wakeup ?? NO_WAKEUP
+ // After a reload, as a change of genericRatios or an update causes, rows follow the current option. They keep their
+ // observation times: an input that came after the reload, before this read ended, is judged by them, not by the read.
+ const items = reloaded(memory.jobs.items, isAnyRatio, now)
+ const isReloaded = items.some((job, index) => job !== memory.jobs.items[index])
+ if (isReloaded) memory.jobs = { ...memory.jobs, items }
+ const isJobsOld = storedJobs.value !== undefined && (jobs === undefined || storedJobs.value.shape !== 'v8')
+ const isWakeupOld = storedWakeup.value !== undefined && (wakeup === undefined || storedWakeup.value.shape !== 'v4')
+ mirror.isLoaded = true
+ // The new shape goes to the mirror; the hook that waits for the read waits for its own change, not for this write.
+ if (isReloaded || isJobsOld || isWakeupOld) carrying($)
+}
+
+/** Makes session `id` the current one from `at` on, and keeps the MAX_SESSIONS most recent sessions. */
+function entered(id: string | undefined, at: number): void {
+ since = at
+ if (id === undefined) return
+ sessions.delete(id)
+ sessions.set(id, at)
+ for (const oldest of sessions.keys()) {
+ if (sessions.size <= MAX_SESSIONS) break
+ sessions.delete(oldest)
}
}
@@ -275,7 +420,7 @@ async function recordRuns($: EngineInterface, runs: readonly HistoryRun[], now:
}
/** Applies every notification block in a delivered prompt, in order; history judges each block on the list the blocks before it left. */
-async function applyNotices($: EngineInterface, text: string, noticeRow?: string, observedAt?: number): Promise {
+async function applyNotices($: EngineInterface, deadline: Deadline, text: string, noticeRow?: string, observedAt?: number): Promise {
const event = noticeRow === undefined ? 'prompt.submit' : 'session.append'
const notices = noticesOf(text)
if (notices.length === 0) {
@@ -287,20 +432,17 @@ 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), kind: 'notice', finalIds: finalIdsOf(notices), noticeRow }, jobs => {
+ const changed = await writeJobs($, deadline, { 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, isAnyRatio), jobs)
})
if (changed) await recordRuns($, runs, now, event)
}
-/**
- * Reconciles with a Stop or SubagentStop snapshot: the wakeup with `session_crons`, the jobs with `background_tasks`;
- * an absent field logs once. The wakeup rule runs again on the wakeup that stands at the write, so a wakeup set
- * meanwhile is judged by its own time.
- */
+/** Reconciles with a Stop or SubagentStop snapshot: the wakeup with `session_crons`, the jobs with `background_tasks`; an absent field logs once. */
async function reconcile(
$: EngineInterface,
+ deadline: Deadline,
event: string,
input: { background_tasks?: readonly SnapshotTask[]; session_crons?: readonly { recurring: boolean }[] },
): Promise {
@@ -309,15 +451,14 @@ async function reconcile(
if (crons === undefined) {
report($, event, 'no session_crons in the hook input; a cancelled wakeup waits for its expiry', 'no-crons')
} else {
- const { pending: wakeup } = await read($, WAKEUP)
- if (wakeupAfterSnapshot(wakeup, crons, now) !== wakeup) await writeWakeup($, now, current => wakeupAfterSnapshot(current, crons, now))
+ await writeWakeup($, deadline, now, current => wakeupAfterSnapshot(current, crons, now))
}
const tasks = input.background_tasks
if (tasks === undefined) {
report($, event, 'no background_tasks in the hook input; reconciliation skipped', 'no-snapshot')
return
}
- await writeJobs($, { at: now, kind: 'snapshot', members: tasks.map(task => task.id) }, jobs => reconciled(jobs, tasks, now))
+ await writeJobs($, deadline, { at: now, kind: 'snapshot', members: tasks.map(task => task.id) }, jobs => reconciled(jobs, tasks, now))
}
/** Runs `tail -c 16384 `: the last TAIL_BYTES bytes, written as text so the command reads in full; only the output file path varies. */
@@ -382,11 +523,11 @@ async function redirectRead($: EngineInterface, job: ShellJob & { outputFile: st
}
/** Reads one shell job's output and applies it; only a failed stat, read, or tail sets the job's note and logs once per job and file. */
-async function readTail($: EngineInterface, job: ShellJob & { outputFile: string }, now: number): Promise {
+async function readTail($: EngineInterface, deadline: Deadline, job: ShellJob & { outputFile: string }, now: number): Promise {
const path = job.outputFile
if (!isOutputFileOf(path, job.id)) {
report($, 'clock.every', `refused to read ${path}: it is not the output file of task ${job.id}`, `path|${job.id}`)
- await writeJobs($, { at: now, ids: [job.id] }, jobs => readFailed(jobs, job.id, 'output unavailable', now))
+ await writeJobs($, deadline, { at: now, ids: [job.id] }, jobs => readFailed(jobs, job.id, 'output unavailable', now))
return
}
let found: TailRead
@@ -398,19 +539,19 @@ async function readTail($: EngineInterface, job: ShellJob & { outputFile: string
const bytes = failure instanceof TailFailure ? failure.bytes : undefined
const verb = bytes === undefined ? 'read' : 'tail'
report($, 'clock.every', `cannot ${verb} ${isRedirect ? 'the redirect file' : 'the output'} of task ${job.id}: ${messageOf(failure)}`, `${isRedirect ? 'redirect ' : ''}${verb}|${job.id}`)
- await writeJobs($, { at: now, ids: [job.id] }, jobs => readFailed(jobs, job.id, bytes === undefined ? 'output unavailable' : 'tail failed', now, bytes))
+ await writeJobs($, deadline, { at: now, ids: [job.id] }, jobs => readFailed(jobs, job.id, bytes === undefined ? 'output unavailable' : 'tail failed', now, bytes))
return
}
- await writeJobs($, { at: now, ids: [job.id] }, jobs => tailRead(jobs, job.id, found, now, isAnyRatio))
+ await writeJobs($, deadline, { at: now, ids: [job.id] }, jobs => tailRead(jobs, job.id, found, now, isAnyRatio))
}
/** Applies `$.agent.list()` to agent jobs; a failed list logs once. */
-async function syncAgents($: EngineInterface, now: number, event = 'clock.every'): Promise {
+async function syncAgents($: EngineInterface, deadline: Deadline, now: number, event = 'clock.every'): Promise {
const agents = await $.agent.list().catch((error: unknown) => {
report($, event, `cannot list agents: ${messageOf(error)}`, 'agents')
return undefined
})
- if (agents !== undefined) await writeJobs($, { at: now, ids: agents.map(agent => agent.id) }, jobs => agentsSynced(jobs, agents, now))
+ if (agents !== undefined) await writeJobs($, deadline, { at: now, ids: agents.map(agent => agent.id) }, jobs => agentsSynced(jobs, agents, now))
}
/** One ticker period: it marks the ticker alive, then refreshes unless a refresh still runs; a failure logs once per message. */
@@ -421,7 +562,6 @@ async function tick($: EngineInterface): Promise {
if (isTicking) return
isTicking = true
try {
- await restoreCarry($)
await refresh($, now)
} finally {
isTicking = false
@@ -431,15 +571,20 @@ async function tick($: EngineInterface): Promise {
}
}
-/** The work of one tick: expire the wakeup, sync agents, read outputs (a pending final read too), redraw, and stop when nothing is live. */
+/**
+ * The work of one tick: expire the wakeup, sync agents, read outputs (a pending final read too), write a mirror that
+ * fell behind, redraw, and stop when nothing is live.
+ */
async function refresh($: EngineInterface, now: number): Promise {
- const seen = latest
- const [{ items: jobs }, { pending: wakeup }] = await Promise.all([read($, JOBS), read($, WAKEUP)])
- if (wakeupAfterTick(wakeup, now) !== wakeup) await writeWakeup($, now, current => wakeupAfterTick(current, now))
- if (jobs.some(job => job.kind === 'agent' && isRunning(job))) await syncAgents($, now)
- for (const job of readsDue(jobs, now)) await readTail($, job, now)
- // Every write replaces `latest`: after a write during this tick, the next tick judges liveness on fresh state.
- if (!isLive(jobs, wakeup, now) && latest === seen && (carry === null || carry.attempts >= CARRY_ATTEMPTS || carry.destination === undefined)) {
+ const deadline = deadlineOf()
+ const seen = { ...memory }
+ const { jobs: { items: shown }, wakeup: { pending: wakeup } } = viewNow()
+ if (wakeupAfterTick(wakeup, now) !== wakeup) await writeWakeup($, deadline, now, current => wakeupAfterTick(current, now))
+ if (seen.jobs.items.some(job => job.kind === 'agent' && isRunning(job))) await syncAgents($, deadline, now)
+ for (const job of readsDue(seen.jobs.items, now)) await readTail($, deadline, job, now)
+ if (mirror.isBehind) await mirrored($, deadline)
+ // Every change replaces the jobs or the wakeup: after a change during this tick, the next tick judges liveness on fresh data.
+ if (!isLive(shown, wakeup, now) && memory.jobs === seen.jobs && memory.wakeup === seen.wakeup && !mirror.isBehind) {
ticker?.cancel()
ticker = null
}
@@ -456,7 +601,7 @@ async function pressed($: EngineInterface, control: Control): Promise {
const now = await $.clock.now()
await update($, BAND_HIDDEN_AT, () => now)
} else if (control === 'Clear finished') {
- await writeJobs($, { at: await $.clock.now() }, finishedCleared)
+ await writeJobs($, deadlineOf(), { at: await $.clock.now() }, finishedCleared)
} else {
await $.ui.close({ id: PANE_ID })
}
@@ -524,30 +669,29 @@ export const register: Register = (on, options) => {
} catch (error) {
$.ui.log(`downrange: session.start: /downrange was not registered: ${messageOf(error)}`)
}
- 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)
+ await loaded($)
+ // The session that this module first serves shows every ended job that memory holds; a later session.start of the
+ // module keeps the current session as it is. A session id that cannot be read keeps the session out of `sessions`.
+ const id = await $.session.id().catch(() => undefined)
+ if (id !== undefined && !sessions.has(id)) entered(id, since)
+ await ensureTicker($)
return next(e)
}).catch(($, e, next) => passOn($, 'session.start', e, next))
- on('session.end', ($, e, next) => {
- const current = latest.sessionId === e.sessionId ? latest : undefined
- if (current === undefined && carry?.destination !== e.sessionId) return next(e)
- const jobs = current === undefined ? carry?.jobs : carry === null ? current.jobs : restoredJobs(carry.jobs, current.jobs)
- const wakeup = current === undefined || (carry?.wakeup.updatedAt ?? -Infinity) > (current.wakeup.updatedAt ?? -Infinity) ? carry?.wakeup : current.wakeup
- if (jobs !== undefined && wakeup !== undefined) carry = { jobs: carriedJobs(jobs), wakeup, attempts: 0, restoring: false }
+ on('session.end', async ($, e, next) => {
+ // /clear, /resume, and /branch go on in this process: the next session that this process has not shown starts its
+ // Finished here. Memory stays as it is.
+ if (e.reason === 'clear' || e.reason === 'resume') switchedAt = await $.clock.now()
return next(e)
}).catch(($, e, next) => passOn($, 'session.end', e, next))
on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
- const sessionId = await $.session.id()
- if (e.session_id !== sessionId) return next(e)
- if (carry !== null && carry.destination === undefined) carry.destination = sessionId
+ // e.session_id names the session that starts; this hook asks no session id. The engine reset $.state, and the
+ // mirror write here puts the new session's view in it. One mirror write runs at a time, so a write that began
+ // before the switch lands first, and the write after it holds the new session's view (see mirrored).
+ entered(e.session_id, sessions.get(e.session_id) ?? switchedAt ?? await $.clock.now())
+ await persisted($, deadlineOf())
await ensureTicker($)
- await restoreCarry($)
return next(e)
}).catch(($, e, next) => passOn($, 'classic.SessionStart{source=clear|resume|fork}', e, next))
@@ -557,7 +701,7 @@ export const register: Register = (on, options) => {
if (argument === 'pane') return togglePane($, e.presentation.columns)
return { text: 'Usage: /downrange [pane]' }
}).catch(($, e, next) => {
- if (next.error.kind === 're-entry') return { text: '/downrange is unavailable during a nested call.' }
+ if (isReentry(next.error)) return { text: '/downrange is unavailable during a nested call.' }
reportHook($, 'command.run{command=downrange}', next.error)
return { text: `/downrange could not run: ${failureText(next.error)}.` }
})
@@ -574,10 +718,11 @@ export const register: Register = (on, options) => {
}
const note = shellLaunchNote(ran.result, outputFile !== undefined)
const redirectFile = await redirectFor($, id, e.command, e.agentId)
- await writeJobs($, { at: now, ids: [id] }, jobs => launched(jobs, shellJob({ id, command: e.command, description: e.description, outputFile, redirectFile, note, ownerAgentId: e.agentId, endsWithAgent: ran.result.backgroundEndsWithFinalResponse === true }, now)))
+ const deadline = deadlineOf()
+ await writeJobs($, deadline, { at: now, ids: [id] }, jobs => launched(jobs, shellJob({ id, command: e.command, description: e.description, outputFile, redirectFile, note, ownerAgentId: e.agentId, endsWithAgent: ran.result.backgroundEndsWithFinalResponse === true }, now)))
// The history read comes after the launch, so an end or a clear that comes meanwhile is never undone.
const history = await historyFor($, e.command)
- if (history !== undefined) await writeJobs($, { at: now, ids: [id] }, jobs => historyAdded(jobs, id, history))
+ if (history !== undefined) await writeJobs($, deadline, { at: now, ids: [id] }, jobs => historyAdded(jobs, id, history))
return ran
}).catch(($, e, next) => passOn($, 'tool.call{tool=Bash}', e, next))
@@ -586,7 +731,7 @@ export const register: Register = (on, options) => {
if (ran.deny !== undefined || ran.isError === true) return ran
const now = await $.clock.now()
const job = monitorJob({ id: ran.result.taskId, description: e.description, timeoutMs: ran.result.timeoutMs, isPersistent: ran.result.persistent === true }, now)
- await writeJobs($, { at: now, ids: [job.id] }, jobs => launched(jobs, job))
+ await writeJobs($, deadlineOf(), { at: now, ids: [job.id] }, jobs => launched(jobs, job))
return ran
}).catch(($, e, next) => passOn($, 'tool.call{tool=Monitor}', e, next))
@@ -605,7 +750,7 @@ export const register: Register = (on, options) => {
: result.status === 'async_launched'
? agentJob({ id: result.agentId, description: result.description }, now)
: agentJob({ id: result.taskId, description: result.description, note: 'remote', runsElsewhere: true }, now)
- await writeJobs($, { at: now, ids: [job.id] }, jobs => launched(jobs, job))
+ await writeJobs($, deadlineOf(), { at: now, ids: [job.id] }, jobs => launched(jobs, job))
return ran
}).catch(($, e, next) => passOn($, 'tool.call{tool=Agent}', e, next))
@@ -614,7 +759,7 @@ export const register: Register = (on, options) => {
if (ran.deny !== undefined || ran.isError === true || ran.result.error !== undefined) return ran
const now = await $.clock.now()
const job = workflowJob({ id: ran.result.taskId, name: ran.result.workflowName }, now)
- await writeJobs($, { at: now, ids: [job.id] }, jobs => launched(jobs, job))
+ await writeJobs($, deadlineOf(), { at: now, ids: [job.id] }, jobs => launched(jobs, job))
return ran
}).catch(($, e, next) => passOn($, 'tool.call{tool=Workflow}', e, next))
@@ -623,7 +768,7 @@ export const register: Register = (on, options) => {
if (ran.deny !== undefined || ran.isError === true) return ran
const now = await $.clock.now()
const wakeup = ran.result.stopped === true ? null : wakeupOf(ran.result.scheduledFor, e.reason, now)
- await writeWakeup($, now, () => wakeup, true)
+ await writeWakeup($, deadlineOf(), now, () => wakeup, true)
return ran
}).catch(($, e, next) => passOn($, 'tool.call{tool=ScheduleWakeup}', e, next))
@@ -632,17 +777,19 @@ export const register: Register = (on, options) => {
if (ran.deny !== undefined || ran.isError === true) return ran
const now = await $.clock.now()
const id = ran.result.task_id
- await writeJobs($, { at: now, ids: [id] }, jobs => stoppedJob(jobs, id, now))
+ await writeJobs($, deadlineOf(), { at: now, ids: [id] }, jobs => stoppedJob(jobs, id, now))
return ran
}).catch(($, e, next) => passOn($, 'tool.call{tool=TaskStop}', e, next))
on('tool.call', async ($, e, next) => {
const agentId = e.agentId
if (agentId === undefined) return next(e)
- if (!(await read($, JOBS)).items.some(job => job.kind === 'agent' && job.id === agentId)) return next(e)
+ if (!mirror.isLoaded) await loaded($)
+ if (!memory.jobs.items.some(job => job.kind === 'agent' && job.id === agentId)) return next(e)
const started = await $.clock.now()
const summary = activityOf(String(e.tool), e)
- await writeJobs($, { at: started, ids: [agentId], kind: 'activity' }, jobs => activityStarted(jobs, agentId, summary, started))
+ const deadline = deadlineOf()
+ await writeJobs($, deadline, { at: started, ids: [agentId], kind: 'activity' }, jobs => activityStarted(jobs, agentId, summary, started))
// A call that rejects counts as failed; the in-flight count drops whatever happens.
let isFailure = true
try {
@@ -651,7 +798,7 @@ export const register: Register = (on, options) => {
return ran
} finally {
const settled = await $.clock.now()
- await writeJobs($, { at: settled, ids: [agentId], kind: 'activity' }, jobs => activityEnded(jobs, agentId, isFailure, settled))
+ await writeJobs($, deadline, { at: settled, ids: [agentId], kind: 'activity' }, jobs => activityEnded(jobs, agentId, isFailure, settled))
}
}).catch(($, e, next) => passOn($, 'tool.call', e, next))
@@ -661,7 +808,7 @@ export const register: Register = (on, options) => {
const stored = await next(e)
if (stored.deny !== undefined || stored.uuid !== e.uuid) return stored
const text = stored.message.content.flatMap(block => block.type === 'text' && 'text' in block && typeof block.text === 'string' ? [block.text] : []).join('\n')
- await applyNotices($, text, JSON.stringify([e.agentId, stored.uuid]), now)
+ await applyNotices($, deadlineOf(), text, JSON.stringify([e.agentId, stored.uuid]), now)
return stored
}).catch(($, e, next) => passOn($, 'session.append', e, next))
@@ -669,21 +816,22 @@ export const register: Register = (on, options) => {
const now = await $.clock.now()
const result = await next(e)
if (e.agentId !== undefined) {
+ const deadline = deadlineOf()
if (e.reason === 'answer' && !e.isAborted) {
const agentId = e.agentId
- await writeJobs($, { at: now }, jobs => ownerAnswered(jobs, agentId, now))
+ await writeJobs($, deadline, { at: now }, jobs => ownerAnswered(jobs, agentId, now))
}
- await syncAgents($, await $.clock.now(), 'turn.complete')
+ await syncAgents($, deadline, await $.clock.now(), 'turn.complete')
}
return result
}).catch(($, e, next) => passOn($, 'turn.complete', e, next))
on('prompt.submit', async ($, e, next) => {
if (e.origin.kind === 'task-notification') {
- await applyNotices($, e.text)
+ await applyNotices($, deadlineOf(), e.text)
} else if (e.origin.kind === 'scheduled-trigger') {
const now = await $.clock.now()
- await writeWakeup($, now, wakeup => wakeupAfterTrigger(wakeup, now))
+ await writeWakeup($, deadlineOf(), now, wakeup => wakeupAfterTrigger(wakeup, now))
} else if (options.answerStatusPrompts === true && e.origin.kind === 'composer' && e.turnId === undefined && e.attachments === undefined) {
const answer = await statusAnswer($, e.text)
if (answer !== undefined) return { drop: answer }
@@ -692,14 +840,16 @@ export const register: Register = (on, options) => {
}).catch(($, e, next) => passOn($, 'prompt.submit', e, next))
on('classic.Stop', async ($, e, next) => {
- await reconcile($, 'classic.Stop', e)
- if ((await read($, JOBS)).items.some(job => job.kind === 'agent')) await syncAgents($, await $.clock.now(), 'classic.Stop')
+ const deadline = deadlineOf()
+ await reconcile($, deadline, 'classic.Stop', e)
+ if (memory.jobs.items.some(job => job.kind === 'agent')) await syncAgents($, deadline, await $.clock.now(), 'classic.Stop')
return next(e)
}).catch(($, e, next) => passOn($, 'classic.Stop', e, next))
on('classic.SubagentStop', async ($, e, next) => {
- await reconcile($, 'classic.SubagentStop', e)
- if ((await read($, JOBS)).items.some(job => job.kind === 'agent')) await syncAgents($, await $.clock.now(), 'classic.SubagentStop')
+ const deadline = deadlineOf()
+ await reconcile($, deadline, 'classic.SubagentStop', e)
+ if (memory.jobs.items.some(job => job.kind === 'agent')) await syncAgents($, deadline, await $.clock.now(), 'classic.SubagentStop')
return next(e)
}).catch(($, e, next) => passOn($, 'classic.SubagentStop', e, next))
@@ -731,7 +881,7 @@ export const register: Register = (on, options) => {
}
return paneTree($.ui.resolve(e), paneModelOf(jobs, wakeup, now, e.props.bodyColumns), actions)
}).catch(($, e, next) => {
- if (next.error.kind === 're-entry') return { type: 'Text', props: {}, children: ['The Jobs pane is unavailable during a nested render.'] }
+ if (isReentry(next.error)) return { type: 'Text', props: {}, children: ['The Jobs pane is unavailable during a nested render.'] }
reportHook($, 'ui.render{component=Pane, requestId=downrange}', next.error)
return failedPaneTree($.ui.resolve(e), `The Jobs pane could not draw: ${failureText(next.error)}`)
})
From 700d30e1f4aa22d131896025a60a378b78620041 Mon Sep 17 00:00:00 2001
From: dot-agi
Date: Sun, 25 Oct 2026 06:53:19 +0530
Subject: [PATCH 2/2] Keep ticking until the view's copy is written
---
hooks/register.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/hooks/register.tsx b/hooks/register.tsx
index 4b64ec1..650f0de 100644
--- a/hooks/register.tsx
+++ b/hooks/register.tsx
@@ -584,7 +584,7 @@ async function refresh($: EngineInterface, now: number): Promise {
for (const job of readsDue(seen.jobs.items, now)) await readTail($, deadline, job, now)
if (mirror.isBehind) await mirrored($, deadline)
// Every change replaces the jobs or the wakeup: after a change during this tick, the next tick judges liveness on fresh data.
- if (!isLive(shown, wakeup, now) && memory.jobs === seen.jobs && memory.wakeup === seen.wakeup && !mirror.isBehind) {
+ if (!isLive(shown, wakeup, now) && memory.jobs === seen.jobs && memory.wakeup === seen.wakeup && mirror.writing === null && !mirror.isBehind) {
ticker?.cancel()
ticker = null
}