perf(helpers): a native canvastty-helper for the MCP servers and hooks - #110
BIackFIame wants to merge 84 commits into
Conversation
An orchestrator with canvastty_agents did not know which providers it
could spawn, so it searched ~/.local/bin and ~/.config instead of
delegating, and had to poll for results.
- list_providers: every agent provider CanvasTTY can launch as a
subagent, with the exact spawn_agent.provider id, name, installed and
available from the provider CLI registry, sign-in state from the last
usage-limits read (ok, signed_out, expired, unknown; LimitsService.peek
never starts a read and nothing reads credentials), subagent and
orchestrator support, and plugin launch options with the plugin tool
that picks them (such as list_routes). The control CLI answers the
same list as `providers`.
- spawn_agent lists the known ids (enum, mirrored from providerCatalog
and kept equal by a test) and refuses an unknown provider with
INVALID_REQUEST naming list_providers. The stdio helper answers
malformed core calls itself, since the bridge drops the connection
over them.
- wait_for_agent({ sessionId, timeoutSeconds <= 600 }): returns when the
subagent is idle (after its screen settled), needs_approval, exited
(done/failed), quiet, closed, or on timeout, with status, exit code,
waited time and the tail masked before it is cut. Own subagents only,
never the orchestrator itself; it stops at once on cancel or
disconnect. It reads only metadata and the output offset while it
waits.
- The MCP instructions, tool descriptions, orchestrator skill, docs and
both refusal messages give the workflow list_providers -> spawn_agent
-> wait_for_agent -> get_agent_result and say not to search the
filesystem for agent CLIs or their configuration.
OpenCode subagents asked "Access external directory ~/Downloads/<name>" for their own project when its name had a letter with two Unicode spellings (Cyrillic "й"). Finder stores names decomposed (NFD); the path an orchestrator types is composed (NFC). macOS opens either, so the CLI started in the NFC path, read its working folder back from getcwd in NFD, and OpenCode's string comparison between its project root and the paths in its prompt made the project external. Codex trust entries had the same mismatch. - TerminalManager.create spells the requested cwd as stored on disk (onDiskPath: each non-ASCII part replaced with the parent's entry of that name, exact match first, else the one entry equal after normalization; symlinks are kept, ambiguous or unreadable parts are left as given). This covers spawn_agent, the control CLI, plugin sessions.create and the launcher. - Every launch gets PWD set to its folder (the wrapped one for plugin environments) instead of inheriting the app's. - OpenCode gets permission.external_directory "allow" for exactly its project folder in its other Unicode spelling (the folder and its contents) in the per-run inline config; a person's own external_directory word is kept as "*", YOLO and ASCII paths are unchanged.
…effort The person asked an orchestrator for subagents on "GLM-5.3 Flash"; they ran on OpenCode's default GLM-5.3 because spawn_agent had no model. - CreateSessionRequest/SessionMetadata carry model and effort; the launch passes them to the CLI's own flags for that run only (--model: OpenCode provider/model, Codex, Claude, Qwen, Kimi alias, Grok, OMP, Pi, Cursor; effort: Codex -c model_reasoning_effort, Claude --effort, Grok --reasoning-effort, as their --help lists them). Hermes, MiniMax, Devin and Antigravity take none. Nothing is written to CLI config. - Checked per provider (shared/launchModel.ts) with the reason in the refusal and a pointer to list_providers / the providers command; kept on restart and restore (persisted, and a stored value the CLI would not take is dropped instead of failing the restore). - spawn_agent gets model and effort (effort enum mirrored and tested); the control CLI gets --model and --effort. - list_providers shows each provider's model format and effort levels, and for OpenCode the models `opencode models` lists: run in the background with a 5 s timeout, cached for 10 minutes, never awaited. - The skill, docs and tool texts say: if the person names a model, pass it as model. - OpenCode stops with only "Unexpected server error" on a model it does not know. A model missing from the cached `opencode models` list is refused before launch (spawn_agent, control create, TerminalManager for the launcher and plugins) with up to five closest ids; when the list cannot be read, the model is passed as given. - A subagent that exits anyway reports the last lines of its screen as plain masked text (exitLines) in wait_for_agent, observe_agent and get_agent_result.
OpenCode subagents asked the person for every action. OpenCode 1.18 has
no auto flag, so its auto profile is an inline OPENCODE_CONFIG_CONTENT,
merged with the core's own inline config and never written to
~/.config/opencode.
Checked against the installed opencode 1.18.33: Permission.evaluate is
rules.findLast(permission and pattern match), fromConfig turns
{ tool: action } into a "*" rule and { tool: { pattern: action } } into
one rule per pattern in order, and an agent's permission is appended
after the top-level one. So the rules go under agent.build (OpenCode's
default agent): after the person's own rules, and every tool not named
keeps what the person's configuration says.
- read, glob, grep, list: allow (".env" files still ask, as OpenCode's
default does); edit (edit, write, apply_patch): allow.
- bash: allow only when base protection is on and CanvasTTY's guard is
installed in that OpenCode (tool.execute.before, hard denies still
deny before OpenCode's own check); otherwise it asks, so auto never
grants more than the person chose. A launch contributor's other model
keeps bash asking, like accept-edits elsewhere.
- external_directory is untouched: outside the project asks as before.
- hasAutoMode includes OpenCode (launcher, card label, control CLI);
TerminalManager learns base protection through configureBaseProtection.
…ofile Subagents were always launched in the normal profile, so a person who ran the orchestrator in auto was still asked about every step of every subagent. - spawn_agent takes an optional profile, "normal" or "auto" (auto only where the subagent's CLI has one). Without it the subagent gets its orchestrator's profile; an auto its CLI lacks becomes normal. - YOLO is never handed to a subagent: core allows it only in an isolated environment the person chose, which spawn_agent cannot pick. An explicit "yolo" is refused with the reason, and a YOLO orchestrator's subagents run in auto (or normal). - The answer reports profile and profileInherited, list_agents shows each subagent's profile (and model), and the card shows the profile it runs in. The control CLI's create --profile stays its equivalent. - The skill and docs describe it.
…screen tail How core decided an OpenCode subagent finished: CanvasTTY's OpenCode plugin reports working on session.status busy and idle on session.idle through the runtime gateway, so wait_for_agent returned at the right time. But get_agent_result only returned the raw PTY tail (the last 8,192 characters of a full-screen TUI's redraw sequences), and state stayed "running" while the CLI was open, so the orchestrator never got the answer text. - The OpenCode plugin, when result capture is on for its session, reads the session's last assistant message (text parts, not tool, reasoning or synthetic parts; v2 and v1 SDK shapes; 2 s timeout) at session.idle and reports it with the turn's end, at most 4,096 characters with the end kept. The runtime gateway accepts a result on OpenCode's session.idle as it does on a Stop hook, still only for a lease that captures results. - spawn_agent turns result capture on for Codex and OpenCode subagents only (TerminalManager accepts it for both); ordinary cards never keep an answer. - TerminalManager keeps the last answer in memory, masks it when read, and clears it when the next turn starts. get_agent_result returns it as answer, plus status; wait_for_agent includes it when the turn ended. - Tests drive the plugin in a separate process with a fake SDK client against a real runtime gateway, and cover masking, truncation, the v1/v2 client shapes and the no-capture path.
…riables Base protection let three kinds of calls through unchecked: - A shell call whose input was over the 40 KB bound reached main as a preview only; no command was read, so nothing was denied and, with no decision plugin, the call ran. With base protection on, a cut shell call, or a cut file write whose target is not at the start of the preview, is now denied with advice to split the work. - A folder named in a variable of the same command (`OUT=/x; rm -rf "$OUT"`, `export OUT=/x`) was unresolved. Standalone assignments and export/declare/local/readonly/typeset now set the value for the rest of the command (a prefix assignment does not, as in the shell), and a value that cannot be known clears it. - `tar -C<dir>` with the folder attached, and `git -c alias.x='!cmd'` or a `-c` key whose value git runs (fsmonitor, pager, editor, filters, helpers) were not read. Their commands are now analyzed like any other. A decision-plugin list that cannot be read is no longer an empty list: the handler fails, so the gateway answers as unavailable (a CLI that can ask asks the person, a fail-closed gate denies), and a launch still installs the hook.
…s restore, close and cancel Saved cards: - A card list written by a newer version is left as it is and never saved over for the rest of the run; one that cannot be read at all is left alone too. A file that is not a card list is kept beside a fresh one (terminal-sessions.json.corrupt-<time>) instead of being replaced by an empty state when the manager saves. - Restore resumes environments only for cards that come back. Cards that do not (not restored, or a subagent whose parent is gone) release their environment with its data kept, instead of leaking it. - Subagents whose parents loop back on each other have no owner and do not come back. Owners: - Closing a card closes its subagents first, so none keeps running without an owner. - An environment a plugin prepared after the launch timed out is still heard of (the plugin call keeps the host-call budget) and released. - A controlled create whose setup failed closes the card it started. Gateways and cancel: - The orchestration gateway starts once for concurrent callers, and a stop during start leaves nothing listening; the browser agent gateway closed while its Unix socket opens closes that socket. - A Windows pipe host that fails and exits late no longer clears the host started after it. - A late start hook of an earlier turn no longer takes over from the newer turn, whose Stop was then dropped as stale. - A cancelled send_to_agent passes its signal down to the delivery: text still waiting for the launch is dropped and the answer is CANCELED.
… keep host replies with their process - Two installs of the same plugin both passed the "already installed" check; the loser's failed move then deleted the winner's directory and registry entry. Install, module changes, update and uninstall of one plugin now run one after another, the check runs inside that order, and a failed install removes only what it created. - A host call a service made before it crashed was answered into the stdin of the restarted process. The reply now goes only to the process that asked. - Uninstall revoked secrets before stopping the plugin, so a secret write in flight could recreate the file, and a failed uninstall left the plugin without its secrets. Uninstall now stops and removes the plugin first, then revokes; secret writes are refused while a revoke is pending and re-check permission when they run.
- The companion (glasses) read the terminal screen and captured answers without the secret registry. The screen text is now masked as a whole before it is cut, so a key the terminal wrapped over two lines is found, and captured answers are masked when they arrive. - An owner holding 64 values dropped the oldest one even when it was still in use. Adding a held value again now makes it the newest (the vault adds each key it reads), and a drop or a refused owner is logged without the value instead of happening silently.
… went in When focusing or selecting the target opened a JavaScript dialog (a focus handler's alert), browser_type inserted nothing but answered typed: true. It now answers DIALOG_OPEN with the dialog type, so the agent handles the dialog and types again. A dialog raised by the page's input handler comes after the text went in and still counts as typed. browser_select sets the selection before it fires its events, so a dialog from those events leaves it selected and its answer is unchanged.
…v names as the OS does - Clipboard, settings reads, file pickers, limits, plugin canvas and window opening, plugin storage and media, the Hermes HUD, and terminal list, resize, bounds, rename, restore and visibility handlers now check that the call comes from the main window's own frame, like the other privileged channels; the terminal ids they take are checked as text. - Plugin environment names: __proto__ is refused as a name (assigning it was silently dropped), a name such as constructor no longer looks like one another plugin already set, and on Windows two spellings of one name (Path, PATH) collide with each other and with what CanvasTTY sets.
…plies with what they belong to - A card whose history snapshot failed also lost its live output: the error disposed the only subscription. The failure is still reported, and the card now goes on with live output from the oldest event queued. - Provider keys and API profiles could be saved twice (Enter bypassed the busy state), and a save that finished late cleared text typed after it was submitted. A second save of the same entry is ignored while the first runs, and a draft is cleared only while it still holds what was saved. Profile edits no longer merge into a draft from an earlier render. - After the launcher's agent changed, the previous agent's launch plugins and environments stayed offered until the new list arrived, so their options could be sent with the new agent. Lists are shown only for the agent they were loaded for. - A plugin frame request still running when the frame reloaded or navigated was answered into the next document (a secrets.get reply included). A reply is now dropped once the frame shows a new document or its window is no longer the one that asked.
…cement candidates - The browser card reports its rectangle on every camera step, resize and layout pass. A report that rounds to the placement already applied now returns before the native view is re-laid out (hidden reports still run: they end gestures). In a simulated slow pan with a duplicate report per frame, 1200 reports caused 1200 view syncs before and 300 after; idle re-renders go from 1200 to 1. A fast pan is unchanged (every report moves the view). - Placing a new card near Home built and sorted a lattice of about 3000 candidate positions every time. The sorted list is kept for the same Home, card size and options, and the search still stops at the first free position: 500 placements beside 20 cards took about 240 ms before and under 3 ms after.
…ants and plugin frame replies - Base protection: rm, rmdir and the other deleters, find -delete, and mv operands whose path is a variable or command output that cannot be resolved are now denied as an unknown target, with advice to write the path out. A variable set in the same command, or a for-loop over plain words, still resolves (a loop over a folder outside is the delete outside it). - Uninstall marks the plugin as being removed: a music folder pick that finishes while it runs, or after it, stores no grant (the grant is checked again right before it is stored). - A plugin frame reply is tied to the window that asked, the plugin the frame served and the frame's document generation, all captured when the request arrived. Pointing the frame at another plugin or entry starts a new generation at once, so a reply that finishes before the new document loads or asks anything is dropped.
…not words in values A contributed argument was refused when its text anywhere contained words such as "dangerously" or "approval_policy", so a context plugin whose rule merely mentioned them blocked the Codex or Grok launch. Arguments are now read by structure: a flag whose name is core-owned or asks to bypass approvals, a config override (`-c key=value`, `--config=key=value`, `-ckey=value`) whose key decides approvals, the sandbox or the hooks, and the core-owned subcommands are refused as before. Text inside a value is the plugin's own.
A Claude Code HTTP lifecycle hook whose body is over the 512 KB bound was answered (and its connection closed) while Claude was still sending it, so the sender could see a reset instead of the answer. The state is still reported at once; the answer now waits until the rest of the body has been read and dropped.
…s reason Only Claude Code takes "ask" from a hook. For Codex, Qwen, OpenCode and the rest, an ask from a decision plugin (or a plugin that did not answer in time) printed nothing, so the tool call simply ran. It is now a deny that says the person must decide, and the gate turns a gateway-level ask into the same deny for those CLIs. Decision services now receive the card's profile and canAsk.
Delegation rules, in the core, for every way an agent starts another one (spawn_agent, an orchestrator's control connection, restore, restart): - a subagent never gets more than its orchestrator (plan < normal < acceptEdits < auto), never YOLO; a restored record cannot raise it; - its folder is the orchestrator's project or inside it, compared as real paths in both Unicode spellings; /, the home folder and other projects are refused with a reason the agent can act on; - nesting depth (2) and live subagents per orchestration (8) are limits only the person sets (Settings -> Agents); - plugin launch options from an agent reach only plugins that declared launch.delegable; OpenCode/Kimi configuration a plugin hands the CLI may not set approval keys; cursor's --force/-f and other bypass flags are core-owned; - YOLO is checked in the main process: acknowledged by the person for that CLI, never for a subagent or an orchestrator's connection, and a plugin with sessions:launch needs the same acknowledgement; - an orchestrator gets a control connection of its own (never the app-wide descriptor): create makes its subagents under these rules, and theme or settings commands are refused. Agent isolation (Settings -> Agents, on by default) wraps the agent's whole process tree for every subagent, every plugin-started agent and every agent not in manual: macOS sandbox-exec with a profile generated per launch, Linux bubblewrap when installed. Writes only in the project (both spellings), the launch's own TMPDIR and the CLI's own folders; git hooks, an existing repository's config and the CLI's permission settings stay unwritable; keys, other CLIs' credentials and CanvasTTY's tokens are unreadable except what the launch was handed; no signals to other processes, Launch Services, Apple events, cfprefsd writes, launchd jobs or foreign Unix sockets. It fails closed. Without a layer (Windows, Linux without bwrap) a subagent runs in normal and the card says why; turning it off is the person's opt-in. Inside the layer Claude Code's own sandbox block is left out (macOS refuses a sandbox in a sandbox); Codex keeps its flags and escalates through its reviewer. Plugin environments declare what they keep (keeps.launch, isolated, confines); an isolated one is not wrapped again, and without launch only normal runs there. Launches never count as shell-guarded inside an environment. Modes: auto is the default launch mode; manual, accept edits, plan and bypass are offered only where the CLI has them (Codex plan is its read-only sandbox, OpenCode plan its plan agent, cursor --mode plan). A CLI without an auto of its own gets its bypass as auto only inside isolation. OpenCode auto keeps the person's own deny and ask rules after its allow rules. Claude's sandbox, where it runs, no longer lets commands leave it. A manual card shows when the CLI's own configuration skips approvals. The card shows the isolation state; list_providers shows each provider's subagent profiles.
What each layer guarantees and what it does not: the agent's own mode, base protection, the delegation rules and the OS isolation layer (including nested sandboxes, network, the macOS login keychain and Windows). The orchestration guide covers subagent profiles, folders, limits, an orchestrator's own control connection and environments' keeps; the plugin guide covers launch.delegable and keeps. The ru and zh-CN security pages get a short version of the new section.
macOS refuses a sandbox inside another one, so inside CanvasTTY's isolation layer Codex's seatbelt could not start and every command failed once before being re-requested. Inside the layer Codex now runs with its own sandbox off (--sandbox danger-full-access, never the bypass flag) and the same approvals: on-request, and in auto the reviewer --approve-for-me uses (approvals_reviewer="auto_review"). Verified with codex debug prompt-input under a fake HOME, run inside the layer by the tests. Outside the layer Codex keeps its own sandbox. In plan the layer keeps the project read-only. Claude Code keeps its sign-in in the macOS login keychain and rewrites that file from inside the process when it refreshes it. A Claude launch inside the layer may now write that one file and its temporary siblings (tested with the security CLI on a temporary keychain in a fake HOME), so a refreshed sign-in is saved. Other keychain items stay behind their access lists; the tradeoff is documented.
scripts/bench/baseline.mjs runs the built app hidden (bench-runtime shims, isolated HOME and provider homes, no keychain) with plain shells or a stub opencode/claude CLI that starts the MCP helpers, lifecycle plugin and hooks CanvasTTY attaches. It reports memory per process kind (RSS and phys_footprint) for 1/5/10 cards plus an orchestrator, CPU while idle, under output or status churn and during a canvas pan and zoom, startup time to the first interactive frame, heaps, and with --size the breakdown of a packaged app (framework, locales, asar by folder, node_modules that main and preload never load).
One static Go binary (standard library only, no cgo) with the subcommands mcp-browser, mcp-orchestration, permission-gate and hook. Each speaks exactly the wire protocol of its .mjs original: JSON with V8's parse/stringify semantics (key order, number form, lone surrogates, invalid UTF-8), the same NDJSON bounds checked after every append, timeouts, reconnects with the rotated token, and the gate's fail-closed deny. Windows named pipes use overlapped I/O through syscall. The tool catalogs are generated from the .mjs sources by scripts/build-native-helpers.mjs.
The same scenarios run through both implementations: every tool input kind, every gateway answer and failure for claude/codex/qwen/opencode, lifecycle hooks with answer grants, MCP handshakes, validation, oversized lines both ways, malformed gateway lines, cancel, timeouts, gateway down and reconnects, plus the real Runtime, Agent and Orchestration gateways.
…t one otherwise The browser and orchestration MCP servers, the decision hook and the lifecycle hook launch canvastty-helper on macOS and Linux when the binary for this platform and architecture is present (resources/helpers when packaged, build/native-helpers in development). Windows keeps the .mjs helpers until the native named-pipe transport has run on Windows; CANVASTTY_HELPERS=node forces the JavaScript helpers everywhere and =native opts in anywhere. Hook files written with either form are recognized as CanvasTTY's own on recovery, and the native hook commands run inside the isolation layer.
…sources/helpers `npm run build:helpers` builds every release target (darwin arm64/x64, linux x64/arm64, windows x64) with -trimpath -ldflags "-s -w", CGO off, no module proxy; `npm run build` builds this computer's target before bundling, and electron-builder copies the matching one into resources/helpers. CI and release jobs set up Go from go.mod, require the helper to build, vet every target, run the parity tests against it and test the Windows pipe transport on Windows.
--helpers auto|node|native sets CANVASTTY_HELPERS for the app; the bench app folder links build/native-helpers so the app finds the binary as it would in development, and native helper processes and hook runs are attributed as native:<subcommand>.
With "Agent access" to the browser off, the browser bridge returned no launch at all, so orchestrators (and sessions a plugin tool applies to) lost their canvastty_agents MCP server with it. Now such a launch attaches only canvastty_agents for every provider that takes it (Claude Code, Codex, Qwen Code, OpenCode, Hermes, Kimi per-run and shared): no browser capability is issued, no browser entry, environment or allowed browser tools. Hermes and Kimi's shared temporary configurations record whether they carry the browser entry and refuse a launch with the other browser access while in use.
The launch fixtures use the injected platform's path semantics. The generated/embedded catalog mismatch was a CRLF checkout issue: source inputs are pinned to LF and the regression compares reproducible bytes/content. The native helper requirement and Windows pipe-host assertions remain enabled. The owning #109 fixes are propagated here, including The Windows native helper tests, actual pipe-host self-test and packaging checks run in required CI. macOS/stub-agent performance claims remain scoped to those measurements; this update does not claim a measured real-provider speed-up on Windows/Linux. Updated head: Independent GPT-6 Luna reviews found no remaining concrete defect in the reported cases. Final cumulative local validation: 1605 passed, 3 skipped, 0 failed; production build/typecheck, secret audit and real-Electron hidden-terminal proof passed. Lower branches were additionally checked with their own gateway/environment/history tests and typecheck. |
…close The gateways answer a protocol failure (an unauthenticated command, a replayed token) with an error message and close the connection. On Windows both go through the current-user pipe host as a write frame and a destroy frame; the destroy marked the connection closing at once and its writer thread stopped without writing what was still queued, so the client sometimes saw the pipe close without the error. The Windows job failed orchestration-gateway's "unauthenticated commands and replayed bootstrap tokens are rejected" this way (a timeout waiting for the error), intermittently. A destroy now lets the writer drain what was queued before it, bounded by two seconds for a client that stops reading, then closes. The relay self-test (npm run test:windows-pipe-host) sends a last message and the close in one write and requires the message to arrive.
…#111) Flush final replies through the Windows pipe buffer, cancel draining on the original deadline or shutdown, and exercise delayed and stalled readers. Note: pre-existing failure in the local focused-wheel browser smoke is not addressed by this change.
|
Updated head: The same run found platform-dependent handling of tool input nested 3,000 levels deep. Node and Go now use the same permission-input depth bound: 256 containers are accepted, 257 are rejected before contacting the gateway. Regressions check the exact boundary, quoted/escaped braces and legacy mode; fail-closed mode still denies unavailable checks. Other native helper protocols retain their previous JSON parsing behavior. The portable OpenCode config fixtures and all #109 inspection/isolation fixes are included. Fresh independent GPT-6 Luna reviews checked both the changes and their backports. All three upstream jobs passed, including the real Windows native parity/pipe-host tests and packaging checks: https://github.com/howdeploy/CanvasTTY/actions/runs/36858798233. This is not evidence of a live-provider Windows speed-up. |
|
Reviewed the latest head Code-review conclusion: ready for integration after #109. The sequential merge rehearsal was clean, and I found no additional concrete blocker in the changes reviewed. The native helper keeps the existing MCP/hook protocol and authorization behavior while avoiding the heavyweight Electron-as-Node helper process on the native path. The JavaScript fallback remains available. The new Windows validation is materially better: CI builds the native helper explicitly, uses actual named-pipe endpoints and the required current-user pipe host in gateway fixtures, and exercises Node/Go parity instead of broadly skipping Windows. The permission JSON depth limit is now the same portable boundary in both implementations; the tests retain checks at and beyond the boundary and ensure quoted braces/escaped quotes are not counted as nested containers. Please retain the distinction between validation and product defaults: Windows production launches still use the bundled JavaScript helpers by default even though the native implementation is now exercised by parity tests. The build/release documentation should continue to explain the Go toolchain requirement and required-native release behavior. The structural reduction in helper startup cost is reasonable, but this review does not establish a measured live-provider speed-up on every platform. Please keep performance claims tied to the actual measured process/platform and scenario. |
Branch:
pr/4-native-helpers→ basemain(stacked on the previous PR).Depends on #109 (agent isolation).
Only this PR's commits: BIackFIame/CanvasTTY@pr/3-agent-isolation...pr/4-native-helpers
Why
What changes for users after merging
On macOS and Linux, agent cards get much lighter and Claude tool calls stop paying a process start of a whole JavaScript runtime. The JavaScript helpers stay as the fallback (and remain the default on Windows until the native pipe transport has run there);
CANVASTTY_HELPERS=nodeforces them anywhere.Measured on the whole stack vs 1.7.0 before the rebase onto
15983c4(Windows-only upstream changes) (interleaved A/B, 3 runs each, stub CLIs, see "How to verify"). These rows come from the helper processes and hook runs themselves, so they belong to this PR:Medians of 3 runs. OpenCode stub turns are 10 → 13 ms, inside the run-to-run spread (7–15 ms); OpenCode runs its hook in-process, which this PR does not change.
What's inside
canvastty-helper(native/canvastty-helper, Go, standard library only, no cgo): subcommandsmcp-browser,mcp-orchestration,permission-gate,hook. Same wire protocol as the.mjsoriginals, including V8 JSON semantics, NDJSON bounds, timeouts, reconnect with the rotated token and the gate's fail-closed deny. Windows named pipes via overlapped I/O. Tool catalogs generated from the.mjssources.resources/helperspackaged,build/native-helpersin development); Windows keeps.mjsby default; hook files of either form are recognized on recovery; native hooks run inside the isolation layer.npm run build:helpersbuilds all release targets (-trimpath -s -w, CGO off, no module proxy);npm run buildbuilds the host target; electron-builder ships the matching binary; CI sets up Go fromgo.mod, vets every target and runs the parity tests.scripts/bench/baseline.mjs(memory per process kind, CPU windows, pan/zoom, hooks, startup, size) with--helpers auto|node|native.canvastty_agentsno longer disappears when browser access for agents is off.How to verify
Manual: open 10 agent cards and compare Activity Monitor with 1.7.0 — the per-card "CanvasTTY Helper" Node processes are replaced by
canvastty-helperprocesses of a few MB.Risks / follow-ups
codesign -vpasses); signing and notarization with the release identity should be checked on the next release build.