From 3c314fe737101fae3aea7051cc4cd28f997da17c Mon Sep 17 00:00:00 2001 From: dot-agi Date: Wed, 14 Oct 2026 11:25:27 +0530 Subject: [PATCH 1/2] Release 1.1.2 --- .claude-plugin/plugin.json | 2 +- AGENTS.md | 3 +- README.md | 22 ++- docs/usage.md | 38 ++++- hooks/jobs.ts | 34 ++-- hooks/output.ts | 14 ++ hooks/redirect.ts | 312 +++++++++++++++++++++++++++++++++++++ hooks/register.tsx | 92 +++++++++-- hooks/state.ts | 4 +- hooks/view.ts | 12 +- types/index.d.ts | 4 + 11 files changed, 499 insertions(+), 38 deletions(-) create mode 100644 hooks/redirect.ts diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 05beae4..bca7ce4 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "downrange", - "version": "1.1.1", + "version": "1.1.2", "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 b736930..a36c453 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,7 @@ Notes: | `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/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 | | `hooks/notices.ts` | Task-notification parsing | | `hooks/jobs.ts` | Job builders and pure reducers | | `hooks/history.ts` | The usual-duration history | @@ -80,7 +81,7 @@ Run all of them after each change. All must pass. - 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. - Add no network call, no file write, and no host command other than `tail -c` for outputs over 4 MiB. -- Read only output files whose path matches the output path rule for the job's own id. +- Read only two files of a job: its output file, whose path matches the output path rule for the job's own id, and its redirect file, the one regular file that `hooks/redirect.ts` finds in the job's command. - Look up words that come from the engine (statuses, task types, tool names) in a `Map`, never in a plain object. - In tests, stub every tool result, file, and process. A test never runs a real command. - Name each new limit in `hooks/limits.ts`. diff --git a/README.md b/README.md index 2c1558a..fba3a63 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Claude Code 2.1.288+ Claude Code mod License: MIT - Version: 1.1.1 + Version: 1.1.2

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

