Repository navigation
feat: a worktree per subagent, a combined change review and rollback points - #133
BIackFIame wants to merge 28 commits into
Conversation
|
@codex review Please review this PR for correctness, regressions and security issues. Review only; post findings on GitHub without changing code. This is part 3/7 of a cumulative series. Focus on changes introduced after predecessor |
…h files Closing a card started the run cleanup and the removal of runs/<id> at the same time, and a launch still being prepared (plugin prepare, environment wrap) could write run files after the folder was gone. A throw from the environment wrap also skipped the planned and contributed cleanup. Contributed launches are now tracked. Closing waits for pending launches, then the serialized exit/restart cleanups, then removes the run folders each pipeline created for this card object, and the card folder only when it is empty, so a card brought back under the same id keeps its new run. An unadopted launch releases its planned spawn and contribution in a finally block, and a launch whose card was closed or restarted in the same tick never asks its plugins. Run folders are removed with retries for files Windows still holds open.
If closing the Even G2 companion threw, quitting skipped terminalManager.shutdown(), so PTYs were not hung up and sessions were not saved. Shutdown now runs its steps in order through runShutdownSteps, which logs a failed step and continues.
An isolated agent whose project is a linked worktree inside plugin data (a plugin environment's worktree) now runs only when the host validates that worktree against the task's own repository: its .git pointer, its administration folder and common directory, and a branch only it has checked out. Otherwise the launch is refused. For a validated worktree the whole worker tree is the project. Its .git pointer, HEAD, administration folder and the shared repository stay read-only on macOS and Linux, their hooks are denied, and the other worktrees' administration folders are unreadable. A Plan launch refuses CLI state inside or above the read-only worktree. The agent is told in its environment that git add and git commit are unavailable there, and optional index locks are switched off. (bubblewrap cannot allow atomic ref updates to one branch without making its whole folder writable, so linked-worktree Git metadata stays read-only there.)
This integrates the six contributions accepted during the maintainer's DEV review, together with the maintainer's local UI and minimap fixes. Rejected orchestration features, including #133, are excluded entirely. ## Included contributions and credit - #125 by @tab11pm: read-only file cards, directory browsing, and richer Markdown/code previews. - #130 by @SaneSanders: image/material remarks and handoff to an existing agent session. - #131 by @BIackFIame: macOS clipboard/edit context menus for text fields. - #138 by @pepechka-kun: usage history, measured quota changes, local-log evidence, and explicit attribution uncertainty. - #139 by @CLOSETTTY: interactive mascots and the Codex mascot creation workflow. - #140 by @teo-nex: the macOS node-pty file-descriptor leak fix, patch delivery, and resource diagnostics. Thank you to all six contributors. Each original PR head and its author commits are retained in Git history through separate merge commits. This integration must use a merge commit rather than squash or rebase. ## Maintainer patches - Fit the minimap overview to the camera viewport and all canvas objects together, with padding; retain stable drag coordinates during a gesture. - Keep the material handoff icon fixed in the header, remove the count beneath it, and place equally sized Cancel/Send actions at the bottom right of the handoff dialog. - Split usage history into five left-side sections: conditional estimate, quotas, agents, local-log tokens, and source state. Use pages within sections to fit the window without a long scrolling report. - Resolve shared App/IPC/preload integrations while retaining the file, handoff, and usage-history flows. - Retain the approved DEV dependency set and Electron 43.7.7; reuse the matching lockfile with patch-package 8.0.1. ## Scope #132, #133, #134, #135, and #137 are not included. #136 is also excluded pending the previously requested UI/UX redesign. No commits from that cumulative orchestration series are ancestors of this branch. Material handoff in #130 does not automatically apply a saved JSON agent report to the material state; this remains the reviewed behavior. ## Validation - `VITE_BUILD_CHANNEL=DEV npm run build` passed, including the script's TypeScript checks and native-helper build. - `git diff --check` passed. - Verified that all six accepted PR heads are ancestors of the integration and all six excluded PR heads are not. - Verified that the three approved local UI/minimap implementations match the selected DEV copy. - Added minimap geometry coverage for distant camera positions and adapted usage-history fixtures for section navigation. - The maintainer manually reviewed the accepted features in the earlier combined DEV build. The final integration without #133 has not received another manual UI pass. Automated tests and macOS/Windows runtime checks were not run locally for this integration.
…e off
A Codex or OpenCode subagent's final answer could belong to an earlier
turn: OpenCode reused its session id as the turn id and reported idle
before its asynchronous answer read finished, and recordAnswer accepted
any answer. OpenCode now names each turn, reports no idle before the
answer is read and drops a read a newer turn superseded; retry counts as
a turn start. A submitted input (not an approval answer, not an empty
Enter in an idle card) starts a new task, and only an answer of the turn
that answers it is kept. A signal of that turn which predates the input
still moves the status, so wait_for_agent ends instead of hanging, but
brings no answer. An answer reported as the agent exits still counts.
main's signal glue is now applyRuntimeSignal, shared with the tests.
With CanvasTTY status hooks off a launch gets no hooks, so no answer can
be captured. Whether a launch has them is decided when it is planned
(deferred plugin launches included). wait_for_agent and get_agent_result
then return answerUnavailable { reason: "status_hooks_off", message } so
the orchestrator reads the output instead.
Every write of person or agent input now goes through one method,
writeInput, which does this bookkeeping; input() and deliverInput call it,
and a later writer that holds input and replays it must call it too, so a
replayed Enter counts as a submitted prompt. It writes nothing into a
process the card no longer runs.
…UseFailure Once a turn id was active, the gateway dropped every non-start event with a new turn id. Each hook runs on its own connection and can time out, so when a turn's start hook was lost the whole turn, including its Stop, was ignored and the card kept the previous turn's status. The gateway now tracks whether a turn is open. After an idle, an unseen turn id opens the next turn; during an open turn, or for an id already seen, the event is still dropped as out of order. When a tool the person approved fails, the agent CLI runs PostToolUseFailure instead of PostToolUse. CanvasTTY did not register it, so the card stayed needs_approval until the next hook although the turn went on. It now maps to working.
node-pty reports a process killed by a signal as exitCode 0 plus the signal. The signal was dropped, so an agent killed with SIGKILL (OOM, kill -9) was recorded as "done, exit 0": wait_for_agent returned done, get_agent_result said done, and the saved session restored as exited. Record such an exit the way a shell does, as 128+signal (SIGKILL -> 137), mark the session failed, and put the signal name first in the failure details. Every reader of the exit (wait_for_agent, get_agent_result, the session store, the HOME failure summary) already derives its state from the exit code, so they now agree. Exits CanvasTTY causes itself (closing a card, quitting) are unaffected: those sessions are removed before the kill, and the exit handler ignores them.
A subagent spawned without launch options ran on its CLI's own default model and sign-in (for OpenCode the hosted OpenCode Zen model), even when its orchestrator ran on a model the person picked through a plugin's launch options. A plugin the spawn does not name now keeps the orchestrator's own values for a subagent of the same CLI; repeating the person's choice needs no delegable declaration, and then the normalized values must equal the orchestrator's exactly (missing keys come from the orchestrator, never from the plugin's defaults). A subagent of another CLI must name a plugin whose contribution set the orchestrator's model, and spawn_agent reports servedBy (CLI, requested model, inherited or explicit options). - Recognise every form a CLI takes for its model: --model and --model=, -m and -mX for OpenCode, Codex, Qwen, Kimi and Grok, and for Codex also --profile/-p and -c/--config setting model, model_provider or profile. - Refuse a second model (an explicit model next to a plugin that sets it, or two plugins that both set it; OpenCode crashes at start on two --model values) only for a subagent's launch. A person's own launches and restores start as before. - Inherit an empty choice (a plugin without fields) as chosen. Leave out an inherited plugin that was turned off since, unless it set the model (then refuse); refuse with a clear reason when its fields changed. - Reset modelSetBy at the start of each launch. - Describe inheritance, servedBy and the refusals in the orchestrator skill and docs. The core stays plugin-agnostic: it reads the plugin from the launch options and the contribution, never from a fixed plugin id.
OpenCode drops every key that arrives while its full-screen view starts (a few seconds). CanvasTTY typed spawn_agent's first prompt, send_to_agent and plugin sessions.send as soon as the PTY existed, so an OpenCode subagent sat at an empty prompt until the orchestrator gave up. deliverInput now waits, within the same bounded wait it already had for the launch, until OpenCode can take input: the terminal control sequence that shows its text cursor on the alternate screen once its prompt has focus, or a lifecycle hook from its plugin, counted only 2 s after the launch's first one (the plugin can report before the prompt takes keys). Only DEC private mode sequences are read; human-readable screen text is never parsed and no status changes. Other CLIs keep getting input when the PTY exists. - The wait is used on macOS and Linux. Windows' pseudo console may not pass those sequences, so there input is typed when the PTY exists, as before. - spawn_agent (checks, launch and readiness together) and send_to_agent wait at most 45 s, so the orchestrator gets the reason and the session id, not an MCP client timeout. Nothing is typed then. - A handoff waits up to 15 s for the CLI to take input instead of failing at once; a caller that does not wait is told the state. - Sends to one card are delivered in the order they were made, a queued send never reaches a later launch, and a send cancelled while it waits behind another settles at once and types nothing.
wait_for_agent accepted up to 600 seconds, but the MCP clients of several supported CLIs (OpenCode among them) end a tool call after about 60 seconds, so an orchestrator that asked for a long wait got a failed call instead of a result. A call now asks for at most 100 seconds (default 50). Unless the orchestrator's CLI is known to let a tool call run longer, a call returns after at most 50 seconds. A timeout carries the status and the masked output as progress plus a message to call again, and the tool description in both catalogs, the orchestrator skill and the docs say that a timeout means the subagent is still running.
Base protection is documented to refuse use of CanvasTTY's private data, but its hooks only saw shells and file writes: Claude Code's Read, Grep, Glob or LS, and the Qwen Code and OpenCode equivalents, could read provider-secrets.bin, the control token or another account's home without a check. While base protection is on at launch, the decision hook now also matches those read-only tools (Claude Code and Qwen Code matchers, OpenCode's guard through CANVASTTY_RUNTIME_DECISION_READS). With base protection off, a decision plugin's fail-closed hook still covers only shells and writes, so reads never wait on the gateway. They map to a read action that is checked only against the private data, with the same rules as a shell read: the agent's own account home stays its own, a search walks its folder, and nothing outside the private data is refused or asked about. Decision services are not asked about reads. OpenCode's grep without a path is sent as a search of ".", and glob's pattern is always sent. Qwen Code's read_many_files is refused when its paths and include globs together exceed 32, instead of checking only the first 32, and include globs are checked. A cut read-only call is refused like a cut shell call. Codex has no read-only file tool in its hooks; it reads through its shell, which is already checked. The docs now say base protection is best effort: it does not see paths built at run time or folder walks started above the app's data, so a project at the home folder needs agent isolation to keep the data private.
The private-data rule matched globs one component at a time, so `**`
stood for a single folder and `cat ~/Library/**/provider-secrets.bin`
was allowed; brace expansion was not seen at all, so
`cat <userData>/provider-secrets.{bin,}` was allowed too.
- `**` now matches zero or more folders. A glob that only passes by the
app's folder on its way to other names (`~/**/*.ts`) is not counted.
- Unquoted braces in a shell word, and braces in a Glob tool pattern,
are expanded (at most 64 words) before matching.
- On macOS and Windows a glob matches private names in any letter case.
Base protection also had limits that let the rest of a call through
unchecked. A call now is refused ("uncheckable") instead when:
- a path field is not a string, or is longer than 4096 characters after
folding `./` and `a/..` padding;
- read_many_files names more than 32 paths and include globs, or an
apply_patch touches more than 256 files or a too-long path;
- the private-path budget (now 1024 checks), the brace-expansion limit,
the path candidates in one word or heredoc (256), or the nesting of
shells and substitutions runs out;
- checking a shell, file-edit or read call fails with an error. Before,
an exception in the analyzer let the call through; other tools still
get no opinion.
An apply_patch whose command is longer than 4096 characters is read in
full; before, it was treated as touching no file.
bubblewrap bound the shared gateway socket folders writable into every isolated agent, so an agent could replace a gateway socket and receive other agents' requests. It also took a socket folder from dirname() of every CANVASTTY_*_ADDRESS in the launch environment, which holds the person's variables and plugin contributions: $HOME/foo.sock bound all of HOME after the tmpfs masks, and an address inside plugin data reopened every account home. The control grant folder was read from the same environment. Socket folders are now bound read-only (connect() needs no write access to the folder) and come only from the sockets CanvasTTY's own gateways prepared for the launch; the control grant only from the control environment the host gave an orchestrator. A socket that must not be read is masked with /dev/null, and a gateway socket folder that holds a hidden path is refused. On macOS the seatbelt Unix-socket allow list is built from the same gateway sockets.
… them With a project that contains userData (the home folder), an orchestrator could start a subagent in <userData>/plugin-data/<plugin>/homes. The isolation layer reopened any project at least two folders deep in plugin data, so that subagent could read and write every account's sign-in. A subagent's folder may no longer be inside CanvasTTY's private data, unless the project itself is a folder inside that same plugin's data (an environment's worktree). The layer reopens a project inside plugin data only when the host placed the launch there: a folder an environment prepared inside its own plugin's data, or the folder the person chose for the card the launch descends from. Anything else is refused with the reason. Also: a gateway socket folder that contains a writable folder is refused instead of mounted over it; a trailing separator in a CLI home variable names the same account home; the docs list which variables count as an account home, in English, Russian and Chinese.
…ct that contains it When an isolated agent's project contains CanvasTTY's userData folder (a project at the home folder), the whole project was writable, so the app's settings.json and the plugins folder were writable to the agent; only the private parts were hidden. The isolation paths now carry readOnlyFolders (userData when a writable folder contains it) and fixedFolders (its parents inside that folder). Seatbelt denies writes in userData after the project allow, re-allows the writable folders inside it (a selected account home) and denies renaming or replacing the parents. bubblewrap binds each parent onto itself (a mount point cannot be renamed), mounts userData read-only over the project and binds the writable folders inside it again. A project located inside userData is unaffected.
…worktrees Inside the isolation layer only a repository's hooks, info/attributes and an existing .git/config were protected. .git/commondir (where git reads the shared config and hooks from), .git/config.worktree and the linked worktrees' records in .git/worktrees stayed writable, and the path-based rules could be sidestepped by renaming .git, .git/hooks or .git/info away and back. Seatbelt now denies writes to commondir, config.worktree and worktrees/ in any repository of the project, existing or not, and denies renaming or removing .git, .git/hooks and .git/info. For an existing repository the paths list config.worktree and worktrees/ (Linux mounts them read-only, with neutral placeholders when missing) and an existing commondir; on Linux the project's .git is bound onto itself so it cannot be renamed. Objects, refs, the index, logs and HEAD stay writable.
Base protection treated anything inside the working folder (and the agent's config folder) as the agent's own before it checked CanvasTTY's private data. With the project at the home folder, or at any folder that holds the app's userData, an agent's shell command or file tool could read or overwrite provider-secrets.bin, the control token, other account homes and the gateway records. A project in a temporary folder likewise reopened the gateways' socket folders. A folder is now the agent's own only below the private data: a private path or socket folder inside the project or config folder stays private. A folder inside a private path (the account home the agent was launched with) is still its own, and its neighbours are not. A recursive walk of a folder in the app's data that holds private paths is refused wherever the project is. The per-card Claude Code draft folders (<userData>/claude-draft) are listed as private data too; nothing creates them yet.
When the agent the person started works at the top of a Git project and a trusted environments plugin offers the worktree kind for the subagent's CLI, each subagent (spawn_agent or the control connection's create) runs in its own worktree from that plugin. The plugin's prepare now receives the task's project as projectRoot. spawn_agent gains isolate: "worktree" to ask for one explicitly; it is refused when no plugin provides it. The parent and the person's depth, child and live limits are checked again after the plugin answered, so subagents started together cannot pass the live limit while their environments resolve. Under isolation a project inside plugin data is first refused when CanvasTTY did not place the launch there, then when it is not a linked worktree validated against the task's repository. An agent card started in a Git-worktree environment keeps the folder the person chose (saved with the card as projectRoot, only with an environment); its launches and its subagents' are validated against that repository, not against the worktree itself. A folder inside plugin data that the person chose for a card is opened as placed and need not be a linked worktree.
… immutable A database without -wal/-shm was always opened with immutable=1, which skips locking and hot-journal checks, so a rollback-journal database could be read while Hermes was writing it. Use the immutable read only when the header says WAL (bytes 18 and 19 are 2) and no side files exist; open other databases with mode=ro and retry SQLITE_BUSY a bounded number of times before skipping the poll.
…edential rules Held values were masked leftmost-first and the scan jumped past each match, so a second held value overlapping the first showed its suffix. Masking held values before the generic rules could also erase a label such as password or Authorization that a rule needed, leaving the credential visible, and each call allocated text-sized typed arrays. Find held values, JSON values and credential shapes as source ranges on the original text and mask the union of overlapping ranges; search held values with indexOf/KMP streams and a ring buffer instead of text-sized arrays; count entropy characters without regex match arrays. The empty-registry path is unchanged.
…ere read The Files reader and the usage history reader bind an opened descriptor to its path by dev/ino. As numbers, 64-bit file ids (NTFS, some network file systems) lose precision, so two different files could compare equal. Compare bigint stats instead. The history reader also compares the pre-open lstat with the descriptor, so a file swapped between the symlink check and the open is refused rather than read and then quarantined. When invalid records are dropped, the original is preserved from the bytes read through the checked handle (exclusive create, mode 0600) instead of a second copy by path.
A card action that answers with a review now opens it in a native modal dialog that App renders inside the themed tree, so it reads the theme tokens and root --ui-scale. Subtasks are a segmented row; the file list (virtualized, one all-files checkbox) and the diff sit side by side (stacked below 760px), added and removed lines tinted with the success and danger tokens. A conflicting file offers Current version / Agent version above both texts. - One primary action (Accept selected) and one secondary (Reject subtask); the task-wide and whole-subtask variants are in an overflow menu (first enabled row focused, Arrow Up/Down between enabled rows). - Accept and reject run only after an inline confirmation that holds the group, files and versions of the moment it opened; confirm re-checks them and asks again when they changed. - A group may carry active: true (its agent is still working): its files are not preselected, what the plugin refuses until the agent finishes is disabled with the reason, and the plugin's note is an informational notice. Disabled buttons stay readable on every theme. - Failed plugin calls show a localized line, details go to the log; Git statuses get localized labels. The orchestrator's own turns no longer refresh the review and drop the person's choices. - Card actions can be declared "menu": false (not listed in the card menu, refused without input), and may add "title.ru"/"title.en" (at most 40 characters) shown in the app's language. Schema, plugin-api.d.ts and docs/plugins (en/ru/zh-CN) document both. - Bidirectional formatting controls are removed from review text and refused in file names. While the dialog is open the native Browser is hidden and canvas shortcuts do not fire; focus returns to the card's options button. The dialog frame is a shared card-dialog base. The host and its Russian and English texts are a separate chunk, so the startup bundle carries none of them; App preloads that chunk once it is idle and renders the host directly, without a Suspense boundary (React holds a suspended reveal for up to 300 ms), so the dialog opens as soon as the plugin answers. Icons are the vendored Lucide set.
For a live agent in auto or YOLO whose folder is the top of a Git project, the host saves a rollback point once per turn: when the authenticated lifecycle hook reports a turn start (the runtime gateway holds that hook's answer for at most 750 ms, fail-open, cancelled by a newer signal, a disconnect or a revoke; the HTTP barrier always answers) and, when no turn is known, before a permission decision. A turn is the hook's turn id, or lasts until the agent stops or the person submits input while the card is not working. A failed attempt is not retried within its turn. A point is a git stash create snapshot (or HEAD) plus its index. Inside the barrier only the snapshot, its ref and the registry row are taken (~130 ms on a 2000-file, ~33 MiB repository); the trees are copied afterwards into host packs chained to the previous point (a complete pack every 16). All data lives in userData/checkpoints, private to the app and hidden from isolated agents. Retention: 50 per card, 1000 total, 2 GiB of packs; a new point's time is after every registered point's. Closing a card and cards known not to come back drop their points; folders that cannot be found (an unmounted volume) keep theirs; an unreadable registry is set aside, not fatal. Git runs per repository queue with a stripped environment and hardened config (--no-replace-objects, no hooks, no external diff, every filter driver blanked, attributes from a fixed tree). Projects whose files use a filter attribute (Git LFS, git-crypt) are refused, and the check runs again after the snapshot. Restore puts back tracked files and the index; HEAD and the branch stay. It is refused while any card in the folder runs or launches, for submodules, untracked files in the way, assume-unchanged/skip-worktree entries, cards related to an isolated plugin environment, and when the before-restore safety point cannot be taken. While it runs the folder's cards take no input (typed or replayed: the turn counters and this check live in writeInput, the one input write), nothing launches there, closes wait and card actions on the folder are refused. A case-only rename restores on case-insensitive file systems; an unset core.ignorecase reads as false. Previews run in their own lane, are bounded and show the restore direction (a/ is the file now, b/ after the restore). The main window reaches list, preview and restore over IPC; refusals are stable codes.
An agent card's options menu gets "Rollback points…" when its folder can have rollback points (an auto or YOLO agent at the top of a Git project) or once some exist; the options button shows only when its menu has an item. It opens a native modal dialog on the shared card-dialog frame, rendered by App inside the themed tree: the card's points with localized time and kind, and a preview of the selected one. - The preview lists changed files and a bounded diff (2000 lines and 500 file names, then "Show more"), classified by position, with a legend: "+" lines come back, "-" lines are removed. Added and removed lines use the success and danger tokens, headers are muted. Bidirectional controls are removed. - Restore needs an inline confirmation; the confirmation and the outcome line reserve the same three lines, so the dialog keeps its height. The main process answers with codes that the dialog shows as localized text; the preview is read again after a restore. While it is open the native Browser is hidden and canvas shortcuts do not fire; focus returns to the card's options button. The dialog and its Russian and English texts are a separate chunk (only the menu label is in lib/i18n.ts), preloaded once the app is idle and rendered directly like the change review, so the first open is not held back.
wait_for_agent, get_agent_result and observe_agent returned the raw PTY
tail, which for a full-screen TUI such as OpenCode is cursor moves,
colours and spinner redraws, often starting mid-sequence. They now render
the masked scrollback window (the last 65,536 characters) through the
existing headless xterm at the session's size and return the visible
text, masked again and capped as before (observe_agent within its
maxChars). A cut window starts at its first escape or line break, so the
rest of a cut sequence ("18;9H") is never printed as text.
exitLines were the regex-stripped last 16 KiB, so a TUI that redraws only
the cells that changed read as its redraws joined together, and a line
drawn early and never redrawn (the model's status line) was lost. They
are now rendered by the same headless terminal from the same window,
masked before and after, and the three tools return the same exitLines.
An isolated agent could read every plugin's private data folder (<userData>/plugin-data), including every CLI home and token an accounts plugin keeps there, not only the account it runs on. plugin-data is now private app data for the isolation layer. The one folder reopened is the CLI home a chosen launch contributor started the agent with: the provider's own home variable set to an exact absolute folder strictly inside that contributor's host-assigned data folder. The host derives it from the contribution; a plugin cannot name a grant. AgentIsolation checks it again when the layer is built (real folders, no symbolic links from plugin-data down) and refuses the launch otherwise, giving the reason once. A project inside plugin data is reopened as that one folder.
d926c52 to
a68bd8a
Compare
|
I rebuilt this PR from
The branch is stacked on two new PRs, #147 (orchestration fixes) and #148 (isolation hardening). Their commits show here until they merge; this PR's own commits are the last six. If you want only the worktree and review part, I can drop the two rollback commits. |
Claude Code keeps its Bash tool output and external-editor files in
${CLAUDE_CODE_TMPDIR:-/tmp}/claude-<uid> and ignores TMPDIR. The isolation
layer does not make /tmp/claude-<uid> writable, so every shell call of an
isolated Claude failed with "Operation not permitted".
Set CLAUDE_CODE_TMPDIR to the launch's private temp folder for Claude on
both layers (seatbelt and bubblewrap). The shared /tmp/claude-<uid> is not
granted: all of the person's Claude sessions use it, and an isolated agent
could otherwise rewrite another session's task output.
…card's own account home An accounts plugin keeps every account's sign-in in CanvasTTY's plugin data folder. Base protection's private list had account-homes but not plugin-data, so an agent outside the isolation layer (a person-launched card, or one with isolation off) could read another account's tokens. Add plugin-data to the private list. The account home the card's launch plugin selected (the home the launch pipeline already validates for the isolation layer) is recorded with the card and counts as one of its own config folders, as CLAUDE_CONFIG_DIR does, but only while the CLI is still started with it. Other accounts' homes, other plugins' data and the listing stay refused; a project CanvasTTY placed inside plugin data keeps its own files. plugin-data is not added as a name marker, since such a project names that folder in its own paths.
A card action can now answer with a structured, plain-text review of file changes grouped per agent, and the app window can call the action again with a bounded JSON input (pages, selected files, conflict choices). The host normalizes the review: at most 512 KiB, 24 groups and 400 files, text masked and stripped of control characters, file paths that masking would change are refused, and accept/reject action ids must be the plugin's own declared actions. The input is a JSON object of at most 1 MiB, copied before dispatch and never used as the session's identity. A service may declare up to 16 card actions.
a68bd8a to
ac912f9
Compare
|
Reworked as #162, because it is stacked on the reworked #147/#148 (#160, #161), which are now on current |
Branch
pr/subagent-worktrees-rebuild(headac912f9f, 6 commits of its own; previouslya68bd8ab, rebased onto the newpr/security-hardeningheadb4b69198). It is pushed to #133's branchbacklog/2-worktreesand stacked onpr/security-hardening, which is stacked onpr/orchestration-reliability.Why
In your review of #136 you said #133's functionality is accepted after your local check. The PR could not merge because it sat on the rejected #132. This is #133 rebuilt from
mainwithout any #132 code: no task board, budgets, templates, auto-review,retry_agent, inspector, timeline, usage/pricing, process-tree pause or task scopes, and none of their types, i18n keys or file names. The UI was redone to your contract points:--ui-scale, existing components, Lucide viaUiIcon(no glyphs);What changes for users
spawn_agentisolate: "worktree"asks for one explicitly and is refused if no plugin provides it..gitpointer, administration folder and a branch only it has checked out). Its Git metadata stays read-only and its hooks are denied. The agent learns about the worktree only through its environment, with no UI."menu": false) stay out of the card menu. Card action titles can be localized (title.ru/title.en).writeInput, the one input write from fix(orchestration): reliable subagent launches, prompts, waits, answers and exits #147. Projects that use Git LFS or git-crypt, submodules and sparse checkouts are refused.Commits
690ec959feat(plugins): let card actions answer with a change review56192d24feat(isolation): keep a plugin worktree's Git metadata read-only5b516fdffeat(orchestration): run subagents in plugin worktreesf0582167feat(ui): show a card action's change review in a modal dialogec76e5acfeat(checkpoints): save rollback points of agent turnsac912f9ffeat(ui): add a Rollback points dialog to agent cardsRebase (2026-10-14): the same six commits (messages, authors and dates unchanged) moved onto #148's two new commits (
3f62b7e1Claude Code temp folder,b4b69198plugin data in base protection). Two conflicts, both unions: inAgentIsolation.wrapthe env line keeps commit 2'sisolationNote/GIT_OPTIONAL_LOCKSand #148'sCLAUDE_CODE_TMPDIRfor Claude; incanvasTtyPrivateDatathe list keeps #148'splugin-dataand commit 5'scheckpoints,checkpoints.json,checkpoint-objects. The tree differs froma68bd8abexactly by #148's two commits.Commits 1–4 are the worktree and review part; 5–6 are rollback points. Dropping 5–6 leaves a complete worktree-and-review PR. Each commit type-checks on its own.
Verification
ac912f9f: fake HOME, short TMPDIR, real environments plugin (pe-fix, 83c76fb): helpers, typecheck, build andaudit:secretspass; full suite 2451 / 2446 / 1 fail / 4 skip; startup bundle 1,694,423 bytes (budget 1,700,000). The one failure issubagent-worktree-plugin-acceptance("two delegated workers…"): the plugin's review collection runs out of its time budget ("Collecting did not finish in time") on a heavily loaded machine (load average up to 88 from other jobs). It fails the same way on the old heada68bd8ab, and on rerun it passed onac912f9f(65.6 s) and failed again on the next run, so it is a timing flake of the plugin's collection budget, not a regression of the rebase. Worth rerunning on a quiet machine before pushing.a68bd8ab: this exact tree, with a fake HOME and a short TMPDIR, and the real environments plugin (CANVASTTY_ENVIRONMENTS_PLUGIN_DIR) so the worktree acceptance tests run against it. Results: helpers, catalog (no diff), typecheck, full suite 2447 / 2443 / 0 / 4, build andaudit:secretspass; startup bundle +5,339 bytes overmain(1,694,462). Each changed commit also type-checks and passes the tests it touches on its own. The tree equals the previously verified rollback-points layer merged with the four fix(isolation, safety): keep CanvasTTY's private data out of agents' reach #148 fixes that are not part of the stack, plus thewriteInputandclaude-draftchanges described in fix(orchestration): reliable subagent launches, prompts, waits, answers and exits #147/fix(isolation, safety): keep CanvasTTY's private data out of agents' reach #148 and one new test (checkpoint-restore-lease: replayed input moves the rollback point generation and is refused during a restore).Review dialog (ru, sage 1.25 / night 1.0) and rollback dialog (list with preview, confirm):
The rollback dialog was also checked for both refusals (agent running, no safety point) in ru/en, sage/night, 1.0 and 1.25; the files were unchanged after each refusal.
The full VM suite on the integration fails only the three tests that also fail on
mainthere.First open (integration build, 3 launches × 2 combos): Rollback points 7.7–10.7 ms (2nd open 3.5–4.2 ms); change review 93–111 ms, the same as its 2nd open (90–120 ms, the plugin's own round trip). Both are separate chunks, preloaded once the app is idle and rendered without
Suspense.send_to_agentfollow-up whose turn reports two lifecycle signals (OpenCode does) keeps its rollback point. The capture belongs to the turn (cancelled only when no barrier waits for it any more, one barrier after it started, or when the turn ends or is replaced). Testa follow-up prompt keeps its rollback point when a second lifecycle signal of the same turn arrives (real Git project)reproduces it with the real gateway, TerminalManager delivery and GitCheckpoints;a superseded barrier of the same turn leaves the capture running; a new turn or the turn end cancels itcovers the rest. Verified live (hidden app, OpenCode + GLM, auto profile, the follow-up into an idle subagent at the top of its own Git repository): the follow-up turn has its own rollback point and nothing is logged.Risks
git rev-parsewhen it mounts to decide whether to show the menu item.main(same measure:main1,689,123), and 1,689,948 with macOS: editing keys for Claude Code cards (newline, select-all, copy/cut/paste) and UTF-8 locale #145 merged on top (temporary merge).Depends on
pr/security-hardening, now with 13 commits), which depends on fix(orchestration): reliable subagent launches, prompts, waits, answers and exits #147 (pr/orchestration-reliability). Merge fix(orchestration): reliable subagent launches, prompts, waits, answers and exits #147, then fix(isolation, safety): keep CanvasTTY's private data out of agents' reach #148, then this PR. Until then, this PR also shows their 22 commits.activereview flag is set by the environments plugin'sfix/review-robustness(2dbb589). Without it the dialog works as before for running agents (the plugin refuses after confirmation).