Why the "tmux Operator" shows iTerm2 windows
Because it is not a tmux agent. It is a generic terminal agent with a tmux name.
Its declared tools (live, list_instance_tools on cda75e28) are the generic connector's:
terminal_list_targets · terminal_capture · terminal_run_command
terminal_send_keys · terminal_new_target · terminal_kill_target
terminal_list_targets takes backend: "all" | "tmux" | "kitty" | "iterm2", defaulting to
all (connectors/terminal.ts:50), and backend(input) (:22) simply reads back whatever the
model passed. Nothing in the agent's capabilities constrains it. So the name is a label and the
capability is "every local terminal on the machine" — the agent behaved correctly and reported two
iTerm2 windows, because that is genuinely what it can reach.
There is an immediate fix that needs no code. A tmux-only connector already exists in the
registry (registry.ts id: "tmux") with six backend-exclusive tools — tmux_list_sessions,
tmux_capture_pane, tmux_run_command, tmux_send_keys, tmux_new_session, tmux_kill_session.
Declaring THOSE in the agent's capabilities.tools instead of the generic six makes it a tmux agent
by construction: there is no iTerm2 code path behind them to reach. That is a config change on the
agent row.
That fixes one agent. The rest of this ticket is why the general case still needs building.
The gap: there is no ceiling a creator can declare
capabilities already lets a creator declare surfaces, runtime, workflow and a tool allowlist. What
it cannot express is a constraint on a tool's arguments — "this agent may use the terminal
connector, but only the tmux backend". Today the only ways to narrow an agent are to pick different
tools (works here only because a parallel tmux connector happens to exist) or to write it in the
prompt (which is a request, not a boundary — #386 and ADR 0001 are this month's reminder that a
constraint the model can talk around is not a constraint).
The design — two different things, and conflating them is the mistake
The platform already draws this line and it should be applied rather than re-invented:
|
who decides |
what it is |
capabilities (surfaces/runtime/workflow/tools) |
creator, immutable by the subscriber |
what the agent IS |
instance config / settings (runnerNode, settings, consent) |
subscriber |
how this instance is used |
(a) The ceiling — creator-declared, subscriber CANNOT widen.
capabilities.surfaceOptions.terminal.backends: ["tmux"]. A tmux Operator can never see or touch
iTerm2, on any instance, ever. This belongs with capabilities because it is a claim the catalog
makes: the agent is named tmux Operator and lintAgentClaims (#362) already checks descriptions
against capabilities. If a subscriber could widen it, the description would become false by
configuration — which is exactly what that lint exists to prevent.
(b) The binding — subscriber-chosen, within the ceiling.
Which tmux session this instance drives. This is the same shape as config.runnerNode: that the
agent uses a machine is the creator's declaration; which machine is the subscriber's.
"Should an agent connect to only one terminal?" — right for some, wrong as a rule
Making one-target universal would be a mistake, and the platform already has the precedent for the
correct form: surfaceOptions.coding.repos: "single" — declared per agent, not imposed on all
of them, because a Repo Coder owns one repo while the Coder surveys many.
The same reasoning applies exactly:
- A named operator ("Heartfull tmux") should be
targets: "single". One pane, one job. A
mis-selected target here is a shell command on a real machine, so ambiguity is not a cosmetic
problem.
- The generic Terminal Operator must stay
many — surveying every backend is its entire
purpose; it is the agent that exercises the connector.
So: terminal: { backends: [...], targets: "single" | "many" }, default many so nothing
changes for existing agents, and a single-target agent gets a subscriber-bound target the way it
gets a bound runner node.
The part to insist on: enforce it server-side
With targets: "single" and a bound target, a tool call naming a different target must be
refused in the handler, not merely hidden in the console and not merely discouraged in the
prompt. Same for a backend outside the declared set. Otherwise this is a labelling convention that
the first confidently-wrong model call walks straight through — and the value here is the guarantee,
not the tidiness.
Natural home: runRegistryTool, beside the write-consent gate (#90), which is already the place a
tool call is refused for reasons the model does not control.
Acceptance
Files: workers/api/src/lib/connectors/terminal.ts:22,50,
workers/api/src/lib/connectors/registry.ts (terminal + tmux),
workers/api/src/lib/agent-capabilities.ts (sanitizeDeclaredCapabilities, surfaceOptions),
workers/api/src/lib/tool-registry.ts (runRegistryTool). Related: #362 (claims lint), #90
(write consent), ADR 0001 (why a prompt is not a boundary).
Why the "tmux Operator" shows iTerm2 windows
Because it is not a tmux agent. It is a generic terminal agent with a tmux name.
Its declared tools (live,
list_instance_toolsoncda75e28) are the generic connector's:terminal_list_targetstakesbackend: "all" | "tmux" | "kitty" | "iterm2", defaulting toall(connectors/terminal.ts:50), andbackend(input)(:22) simply reads back whatever themodel passed. Nothing in the agent's capabilities constrains it. So the name is a label and the
capability is "every local terminal on the machine" — the agent behaved correctly and reported two
iTerm2 windows, because that is genuinely what it can reach.
There is an immediate fix that needs no code. A tmux-only connector already exists in the
registry (
registry.tsid: "tmux") with six backend-exclusive tools —tmux_list_sessions,tmux_capture_pane,tmux_run_command,tmux_send_keys,tmux_new_session,tmux_kill_session.Declaring THOSE in the agent's
capabilities.toolsinstead of the generic six makes it a tmux agentby construction: there is no iTerm2 code path behind them to reach. That is a config change on the
agent row.
That fixes one agent. The rest of this ticket is why the general case still needs building.
The gap: there is no ceiling a creator can declare
capabilitiesalready lets a creator declare surfaces, runtime, workflow and a tool allowlist. Whatit cannot express is a constraint on a tool's arguments — "this agent may use the terminal
connector, but only the tmux backend". Today the only ways to narrow an agent are to pick different
tools (works here only because a parallel tmux connector happens to exist) or to write it in the
prompt (which is a request, not a boundary — #386 and ADR 0001 are this month's reminder that a
constraint the model can talk around is not a constraint).
The design — two different things, and conflating them is the mistake
The platform already draws this line and it should be applied rather than re-invented:
capabilities(surfaces/runtime/workflow/tools)runnerNode,settings, consent)(a) The ceiling — creator-declared, subscriber CANNOT widen.
capabilities.surfaceOptions.terminal.backends: ["tmux"]. A tmux Operator can never see or touchiTerm2, on any instance, ever. This belongs with capabilities because it is a claim the catalog
makes: the agent is named tmux Operator and
lintAgentClaims(#362) already checks descriptionsagainst capabilities. If a subscriber could widen it, the description would become false by
configuration — which is exactly what that lint exists to prevent.
(b) The binding — subscriber-chosen, within the ceiling.
Which tmux session this instance drives. This is the same shape as
config.runnerNode: that theagent uses a machine is the creator's declaration; which machine is the subscriber's.
"Should an agent connect to only one terminal?" — right for some, wrong as a rule
Making one-target universal would be a mistake, and the platform already has the precedent for the
correct form:
surfaceOptions.coding.repos: "single"— declared per agent, not imposed on allof them, because a Repo Coder owns one repo while the Coder surveys many.
The same reasoning applies exactly:
targets: "single". One pane, one job. Amis-selected target here is a shell command on a real machine, so ambiguity is not a cosmetic
problem.
many— surveying every backend is its entirepurpose; it is the agent that exercises the connector.
So:
terminal: { backends: [...], targets: "single" | "many" }, defaultmanyso nothingchanges for existing agents, and a single-target agent gets a subscriber-bound target the way it
gets a bound runner node.
The part to insist on: enforce it server-side
With
targets: "single"and a bound target, a tool call naming a different target must berefused in the handler, not merely hidden in the console and not merely discouraged in the
prompt. Same for a backend outside the declared set. Otherwise this is a labelling convention that
the first confidently-wrong model call walks straight through — and the value here is the guarantee,
not the tidiness.
Natural home:
runRegistryTool, beside the write-consent gate (#90), which is already the place atool call is refused for reasons the model does not control.
Acceptance
terminal.backendsandterminal.targets; both are sanitised andpersisted with the rest of
capabilities.message that says which constraint applied.
true.
backendsabsent,targets: "many") is byte-identical to today.Files:
workers/api/src/lib/connectors/terminal.ts:22,50,workers/api/src/lib/connectors/registry.ts(terminal+tmux),workers/api/src/lib/agent-capabilities.ts(sanitizeDeclaredCapabilities,surfaceOptions),workers/api/src/lib/tool-registry.ts(runRegistryTool). Related: #362 (claims lint), #90(write consent), ADR 0001 (why a prompt is not a boundary).