Repository navigation
fix(*): the desk lists a task's files as changes and hands over only what was delivered - #656
Conversation
…what was delivered
A sub-agent's writes reached the desk's deliverables tab as "Task output" and
never its diff tab, so a spawn that wrote ten files showed a shelf of ten
products and a diff tab saying nothing changed. Delivery is what deliver_files
decides; a file a node wrote is a change to the working directory.
- DeskPalette: the diff tab lists every file a task's nodes wrote or edited
under a "Task changes" heading; the deliverables tab lists only deliver_files
rows and its empty state depends on those alone.
- desk/store: the diff tab's badge and pickTab count task files, under the same
id openDeskDiff marks read.
- tasks/store: taskChangeKey / taskChangeKind; an edit_file is always a
modification, a whole-file write that replaced nothing is a creation.
- workspace/record: a write_file that created the file becomes kind 'add' --
from file_change without 'before' on the live event, from the diff's
"@@ -0,0" header on replay -- and a created file rewritten in the same turn
stays a creation; the transcript's "New" badge follows the same rule instead
of calling every whole-file write new.
- i18n: gui.ws.task_output -> gui.ws.task_changes, gui.tasks.artifacts_n/_1 ->
gui.tasks.files_n/_1 ("files changed"), gui.ws.chip.add for the new kind; the
TUI table is regenerated.
- docs/specs/2026-09-18-desk-tasks-list-design.md section 7 says where task
files go now.
Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
The pre-commit prettier hook checks the whole ui-tui tree whenever any file under it changes, and these two arrived on main unformatted, so every PR that touches ui-tui -- this one regenerates the i18n table -- fails that hook until somebody runs prettier on them. Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
gloryfromca
left a comment
There was a problem hiding this comment.
No blockers; suggestions only, and they are marked inline.
The deliverables/task-changes separation is coherent, the unread identity matches openDeskDiff, and the live/replay add kind is carried through the session pipeline and transcript consumer without weakening the existing assertions. I covered AGENTS.md and the Web UI context rules, the full diff, relevant callers and persisted/live history paths, the two-commit history, backward compatibility, test changes, and the Source/Store/Seam architecture constraints.
Verification: the seven focused web test files passed (427 tests); web and TUI type checks passed; the web RPC generator check and TUI i18n catalogue check passed. The one inline item is a narrow display-classification edge case and does not hold up the central fix.
…der it (#658) ## Summary The composer is an **opaque** card floating over the transcript scroller, and nothing ended the transcript's paint before it reached the card. Scrolled up, a line was cut mid-glyph at the card's top edge, and the 22px gutter below the card left the next line legible *underneath* the field the reader types in. This gives `.scroll` a mask whose alpha reaches zero at the top of whatever is docked, over a 40px ramp: ```css .scroll { --tail-fade: 40px; mask-image: linear-gradient(180deg, #000 calc(100% - var(--lift, 96px) - var(--tail-fade)), transparent calc(100% - var(--lift, 96px))); } ``` Three decisions worth stating: - **A mask, not a ground-coloured band.** A band is a rectangle painted *over* the transcript, so it has to match whatever is behind it; on this page's wash (a gradient) it reads as a flat seam. A mask takes the alpha out of the transcript itself, so there is no ground to match, and it needs no second copy per theme. `page.css` records that an earlier band above the composer was deleted for exactly that reason, which is why this does not reintroduce one. - **Anchored to `--lift`, which already exists.** `dockLift` (`features/composer/store.ts`) publishes the distance from the chat column's bottom edge up to the top of the highest docked thing. `.col` spends it as bottom padding and `.backpill` as its offset. Spending the same number here is what makes the clear edge land on the composer's top edge however tall it has grown (a longer field, queued rows, a clarify sheet), rather than on a hardcoded resting height. - **The tail is never under the ramp.** Because `.col` reserves the same `--lift`, the newest message sits above the ramp when the scroller is at rest, so the fade engages only once the reader scrolls back. Nothing is hidden at the moment a message arrives. The second commit corrects the `.dock` leading comment, which this change falsified: it claimed the transcript "scrolls underneath and stays legible through the glass" and that "the old fade band above the composer is gone". ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Verified in headless Chrome two ways: against `page.css` itself in a probe page carrying the real DOM structure, and against the running app over CDP. Painted ink was measured, not layout rects -- masked text still occupies its box, so a rect-based measurement is byte-identical before and after and cannot see this change at all. Ink is counted as pixels differing from the page's own background (the wash is not white, so the background is sampled from an empty band). **The clear edge lands on the composer's top edge** (`aligned` compares the `getBoundingClientRect().top` of `.dock-in` against the mask's transparent stop): | case | `--lift` | composer top | mask clears at | aligned | |---|---|---|---|---| | light theme | 106px | 654 | 654 | yes | | dark theme | 106px | 654 | 654 | yes | | composer grown to three lines | 180px | 580 | 580 | yes | | landing page (`data-fresh`) | 106px | 654 | 654 | yes | | live app, session "ESG..." | 139px | 721 | 721 | yes | **Nothing is painted at or past the clear edge.** Ink in a 14px band below it, measured in the gutters either side of the card: `0` in all five cases. **The fade is the only thing that changed.** Ink inside the 28px just above the card went `1651 -> 144` (91 percent removed), while the control band 100-600px above it was `36133` before and `36133` after -- unchanged to the pixel. **No scroll cost.** 240 scroll frames x 3 interleaved runs each, mask on vs off: median frame `16.70ms` both, p95 `16.80ms` both, worst `16.77 -> 17.17ms`. **A mask creates a stacking context, so `position: fixed` descendants of the scroller would be clipped.** Enumerated all 434 descendant elements on the live page: zero are `fixed` and zero are `sticky`. The scrollbar thumbs (`.sbars`), `#deskHost`, `.backpill` and `.dock` are all siblings of `.scroll`, not descendants. This is the commit-authors' manual check; the branch adds no automated guard for it. Commands and results: - `npx vitest run scripts/gates/` -- 39 files passed, 170 tests passed, no unhandled errors. The new gate is collected by the runner: `npx vitest list --filesOnly` prints `scripts/gates/transcript-tail-fade-css.test.mjs`. - The new gate was proven not to be theatre by removing the `.scroll` block and re-running: 4 of its 6 assertions go red. The other 2 assert pre-existing invariants the pair depends on (`.col` must reserve the same `--lift`; `.col` must not carry a mask of its own) and pass on both trees. - `node ui-web/scripts/check-css.mjs` -- OK. `node ui-web/scripts/check-class-namespace.mjs` -- OK. `npx eslint scripts/gates/transcript-tail-fade-css.test.mjs` -- clean. - `node ui-web/scripts/check-page.mjs` -- OK (1,387,731 bytes, 2 scripts parse). - `node ui-web/build.py` -- `boot-snapshot: OK (241 nodes match golden)` twice. - `python3 scripts/check_commit_messages.py origin/main..HEAD`, `check_source_language.py origin/main...HEAD`, `check_large_files.py origin/main...HEAD` -- all exit 0. - Every added line is ASCII, and `git ls-files --error-unmatch` resolves the one path the new CSS comment cites (`ui-web/src/features/composer/store.ts`). Rebased onto `origin/main` after it gained #656, which touched `page.css`. Its one change there is a desk change-chip selector at line 2622 and touches none of `.scroll`, `.col`, `.dock` or `--lift`; both changes are intact at the head and all gates above were re-run after the rebase. ## Risk - [x] Security impact considered -- none. A CSS mask on an existing element; no new input surface, no new file, no change to what is sent anywhere. - [x] Backward compatibility considered -- the composer keeps its position, size, padding and hit area. The mask removes paint only; it does not change layout, so nothing reflows and nothing becomes unclickable. - [x] Rollback path is clear -- revert the two commits. No data, config or persisted state is touched; `ui-web/dist` is a gitignored build artifact. User-visible change: a message scrolled up now dissolves over the last 40px before the composer instead of being cut off at the card's edge. A reader who scrolls back to the tail gets it back, since the column's own `--lift` padding keeps the newest message clear of the ramp. Not covered, stated so it is not mistaken for done: `ui-web` has no test for this beyond the stylesheet gate (happy-dom applies no stylesheet and does no layout, so the pixels are browser-only evidence), and the top of the scroller still cuts a message at the header, which this change does not address. ## Related Issues N/A --------- Co-authored-by: Claude (claude-opus-5[1m]) <noreply@anthropic.com>
…e A and M (#657) ## Summary Nothing in Raven recorded a deleted file. There is no delete tool, and a file an `exec rm` unlinks is gone before anyone can look, so a turn that created three files and removed two drew three additions on the desk's diff tab, in the transcript's changed-files card and in a task's file list, and nothing at all for the deletions. On the task side a whole-file write onto nothing was also indistinguishable from a rewrite: only `write | edit` reached the wire, and #656 had to guess a creation from a zero deletion count (the one review remark on that PR). This is the follow-up #656 named. Backend (first commit): - `ExecTool` notes, before a synchronous local command runs, the paths its tokens name that resolve to regular files under the working directory or an allowed directory (at most 64, text kept up to 256 KB), and reports the ones that no longer exist afterwards as `removed` on the tool result. Not for `machine=` or `run_in_background`, and a command that removes nothing returns exactly what it returned before. - `RemovalWatch` (new `raven/agent/tools/removals.py`) keeps what a turn (main lane) or a run (`raven_loop`) wrote and re-stats those paths after every later call, so a file removed under a name the shell watch could not resolve is still seen. Bounded per path (512 KB) and across the watch (4 MiB); past the bound the path is still reported, only its text is dropped. - `tool.complete` gains `file_removed: [{path, before?}]`, emitted only when non-empty; the stored tool entry keeps `file_removed: [{path, del}]` so a reload draws the deletion; `session.resume` passes it through. - The in-process lane records `op: add` for a write whose `before` is `None` and `op: delete` for a vanished path; `merge_file_change` folds a run's touches like a range of commits (add outlives later writes, delete replaces a write or edit, add then delete leaves nothing, delete then a write is a write). - Wire: `FileRemoval`, `ToolCompletePayload.file_removed`, `TaskFile.op` becomes `add | write | edit | delete`, `TranscriptMessage.file_removed`; `rpc-schema/openrpc.json` mirrors it and both generated clients are regenerated. The ACP translator is unchanged (see Risk). Frontend (second commit): - A removal rebuilds the turn's row for that path as kind `delete`: every line it held as a deleted row (from `before` when the runtime captured it, from the turn's own last write otherwise, from the stored count alone on reload). A file the same turn created leaves no row, the nothing git shows for it. Rows are matched by full-segment suffix, because the model types a relative path and the runtime reports the resolved one. - Desk diff tab: git's own letters, `A` / `M` / `D` in git's colours (added green, modified amber, deleted red), on both the session's rows and a task's; a task row hides a zero count. The file-level letter and the `+N -N` line counts beside it are two different things, and #656's `+` for a created file read as more of the counts. Transcript changed-files card: "Deleted". Task pane: a removed file's chip shows only what it took out and opens a diff that ends on the node's last write of the file taken back out. `taskChangeKind` now reads the op off the wire; the deletion-count heuristic from #656 is gone. - Offline fixtures: the GTM run removes a superseded note with `rm`, exercising the live frame and the stored shape; the tasks fixture carries an `add` and a `delete` file. Contract tier (third commit): `FileRemoval` and the two `removed` fields are a declared-surface change on `raven/contracts/tool.py`, so the ledger admits the export, `CONTRACTS_VERSION` moves to 31 with the surface digest repinned, and the papers' line ceiling moves 3,570 -> 3,620 with the reason recorded beside the earlier moves in `tests/test_kernel_budget.py`. ## Type - [ ] Fix - [x] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Run from the worktree on top of `da13bddd` (main with #656): - `.venv/bin/ruff check raven tests`; `.venv/bin/ruff format --check raven tests` -- clean - `.venv/bin/python -m pytest tests/test_shell_file_removals.py tests/test_removal_watch.py tests/test_subagent_activity.py tests/test_subagent_manager.py tests/test_rpc_spine.py tests/test_rpc_tasks.py tests/test_rpc_schema_match.py tests/test_acp_updates.py tests/test_filesystem_tools.py tests/test_agent_loop_session_stamps.py tests/test_rpc_session.py tests/test_shell_workspace_fence.py tests/test_shell_approval.py tests/test_turn_wire_shape_conformance.py tests/test_spine_events.py -q` -- 1532 passed; `tests/test_contracts_two_tier_ledger.py tests/test_kernel_budget.py` -- 14 passed with the contract-tier commit - `make lint-python`; `make test-python` -- 25023 passed, 109 skipped (the full Python suite, which is what caught the three contract-tier guards the focused set missed) - `npm test --prefix ui-web` -- 192 files, 2631 tests passed (whole suite, gates included; the fixture-shape gate is satisfied by the fixture sending `file_removed`, no new UNSENT pin) - `npm run lint --prefix ui-web` (0 errors, 4 pre-existing warnings); `npm run type-check --prefix ui-web`; `npm run gen:check --prefix ui-web` - `npm run lint --prefix ui-tui`; `npm run type-check --prefix ui-tui`; `npm test --prefix ui-tui` -- 143 files, 2075 tests; `npm run lint:rpc --prefix ui-tui`; `npm run lint:i18n --prefix ui-tui` - Review round: `rm gone.txt;` (an operator adjacent to the path) now records the removal -- the watch splits the command on shell operators the way the fence does before lexing (7 parametrized cases); a deletion's body follows the file through its edits the way `edit_file` applies them -- every occurrence with `replace_all`, the one occurrence otherwise, given up when the reader cannot know what the tool did -- on the task side and the conversation side (one shared helper in `lib/hunks.ts`; 7 new cases). Real gateway rerun with `rm work/main_old.md;` draws the same red `D`. - Mutation checks, each restored afterwards: ExecTool never reporting removals, RemovalWatch returning only what the tool reported, add+delete keeping the entry, raven_loop recording write instead of add, the outlet dropping file_removed, record.ts never producing a delete row, the D glyph reverting to M, taskChangeKind ignoring delete, the replay ignoring stored file_removed, fromDelete numbering the wrong side -- every one turned at least one new test red. - Real gateway (`raven serve` from this branch, isolated `RAVEN_HOME`, default model `deepseek/deepseek-v4-flash`), two fresh conversations. The main agent spawns the in-process `Raven` sub-agent, which writes two files, edits one, runs `rm work/sub_old.md` on a pre-seeded file, then writes and removes a scratch file: the desk diff tab shows `A` for the two files and a red `D work/sub_old.md -3`, nothing for the scratch file, and `tasks.list` carries `op` add / add / delete. The main agent itself writes a new file, runs `rm work/main_old.md`, then writes and removes a scratch file: the diff tab shows a red `D work/main_old.md -2`, `A work/e2e_main_new.md` and, for an `edit_file` of a pre-seeded file, `M work/main_keep.md`, nothing for the scratch file. Before this change the same runs showed no trace of either `rm`. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed ## Risk Behaviour changes: every synchronous local `exec` now stats each command token and reads up to 64 named files (256 KB each) before running, and every tool call re-stats the paths the turn has written so far; a `tool.complete` for a call that removed files carries `file_removed`, and stored transcripts of such calls carry `file_removed: [{path, del}]`. The wire additions are optional fields and a widened enum, so older clients keep parsing. Known limits, unchanged here: the ACP translator (`raven/acp/updates.py`) does not surface `file_removed`, so an ACP client sees only the result preview for a call that deleted files; the shell watch only sees paths the command names literally (a glob, a directory, a computed path, or `mv a b` are not read as deletions); two absolute spellings of one file (a symlinked root) are not matched to the same row. Rollback: revert the three commits; nothing stored is required by older code. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A --------- Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…ed and removed (#670) ## Summary Only the in-process sub-agent lane recorded a node's files, and only from its own file tools. A Raven-PPT or Raven-Code run (ACP) wrote a deck, a note and a build script and its node's file list stayed empty, so the desk's diff tab said "No changes this turn" for the whole task; and in every lane a file a shell command produced -- a build script's PPTX, a rendered PDF -- was invisible, because a command's result names no files. This is the gap left open by #656 and #657, which draw whatever the lanes record. No wire change: everything lands in the run's file entries and reaches the page through `tasks.list.files` as it stands, so the desk's `A / M / D` rows, the task pane's chips and the transcript card light up with no frontend edit. - `raven/agent/tools/snapshot.py` (new): a bounded listing of a working directory, path -> (size, mtime_ns), skipping machinery directories (`.git`, `node_modules`, `.venv`, ...) and giving up past 20000 entries rather than describing part of a tree; `diff()` names what was created, modified and deleted between two listings. - `activity.record_snapshot_changes` turns such a diff into file entries (created -> `add` with its line count, modified -> `write`, deleted -> `delete`), skipping paths the call already accounted for and, for a lane whose calls overlap, paths an earlier listing already reported created. `count_line_changes` and `workspace_relative` move here from `raven_loop` so every lane shares them. - ACP lane: the collector records the diff blocks a tool call carries, read the way the installed adapters send them. Per the spec a block without `oldText` is a new file, and codex-acp and raven's own agent send it that way; claude-agent-acp announces every Write without one, so its dialect consults the call's opening listing instead (a path already listed is a `write`, not an `add`). One path's blocks in one frame are summed (claude-agent-acp emits one per hunk); codex-acp's deletion (old content, empty `newText`) is a `delete`. A listing is taken at the earliest frame that names a call -- the permission request when the agent asks first, else the announce -- and compared when the call settles, for the files the agent's own commands produced; a call that reports failure keeps that listing (a command that wrote a file and then exited non-zero left the file) and drops only its blocks; a per-turn `RemovalWatch` reports files it recorded that later vanished. Listings run off the connection's read loop, are skipped for `read` / `search` / `think` / `fetch` calls and for a call first seen already over, and the oldest open listing is evicted at the ceiling. - In-process lane: a listing on either side of an `exec` call (off the loop), beside the file-tool results it already recorded. - CLI lane: one listing before the child starts and one after it exits. Not in this PR: the main conversation's own `exec`-produced files (the desk builds the main lane's rows from tool arguments on the client, so those need a wire field); the ACP translator still surfaces no file changes to an ACP client. ## Type - [ ] Fix - [x] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Run from the worktree on top of `5186f672` (main with #657): - `.venv/bin/ruff check raven tests`; `.venv/bin/ruff format --check raven tests` -- clean - `.venv/bin/python -m pytest tests/test_workdir_snapshot.py tests/test_subagent_activity.py tests/test_subagent_acp.py tests/test_subagent_manager.py tests/test_subagent_third_party.py tests/test_removal_watch.py tests/test_shell_file_removals.py tests/test_rpc_tasks.py tests/test_acp_dialects.py tests/test_acp_backend_cwd.py tests/test_contracts_two_tier_ledger.py tests/test_kernel_budget.py tests/test_acp_ask_user.py tests/test_acp_elicitation.py tests/test_acp_journal.py -q` -- 943 passed - `make lint-python`; `make test-python` -- 25108 passed, 109 skipped. One timing test (`tests/test_shell_machine_channel.py::test_the_cap_fires_on_the_machine_and_takes_the_whole_group`) failed once under load while an E2E gateway ran beside the suite; it passes 3/3 alone and the whole file passes, and this branch does not touch the machine channel. - Mutation checks, each restored afterwards: the skip list dropped, the cap returning a partial listing, `count_line_changes` returning 0/0 for a creation, the exec listing skipped in raven_loop, the collector recording a failed call, `already` ignored, the CLI after-listing dropped, the rewrite-vs-creation branch disabled, the delete-block branch disabled, the overlapping-call guard dropped, a block group truncated to its last hunk, eviction reverted to refusal -- every one turned at least one new test red. - The rule about a block without `oldText` is pinned per dialect: the spec dialect records `add` whatever the listing held, the claude-agent-acp dialect consults the listing, and a permission frame that precedes the announce opens the listing early (three cases, each red under the inverse mutation). - Review round: a failed ACP call keeps what the listing found on disk (the `partial.txt` reproduction now records `add`; a block the failed call never made records nothing); the same-size rewrite test stamps its own mtime after the write instead of relying on the clock advancing. - Adapter claims checked against the installed copies: claude-agent-acp 0.79.0 (`dist/tools.js` Write announce, one block per hunk, PostToolUse hook frame) and codex-acp 1.1.14 (`createDeleteFileContent`). - Real gateway (`raven serve` from this branch, isolated `RAVEN_HOME`, default model `deepseek/deepseek-v4-flash`): the main agent spawns the ACP `Raven-Code` sub-agent, which creates `work/acp_written.md` with its own file tool, runs `printf ... > work/acp_generated.txt`, and runs `rm work/acp_old.md` on a pre-seeded file. Before this change `tasks.list` reported `files: []` for the node (three tool calls, three changes on disk) and the desk diff tab said "No changes this turn". After: `tasks.list.files` is `add work/acp_written.md (+3)`, `add work/acp_generated.txt (+2)`, `delete work/acp_old.md`, and the desk diff tab draws `A`, `A` and a red `D` under "Task changes" (this agent asks permission before announcing the call, which is what the permission-frame listing is for). - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed ## Risk Behaviour changes: every sub-agent run now walks its working directory around commands (in-process: around `exec`; ACP: around every call that could write; CLI: once around the run), bounded at 20000 entries with the machinery directories skipped, and off the event loop; a tree past the bound records nothing from listings (the file-tool and diff-block records stand). A created file's line count reads the file (up to 256 KB) inline. A whole-file claude-agent-acp Write over an existing file reads as `M` (write), not `A`, which is the truthful reading and what keeps a later deletion of that file visible; codex and raven blocks are read as the spec says. Snapshot-discovered rewrites and deletions carry no line counts (the desk hides zero counts and shows a bare `M` / `D`). No stored or wire shape changes; rollback is reverting the four commits. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A --------- Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…iff (#692) ## Summary The desk builds the main conversation's changed-files rows on the client, from the file tools' own arguments and results. A file a shell command created (`python3 build.py > out.txt`), rewrote (`echo more >> notes.md`) or removed through a glob left no row, because a command returns its output and names no file. #670 closed that gap for every sub-agent lane by listing the working directory on either side of a command; this does the same for the main conversation, which needs a wire field because its rows are drawn on the page rather than read from a run record. - `raven/agent/loop/turn_path.py`: around a call named `exec` and only that, the directory the command runs in is listed before and after the call with `snapshot.take`, off the event loop. The tool says where that is (`ExecTool.listing_root`, resolved the way `execute` resolves its cwd): the turn's working directory unless the call names a `working_dir`, and no listing at all for a command sent to a registered `machine`, whose files are not on this disk. The in-process sub-agent lane asks the same question through `snapshot.root_for`, so a sub-agent's `working_dir` command is recorded too. Created and modified paths the call did not name itself (its `file_change`, its removals) become the new `tool.complete.file_written` list of `{path, created, size, lines}`; a deletion the listing finds joins `file_removed` with no body, since the file was gone before anything read it. Stored on the tool entry under the same name and passed through `session.resume`, so a reloaded page draws what the live one drew. - Wire: `FileWritten` in `raven/rpc/models.py`, on `ToolCompletePayload` and `TranscriptMessage`; `rpc-schema/openrpc.json` and both generated clients regenerated. `lines` is an integer for a created text file of 256 KB or less and `null` otherwise, including every rewrite, whose old contents the listing never held. - Page: a created file is an `A` row with its line count, a rewritten one a bare `M`, neither with a patch; a row a file tool already made this turn is left alone (its arguments say more than a listing can); the reload path replays the stored field. A change row with no patch to show opens the file itself instead of an empty patch pane (a removal keeps its pane; a task file's row, keyed by node rather than path, is untouched). The offline fixture's GTM run gains one such command so the demo desk shows both rows and the fixture-shape gate sees the field sent. Not in this PR: the ACP translator still surfaces no file changes to an ACP client, the same as for `file_removed`. ## Type - [ ] Fix - [x] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Run from the worktree on top of `0133c67c` (main with #656, #657 and #670; the branch was rebased onto it when main's own contract additions collided with the generated client, which was regenerated from the merged schema): - `.venv/bin/ruff check raven tests`; `.venv/bin/ruff format --check raven tests` -- clean - `.venv/bin/python -m pytest tests/test_agent_loop_session_stamps.py tests/test_rpc_spine.py tests/test_rpc_session.py tests/test_rpc_schema_match.py tests/test_turn_wire_shape_conformance.py tests/test_spine_events.py tests/test_contracts_two_tier_ledger.py tests/test_kernel_budget.py tests/test_workdir_snapshot.py tests/test_subagent_activity.py tests/test_shell_file_removals.py tests/test_acp_updates.py -q` -- 952 passed - `make test-python` (`uv run --frozen --all-extras pytest -q`) -- 25798 passed, 109 skipped - `npm test --prefix ui-web` (the whole suite, including the fixture-shape gate) -- 200 files, 2767 tests passed; `npm run lint --prefix ui-web` -- 0 errors (4 pre-existing hook-dependency warnings in files this change does not touch); `npm run type-check --prefix ui-web`, `npm run gen:check --prefix ui-web`, `npm run lint:rpc --prefix ui-tui` -- clean - Mutation checks, each restored afterwards: the listing taken around every tool; a created file reported as a rewrite; the held-out result path dropped; the storage rename disabled; `file_written` emitted when empty; the key dropped from `session.resume`; `file_written=None` on the served `ToolEvent`; a count of 0 on a decode failure; the 256 KB cap dropped; the created row drawn as `M`; the existing-row guard dropped; the ninth argument dropped in the stage; the file door removed, ignoring the kind, ignoring a task key; the fixture no longer sending the field (stored, then live); the row takeover removed and its removal guard dropped; `.raven` back in the walk; the count back on the loop; the shell ignoring its bound directory -- every one turned at least one new test red. - Review round two (reviewer's blocker): a command with a `working_dir` of its own is listed there, in both lanes, and a `machine` command takes no listing -- `tests/test_shell_approval.py` (the root follows the arguments), `tests/test_workdir_snapshot.py` (`root_for`), `tests/test_agent_loop_session_stamps.py` (the real `ExecTool` with `working_dir`; a remote command takes no listing) and `tests/test_subagent_manager.py` (a sub-agent command outside its workspace); each red with the root pinned back to the lane's directory or the machine guard dropped. - Review round: one row per file when a file tool follows a command that already left the file behind (the listing keys by the resolved path, the tool by the path the model typed); the listing skips `.raven`, where the checkpoint keeps a shadow git repo inside the working directory; the line counting moved into the worker beside the walks; a test through the real `ExecTool` (its cwd is the directory the listing walks), the served `ToolEvent` hop, a non-text and an over-cap created file, and the live fixture frame. - Real gateway (`raven serve` from this branch, isolated `RAVEN_HOME`, default model `deepseek/deepseek-v4-flash`): the main agent runs `printf "gen one\ngen two\n" > work/main_generated.txt` and `echo more >> work/main_shell_mod.md` beside its file-tool steps. Before this change neither command left a row. After: the desk diff tab draws `A work/main_generated.txt +2` and `M work/main_shell_mod.md` beside `M work/main_keep.md +1-1` (edit_file), a red `D work/main_old.md -2` (`rm`) and `A work/e2e_main_new.md +2` (write_file); a file one command created and the next removed draws nothing; reopening the page (`session.resume`) draws the identical five rows, and the stored tool entries carry `file_written` with `created: true, lines: 2` and `created: false, lines: null`. The same run repeated on the final tree (after the review round) and its reload draw the same rows. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed ## Risk Behaviour change: every main-conversation `exec` now walks the turn's working directory twice, bounded at 20000 entries with the machinery directories (`.git`, `node_modules`, `.venv`, caches) skipped and run off the event loop; a tree past the bound records nothing from listings, and the file tools' own rows stand as before. A created file's line count reads the file (up to 256 KB). Wire: one new optional key on `tool.complete` and on `session.resume` tool entries, absent on every call that is not a command; a client that does not read it sees what it saw before. Rollback is reverting the commits. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A --------- Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…what was delivered (#656) ## Summary A spawned sub-agent that wrote ten markdown files showed up on the desk as ten "Task output" rows on the deliverables tab and a diff tab saying "No changes this turn". Two things were wrong at once: the deliverables tab listed files nobody delivered, and the diff tab hid every whole-file write a task made (it only took `op === 'edit'` task files). - The diff tab now lists every file a task's nodes wrote or edited, under a "Task changes" heading; its badge and the tab the desk opens on count them too, under the same id `openDeskDiff` marks read. - The deliverables tab lists only what `deliver_files` handed over. A sub-agent has no `deliver_files`; the main agent decides what is a product, which is what the system prompt already tells it. - A file the turn created draws `+` (kind `add`) instead of `M`: live from `tool.complete.file_change` without `before`, on reload from the stored diff's `@@ -0,0` header. A whole-file write over an existing file stays `M`, and the transcript's "New" badge follows the same rule instead of calling every whole-file write new. A created file rewritten in the same turn stays a creation. - Task-file glyphs: an `edit_file` is always a modification (the prototype rule from #509 drew `+` for an edit that deleted nothing); a whole-file write that replaced nothing is a creation. - Labels: "N products" on a task row becomes "N files changed"; "Task output" becomes "Task changes". The TUI i18n table is regenerated; the desk tasks spec (section 7) says where task files go now. - Second commit: `prettier --write` on two ui-tui files that arrived on main unformatted. The pre-commit prettier hook checks the whole ui-tui tree whenever a file under it changes (this PR regenerates the i18n table), so it fails until those two are formatted. Not in this PR: a file deleted by `exec rm` still leaves no row anywhere. A follow-up branch (`feat/track_file_deletions`) adds deletion tracking on the backend and the red `D` row; it is stacked on this one. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Run from the worktree after rebasing onto `a48c4edf`: - `npm test --prefix ui-web` -- 192 files, 2599 tests passed (the whole suite, gates included) - `npm run lint --prefix ui-web` -- 0 errors (4 pre-existing warnings); `npm run type-check --prefix ui-web`; `npm run gen:check --prefix ui-web` -- generated.ts matches the contract - `npm run lint --prefix ui-tui` -- 0 errors (23 pre-existing warnings); `npm run type-check --prefix ui-tui`; `npm run lint:i18n --prefix ui-tui` -- generated catalogue is up to date - `uv run pre-commit run --from-ref origin/main --to-ref HEAD` -- passes with the second commit (fails on `prettier (ui-tui)` without it) - `make lint-python`; `make test-python` -- no Python file changes; both pass - Real gateway: `raven serve` from this branch with an isolated `RAVEN_HOME` (channels off, default model `deepseek/deepseek-v4-flash`), one conversation that spawns the in-process `Raven` sub-agent to `write_file` two files and `edit_file` one, one conversation whose main agent `write_file`s a new file. Before: diff tab "No changes this turn", deliverables tab listing both task files under "Task output", the main agent's new file drawn `M`. After: diff tab lists both task files with `+` under "Task changes", deliverables tab "Nothing produced yet", the main agent's new file drawn `+`. Same two conversations reopened after a reload read the same. - Mutation check on the new tests: dropping task ids from `idsOf('diff')`, reverting `taskChangeKind` to the deletion-count rule, dropping the `add` mark in `record.ts`, and widening the transcript's "new" back to every write each turn at least one new test red. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed ## Risk UI only; the wire is untouched. Files a task wrote leave the deliverables tab (they were never delivered) and appear on the diff tab, so a reader who relied on the shelf to open a sub-agent's output now finds it under diff, or in the task pane's file chips as before. Two i18n keys are renamed (`gui.ws.task_output` -> `gui.ws.task_changes`, `gui.tasks.artifacts_n/_1` -> `gui.tasks.files_n/_1`); no other consumer of them exists. Rollback: revert the two commits. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A --------- Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…der it (#658) ## Summary The composer is an **opaque** card floating over the transcript scroller, and nothing ended the transcript's paint before it reached the card. Scrolled up, a line was cut mid-glyph at the card's top edge, and the 22px gutter below the card left the next line legible *underneath* the field the reader types in. This gives `.scroll` a mask whose alpha reaches zero at the top of whatever is docked, over a 40px ramp: ```css .scroll { --tail-fade: 40px; mask-image: linear-gradient(180deg, #000 calc(100% - var(--lift, 96px) - var(--tail-fade)), transparent calc(100% - var(--lift, 96px))); } ``` Three decisions worth stating: - **A mask, not a ground-coloured band.** A band is a rectangle painted *over* the transcript, so it has to match whatever is behind it; on this page's wash (a gradient) it reads as a flat seam. A mask takes the alpha out of the transcript itself, so there is no ground to match, and it needs no second copy per theme. `page.css` records that an earlier band above the composer was deleted for exactly that reason, which is why this does not reintroduce one. - **Anchored to `--lift`, which already exists.** `dockLift` (`features/composer/store.ts`) publishes the distance from the chat column's bottom edge up to the top of the highest docked thing. `.col` spends it as bottom padding and `.backpill` as its offset. Spending the same number here is what makes the clear edge land on the composer's top edge however tall it has grown (a longer field, queued rows, a clarify sheet), rather than on a hardcoded resting height. - **The tail is never under the ramp.** Because `.col` reserves the same `--lift`, the newest message sits above the ramp when the scroller is at rest, so the fade engages only once the reader scrolls back. Nothing is hidden at the moment a message arrives. The second commit corrects the `.dock` leading comment, which this change falsified: it claimed the transcript "scrolls underneath and stays legible through the glass" and that "the old fade band above the composer is gone". ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Verified in headless Chrome two ways: against `page.css` itself in a probe page carrying the real DOM structure, and against the running app over CDP. Painted ink was measured, not layout rects -- masked text still occupies its box, so a rect-based measurement is byte-identical before and after and cannot see this change at all. Ink is counted as pixels differing from the page's own background (the wash is not white, so the background is sampled from an empty band). **The clear edge lands on the composer's top edge** (`aligned` compares the `getBoundingClientRect().top` of `.dock-in` against the mask's transparent stop): | case | `--lift` | composer top | mask clears at | aligned | |---|---|---|---|---| | light theme | 106px | 654 | 654 | yes | | dark theme | 106px | 654 | 654 | yes | | composer grown to three lines | 180px | 580 | 580 | yes | | landing page (`data-fresh`) | 106px | 654 | 654 | yes | | live app, session "ESG..." | 139px | 721 | 721 | yes | **Nothing is painted at or past the clear edge.** Ink in a 14px band below it, measured in the gutters either side of the card: `0` in all five cases. **The fade is the only thing that changed.** Ink inside the 28px just above the card went `1651 -> 144` (91 percent removed), while the control band 100-600px above it was `36133` before and `36133` after -- unchanged to the pixel. **No scroll cost.** 240 scroll frames x 3 interleaved runs each, mask on vs off: median frame `16.70ms` both, p95 `16.80ms` both, worst `16.77 -> 17.17ms`. **A mask creates a stacking context, so `position: fixed` descendants of the scroller would be clipped.** Enumerated all 434 descendant elements on the live page: zero are `fixed` and zero are `sticky`. The scrollbar thumbs (`.sbars`), `#deskHost`, `.backpill` and `.dock` are all siblings of `.scroll`, not descendants. This is the commit-authors' manual check; the branch adds no automated guard for it. Commands and results: - `npx vitest run scripts/gates/` -- 39 files passed, 170 tests passed, no unhandled errors. The new gate is collected by the runner: `npx vitest list --filesOnly` prints `scripts/gates/transcript-tail-fade-css.test.mjs`. - The new gate was proven not to be theatre by removing the `.scroll` block and re-running: 4 of its 6 assertions go red. The other 2 assert pre-existing invariants the pair depends on (`.col` must reserve the same `--lift`; `.col` must not carry a mask of its own) and pass on both trees. - `node ui-web/scripts/check-css.mjs` -- OK. `node ui-web/scripts/check-class-namespace.mjs` -- OK. `npx eslint scripts/gates/transcript-tail-fade-css.test.mjs` -- clean. - `node ui-web/scripts/check-page.mjs` -- OK (1,387,731 bytes, 2 scripts parse). - `node ui-web/build.py` -- `boot-snapshot: OK (241 nodes match golden)` twice. - `python3 scripts/check_commit_messages.py origin/main..HEAD`, `check_source_language.py origin/main...HEAD`, `check_large_files.py origin/main...HEAD` -- all exit 0. - Every added line is ASCII, and `git ls-files --error-unmatch` resolves the one path the new CSS comment cites (`ui-web/src/features/composer/store.ts`). Rebased onto `origin/main` after it gained #656, which touched `page.css`. Its one change there is a desk change-chip selector at line 2622 and touches none of `.scroll`, `.col`, `.dock` or `--lift`; both changes are intact at the head and all gates above were re-run after the rebase. ## Risk - [x] Security impact considered -- none. A CSS mask on an existing element; no new input surface, no new file, no change to what is sent anywhere. - [x] Backward compatibility considered -- the composer keeps its position, size, padding and hit area. The mask removes paint only; it does not change layout, so nothing reflows and nothing becomes unclickable. - [x] Rollback path is clear -- revert the two commits. No data, config or persisted state is touched; `ui-web/dist` is a gitignored build artifact. User-visible change: a message scrolled up now dissolves over the last 40px before the composer instead of being cut off at the card's edge. A reader who scrolls back to the tail gets it back, since the column's own `--lift` padding keeps the newest message clear of the ramp. Not covered, stated so it is not mistaken for done: `ui-web` has no test for this beyond the stylesheet gate (happy-dom applies no stylesheet and does no layout, so the pixels are browser-only evidence), and the top of the scroller still cuts a message at the header, which this change does not address. ## Related Issues N/A --------- Co-authored-by: Claude (claude-opus-5[1m]) <noreply@anthropic.com>
…e A and M (#657) ## Summary Nothing in Raven recorded a deleted file. There is no delete tool, and a file an `exec rm` unlinks is gone before anyone can look, so a turn that created three files and removed two drew three additions on the desk's diff tab, in the transcript's changed-files card and in a task's file list, and nothing at all for the deletions. On the task side a whole-file write onto nothing was also indistinguishable from a rewrite: only `write | edit` reached the wire, and #656 had to guess a creation from a zero deletion count (the one review remark on that PR). This is the follow-up #656 named. Backend (first commit): - `ExecTool` notes, before a synchronous local command runs, the paths its tokens name that resolve to regular files under the working directory or an allowed directory (at most 64, text kept up to 256 KB), and reports the ones that no longer exist afterwards as `removed` on the tool result. Not for `machine=` or `run_in_background`, and a command that removes nothing returns exactly what it returned before. - `RemovalWatch` (new `raven/agent/tools/removals.py`) keeps what a turn (main lane) or a run (`raven_loop`) wrote and re-stats those paths after every later call, so a file removed under a name the shell watch could not resolve is still seen. Bounded per path (512 KB) and across the watch (4 MiB); past the bound the path is still reported, only its text is dropped. - `tool.complete` gains `file_removed: [{path, before?}]`, emitted only when non-empty; the stored tool entry keeps `file_removed: [{path, del}]` so a reload draws the deletion; `session.resume` passes it through. - The in-process lane records `op: add` for a write whose `before` is `None` and `op: delete` for a vanished path; `merge_file_change` folds a run's touches like a range of commits (add outlives later writes, delete replaces a write or edit, add then delete leaves nothing, delete then a write is a write). - Wire: `FileRemoval`, `ToolCompletePayload.file_removed`, `TaskFile.op` becomes `add | write | edit | delete`, `TranscriptMessage.file_removed`; `rpc-schema/openrpc.json` mirrors it and both generated clients are regenerated. The ACP translator is unchanged (see Risk). Frontend (second commit): - A removal rebuilds the turn's row for that path as kind `delete`: every line it held as a deleted row (from `before` when the runtime captured it, from the turn's own last write otherwise, from the stored count alone on reload). A file the same turn created leaves no row, the nothing git shows for it. Rows are matched by full-segment suffix, because the model types a relative path and the runtime reports the resolved one. - Desk diff tab: git's own letters, `A` / `M` / `D` in git's colours (added green, modified amber, deleted red), on both the session's rows and a task's; a task row hides a zero count. The file-level letter and the `+N -N` line counts beside it are two different things, and #656's `+` for a created file read as more of the counts. Transcript changed-files card: "Deleted". Task pane: a removed file's chip shows only what it took out and opens a diff that ends on the node's last write of the file taken back out. `taskChangeKind` now reads the op off the wire; the deletion-count heuristic from #656 is gone. - Offline fixtures: the GTM run removes a superseded note with `rm`, exercising the live frame and the stored shape; the tasks fixture carries an `add` and a `delete` file. Contract tier (third commit): `FileRemoval` and the two `removed` fields are a declared-surface change on `raven/contracts/tool.py`, so the ledger admits the export, `CONTRACTS_VERSION` moves to 31 with the surface digest repinned, and the papers' line ceiling moves 3,570 -> 3,620 with the reason recorded beside the earlier moves in `tests/test_kernel_budget.py`. ## Type - [ ] Fix - [x] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Run from the worktree on top of `da13bddd` (main with #656): - `.venv/bin/ruff check raven tests`; `.venv/bin/ruff format --check raven tests` -- clean - `.venv/bin/python -m pytest tests/test_shell_file_removals.py tests/test_removal_watch.py tests/test_subagent_activity.py tests/test_subagent_manager.py tests/test_rpc_spine.py tests/test_rpc_tasks.py tests/test_rpc_schema_match.py tests/test_acp_updates.py tests/test_filesystem_tools.py tests/test_agent_loop_session_stamps.py tests/test_rpc_session.py tests/test_shell_workspace_fence.py tests/test_shell_approval.py tests/test_turn_wire_shape_conformance.py tests/test_spine_events.py -q` -- 1532 passed; `tests/test_contracts_two_tier_ledger.py tests/test_kernel_budget.py` -- 14 passed with the contract-tier commit - `make lint-python`; `make test-python` -- 25023 passed, 109 skipped (the full Python suite, which is what caught the three contract-tier guards the focused set missed) - `npm test --prefix ui-web` -- 192 files, 2631 tests passed (whole suite, gates included; the fixture-shape gate is satisfied by the fixture sending `file_removed`, no new UNSENT pin) - `npm run lint --prefix ui-web` (0 errors, 4 pre-existing warnings); `npm run type-check --prefix ui-web`; `npm run gen:check --prefix ui-web` - `npm run lint --prefix ui-tui`; `npm run type-check --prefix ui-tui`; `npm test --prefix ui-tui` -- 143 files, 2075 tests; `npm run lint:rpc --prefix ui-tui`; `npm run lint:i18n --prefix ui-tui` - Review round: `rm gone.txt;` (an operator adjacent to the path) now records the removal -- the watch splits the command on shell operators the way the fence does before lexing (7 parametrized cases); a deletion's body follows the file through its edits the way `edit_file` applies them -- every occurrence with `replace_all`, the one occurrence otherwise, given up when the reader cannot know what the tool did -- on the task side and the conversation side (one shared helper in `lib/hunks.ts`; 7 new cases). Real gateway rerun with `rm work/main_old.md;` draws the same red `D`. - Mutation checks, each restored afterwards: ExecTool never reporting removals, RemovalWatch returning only what the tool reported, add+delete keeping the entry, raven_loop recording write instead of add, the outlet dropping file_removed, record.ts never producing a delete row, the D glyph reverting to M, taskChangeKind ignoring delete, the replay ignoring stored file_removed, fromDelete numbering the wrong side -- every one turned at least one new test red. - Real gateway (`raven serve` from this branch, isolated `RAVEN_HOME`, default model `deepseek/deepseek-v4-flash`), two fresh conversations. The main agent spawns the in-process `Raven` sub-agent, which writes two files, edits one, runs `rm work/sub_old.md` on a pre-seeded file, then writes and removes a scratch file: the desk diff tab shows `A` for the two files and a red `D work/sub_old.md -3`, nothing for the scratch file, and `tasks.list` carries `op` add / add / delete. The main agent itself writes a new file, runs `rm work/main_old.md`, then writes and removes a scratch file: the diff tab shows a red `D work/main_old.md -2`, `A work/e2e_main_new.md` and, for an `edit_file` of a pre-seeded file, `M work/main_keep.md`, nothing for the scratch file. Before this change the same runs showed no trace of either `rm`. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [x] User-facing docs or screenshots are updated when needed ## Risk Behaviour changes: every synchronous local `exec` now stats each command token and reads up to 64 named files (256 KB each) before running, and every tool call re-stats the paths the turn has written so far; a `tool.complete` for a call that removed files carries `file_removed`, and stored transcripts of such calls carry `file_removed: [{path, del}]`. The wire additions are optional fields and a widened enum, so older clients keep parsing. Known limits, unchanged here: the ACP translator (`raven/acp/updates.py`) does not surface `file_removed`, so an ACP client sees only the result preview for a call that deleted files; the shell watch only sees paths the command names literally (a glob, a directory, a computed path, or `mv a b` are not read as deletions); two absolute spellings of one file (a symlinked root) are not matched to the same row. Rollback: revert the three commits; nothing stored is required by older code. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A --------- Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…ed and removed (#670) ## Summary Only the in-process sub-agent lane recorded a node's files, and only from its own file tools. A Raven-PPT or Raven-Code run (ACP) wrote a deck, a note and a build script and its node's file list stayed empty, so the desk's diff tab said "No changes this turn" for the whole task; and in every lane a file a shell command produced -- a build script's PPTX, a rendered PDF -- was invisible, because a command's result names no files. This is the gap left open by #656 and #657, which draw whatever the lanes record. No wire change: everything lands in the run's file entries and reaches the page through `tasks.list.files` as it stands, so the desk's `A / M / D` rows, the task pane's chips and the transcript card light up with no frontend edit. - `raven/agent/tools/snapshot.py` (new): a bounded listing of a working directory, path -> (size, mtime_ns), skipping machinery directories (`.git`, `node_modules`, `.venv`, ...) and giving up past 20000 entries rather than describing part of a tree; `diff()` names what was created, modified and deleted between two listings. - `activity.record_snapshot_changes` turns such a diff into file entries (created -> `add` with its line count, modified -> `write`, deleted -> `delete`), skipping paths the call already accounted for and, for a lane whose calls overlap, paths an earlier listing already reported created. `count_line_changes` and `workspace_relative` move here from `raven_loop` so every lane shares them. - ACP lane: the collector records the diff blocks a tool call carries, read the way the installed adapters send them. Per the spec a block without `oldText` is a new file, and codex-acp and raven's own agent send it that way; claude-agent-acp announces every Write without one, so its dialect consults the call's opening listing instead (a path already listed is a `write`, not an `add`). One path's blocks in one frame are summed (claude-agent-acp emits one per hunk); codex-acp's deletion (old content, empty `newText`) is a `delete`. A listing is taken at the earliest frame that names a call -- the permission request when the agent asks first, else the announce -- and compared when the call settles, for the files the agent's own commands produced; a call that reports failure keeps that listing (a command that wrote a file and then exited non-zero left the file) and drops only its blocks; a per-turn `RemovalWatch` reports files it recorded that later vanished. Listings run off the connection's read loop, are skipped for `read` / `search` / `think` / `fetch` calls and for a call first seen already over, and the oldest open listing is evicted at the ceiling. - In-process lane: a listing on either side of an `exec` call (off the loop), beside the file-tool results it already recorded. - CLI lane: one listing before the child starts and one after it exits. Not in this PR: the main conversation's own `exec`-produced files (the desk builds the main lane's rows from tool arguments on the client, so those need a wire field); the ACP translator still surfaces no file changes to an ACP client. ## Type - [ ] Fix - [x] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Run from the worktree on top of `5186f672` (main with #657): - `.venv/bin/ruff check raven tests`; `.venv/bin/ruff format --check raven tests` -- clean - `.venv/bin/python -m pytest tests/test_workdir_snapshot.py tests/test_subagent_activity.py tests/test_subagent_acp.py tests/test_subagent_manager.py tests/test_subagent_third_party.py tests/test_removal_watch.py tests/test_shell_file_removals.py tests/test_rpc_tasks.py tests/test_acp_dialects.py tests/test_acp_backend_cwd.py tests/test_contracts_two_tier_ledger.py tests/test_kernel_budget.py tests/test_acp_ask_user.py tests/test_acp_elicitation.py tests/test_acp_journal.py -q` -- 943 passed - `make lint-python`; `make test-python` -- 25108 passed, 109 skipped. One timing test (`tests/test_shell_machine_channel.py::test_the_cap_fires_on_the_machine_and_takes_the_whole_group`) failed once under load while an E2E gateway ran beside the suite; it passes 3/3 alone and the whole file passes, and this branch does not touch the machine channel. - Mutation checks, each restored afterwards: the skip list dropped, the cap returning a partial listing, `count_line_changes` returning 0/0 for a creation, the exec listing skipped in raven_loop, the collector recording a failed call, `already` ignored, the CLI after-listing dropped, the rewrite-vs-creation branch disabled, the delete-block branch disabled, the overlapping-call guard dropped, a block group truncated to its last hunk, eviction reverted to refusal -- every one turned at least one new test red. - The rule about a block without `oldText` is pinned per dialect: the spec dialect records `add` whatever the listing held, the claude-agent-acp dialect consults the listing, and a permission frame that precedes the announce opens the listing early (three cases, each red under the inverse mutation). - Review round: a failed ACP call keeps what the listing found on disk (the `partial.txt` reproduction now records `add`; a block the failed call never made records nothing); the same-size rewrite test stamps its own mtime after the write instead of relying on the clock advancing. - Adapter claims checked against the installed copies: claude-agent-acp 0.79.0 (`dist/tools.js` Write announce, one block per hunk, PostToolUse hook frame) and codex-acp 1.1.14 (`createDeleteFileContent`). - Real gateway (`raven serve` from this branch, isolated `RAVEN_HOME`, default model `deepseek/deepseek-v4-flash`): the main agent spawns the ACP `Raven-Code` sub-agent, which creates `work/acp_written.md` with its own file tool, runs `printf ... > work/acp_generated.txt`, and runs `rm work/acp_old.md` on a pre-seeded file. Before this change `tasks.list` reported `files: []` for the node (three tool calls, three changes on disk) and the desk diff tab said "No changes this turn". After: `tasks.list.files` is `add work/acp_written.md (+3)`, `add work/acp_generated.txt (+2)`, `delete work/acp_old.md`, and the desk diff tab draws `A`, `A` and a red `D` under "Task changes" (this agent asks permission before announcing the call, which is what the permission-frame listing is for). - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed ## Risk Behaviour changes: every sub-agent run now walks its working directory around commands (in-process: around `exec`; ACP: around every call that could write; CLI: once around the run), bounded at 20000 entries with the machinery directories skipped, and off the event loop; a tree past the bound records nothing from listings (the file-tool and diff-block records stand). A created file's line count reads the file (up to 256 KB) inline. A whole-file claude-agent-acp Write over an existing file reads as `M` (write), not `A`, which is the truthful reading and what keeps a later deletion of that file visible; codex and raven blocks are read as the spec says. Snapshot-discovered rewrites and deletions carry no line counts (the desk hides zero counts and shows a bare `M` / `D`). No stored or wire shape changes; rollback is reverting the four commits. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A --------- Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…iff (#692) ## Summary The desk builds the main conversation's changed-files rows on the client, from the file tools' own arguments and results. A file a shell command created (`python3 build.py > out.txt`), rewrote (`echo more >> notes.md`) or removed through a glob left no row, because a command returns its output and names no file. #670 closed that gap for every sub-agent lane by listing the working directory on either side of a command; this does the same for the main conversation, which needs a wire field because its rows are drawn on the page rather than read from a run record. - `raven/agent/loop/turn_path.py`: around a call named `exec` and only that, the directory the command runs in is listed before and after the call with `snapshot.take`, off the event loop. The tool says where that is (`ExecTool.listing_root`, resolved the way `execute` resolves its cwd): the turn's working directory unless the call names a `working_dir`, and no listing at all for a command sent to a registered `machine`, whose files are not on this disk. The in-process sub-agent lane asks the same question through `snapshot.root_for`, so a sub-agent's `working_dir` command is recorded too. Created and modified paths the call did not name itself (its `file_change`, its removals) become the new `tool.complete.file_written` list of `{path, created, size, lines}`; a deletion the listing finds joins `file_removed` with no body, since the file was gone before anything read it. Stored on the tool entry under the same name and passed through `session.resume`, so a reloaded page draws what the live one drew. - Wire: `FileWritten` in `raven/rpc/models.py`, on `ToolCompletePayload` and `TranscriptMessage`; `rpc-schema/openrpc.json` and both generated clients regenerated. `lines` is an integer for a created text file of 256 KB or less and `null` otherwise, including every rewrite, whose old contents the listing never held. - Page: a created file is an `A` row with its line count, a rewritten one a bare `M`, neither with a patch; a row a file tool already made this turn is left alone (its arguments say more than a listing can); the reload path replays the stored field. A change row with no patch to show opens the file itself instead of an empty patch pane (a removal keeps its pane; a task file's row, keyed by node rather than path, is untouched). The offline fixture's GTM run gains one such command so the demo desk shows both rows and the fixture-shape gate sees the field sent. Not in this PR: the ACP translator still surfaces no file changes to an ACP client, the same as for `file_removed`. ## Type - [ ] Fix - [x] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Run from the worktree on top of `0133c67c` (main with #656, #657 and #670; the branch was rebased onto it when main's own contract additions collided with the generated client, which was regenerated from the merged schema): - `.venv/bin/ruff check raven tests`; `.venv/bin/ruff format --check raven tests` -- clean - `.venv/bin/python -m pytest tests/test_agent_loop_session_stamps.py tests/test_rpc_spine.py tests/test_rpc_session.py tests/test_rpc_schema_match.py tests/test_turn_wire_shape_conformance.py tests/test_spine_events.py tests/test_contracts_two_tier_ledger.py tests/test_kernel_budget.py tests/test_workdir_snapshot.py tests/test_subagent_activity.py tests/test_shell_file_removals.py tests/test_acp_updates.py -q` -- 952 passed - `make test-python` (`uv run --frozen --all-extras pytest -q`) -- 25798 passed, 109 skipped - `npm test --prefix ui-web` (the whole suite, including the fixture-shape gate) -- 200 files, 2767 tests passed; `npm run lint --prefix ui-web` -- 0 errors (4 pre-existing hook-dependency warnings in files this change does not touch); `npm run type-check --prefix ui-web`, `npm run gen:check --prefix ui-web`, `npm run lint:rpc --prefix ui-tui` -- clean - Mutation checks, each restored afterwards: the listing taken around every tool; a created file reported as a rewrite; the held-out result path dropped; the storage rename disabled; `file_written` emitted when empty; the key dropped from `session.resume`; `file_written=None` on the served `ToolEvent`; a count of 0 on a decode failure; the 256 KB cap dropped; the created row drawn as `M`; the existing-row guard dropped; the ninth argument dropped in the stage; the file door removed, ignoring the kind, ignoring a task key; the fixture no longer sending the field (stored, then live); the row takeover removed and its removal guard dropped; `.raven` back in the walk; the count back on the loop; the shell ignoring its bound directory -- every one turned at least one new test red. - Review round two (reviewer's blocker): a command with a `working_dir` of its own is listed there, in both lanes, and a `machine` command takes no listing -- `tests/test_shell_approval.py` (the root follows the arguments), `tests/test_workdir_snapshot.py` (`root_for`), `tests/test_agent_loop_session_stamps.py` (the real `ExecTool` with `working_dir`; a remote command takes no listing) and `tests/test_subagent_manager.py` (a sub-agent command outside its workspace); each red with the root pinned back to the lane's directory or the machine guard dropped. - Review round: one row per file when a file tool follows a command that already left the file behind (the listing keys by the resolved path, the tool by the path the model typed); the listing skips `.raven`, where the checkpoint keeps a shadow git repo inside the working directory; the line counting moved into the worker beside the walks; a test through the real `ExecTool` (its cwd is the directory the listing walks), the served `ToolEvent` hop, a non-text and an over-cap created file, and the live fixture frame. - Real gateway (`raven serve` from this branch, isolated `RAVEN_HOME`, default model `deepseek/deepseek-v4-flash`): the main agent runs `printf "gen one\ngen two\n" > work/main_generated.txt` and `echo more >> work/main_shell_mod.md` beside its file-tool steps. Before this change neither command left a row. After: the desk diff tab draws `A work/main_generated.txt +2` and `M work/main_shell_mod.md` beside `M work/main_keep.md +1-1` (edit_file), a red `D work/main_old.md -2` (`rm`) and `A work/e2e_main_new.md +2` (write_file); a file one command created and the next removed draws nothing; reopening the page (`session.resume`) draws the identical five rows, and the stored tool entries carry `file_written` with `created: true, lines: 2` and `created: false, lines: null`. The same run repeated on the final tree (after the review round) and its reload draw the same rows. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed ## Risk Behaviour change: every main-conversation `exec` now walks the turn's working directory twice, bounded at 20000 entries with the machinery directories (`.git`, `node_modules`, `.venv`, caches) skipped and run off the event loop; a tree past the bound records nothing from listings, and the file tools' own rows stand as before. A created file's line count reads the file (up to 256 KB). Wire: one new optional key on `tool.complete` and on `session.resume` tool entries, absent on every call that is not a command; a client that does not read it sees what it saw before. Rollback is reverting the commits. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A --------- Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
Summary
A spawned sub-agent that wrote ten markdown files showed up on the desk as ten "Task output" rows on the deliverables tab and a diff tab saying "No changes this turn". Two things were wrong at once: the deliverables tab listed files nobody delivered, and the diff tab hid every whole-file write a task made (it only took
op === 'edit'task files).openDeskDiffmarks read.deliver_fileshanded over. A sub-agent has nodeliver_files; the main agent decides what is a product, which is what the system prompt already tells it.+(kindadd) instead ofM: live fromtool.complete.file_changewithoutbefore, on reload from the stored diff's@@ -0,0header. A whole-file write over an existing file staysM, and the transcript's "New" badge follows the same rule instead of calling every whole-file write new. A created file rewritten in the same turn stays a creation.edit_fileis always a modification (the prototype rule from feat(*): draw the desk tasks tab from a tasks.list read #509 drew+for an edit that deleted nothing); a whole-file write that replaced nothing is a creation.prettier --writeon two ui-tui files that arrived on main unformatted. The pre-commit prettier hook checks the whole ui-tui tree whenever a file under it changes (this PR regenerates the i18n table), so it fails until those two are formatted.Not in this PR: a file deleted by
exec rmstill leaves no row anywhere. A follow-up branch (feat/track_file_deletions) adds deletion tracking on the backend and the redDrow; it is stacked on this one.Type
Verification
Run from the worktree after rebasing onto
a48c4edf:npm test --prefix ui-web-- 192 files, 2599 tests passed (the whole suite, gates included)npm run lint --prefix ui-web-- 0 errors (4 pre-existing warnings);npm run type-check --prefix ui-web;npm run gen:check --prefix ui-web-- generated.ts matches the contractnpm run lint --prefix ui-tui-- 0 errors (23 pre-existing warnings);npm run type-check --prefix ui-tui;npm run lint:i18n --prefix ui-tui-- generated catalogue is up to dateuv run pre-commit run --from-ref origin/main --to-ref HEAD-- passes with the second commit (fails onprettier (ui-tui)without it)make lint-python;make test-python-- no Python file changes; both passReal gateway:
raven servefrom this branch with an isolatedRAVEN_HOME(channels off, default modeldeepseek/deepseek-v4-flash), one conversation that spawns the in-processRavensub-agent towrite_filetwo files andedit_fileone, one conversation whose main agentwrite_files a new file. Before: diff tab "No changes this turn", deliverables tab listing both task files under "Task output", the main agent's new file drawnM. After: diff tab lists both task files with+under "Task changes", deliverables tab "Nothing produced yet", the main agent's new file drawn+. Same two conversations reopened after a reload read the same.Mutation check on the new tests: dropping task ids from
idsOf('diff'), revertingtaskChangeKindto the deletion-count rule, dropping theaddmark inrecord.ts, and widening the transcript's "new" back to every write each turn at least one new test red.Relevant tests pass locally
Relevant lint / type checks pass locally
User-facing docs or screenshots are updated when needed
Risk
UI only; the wire is untouched. Files a task wrote leave the deliverables tab (they were never delivered) and appear on the diff tab, so a reader who relied on the shelf to open a sub-agent's output now finds it under diff, or in the task pane's file chips as before. Two i18n keys are renamed (
gui.ws.task_output->gui.ws.task_changes,gui.tasks.artifacts_n/_1->gui.tasks.files_n/_1); no other consumer of them exists. Rollback: revert the two commits.Related Issues
N/A