Repository navigation
Description
Found while reviewing the #403 delivery (branch worktree-agent-a667a7d7b9f5038f3, commit c474a70, migration 0099_tmux_operator_backend_exclusive_tools.sql — committed, not yet pushed). The migration names this cost in its own header. This ticket is the fix, the measurement, and the generalisation.
The problem, from the owner's position
You open tmux Operator → Terminal. The runner is up. Instead of a pane you get a red line:
"terminal_list_targets" is not one of this agent's tools. It can only run the tools its agent declares.
…which is addressed to a creator, not to you — you declared nothing. Underneath it, the pane's empty state says:
No terminal targets found on <machine>. Open tmux, kitty, or iTerm2 there, or create one from Controls.
That sentence is false and actively misleading: it sends you to open a tmux session, which will not help, because nothing was ever asked of the machine. Every write control is disabled, and the pair of failing calls repeats every 4 seconds, forever — tmuxBusy(targets, !!error || runner?.online === false) returns busy because the last call failed, pinning the fast tier (TmuxTab.tsx:202, rule in lib/pollBusy.ts:109-113). Chat-driven tmux is unaffected and becomes more correct.
Verified — yes, this is real
Measured, not inferred. Executing the real gate functions against 0099's declared list:
BEFORE (terminal_* declared): terminal_list_targets=true … terminal_kill_target=true
AFTER (tmux_* declared): terminal_list_targets=false … terminal_kill_target=false
AFTER tmux_* present : tmux_list_sessions=true … tmux_kill_session=true
POLICY terminal_list_targets: allowed=false reason=not_declared
POLICY terminal_capture: allowed=false reason=not_declared
POLICY terminal_run_command: allowed=false reason=not_declared
POLICY terminal_send_keys: allowed=false reason=not_declared
POLICY terminal_new_target: allowed=false reason=not_declared
POLICY terminal_kill_target: allowed=false reason=not_declared
Reproduced end-to-end against production, on the real route with the real gate. tmux_* is not_declared on that instance today, so calling it now is an exact simulation of what terminal_* becomes after 0099:
POST https://api.proagentstore.online/v1/instances/cda75e28-…/tools/tmux_list_sessions
→ HTTP 403
{"error":"\"tmux_list_sessions\" is not one of this agent's tools. It can only run the tools its agent declares."}
Live "before" state (GET /v1/instances/:id/tools, both tmux-operator instances):
| tool | today | after 0099 |
|---|---|---|
terminal_* ×6 |
allowed=true reason=ok |
allowed=false reason=not_declared |
tmux_* ×6 |
allowed=false reason=not_declared |
allowed=true |
The mechanism — two correct decisions composing
1. The tab gates its writes on the policy but treats its reads as unconditional. 1f3ca00 "fix(console): gate terminal actions per tool" deliberately introduced per-tool gating — TmuxTab.tsx:205-208 reads the live verdict for each of the four write tools. It did not gate the two reads, because at the time the reads were guaranteed present: every agent on this surface declared terminal_*. So refreshTargets (:149) and capture (:169) fire unconditionally.
2. 0099 correctly narrows the allowlist and correctly keeps the surface. toolNamesFor (agent-do-tools.ts:187-193) is an exclusive allowlist — a declared tools[] yields BASE ∪ (declared ∩ CREATOR_SELECTABLE_TOOLS), and BASE (:15-70) contains no terminal_*. resolveToolPolicy (instance-tool-policy.ts:132) therefore stamps not_declared, and the invoker refuses at routes/tools.ts:153-157.
Neither half is wrong. The composition is: the tab's write controls correctly disappear, and its read calls correctly 403. The surface still renders because surfaces.tsx:194-199 gates the tab on surfaces.includes("tmux") alone, with no tool check — which is #403's deliberate choice, and the right one.
It is not a rename — three shape changes, not one
Worth stating plainly, because "just swap the names" will not compile into working behaviour:
terminal_* |
tmux_* |
|
|---|---|---|
| target arg | target: "tmux:main" (backend-prefixed), requireTarget (terminal.ts:27-30) |
session: "main", requireSession (tmux.ts:28-31) |
| create | {backend, name, workDir, command}, required:["backend"] (terminal.ts:147-155) |
{session, workDir, command}, required:["session"] (tmux.ts:139-147) |
| list rows | /terminal/list → {backend, id, …} |
/tmux/list → {name, windows, attached, activeCommand, activeWindow, created} — no backend, no id (browser-runner/src/coding/tmux.ts:96-118, server.ts:302-304) |
The last row is the trap: parseTargets (TmuxTab.tsx:56-72) drops any row lacking both backend and id (:59-61). A naive name swap yields a silently empty target list — a worse failure than the 403, because it looks like a working tab with nothing on the machine.
Blast radius — one correction to the hand-off
The hand-off said "one user, and the kitty/iTerm2 rows are draft". The first is true today; the second does not bound it, because tmux-operator itself is published. Measured against production D1 and the public catalog:
| slug | visibility | instances | users |
|---|---|---|---|
tmux-operator |
published |
2 | 1 |
terminal-operator |
draft |
1 | 1 |
kitty-operator |
draft |
1 | 1 |
iterm-operator |
draft |
1 | 1 |
GET https://api.proagentstore.online/v1/agents returns tmux Operator among 15 published agents. So this is not confined to the author's two instances: it is a live catalog entry, and the next person to subscribe gets a Terminal tab that has never worked. All four rows declare surfaces:["tmux"], so all four render the tab — but only tmux-operator's tools change, so the other three keep working. Blast radius is exactly tmux-operator's instances, present and future.
One thing 0099 already gets right and should not be undone: its instance_connector_consent carry. Instance cda75e28 holds terminal write consent but no tmux row (writeConsent: required on tmux_* today); 1a8486b5 holds both. The migration's INSERT OR IGNORE covers the first, so once the tab is fixed the writes work without the owner re-granting anything. Verified live.
What to do — cheapest first
(a) The fix that should ship with #403 — teach the tab the tmux_* family
Not a rename; the three shape changes above. Confine the backend vocabulary to tmux while doing it (the create-target form's backend picker at TmuxTab.tsx:111,261 has nothing to choose between).
(b) The part that makes it not happen again — gate the reads the way the writes are gated
Extend 1f3ca00's promise to the two read calls: consult allowedTools before firing refreshTargets/capture, and when the tool is absent render why rather than the transport error. McpConnections.tsx:214-226 is the pattern already in the tree — it hardcodes mcp_call_tool but only offers the button once the report says that tool is callable, and its header states the reason. Same discipline, applied to a tab.
This also repairs the false empty state: with the reads gated, targetCount === 0 no longer means "the machine has no terminals" (tmuxView.ts:187,193-195).
(c) Do not wait for #402
#402's backends ceiling would let the tmux Operator go back to terminal_* constrained to tmux, making (a) moot — but #402 sits on #404, the constraints primitive, which is in flight now. Shipping 0099 and leaving the tab broken until a two-ticket chain lands puts a published agent in the catalog with a dead tab for the duration. (a) is small, and it is not wasted: if #402 later restores terminal_*, the tab's tool names change back in one commit, while (b) — the gate — is what #402 needs anyway, since a constrained agent can still lose a tool.
Alternatives considered and rejected
- Drop
surfaces:["tmux"]in 0099 so the tab vanishes. Rejected, and [chore] The tmux, kitty and iTerm2 Operators are one agent wearing three names — declare backend-exclusive tools (no code) #403 already rejected it: a tab that disappears hides the reason. An honest error is more discoverable — provided it is honest, which (b) is what makes true. - Add
terminal_*back to 0099's declared list. Rejected — it reinstates exactly the defect [chore] The tmux, kitty and iTerm2 Operators are one agent wearing three names — declare backend-exclusive tools (no code) #403 exists to fix;terminal_list_targetsdefaults tobackend:"all"(terminal.ts:50), so the tmux Operator would again reach iTerm2. - Have the tab fall back: try
terminal_*, thentmux_*on 403. Rejected — two round trips per poll at a 4s tier to discover a fact the policy endpoint already states, and it buries the shape differences in a catch block where they will be got wrong. - Make
toolNamesFortreattmux_*as satisfying aterminal_*request (aliasing). Rejected — the two connectors have different argument shapes and different consent keys; an alias makes the allowlist mean something other than what it says, and quietly re-couples the connector the migration just decoupled. - Ship a
kitty_*/iterm2_*connector so every backend has an exclusive family. Rejected — this is precisely the workaround [design] Capability constraints — declare what an agent may PASS, and enforce it at the tool boundary #404 argues does not scale (docs/capability-constraints.md:41-42).
The generalisation — is any other console surface exposed?
Grepped; today the answer is: two call sites, and only one is unguarded.
$ grep -rnoE '"(terminal|tmux|github|http|web_search|meta|browser|mcp)_[a-z_]+"' store/console/src agents/coder/web/src
Excluding tests, every hardcoded registry tool name in the console tree is in TmuxTab.tsx (10 occurrences, lines 149/169/205-208/226/241/261/281). The only other surface that invokes the generic tool route is McpConnections.tsx:226 (mcp_call_tool) — and it is already correct: guarded on the live callability verdict from /mcp/test, with the reasoning in its header at :214-221.
Every other console consumer of /v1/instances/:id/tools reads the list generically, never by name — ToolPermissions.tsx:45,62, TeamworkSection.tsx:126, lib/toolPolicy.ts, lib/teamwork.ts:292. And lib/surfaces.tsx derives tab visibility from declared tools (surfaces.test.ts:97-120), which is the robust direction: it reads the declaration rather than assuming it.
So the defect class has a population of one, and this is the moment to keep it that way. The exposure is not "the console hardcodes names" — that is unavoidable for a tab built on a specific connector. It is a hardcoded name with no check that the agent still declares it. Proposed ratchet, following the source-scanning precedent already in this tree (lib/control-shapes.test.ts, lib/designTokens.test.ts, tabs/BehaviourTab.test.ts all read source):
A test that scans the console tree for string literals matching a registry connector tool name, and asserts each one is either (i) reached only behind an
allowedTools/verdict check, or (ii) listed in a small explicit exemption set. Failing closed on a new hardcoded name is the point — this regression was authored in6cbd8dc, five days before the migration that broke it, and nothing objected.
Pair it with the server-side half in the #403 branch's own tmux-operator-seed.test.ts, which already resolves the seeded list through the real registry and real toolNamesFor — it verifies the agent end and stops at the console boundary.
Acceptance criteria
- With 0099 applied, opening Terminal on a
tmux-operatorinstance with a live runner lists tmux sessions and captures a pane. Verified oncda75e28-cace-4958-ac3e-6a7528e6b719. - Session rows render with a name (
parseTargetsno longer discards rows for a missingbackend/id), i.e. the list is non-empty when tmux has sessions. - Run-command, send-keys, new-session and kill-session work on both live instances without the owner re-granting write consent (0099's consent carry covers it).
- When a tool the tab needs is genuinely absent, the tab says which tool and why — not a 403 string, and not "No terminal targets found on ".
- No
terminal_*literal remains inTmuxTab.tsx; the backend picker is gone or fixed to tmux. -
kitty-operator,iterm-operatorandterminal-operatorare unchanged — they keepterminal_*and their tab still works. - A guard test fails if a new hardcoded registry tool name appears in the console tree unguarded.
Regression risk
- The other three Operators are the real risk. They share one tab and one file and keep the generic connector. A shape change made unconditionally breaks three working agents to fix one. The tab must branch on what the instance actually declares (which is also fix (b)) rather than being rewritten tmux-only. Caught by: the acceptance criterion above, exercised on
da39100b/59cc5144/8d21ea1a. - [bug] The Terminal tab learns the runner is gone only by failing — and blanks that error every 4s, so a phone shows an empty pane and a flicker #378's promise must survive.
TmuxTab.tsx:139-145writes the error slot only when a call settles, never on attempt — that is [bug] The Terminal tab learns the runner is gone only by failing — and blanks that error every 4s, so a phone shows an empty pane and a flicker #378's fix for a message that blinked every 4s and could never be read. Adding a pre-flight gate must not reintroduce a clear-on-attempt. Caught by:lib/tmuxView.test.ts+lib/pollBusy.test.ts; add a case asserting a not-declared notice is stable across polls. 1f3ca00's promise must survive. Per-tool gating of the four write controls is a shipped capability, not incidental — do not collapse it back to onecanWrite.- Polling cost while broken. Until fixed, each affected instance issues two refused calls every 4s for as long as the tab is open. Not dangerous, but it is the fast tier spent entirely on 403s.
Open question for the owner
Should tmux-operator be moved to draft until the tab is fixed? It is the only published row of the four and the only one 0099 touches. I would not unpublish: there is exactly one subscriber (you), the tab is broken rather than harmful, chat-driven tmux keeps working and gets more correct, and unpublishing/republishing a catalog entry is more moving parts than a console fix that is a day's work. But it is a catalog-honesty call, and it is yours.
Files: store/console/src/tabs/TmuxTab.tsx:56-72,111,121-137,149,169,202,205-208,226,241,261,281, store/console/src/lib/surfaces.tsx:194-199, store/console/src/lib/tmuxView.ts:180-195, store/console/src/lib/pollBusy.ts:109-113, store/console/src/components/McpConnections.tsx:214-226, workers/api/src/routes/tools.ts:143-163, workers/api/src/lib/instance-tool-policy.ts:120-145, workers/api/src/agent-do-tools.ts:15-70,187-193, workers/api/src/lib/tool-refusal.ts:24-28, workers/api/src/lib/connectors/tmux.ts:28-31,36-63,133-147, workers/api/src/lib/connectors/terminal.ts:27-30,41-58,141-156, packages/browser-runner/src/coding/tmux.ts:96-118, packages/browser-runner/src/server.ts:302-304, workers/api/migrations/0099_tmux_operator_backend_exclusive_tools.sql (unpushed), workers/api/src/lib/tmux-operator-seed.test.ts (unpushed), docs/capability-constraints.md:41-42,111-112. Related: #403 (the delivery that lands this), #402 (the backends ceiling that would make fix (a) moot), #404 (the constraints primitive #402 sits on), #378 (the error-slot fix this must not undo), #351 (the writeConsent verdict this tab reads), commits 6cbd8dc (converted the tab tmux_* → terminal_*) and 1f3ca00 (established per-tool gating of the write controls).
Activity
fix(console): the Terminal tab calls the tools its agent declares, no…
serge-ivo commentedon Aug 8, 2026
Fixed in
782f00c, together with #403 (78d2cfe) — the two shipped as one unit precisely so the migration never landed without the console fix. CI green, migration0099applied to the remote D1.You were right that this is not a rename. The two families differ in four shapes, and
/tmux/listrows carry nobackend/id, so a name swap would have produced a silently empty list — a worse failure than the 403. All of it now lives in a pure modulestore/console/src/lib/terminalTools.ts(families + argument builders +resolveTerminalAccess), withTmuxTab.tsxreduced to glue.The family is resolved from
GET /tools?allowed=true, not from a slug — three of the four Operators keep the generic connector, and a test asserts their copy is byte-identical. Both shipped promises survive:1f3ca00's per-tool gating now extends to the two reads (still four separate write verdicts, not onecanWrite), and #378's rule holds — a predictable refusal is never written to the error slot, so the notice is derived from policy and stands across polls. With no family resolved, nothing is polled at all, which stops the 4s refusal storm you identified.loadingis a first-class state so "this agent has no terminal tools" is never rendered and then withdrawn.The ratchet (
store/console/src/lib/toolLiterals.test.ts) asserts four things: the vocabulary is derived from the connector definitions and is non-vacuous (a guard whose vocabulary silently emptied would pass forever); every hardcoded connector tool name instore/console/src+agents/coder/web/srcis pinned per name with a stated reason;TmuxTab.tsxnames no tool at all; and every name in the family table is a real registry tool, so a server-side rename fails here rather than at a user's tab. Proved it bites by adding aterminal_captureliteral back — two assertions failed with file:line.A correction to your grep: it matched only names filling a whole string literal, which is why it found a population of one. Scanning for the name anywhere finds two more —
McpConnections.tsxkeepsmcp_call_toolinside a URL path (already guarded), and two files mention a tool in copy. None is a call; all four are pinned with reasons rather than quietly absorbed.The runner was deliberately not touched. The tmux rows get their
backendin the console, becausepags upis an installed npm package on someone else's machine — a fix requiring a new runner does not reach the person with the bug until they upgrade.The open question dissolves: the tab works, so there is nothing to unpublish.
tmux-operator's visibility is unchanged.Verified: full unit bar, plus e2e in Chromium and WebKit at 320px and 390px on a
tmux_*-only fixture using the runner's real row shape, and the unsupported state asserting the notice stands across 5.5s of polls with zero tool calls made. The pre-existing #370/#378 terminal block re-run 12/12, unregressed.Left to a live check: open the Terminal tab on the tmux Operator with
pags uprunning and confirm it lists real sessions and captures a pane. Leaving open until you have.serge-ivo commentedon Aug 8, 2026
Verified and closing — including the live check that was left open
Independent verification of
782f00c(with78d2cfe, which carries migration0099). The previous comment left this open pending "open the Terminal tab withpags uprunning". That check has now been done against production, through the real gate and the real runner, and every acceptance criterion holds.Criterion by criterion
1. With 0099 applied, Terminal on a
tmux-operatorinstance with a live runner lists sessions and captures a pane — oncda75e28-…. ✅ Verified LIVE.GET /v1/instances/cda75e28-…/runtime/status→online, nodeMac, runner0.4.40. Then, against the real route with the real gate, using the exact argument shapeslib/terminalTools.tsnow builds:tmux_new_session{session, workDir}(createArgs, nobackend)Created tmux session "pags-verify-409" in /tmp.tmux_list_sessions{}{"name":"pags-verify-409","windows":1,"attached":false,"activeCommand":"zsh","activeWindow":"tmp","created":"1786160412"}tmux_run_command{session, command}(runArgs)tmux_capture_pane{session, lines:500}(captureArgs)tmux_send_keys{session, text, keys:"Enter"}— comma-string (sendKeysArgs)tmux_kill_session{session}(killArgs)Killed tmux session "pags-verify-409".The session was created and killed by this check;
tmux_list_sessionsis back to[]. Control:POST …/tools/terminal_captureon the same instance still returns 403"terminal_capture" is not one of this agent's tools— the family really did move, and the tab really is calling the other one.2. Session rows render with a name —
parseTargetsno longer discards rows for a missingbackend/id. ✅ Verified against the live runner's own output, not a fixture. That row above has nobackendand noid— exactly the trap. Fed through the shippedparseTargets(TMUX_FAMILY, …)it yields[{"backend":"tmux","id":"pags-verify-409","name":"pags-verify-409","windows":1,…}], keytmux:pags-verify-409. Non-empty.store/console/src/lib/terminalTools.ts:271-273is where the backend is supplied console-side.3. All four writes work on both live instances without re-granting consent. ✅
GET /toolson both tmux-operator instances (cda75e28-…,1a8486b5-…) reportswriteConsent: grantedfortmux_run_command,tmux_send_keys,tmux_new_session,tmux_kill_session— nothing was re-granted. Three of the four were then actually executed oncda75e28(table above);1a8486b5's were verified by consent verdict only, not executed.4. An absent tool says which tool and why — not a 403 string, not "No terminal targets found". ✅
lib/tmuxView.ts:201-215: theunsupportedbranch outranks offline and prints "This agent doesn't have the tool this tab needs (terminal_list_targets or tmux_list_sessions). An agent's creator declares which tools it has — Settings › Tools lists them.", with empty states "This agent has no terminal tool, so nothing was asked of your machine." Also verified by e2e (below), including that the notice stands across polls and that no tool call is made at all in that state — the 4s refusal storm is gone.5. No
terminal_*literal inTmuxTab.tsx; the backend picker is gone. ✅ Ran the shippedfindToolLiteralsover the realTmuxTab.tsxagainst the derived 43-name vocabulary →[]. Picker:TmuxTab.tsx:456-463renders it only whenbackends.length > 1, andTMUX_FAMILY.backends === ["tmux"], so it is absent for this agent and unchanged for the other three.6.
terminal-operator,kitty-operator,iterm-operatorunchanged. ✅ Verified LIVE against production D1 viaGET /v1/agents/my/agents— all three agent rows still declare the sixterminal_*tools, all stilldraft;tmux-operatoralone declares the sixtmux_*and ispublished.GET /v1/instances/da39100b-…/toolsconfirmsterminal_*=allowed=true reason=okandtmux_*=not_declared. The family is resolved from the declared set, not from a slug (resolveTerminalAccess,terminalTools.ts:132-137), the generic family is first in preference order (:104), and the copy is derived fromfamily.backendsrather than switched on the id (tmuxView.ts:169-171) so the three Operators' sentences come out byte-identical. Covered byterminalTools.test.ts:41-47and:102-110.7. A guard test fails if a new hardcoded tool name appears unguarded. ✅
store/console/src/lib/toolLiterals.test.tsexists — and I proved it bites rather than taking it on trust, without editing the tree: running the shippedtoolVocabulary/findToolLiteralsout of a scratch copy,callTool("terminal_capture")→[{line:1,name:"terminal_capture"}], i.e. a stray not inALLOWED→ the "every hardcoded tool name is in a file that is allowed to hold one" assertion fails;TmuxTab.tsxwith one literal prepended → a finding → the "TmuxTab names no tool at all" assertion fails.Migration 0099 is applied in production
Not merely merged.
GET /v1/agents/my/agentsshows thetmux-operatoragent row carryingcapabilities.tools = [tmux_list_sessions, tmux_capture_pane, tmux_run_command, tmux_send_keys, tmux_new_session, tmux_kill_session],surfaces:["tmux"],published; both instances resolve the same. Theinstance_connector_consentcarry is live (criterion 3).Tests re-run here
Playwright, Chromium and WebKit at 320px and 390px:
CI on
782f00c: CI ✅, Deploy API ✅, Deploy Host ✅.What I could NOT verify
tmux_*-only fixture using the runner's real row shape, and the API half is covered live — but nobody has literally openedproagentstore.online/consoleon this instance and looked at it.1a8486b5-…'s write tools were not executed, only their consent verdict read.cda75e28is the instance the criteria name and it was exercised fully.0.4.40. Irrelevant here — [bug] #403 breaks the Terminal tab: the console calls terminal_* by name, and the tmux Operator will no longer declare them #409 deliberately did not touch the runner, which is why the tmux rows get theirbackendconsole-side.Closing.
feat(capabilities): an operator drives ONE terminal, chosen by its su…