@@ -23,7 +23,7 @@ Downrange reads that output on your machine and shows where each job stands. ## Features -- **Progress from real output.** `45%`, `[12/40]`, and `Step 3 of 7` lines, and the progress lines of tools such as Docker, cargo, pytest, and git, become a progress label, and a bar in the pane. Shell output and Monitor events both count. A ratio in another place, such as `loss 12/40`, counts only with the `genericRatios` option on. +- **Progress from real output.** `45%`, `[12/40]`, and `Step 3 of 7` lines, and the progress lines of tools such as Docker, cargo, pytest, and git, become a progress label, and a bar in the pane. Shell output and Monitor events both count. A ratio in another place, such as `loss 12/40`, counts only with the `genericRatios` option on. A shell command that sends its output to a file, such as `make > build.log 2>&1`, shows progress from that file. - **ETA.** From the progress rate. With no rate yet, from the usual duration of the same command in the same folder, or from a Monitor watch's deadline. In the pane, a usual-duration ETA turns yellow once the run passes 1.5 times its usual duration. - **The background tasks of your session.** Shell commands (`run_in_background`, ctrl+b, or a command moved at its timeout), Monitor watches, background and remote subagents, teammates, workflow runs, and the pending dynamic `/loop` wakeup. For background sessions, use agent view (`claude agents`). - **Liveness flags.** `quiet 3m 00s` for a shell command or subagent that stayed silent for 2 minutes or more, and `3 failed calls in a row` for a struggling subagent. @@ -58,6 +58,7 @@ flowchart LR - **Launches.** Hooks on the Bash, Monitor, Agent, and Workflow tools record each background launch with its id and title, and a shell's output file. A ScheduleWakeup result sets the wakeup. A TaskStop result marks its job stopped. - **Output.** The ticker reads each running shell's output file every 2 seconds, or every 10 seconds above 256 KiB. It keeps the end of the file: the last 16,384 characters, or the last 16,384 bytes through `tail` above 4 MiB. Progress comes from the last 10 display lines of that end. +- **Redirect files.** Some commands send their output to a file, such as `> build.log` or `| tee build.log`. The ticker then also reads the one file that the command's text names, its redirect file, in the same way. At each read, the row shows the file that changed last: Claude Code's output file or the redirect file. On equal times, it shows Claude Code's output file. While the row shows the redirect file, it notes `output: build.log`. The end and the exit code still come from Claude Code's notification. Downrange follows no redirect file on Windows, or in a command with code that may not run, such as an `if` or a function. - **Ends.** Task notifications end jobs and carry Monitor events. The agent list updates subagent states. Stop and SubagentStop snapshots add tasks that Downrange missed, and end tasks that left the list after a 10-second grace period. A scheduled prompt that fires from 5 seconds before the wakeup's due time clears the wakeup. So does a snapshot with no one-shot schedule, after the same grace period. - **Usual durations.** A clean end (exit code 0 or none) of a shell that Downrange saw start adds its run time to a history keyed by a hash of folder and command. The usual-duration ETA is the median of the last 5 runs. - **Views and carry.** The band, the pane, and `/downrange` draw from the same state. The ticker stops when nothing is live. At session end, Downrange keeps the running jobs and the wakeup in memory, and restores them after `/clear`, `/resume`, or `/branch`. @@ -187,7 +188,7 @@ Set options in `/config`, or under `pluginConfigs["downrange@downrange"].options ### What it does not catch - **Progress.** Ratios outside known formats, such as `loss 12/40`, unless `genericRatios` is on. Dates such as `[10/11]` can still count. -- **Jobs.** Fixed-interval loops such as `/loop 10m`, other cron schedules, and jobs after `/background` until the next turn end. +- **Jobs.** Fixed-interval loops such as `/loop 10m`, other cron schedules, jobs after `/background` until the next turn end, and work that goes on after its job ends. `nohup` alone does not end a job at once. A command that ends with `&` does. Downrange follows no file of a command with `&` or `nohup`. - **Stalls.** Stuck remote subagents, teammates in their own panes, and waiting agents. They show no `quiet` flag. - **ETAs.** Run time in the foreground before ctrl+b or a timeout move. In output silence, a rate ETA runs down to `<1m` and stays there. @@ -216,10 +217,15 @@ Downrange watches events and changes almost none of them. It makes no network re - **What it sends, and where.** Downrange itself sends nothing off your machine. Its output goes to your screen: the band, the pane, the `/downrange` table, the local status answer, and `downrange:` diagnostic lines in the transcript and the debug log. Two of these go further through Claude Code itself: - The `/downrange` table enters the conversation, so Claude reads it with its next request, as it reads any command output. The table cuts last output lines to 80 characters and removes control characters. A subagent row in the table shows only the tool it last used; the pane also shows that tool's input in short. - Where a remote surface draws the band or the pane, Claude Code sends that drawing to the surface. -- **The program it starts.** Only `tail -c 16384 `, with a 2-second time limit, to read the end of a large output. It runs only when a background shell's output file is over 4 MiB, the largest file Claude Code's file read accepts. It prints the last 16,384 bytes of that file. Downrange passes it as an argument list, with no shell. The output file path is the only part that varies. That path must be the output file of the job's own task id: absolute, in a `tasks` folder, and named `.output`. An organization policy that refuses mods which run host commands also refuses this mod. -- **Local data it reads.** The output files of background shells, with their size and change time. Its own duration store, a JSON file that Claude Code keeps for the mod in your Claude Code configuration folder. The session's working folder, only to hash it with a command. 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. 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, 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. +- **The program it starts.** Only `tail -c 16384 `, with a 2-second time limit, to read the end of a large output. It runs only when the file it reads for a background shell is over 4 MiB, the largest file Claude Code's file read accepts. It prints the last 16,384 bytes of that file. Downrange passes it as an argument list, with no shell. The file path is the only part that varies. That path must be the output file of the job's own task id (absolute, in a `tasks` folder, and named `.output`), or the job's redirect file (see the next item). An organization policy that refuses mods which run host commands also refuses this mod. +- **Local data it reads.** Downrange reads these files, and no other file: + - The output file of each background shell, which Claude Code writes in its `tasks` folder, with its size and change time. + - The redirect file of a background shell, with its size and change time, at each read of its output file. Downrange reads the redirect file's text when that file changed last, or while the output file holds no line of the job. That is the one file that the command's own text sends its output to, such as `build.log` in `make > build.log 2>&1`. Downrange reads it only when it is a regular file, never a path under `/dev`, and never on Windows. + - Its own duration store, a JSON file that Claude Code keeps for the mod in your Claude Code configuration folder. + + It also reads the session's working folder, to hash it with a command and to place a relative redirect file, and the list of the session's agents and their states. +- **Conversation data it reads.** The input and result of the tool calls in the hook table below: a command or description, the task id, the output file path, and the wakeup time and reason. Downrange reads a background command's text to find its redirect file. One input field of each tool call a tracked subagent makes (see the last item). Task notifications. The `background_tasks` and `session_crons` lists of Stop and SubagentStop hooks. The text of a prompt you type, only when `answerStatusPrompts` is on, to compare it with the status phrases. Downrange reads no transcript file. +- **What it keeps.** Session state, which Claude Code holds for the session and which other mods in the session can read. It holds job titles, last output lines, notes, tool summaries, and the wakeup's reason, each cut to 200 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. @@ -251,7 +257,7 @@ Downrange watches events and changes almost none of them. It makes no network re | --- | --- | --- | | `/downrange` is missing | Claude Code rejected command registration | Read the cause after `downrange: session.start: /downrange was not registered:` in the transcript. A built-in name conflict is one possible cause. Use the band's Open button | | A job shows `no output file` | The tool result named no output file | Run `claude --debug` and search for `downrange: tool.call:` | -| A job shows `output unavailable` or `tail failed` | The output file is gone, or `tail` is not on PATH (common on Windows, for outputs over 4 MiB) | Run `claude --debug` and search for `downrange: clock.every:` | +| A job shows `output unavailable` or `tail failed` | The output file or the redirect file is gone, or `tail` is not on PATH (common on Windows, for outputs over 4 MiB) | Run `claude --debug` and search for `downrange: clock.every:` | | `/downrange pane` prints `The Jobs pane is not shown here` | Nothing draws in this session, or the surface places no panes | Use `/downrange` | | Ages and output lines stop moving | Another mod refused a timer period | The next change starts the timer again; `claude --debug` shows `the ticker stopped` | | Jobs end only when Claude's turn ends | Some Claude Code notices are plain text and carry no structured end status | No action for plain notices. If `claude --debug` shows `a task notification held no complete` or `unknown notification status`, report that line | diff --git a/docs/usage.md b/docs/usage.md index 35d41ee..4d5380e 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 percentage counts in any place. By default, a fraction or `3 of 7` counts only in a known progress format: +**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, or of its redirect file (see [Output sent to a file](#output-sent-to-a-file)): the last 16,384 characters (UTF-16 code units) of a file up to 4 MiB, or the last 16,384 bytes above that. Values over 100% do not count. A percentage counts in any place. By default, a fraction or `3 of 7` counts only in a known progress format: - In brackets, alone or after one or two labels: `[3/7]`, `[ 5/8]`, `(3/15)`, `(3 of 7)`, or Docker's `[builder 3/7]`. - At the start of a line: `3/7 compiling` or `3 of 7 done`. @@ -136,11 +136,38 @@ A drop from the previous progress sample starts a new phase. The rate estimate w | `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 | +| `output: build.log` | The last line and the progress come from the command's redirect file, here `build.log`. See [Output sent to a file](#output-sent-to-a-file) | | `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 | | `ended with its agent` | The same, for a command that stopped with its subagent | | `output unavailable`, `tail failed` | The output file could not be read. A launch or end note that the job already shows stays in its place, and `claude --debug` shows the read error | +## Output sent to a file + +Some background commands send their output to a file, such as `python3 build.py > /tmp/build.log 2>&1; echo "exit=$?" >> /tmp/build.log`. Claude Code's output file then holds few or no lines of the job. For such a command, Downrange also reads that file, the command's redirect file. + +1. At launch, Downrange reads the command's text and looks for the one file that it sends its output to. These forms count: `> FILE`, `>> FILE`, `1> FILE`, `1>> FILE`, `&> FILE`, `&>> FILE`, and `>& FILE`. A `| tee FILE` or `| tee -a FILE` at the end of a pipeline also counts. +2. At each read, Downrange looks at both files: Claude Code's output file and the redirect file. The row shows the file that changed last, by modification time. When the two times are equal, the row shows Claude Code's output file. For example, in `ls scripts/ && python3 scripts/run_tests.py > /tmp/run_tests_output.log 2>&1`, the `ls` lines go to Claude Code's output file first. The test lines go to the redirect file later, so the row shows them. +3. Claude Code's output file counts only while it holds a line of the job. The `[exited with code N]` line that Claude Code adds at the end is no line of the job, and it does not count as a change of that file. +4. Each read of the redirect file is the same as a read of Claude Code's output file: the same intervals, the same size limits, and `tail` above 4 MiB. Downrange reads the redirect file again when its size or its modification time changes. +5. While the row shows lines from the redirect file, it takes the progress, the ETA, and the last line from that file, and shows the note `output: `. When the row changes from one file to the other, it drops the progress and the last line of the first file, and the rate ETA starts again. +6. When the redirect file does not exist yet, or is not a regular file, the row shows Claude Code's output file if that file counts. Otherwise the row shows `output unavailable` until a read of the redirect file works. +7. The end and the exit code still come from Claude Code's notification. A line such as `exit=1` in the file shows as the last line, and Downrange does not read an exit code from it. + +The path must be literal: absolute, or relative to the session's working folder at launch. On Windows, Downrange follows no redirect file. The shell there can map a path to another folder. Downrange does not follow: + +- A path with a variable, a pattern such as `*.log`, a brace list, a leading `~`, or command text, such as `> "$LOG"` or `> out-$(date +%s).log`. +- A path under `/dev`, such as `/dev/null`. +- A command that sends its standard output to two different files, such as `make > a.log; make test > b.log`, or to the same file in two spellings, such as `out.log` and `./out.log`. `2>&1 | tee out.log > /dev/null` names two files too. A separate file for errors does not count, so `make > build.log 2> errors.log` follows `build.log`. +- A relative path in a command that changes the folder with `cd`, `chdir`, `pushd`, or `popd`, or that holds `eval`, `source`, `command`, or `builtin`, or runs a file with `.`, which can change the folder. An absolute path still counts. +- The zsh forms `>!`, `>>!`, and `>&!`. +- A `>&` target that copies, moves, or closes a file descriptor, such as `>&2`, `>&3-`, or `>&-`. +- A relative path in a subagent's command, because a subagent can run in a folder of its own. +- A command with a function definition, `if`, `case`, `while`, `until`, `for`, `select`, `||`, a `{ ... }` group, or a `( ... )` subshell. Part of such a command may not run, or may run later. Only a plain list of commands, joined by `;`, `&&`, a new line, or a pipe, counts. +- A command with `&` or `nohup` (see [Known limits](#known-limits)). +- Other forms, such as `2> FILE`, `>| FILE`, `3> FILE`, or `< FILE`, and a `tee` with other options or more files. +- A command with a here-document (`< build.log 2>&1 &` ends at once, with the exit code of the command that started the work, while `make` goes on. Downrange does not follow that work. +- **Output sent to a file.** Downrange follows a redirect file only when the command's text names exactly one literal file (see [Output sent to a file](#output-sent-to-a-file)). Other commands show no last line and no progress while Claude Code's output file stays empty. When both files get lines, such as warnings on standard error when only standard output goes to the file, the row can move from one file to the other at each read. Each move drops the progress and the rate ETA of the first file. When the job writes a line to Claude Code's output file after the last read before its end, the time of that file is the time of the `[exited with code N]` line. The row then shows the last line of that file, even when the redirect file changed later. A file that `>>` appends to can show the last line of an earlier run until the job writes to it. A redirect file that does not exist yet, or that is not a regular file, shows `output unavailable` until a read of it works, while Claude Code's output file does not count. A pipeline that ends in a command that writes only at its end, such as `| tail -n 200 > out.txt`, shows no line until the end. The read that follows the job's end still shows the last line then. - **ETAs during output silence.** When output stops, the bar keeps its last reading, and the rate ETA keeps counting down from the time of that reading. Once the estimate runs out, the ETA stays at `<1m` while the job runs. This holds after `quiet` shows too, so a row can show `20%`, `<1m`, and `quiet 2m 00s` together. A Monitor whose events stop behaves the same way. - **Monitor progress after an update.** An earlier version of Downrange saved progress with no mark of its format, and counted any ratio. With `genericRatios` off, the first load of this version drops that progress and its rate ETA. A shell reads its output again at once, and its rate ETA starts again from the new reading. A Monitor shows its event count until its next event in a known format. - **Monitor deadlines and rate ETAs.** A Monitor watch shows its deadline only when it has no progress-based ETA. Once its progress gives a rate ETA, or reaches 100% and shows `finishing`, the deadline no longer shows. A rate ETA can run past the deadline: a 30-minute watch that goes from 1% to 2% in its first minute shows `~1h 38m`, although its deadline is 29 minutes away. - **Time in the foreground before a move to the background.** A shell job's clock starts when the Bash call returns its background result. A command that first ran in the foreground, such as one moved at its timeout or with ctrl+b, loses that time. Its age, its `took` time, and the run it adds to the duration history count only the time after the move. For example, a command moved after 2 minutes that ends 1 minute later shows `took 1m 00s` and records 1 minute. Runs of one command in one folder share one history, whatever the launch mode. The next run of that command that starts in the background then shows `~1m usual`, even when it needs 3 minutes, and in the pane it can turn yellow early. A moved run can get a usual-duration ETA that is too long. - **Duration history after a snapshot end.** A run adds to the duration history only when its completion notice arrives while its row still runs. When a turn-end snapshot already ended the row as `ended without a report`, the later notice marks it completed but adds no duration. - **Simultaneous duration records.** Two sessions recording durations together can lose new runs, because the duration store has no versioned write. One lost write can hold several runs. When it held a command's first runs, that command keeps no history and shows no usual-duration estimate. Runs stored before both writes began remain, with their estimates. -- **Failed output reads.** While reads of a shell's output fail, its row shows no `quiet` flag and no rate ETA. When a shell job ends, Downrange reads its output one more time. If that read fails, Downrange does not try it again. The job can keep `output unavailable` or `tail failed` and the last line of an earlier read. Only a later ending, or a later notification that names the output file, asks for a new read. +- **Failed output reads.** While reads of a shell's output fail, its row shows no `quiet` flag and no rate ETA. When a shell job ends, Downrange reads its output one more time. If that read fails, Downrange does not try it again. The job can keep `output unavailable` or `tail failed` and the last line of an earlier read. Only a later ending, or a later notification that names the output file, asks for a new read. Reads of a redirect file fail and try again in the same way. - **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. @@ -223,6 +252,9 @@ Run `claude --debug`, or `claude --debug-file `, and search for `downrange | `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 | | `clock.every: cannot read the output of task : …` | The output file is gone or unreadable | Nothing. Reads try again while the job runs | | `clock.every: cannot tail the output of task : …` | `tail` failed on an output over 4 MiB | Check that `tail` is on PATH | +| `clock.every: cannot read the redirect file of task : …` | The file that the command sends its output to is not there yet, is gone, is not a regular file, or is unreadable | Nothing. Reads try again while the job runs | +| `clock.every: cannot tail the redirect file of task : …` | `tail` failed on a redirect file over 4 MiB | Check that `tail` is on PATH | +| `tool.call: cannot place the redirect file of task : …` | The session's working folder could not be read at launch, so Downrange does not follow the redirect file of this job | Nothing. The job shows the lines of Claude Code's output file | | `clock.every: cannot list agents: …` | The agent list failed | Nothing. Agent states update after the next good list | | `tool.call: duration history unavailable: …` | The history lookup failed for this launch; its usual-duration estimate stays unavailable | Recovery benefits later launches | | `prompt.submit: duration history unavailable: …` or `session.append: duration history unavailable: …` | Recording a completed run failed; that sample is lost, but existing estimates remain | Recovery benefits later recordings | diff --git a/hooks/jobs.ts b/hooks/jobs.ts index e9ea51c..4302783 100644 --- a/hooks/jobs.ts +++ b/hooks/jobs.ts @@ -20,7 +20,7 @@ import { WAKEUP_STALE_MS, } from './limits' import type { Notice } from './notices' -import { anchorOf, clip, displayLinesOf, isOutputFileOf, oneLine, progressOf } from './output' +import { anchorOf, clip, displayLinesOf, isOutputFileOf, jobLinesOf, oneLine, progressOf } from './output' /** The launch note of a command that its synchronous subagent ends when it answers. */ const ENDS_WITH_AGENT = 'ends with its agent' @@ -99,7 +99,7 @@ function withJob(jobs: readonly Job[], job: Job): Job[] { /** Refines an inferred shell without carrying shell-only metadata into the Monitor. */ function asMonitor(job: ShellJob): MonitorJob { - const { kind, outputFile, outputBytes, failedReadBytes, lastReadAt, successfulReadAt, finalReadAskedAt, usualMs, historyKey, ownerAgentId, endsWithAgent, ...details } = job + const { kind, outputFile, redirectFile, redirectBytes, outputBytes, failedReadBytes, lastReadAt, successfulReadAt, finalReadAskedAt, usualMs, historyKey, ownerAgentId, endsWithAgent, ...details } = job return { ...details, kind: 'monitor', eventCount: 0, note: job.note === 'no output file' || READ_NOTES.has(job.note ?? '') ? undefined : job.note } } @@ -114,6 +114,7 @@ function withLaunch(known: Job, launch: Job): Job { const identity = { title: launch.title, startedAt: launch.startedAt, isStartSeen: true, omittedAt: undefined, note: known.note ?? launch.note, launchNote: launch.launchNote ?? known.launchNote } if (known.kind === 'shell' && launch.kind === 'shell') return { ...known, ...identity, outputFile: launch.outputFile ?? known.outputFile, + redirectFile: launch.redirectFile ?? known.redirectFile, 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 } @@ -128,7 +129,7 @@ export function launched(jobs: readonly Job[], launch: Job): Job[] { } /** A background shell command, as its launch result describes it. */ -export function shellJob(launch: { id: string; command: string; description?: string; outputFile?: string; note?: string; ownerAgentId?: string; endsWithAgent?: boolean }, now: number): ShellJob { +export function shellJob(launch: { id: string; command: string; description?: string; outputFile?: string; redirectFile?: string; note?: string; ownerAgentId?: string; endsWithAgent?: boolean }, now: number): ShellJob { return { kind: 'shell', id: launch.id, @@ -137,6 +138,7 @@ export function shellJob(launch: { id: string; command: string; description?: st startedAt: now, isStartSeen: true, outputFile: launch.outputFile, + redirectFile: launch.redirectFile, ownerAgentId: launch.ownerAgentId, endsWithAgent: launch.endsWithAgent, note: launch.note, @@ -420,7 +422,7 @@ export function reconciled(jobs: readonly Job[], snapshot: readonly SnapshotTask } /** What one read of a job's output found. */ -export type TailRead = { bytes: number; mtimeMs: number; lines: readonly string[] } +export type TailRead = { bytes: number; mtimeMs: number; lines: readonly string[]; redirectBytes?: number } /** Notes a successful read clears. */ const READ_NOTES: ReadonlySet = new Set(['output unavailable', 'tail failed']) @@ -517,14 +519,17 @@ export function activityEnded(jobs: readonly Job[], agentId: string, isFailure: }) } -/** Shell jobs whose output the ticker reads now: a pending final read, or a running job not read for READ_EVERY_MS (LARGE_READ_EVERY_MS once its output passed LARGE_FILE_BYTES). */ +/** + * Shell jobs whose output the ticker reads now: a pending final read, or a running job not read for READ_EVERY_MS + * (LARGE_READ_EVERY_MS once a file it reads, its output or the redirect file the row shows, passed LARGE_FILE_BYTES). + */ export function readsDue(jobs: readonly Job[], now: number): (ShellJob & { outputFile: string })[] { return jobs.filter( (job): job is ShellJob & { outputFile: string } => job.kind === 'shell' && job.outputFile !== undefined && (job.finalReadAskedAt !== undefined || - (isRunning(job) && now - (job.lastReadAt ?? 0) >= ((job.failedReadBytes ?? job.outputBytes ?? 0) > LARGE_FILE_BYTES ? LARGE_READ_EVERY_MS : READ_EVERY_MS))), + (isRunning(job) && now - (job.lastReadAt ?? 0) >= ((job.failedReadBytes ?? Math.max(job.redirectBytes ?? 0, job.outputBytes ?? 0)) > LARGE_FILE_BYTES ? LARGE_READ_EVERY_MS : READ_EVERY_MS))), ) } @@ -559,20 +564,27 @@ export function reloaded(jobs: readonly Job[], isAnyRatio: boolean, now: number) /** * The list after a read that began at `now`: size, time, last line, and progress; a read-problem note clears, and a - * later end keeps its final read. isAnyRatio (the `genericRatios` option) lets any ratio count as progress. + * later end keeps its final read. A read of the redirect file carries that file's size and time. A read of the other + * file than the one the row shows replaces the row's last line, progress, and anchor with that file's. isAnyRatio (the + * `genericRatios` option) lets any ratio count as progress. */ export function tailRead(jobs: readonly Job[], id: string, read: TailRead, now: number, isAnyRatio = false): Job[] { - return jobs.map(job => { - if (job.kind !== 'shell' || job.id !== id) return job - const lines = /^\[exited with code -?\d+\]$/.test(read.lines.at(-1) ?? '') ? read.lines.slice(0, -1) : read.lines + return jobs.map(original => { + if (original.kind !== 'shell' || original.id !== id) return original + const lines = jobLinesOf(read.lines) const found = progressOf(lines.slice(-PROGRESS_LINES), now, isAnyRatio) + const isRedirect = read.redirectBytes !== undefined + const job: ShellJob = isRedirect === (original.redirectBytes !== undefined) + ? original + : { ...original, redirectBytes: undefined, lastLine: undefined, progress: undefined, anchor: undefined } // A full read of output that kept its size, as a reload asks for, keeps a reading it finds again, with that reading's time. - const isUnchanged = read.bytes === job.outputBytes && job.failedReadBytes === undefined + const isUnchanged = (isRedirect ? read.redirectBytes === job.redirectBytes : read.bytes === job.outputBytes) && job.failedReadBytes === undefined const progress = isUnchanged && isSameReading(found, job.progress) ? undefined : found const lastLine = lines.at(-1) return { ...job, outputBytes: read.bytes, + ...(isRedirect ? { redirectBytes: read.redirectBytes } : {}), failedReadBytes: undefined, lastOutputAt: read.mtimeMs, lastReadAt: now, diff --git a/hooks/output.ts b/hooks/output.ts index ed30110..a03a652 100644 --- a/hooks/output.ts +++ b/hooks/output.ts @@ -24,6 +24,10 @@ const CLOSER_AFTER = /\s*([\])])/y 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. */ const OUTPUT_NOTICE = 'Output is being written to: ' +/** The line the engine adds to a shell's output when the command ends. */ +const EXIT_LINE = /^\[exited with code -?\d+\]$/ +/** That line at the end of an output's text, with the line break the engine writes before it and the one after it. */ +const EXIT_END = /\n?\[exited with code -?\d+\]\n?$/ /** A carriage return, built from its code so that no string literal holds a control character. */ const CR = String.fromCharCode(13) @@ -170,6 +174,16 @@ export function outputFileOf(text: string, id: string): string | undefined { return undefined } +/** The lines of an output without the engine's closing `[exited with code N]` line: the lines the job wrote. */ +export function jobLinesOf(lines: readonly string[]): readonly string[] { + return EXIT_LINE.test(lines.at(-1) ?? '') ? lines.slice(0, -1) : lines +} + +/** The size of an output without the engine's closing line, from its size and the text of its end: the bytes the job wrote. */ +export function jobBytesOf(text: string, size: number): number { + return size - (EXIT_END.exec(text)?.[0].length ?? 0) +} + /** The last TAIL_BYTES of an output, from a line start: a tail, or a read cut short, drops its partial first line; text with no line break stays whole, one line that `\r` redraws. */ export function outputEndOf(text: string, isTail: boolean): string { const end = text.slice(-TAIL_BYTES) diff --git a/hooks/redirect.ts b/hooks/redirect.ts new file mode 100644 index 0000000..73514ee --- /dev/null +++ b/hooks/redirect.ts @@ -0,0 +1,312 @@ +/** + * The redirect rule: the one file that a background command's own text sends its output to, its redirect file. A + * small reader of shell text finds it. It gives up on text it cannot read for sure, and the command then follows no file. + */ + +/** A word after quote removal; isLiteral is false when the shell can expand it: a variable, command text, a pattern, or a leading `~`. */ +type Word = { kind: 'word'; text: string; isLiteral: boolean } +/** A redirection, with the file descriptor written right before it: digits or `{name}`, or empty. */ +type Redirect = { kind: 'redirect'; op: string; fd: string } +/** An operator between commands. */ +type Separator = { kind: 'separator'; op: string } +type Token = Word | Redirect | Separator + +/** Redirection operators, longest first. */ +const REDIRECTS = ['&>>', '&>', '>>', '>&', '>|', '>', '<<<', '<<-', '<<', '<>', '<&', '<'] +/** A here-document, whose lines this reader does not follow. */ +const HERE_DOCUMENTS: ReadonlySet = new Set(['<<', '<<-']) +/** Operators between commands, longest first. */ +const SEPARATORS = ['&&', '&', '||', '|&', '|', ';;&', ';;', ';&', ';', '(', ')', '\n'] +/** Characters that end a word outside quotes. */ +const METACHARACTERS: ReadonlySet = new Set([' ', '\t', '\n', '|', '&', ';', '(', ')', '<', '>']) +/** Unquoted characters that make a word a pattern or a brace list. */ +const PATTERN_CHARACTERS: ReadonlySet = new Set(['*', '?', '[', '{']) +/** Commands that change the folder, after which a relative path can name another file. */ +const FOLDER_CHANGES: ReadonlySet = new Set(['cd', 'chdir', 'pushd', 'popd']) +/** Words that run other shell text in this shell, or a builtin behind a prefix, which can change the folder out of sight: anywhere in the command. */ +const HIDDEN_CODE: ReadonlySet = new Set(['eval', 'source', 'command', 'builtin']) +/** The operators of a plain command list; every other operator, such as `||`, `(`, or `&`, makes the text one this reader does not follow. */ +const LIST_SEPARATORS: ReadonlySet = new Set(['&&', ';', '\n', '|', '|&']) +/** Words that, as a command, start or belong to code that may not run, or that runs later or in the background: a function, a condition, a loop, or a group. */ +const COMPOUND_WORDS: ReadonlySet = new Set(['function', 'if', 'then', 'elif', 'else', 'fi', 'case', 'esac', 'while', 'until', 'for', 'select', 'do', 'done', 'repeat', 'foreach', 'coproc', '{', '}']) +/** Words before a command that keep the next word a command: `!`, `time`, and `time -p`. */ +const PIPELINE_PREFIXES: ReadonlySet = new Set(['!', 'time', '-p']) +/** A variable assignment before a command, such as `FLAGS=-j4`. */ +const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*\+?=/ +/** The redirections that send standard output to a file, with the file descriptors each may carry: `>`, `>>`, `1>`, `1>>`, `&>`, `&>>`, and `>&`. */ +const OUTPUT_REDIRECTS: ReadonlyMap> = new Map([ + ['>', new Set(['', '1'])], + ['>>', new Set(['', '1'])], + ['&>', new Set([''])], + ['&>>', new Set([''])], + ['>&', new Set([''])], +]) +/** A file descriptor written right before a redirection. */ +const FD = /^(?:\d+|\{[A-Za-z_][A-Za-z0-9_]*\})$/ +/** The target of `>&` that copies, moves (`3-`), or closes a file descriptor instead of naming a file. */ +const DUPLICATE = /^(?:\d+-?|-)$/ +/** A variable name or a special parameter after `$`. */ +const PARAMETER = /[A-Za-z_][A-Za-z0-9_]*|[0-9?$!#*@-]/y +/** A Windows drive path, such as `C:/b.log`. */ +const DRIVE = /^[A-Za-z]:/ + +/** The end of a `'…'` or `` `…` `` part that starts at `at`, after its closing mark; a backslash escapes the next character when isEscaped. */ +function closingMarkOf(command: string, at: number, mark: string, isEscaped: boolean): number | undefined { + for (let end = at + 1; end < command.length; end += 1) { + if (isEscaped && command[end] === '\\') end += 1 + else if (command[end] === mark) return end + 1 + } + return undefined +} + +/** The end of the bracketed part that opens at `open`, after its closing bracket; quotes and expansions inside it are skipped. */ +function closingBracketOf(command: string, open: number, opener: string, closer: string): number | undefined { + let depth = 0 + let at = open + while (at < command.length) { + const char = command[at] + if (char === '\\') { + at += 2 + continue + } + if (char === "'" || char === '"' || char === '$' || char === '`') { + const end = char === "'" ? closingMarkOf(command, at, "'", false) : char === '"' ? doubleQuotedAt(command, at)?.end : expansionEnd(command, at) + if (end === undefined) return undefined + at = end + continue + } + if (char === opener) depth += 1 + else if (char === closer && --depth === 0) return at + 1 + at += 1 + } + return undefined +} + +/** The end of the expansion at `at`: `$(…)`, `$((…))`, `${…}`, `$'…'`, `$name`, a lone `$`, or `` `…` ``. */ +function expansionEnd(command: string, at: number): number | undefined { + if (command[at] === '`') return closingMarkOf(command, at, '`', true) + const next = command[at + 1] + if (next === '(') return closingBracketOf(command, at + 1, '(', ')') + if (next === '{') return closingBracketOf(command, at + 1, '{', '}') + if (next === "'") return closingMarkOf(command, at + 1, "'", true) + PARAMETER.lastIndex = at + 1 + return PARAMETER.test(command) ? PARAMETER.lastIndex : at + 1 +} + +/** The `"…"` part at `at`: its text after quote removal, its end, and whether it holds no expansion. */ +function doubleQuotedAt(command: string, at: number): { text: string; end: number; isLiteral: boolean } | undefined { + let text = '' + let isLiteral = true + let index = at + 1 + while (index < command.length) { + const char = command[index]! + if (char === '"') return { text, end: index + 1, isLiteral } + if (char === '\\' && index + 1 < command.length) { + const next = command[index + 1]! + if (next !== '\n') text += '$`"\\'.includes(next) ? next : `\\${next}` + index += 2 + } else if (char === '$' || char === '`') { + const end = expansionEnd(command, index) + if (end === undefined) return undefined + text += command.slice(index, end) + isLiteral = false + index = end + } else { + text += char + index += 1 + } + } + return undefined +} + +/** The word at `start`: its text after quote removal, its end, whether it is literal, and whether any of it was quoted. */ +function wordAt(command: string, start: number): { text: string; end: number; isLiteral: boolean; isQuoted: boolean } | undefined { + let text = '' + let isLiteral = command[start] !== '~' + let isQuoted = false + let at = start + while (at < command.length && !METACHARACTERS.has(command[at]!)) { + const char = command[at]! + if (char === '\\') { + if (command[at + 1] !== '\n') text += command[at + 1] ?? '' + isQuoted = true + at += 2 + } else if (char === "'") { + const end = closingMarkOf(command, at, "'", false) + if (end === undefined) return undefined + text += command.slice(at + 1, end - 1) + isQuoted = true + at = end + } else if (char === '"') { + const quoted = doubleQuotedAt(command, at) + if (quoted === undefined) return undefined + text += quoted.text + isLiteral &&= quoted.isLiteral + isQuoted = true + at = quoted.end + } else if (char === '$' || char === '`') { + const end = expansionEnd(command, at) + if (end === undefined) return undefined + text += command.slice(at, end) + isLiteral = false + at = end + } else { + if (PATTERN_CHARACTERS.has(char)) isLiteral = false + text += char + at += 1 + } + } + return { text, end: at, isLiteral, isQuoted } +} + +/** + * The tokens of a command, or undefined for text this reader does not follow: an open quote, a here-document, + * arithmetic in `((`, or a word right before `(`, such as a function or an array. + */ +function tokensOf(command: string): Token[] | undefined { + const tokens: Token[] = [] + let fd = '' + let at = 0 + while (at < command.length) { + const char = command[at]! + if (char === ' ' || char === '\t') { + at += 1 + continue + } + if (char === '\\' && command[at + 1] === '\n') { + at += 2 + continue + } + if (char === '#') { + const end = command.indexOf('\n', at) + at = end === -1 ? command.length : end + continue + } + if (command.startsWith('((', at)) return undefined + if ((char === '<' || char === '>') && command[at + 1] === '(') { + // Process substitution: command text in place of a path. + const end = closingBracketOf(command, at + 1, '(', ')') + if (end === undefined) return undefined + tokens.push({ kind: 'word', text: command.slice(at, end), isLiteral: false }) + fd = '' + at = end + continue + } + const redirect = REDIRECTS.find(op => command.startsWith(op, at)) + if (redirect !== undefined) { + if (HERE_DOCUMENTS.has(redirect)) return undefined + tokens.push({ kind: 'redirect', op: redirect, fd }) + fd = '' + at += redirect.length + continue + } + const separator = SEPARATORS.find(op => command.startsWith(op, at)) + if (separator !== undefined) { + tokens.push({ kind: 'separator', op: separator }) + at += separator.length + continue + } + const word = wordAt(command, at) + if (word === undefined || command[word.end] === '(') return undefined + at = word.end + if ((command[at] === '<' || command[at] === '>') && !word.isQuoted && FD.test(word.text)) { + fd = word.text + } else { + tokens.push({ kind: 'word', text: word.text, isLiteral: word.isLiteral }) + } + } + return tokens +} + +/** Whether a redirection sends standard output to the file its target names. */ +function isOutputRedirect(redirect: Redirect, target: Word): boolean { + const fds = OUTPUT_REDIRECTS.get(redirect.op) + return fds !== undefined && fds.has(redirect.fd) && (redirect.op !== '>&' || !DUPLICATE.test(target.text)) +} + +/** The file of `tee FILE` or `tee -a FILE`; `other` for another tee, whose files are unknown; undefined when the command is no tee. */ +function teeFileOf(words: readonly Word[]): Word | 'other' | undefined { + if (words[0]?.text !== 'tee') return undefined + const files = words[1]?.text === '-a' ? words.slice(2) : words.slice(1) + const [file] = files + return files.length === 1 && file !== undefined && !file.text.startsWith('-') ? file : 'other' +} + +/** + * The one file that a command's own text sends its output to, as the text names it: the target of `>`, `>>`, `1>`, + * `1>>`, `&>`, `&>>`, or `>&`, or the file of `tee FILE` or `tee -a FILE` at the end of a pipeline. Only a plain command + * list counts: commands joined by `;`, `&&`, a new line, or a pipe. Undefined when the text names no such file, names two + * different ones, names one that is not literal, holds `&` or `nohup`, holds code that may not run (a function, a + * condition, a loop, `||`, a group, or a subshell), or names a relative path after a folder change. + */ +export function redirectTargetOf(command: string): string | undefined { + const tokens = tokensOf(command) + if (tokens === undefined) return undefined + const targets: Word[] = [] + let pipeline: Word[][] = [[]] + let isCondition = false + let changesFolder = false + /** Whether the next word is a command, where a word such as `if` or `{` starts compound code. */ + let isCommandStart = true + /** Ends a pipeline: its last command's tee file counts, and an unknown tee follows nothing. */ + const isEnded = (): boolean => { + const tee = pipeline.length > 1 ? teeFileOf(pipeline.at(-1) ?? []) : undefined + if (tee !== undefined && tee !== 'other') targets.push(tee) + pipeline = [[]] + return tee !== 'other' + } + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]! + if (token.kind === 'word') { + if (token.text === 'nohup' || (isCommandStart && (COMPOUND_WORDS.has(token.text) || token.text.startsWith('{')))) return undefined + if (HIDDEN_CODE.has(token.text) || (isCommandStart && token.text === '.')) changesFolder = true + isCommandStart &&= PIPELINE_PREFIXES.has(token.text) || ASSIGNMENT.test(token.text) + if (FOLDER_CHANGES.has(token.text)) changesFolder = true + // Inside `[[ … ]]`, `<` and `>` compare strings. + if (token.text === '[[') isCondition = true + else if (token.text === ']]') isCondition = false + pipeline.at(-1)?.push(token) + } else if (token.kind === 'redirect') { + if (isCondition) continue + const target = tokens[index + 1] + // zsh `>!`, `>>!`, and `>&!` force the redirection, so the word after `!` names the file: this reader follows none. + if (target?.kind !== 'word' || target.text.startsWith('!')) return undefined + index += 1 + if (isOutputRedirect(token, target)) targets.push(target) + } else if (!LIST_SEPARATORS.has(token.op)) { + return undefined + } else if (token.op === '|' || token.op === '|&') { + isCommandStart = true + pipeline.push([]) + } else if (isEnded()) { + isCommandStart = true + } else { + return undefined + } + } + if (!isEnded()) return undefined + const names = new Set(targets.map(target => target.text)) + const [name] = names + if (names.size !== 1 || name === undefined || name === '' || targets.some(target => !target.isLiteral)) return undefined + return changesFolder && !name.startsWith('/') ? undefined : name +} + +/** Whether a path, with `.` and `..` folded by their text, is `/dev` or under it. */ +function isDevicePath(path: string): boolean { + const parts: string[] = [] + for (const part of path.split('/')) { + if (part === '..') parts.pop() + else if (part !== '' && part !== '.') parts.push(part) + } + return parts[0] === 'dev' +} + +/** + * A redirect target as an absolute path in the session folder `cwd`. Undefined on Windows, where `cwd` is no POSIX + * path and the shell can map a path to another folder, and for a drive path or a path under `/dev`. + */ +export function redirectFileOf(target: string, cwd: string): string | undefined { + if (!cwd.startsWith('/') || DRIVE.test(target)) return undefined + const path = target.startsWith('/') ? target : `${cwd.replace(/\/+$/, '')}/${target.replace(/^(?:\.\/+)+/, '')}` + return isDevicePath(path) ? undefined : path +} diff --git a/hooks/register.tsx b/hooks/register.tsx index ea3833f..6b94584 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -41,9 +41,10 @@ import { 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 { noticesOf } from './notices' -import { displayLinesOf, isOutputFileOf, oneLine, outputEndOf, outputFileOf } from './output' +import { displayLinesOf, isOutputFileOf, jobBytesOf, jobLinesOf, oneLine, outputEndOf, outputFileOf } from './output' import { jobsStateOf, wakeupStateOf } from './state' import { isStatusPrompt, normalizePrompt } from './prompt' +import { redirectFileOf, redirectTargetOf } from './redirect' import { bandOf, isBandHidden, paneModelOf, tableOf } from './view' /** The slash command, without its slash; declared here so that `claude plugin validate` resolves the matcher. */ @@ -97,6 +98,13 @@ function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) } +/** A stat, read, or tail of a job's redirect file that failed, with that failure. */ +class RedirectFailure extends Error { + constructor(readonly failure: unknown) { + super(messageOf(failure)) + } +} + /** A hook failure as one phrase: `hook throw: `. */ function failureText(failure: HookFailure): string { return `hook ${failure.kind}: ${failure.message ?? 'no message'}` @@ -236,6 +244,21 @@ async function historyFor($: EngineInterface, command: string): Promise<{ histor } } +/** + * The redirect file of a launch, absolute in the session's folder, or undefined. A subagent's command follows only an + * absolute path: a subagent can run in a folder of its own. A failure gives none, logs once, and never stops the launch. + */ +async function redirectFor($: EngineInterface, id: string, command: string, agentId: string | undefined): Promise { + try { + const target = redirectTargetOf(command) + if (target === undefined || (agentId !== undefined && !target.startsWith('/'))) return undefined + return redirectFileOf(target, await $.session.cwd()) + } catch (error) { + report($, 'tool.call', `cannot place the redirect file of task ${id}: ${messageOf(error)}`, `redirect|${id}`) + return undefined + } +} + /** Appends clean runs to the duration history, one record at a time; a failure costs the record only and logs once. */ async function recordRuns($: EngineInterface, runs: readonly HistoryRun[], now: number, event: 'prompt.submit' | 'session.append'): Promise { if (runs.length === 0) return @@ -306,16 +329,59 @@ async function tailOf($: EngineInterface, path: string, bytes: number): Promise< return ran.stdout } -/** What a job's output holds now: no new lines while its size stays, else its end, by `$.fs.read` up to FS_READ_MAX and by `tail` above. */ +/** A file's end, by `$.fs.read` up to FS_READ_MAX and by `tail` above. */ +async function endOf($: EngineInterface, path: string, size: number): Promise { + const isTail = size > FS_READ_MAX + return outputEndOf(isTail ? await tailOf($, path, size) : await $.fs.read(path), isTail) +} + +/** What a job's output holds now: no new lines while its size stays, else its end. A job with a redirect file also looks at that file (see redirectRead). */ async function readOutput($: EngineInterface, job: ShellJob & { outputFile: string }): Promise { const stat = await $.fs.stat(job.outputFile) - if (stat.size === job.outputBytes && job.failedReadBytes === undefined && job.finalReadAskedAt === undefined) return { bytes: stat.size, mtimeMs: stat.mtimeMs, lines: [] } - const isTail = stat.size > FS_READ_MAX - const text = isTail ? await tailOf($, job.outputFile, stat.size) : await $.fs.read(job.outputFile) - return { bytes: stat.size, mtimeMs: stat.mtimeMs, lines: displayLinesOf(outputEndOf(text, isTail)) } + const isAsked = job.failedReadBytes !== undefined || job.finalReadAskedAt !== undefined + const text = stat.size === job.outputBytes && !isAsked ? undefined : await endOf($, job.outputFile, stat.size) + const output: TailRead = { bytes: stat.size, mtimeMs: stat.mtimeMs, lines: text === undefined ? [] : displayLinesOf(text) } + return job.redirectFile === undefined ? output : await redirectRead($, job, job.redirectFile, output, text, isAsked) +} + +/** + * The read of a job with a redirect file, after the read of its output (`text` when that read took its end). The row + * takes its lines from the file that changed last, by modification time, and from the output file when the two times + * are equal. The output file counts only while it holds a job line. Its time is its modification time once the job wrote + * to it since the last read, which the engine's closing line alone is not. Until then it keeps the time of the lines that + * the row shows from it, and it does not count while the row shows the redirect file. When the redirect file is missing + * or not a regular file, the output file shows if it holds a job line. The redirect file is read again when its size or + * its modification time changes. A read of it carries its size and time, which tailRead keeps as redirectBytes and + * lastOutputAt. + */ +async function redirectRead($: EngineInterface, job: ShellJob & { outputFile: string }, redirectFile: string, output: TailRead, text: string | undefined, isAsked: boolean): Promise { + const isRedirectShown = job.redirectBytes !== undefined + const isWritten = text !== undefined && output.bytes !== job.outputBytes && (job.outputBytes === undefined || jobBytesOf(text, output.bytes) > job.outputBytes) + const outputAt = isWritten ? output.mtimeMs : isRedirectShown ? undefined : job.lastOutputAt + /** Whether the output file holds a job line: in the lines read now, else in the line that the row shows from it. */ + const hasJobLine = (lines: readonly string[]): boolean => (text === undefined && !isRedirectShown ? job.lastLine !== undefined : jobLinesOf(lines).length > 0) + let redirect: { size: number; mtimeMs: number } + try { + const stat = await $.fs.stat(redirectFile) + if (stat.kind !== 'file') throw new Error('not a regular file') + redirect = stat + } catch (error) { + // A row that shows the redirect file needs the output file's end to show it instead. + const lines = text === undefined && isRedirectShown ? displayLinesOf(await endOf($, job.outputFile, output.bytes)) : output.lines + if (hasJobLine(lines)) return { ...output, lines } + throw new RedirectFailure(error) + } + if (outputAt !== undefined && outputAt >= redirect.mtimeMs && hasJobLine(output.lines)) return output + try { + const isSame = isRedirectShown && redirect.size === job.redirectBytes && redirect.mtimeMs === job.lastOutputAt + const lines = isSame && !isAsked ? [] : displayLinesOf(await endOf($, redirectFile, redirect.size)) + return { bytes: output.bytes, mtimeMs: redirect.mtimeMs, lines, redirectBytes: redirect.size } + } catch (error) { + throw new RedirectFailure(error) + } } -/** 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. */ +/** 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 { const path = job.outputFile if (!isOutputFileOf(path, job.id)) { @@ -327,9 +393,12 @@ async function readTail($: EngineInterface, job: ShellJob & { outputFile: string try { found = await readOutput($, job) } catch (error) { - const isTail = error instanceof TailFailure - report($, 'clock.every', `cannot ${isTail ? 'tail' : 'read'} the output of task ${job.id}: ${messageOf(error)}`, `${isTail ? 'tail' : 'read'}|${job.id}`) - await writeJobs($, { at: now, ids: [job.id] }, jobs => readFailed(jobs, job.id, isTail ? 'tail failed' : 'output unavailable', now, isTail ? error.bytes : undefined)) + const isRedirect = error instanceof RedirectFailure + const failure = isRedirect ? error.failure : error + 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)) return } await writeJobs($, { at: now, ids: [job.id] }, jobs => tailRead(jobs, job.id, found, now, isAnyRatio)) @@ -504,7 +573,8 @@ export const register: Register = (on, options) => { report($, 'tool.call', `no output file in the result of task ${id}; its last line is unavailable`, `path|${id}`) } const note = shellLaunchNote(ran.result, outputFile !== undefined) - await writeJobs($, { at: now, ids: [id] }, jobs => launched(jobs, shellJob({ id, command: e.command, description: e.description, outputFile, note, ownerAgentId: e.agentId, endsWithAgent: ran.result.backgroundEndsWithFinalResponse === true }, now))) + 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))) // 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)) diff --git a/hooks/state.ts b/hooks/state.ts index a503053..2209acc 100644 --- a/hooks/state.ts +++ b/hooks/state.ts @@ -19,9 +19,9 @@ function job(value: unknown): boolean { !optionalStrings(value, ['note', 'launchNote', 'lastLine'])) return false switch (value.kind) { case 'shell': - return optionalStrings(value, ['outputFile', 'historyKey', 'ownerAgentId']) && + return optionalStrings(value, ['outputFile', 'redirectFile', 'historyKey', 'ownerAgentId']) && (value.endsWithAgent === undefined || typeof value.endsWithAgent === 'boolean') && - optionalNumbers(value, ['outputBytes', 'failedReadBytes', 'lastReadAt', 'successfulReadAt', 'finalReadAskedAt', 'usualMs']) && progress(value.progress) && anchor(value.anchor) + optionalNumbers(value, ['outputBytes', 'redirectBytes', 'failedReadBytes', 'lastReadAt', 'successfulReadAt', 'finalReadAskedAt', 'usualMs']) && progress(value.progress) && anchor(value.anchor) case 'monitor': return number(value.eventCount) && optionalNumbers(value, ['deadline']) && progress(value.progress) && anchor(value.anchor) case 'agent': diff --git a/hooks/view.ts b/hooks/view.ts index db65e59..c96e77a 100644 --- a/hooks/view.ts +++ b/hooks/view.ts @@ -50,6 +50,15 @@ export type PaneModel = { header: string; running: Row[]; ended: Row[] } const GLYPHS: Readonly> = { running: '●', completed: '✓', failed: '✗', stopped: '■', gone: '◌' } +/** The words before a redirect file's name in a row's note, as in `output: build.log`. */ +const REDIRECT_NOTE = 'output: ' + +/** Where a shell row's lines come from when that is its redirect file: `output: `. */ +function redirectNoteOf(job: Job): string | undefined { + if (job.kind !== 'shell' || job.redirectFile === undefined || job.redirectBytes === undefined) return undefined + return clip(oneLine(`${REDIRECT_NOTE}${job.redirectFile.split(/[\\/]/).pop() ?? ''}`), NOTE_MAX) +} + const endOf = (job: Job): number => job.endedAt ?? job.startedAt /** The tool of a subagent's activity summary, `: `. */ @@ -118,6 +127,7 @@ function ageOf(job: Job, now: number): string { function rowOf(job: Job, now: number): Row { const eta = etaOf(job, now) const progress = progressCell(job) + const redirect = redirectNoteOf(job) return { id: job.id, glyph: GLYPHS[job.state], @@ -132,7 +142,7 @@ function rowOf(job: Job, now: number): Row { last: job.lastLine, tableLast: job.kind === 'agent' && job.lastLine !== undefined ? toolOf(job.lastLine) : job.lastLine, flag: flagOf(job, now), - note: job.note, + note: redirect === undefined || job.note === undefined ? job.note ?? redirect : `${job.note} · ${redirect}`, } } diff --git a/types/index.d.ts b/types/index.d.ts index 6a761dd..676e3a0 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -37,6 +37,10 @@ export type ShellJob = JobBase & { endsWithAgent?: boolean successfulReadAt?: number outputFile?: string + /** The file that the command's own text sends its output to, absolute. Each read looks at it and at the output file, and the row shows the one that changed last. */ + redirectFile?: string + /** The size of redirectFile at its last successful read. While it is set, the row's lines and progress come from that file. */ + redirectBytes?: number outputBytes?: number /** The size at a failed tail, used for its retry interval without marking that output as read. */ failedReadBytes?: number From f2657cc2d630adff90ff3ce6fb2bf9b417eb7287 Mon Sep 17 00:00:00 2001 From: dot-agi Date: Wed, 14 Oct 2026 11:34:21 +0530 Subject: [PATCH 2/2] Keep closing-line text that a redirect file holds --- hooks/jobs.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hooks/jobs.ts b/hooks/jobs.ts index 4302783..b530a31 100644 --- a/hooks/jobs.ts +++ b/hooks/jobs.ts @@ -571,7 +571,8 @@ export function reloaded(jobs: readonly Job[], isAnyRatio: boolean, now: number) export function tailRead(jobs: readonly Job[], id: string, read: TailRead, now: number, isAnyRatio = false): Job[] { return jobs.map(original => { if (original.kind !== 'shell' || original.id !== id) return original - const lines = jobLinesOf(read.lines) + // Only Claude Code's output file ends with its closing line; a redirect file holds only what the job wrote. + const lines = read.redirectBytes === undefined ? jobLinesOf(read.lines) : read.lines const found = progressOf(lines.slice(-PROGRESS_LINES), now, isAnyRatio) const isRedirect = read.redirectBytes !== undefined const job: ShellJob = isRedirect === (original.redirectBytes !== undefined)