You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[design] tmux is the one Operator connector with no capability ceiling — CONNECTOR_CONSTRAINTS has a single key, and #403 moved the published agent off it #447
The one published Operator is the one that cannot be bound to a pane — CONNECTOR_CONSTRAINTS has no tmux entry
Filed from the #249 assessment, whose recommendation item 1 is "a tmux entry in CONNECTOR_CONSTRAINTS with a targets binding" and which says plainly: "Needs an issue — I have
not filed one."#249 cannot close without it, because closing #249 "no tmux Coder backend" rests on "use the Operator family if you want attach", and that argument is only honest once the Operator
path can be bound.
The gap, and how two correct decisions produced it
#403 (correct) moved the published tmux-operator off the generic terminal_* six and onto the
six backend-exclusive tmux_* tools, so it is a tmux agent by construction rather than by name.
Verified live — GET /v1/agents/my/agents:
#402/#404 (correct) put the capability ceiling in CONNECTOR_CONSTRAINTS, which has exactly
one key, and says so (workers/api/src/lib/surface-options.ts:186-190):
"terminal is the first and, today, only entry."
Those two compose into a hole. The ceiling vocabulary is keyed by connector id
(parseConstraintSpec(id, raw) → CONNECTOR_CONSTRAINTS[id], :236), and #403 moved the
published agent onto a connector that has no entry. So:
kitty-operator and iterm-operator — drafts, zero instances — carry {"terminal":{"backends":["kitty"]}} / ["iterm2"] and are gated at dispatch.
tmux-operator — published, with two live instances (tmux Operator, Heartfull (tmux)) —
carries no surfaceOptions at all, and could not carry a useful one if someone wrote it: a tmux key is dropped by parseConstraintSpec because the vocabulary has no such connector.
Verified today across all 39 agents on the operator account: exactly three carry surfaceOptions,
and they are the three above.
What that leaves ungoverned.tmux_run_command is scope:"write" arbitrary shell on the user's
machine (connectors/tmux.ts:79-101), gated by one per-connector write-consent row that also
legitimises its five read-only siblings. A named operator — "Heartfull (tmux)" — is exactly the case #402 wrote targets: "single" for: "One pane, one job. A mis-selected target here is a shell
command on a real machine." It is the one shape that cannot be declared.
#441 §3 reports the same absence from the other end — "targets: "single" has never been exercised
end to end" — and explicitly rejects the workaround:
"Ship a targets: "single" declaration on tmux-operator. Rejected for 0104's own reason: since 0099 it declares tmux_* tools whose connector is tmux, which has no constraint vocabulary, so
the field would be dropped by the sanitiser on the next write."
So #441 names this blocker and routes around it (declare on a draft terminal agent instead). This
ticket is the blocker itself. They are complementary and neither subsumes the other: #441 fixes the
gate's behaviour and exercises the binding on the connector that already has a vocabulary; this makes
the published agent able to declare one.
What to do
Add a tmux entry to CONNECTOR_CONSTRAINTS (surface-options.ts:191), one binding
field, no value field:
```ts
tmux: {
sessions: {
kind: "binding",
arg: "session", // every tmux_* tool's schema names it `session`
bindField: "boundSession",
noun: "tmux session",
},
},
```
No `values` field and no `withinField`: the backend is `tmux` by construction (that is what
#403 bought), so there is nothing to narrow and nothing for the binding to sit inside. That
also means `narrowConstraintSpec` and the dispatch gate in `runRegistryTool` are reused
unchanged — this is one entry in a reviewed table, not new enforcement.
Check arg against the tools' own schemas. All six tmux_* tools take session
(connectors/tmux.ts:60,88,114,142,…); tmux_list_sessions takes no arguments at all
(jsonSchema: {type:"object", properties:{}}) and is therefore correctly ungated — a tool
without the arg in its schema is not gated, by design. Add the same schema-vs-vocabulary
assertion terminal.test.ts already makes, so the two cannot drift.
Generalise the binding write route, or add a sibling. routes/instances-terminal.ts:89-91 hardcodes CONNECTOR = "terminal", FIELD = "targets", BIND_FIELD = "boundTarget". The handler body is already connector-agnostic, so the cheapest
correct shape is to parameterise those three and mount a second path — or to make the path
itself carry the connector. This is the decision in the ticket, and I would parameterise
rather than copy: two near-identical handlers writing surfaceOptions is how the two get to
disagree about the read-merge-write at :75-80, which is the part that must not be duplicated.
Declare it on tmux-operator, as a migration in 0099's shape (json_set on $.capabilities.surfaceOptions.tmux, WHERE slug = 'tmux-operator'). Whether the seeded value
is {"sessions":"single"} is the owner's call: it makes an unbound instance refuse rather
than guess, which is the safe default but is a behaviour change for the two live instances.
My recommendation: ship the vocabulary and the route first, leave the agent undeclared, and let
the owner opt in per instance — the capability is what is missing, not the policy.
Give tmux a values constraint too, for symmetry. Rejected: there is nothing to constrain.
A single-valued list covering the whole vocabulary is dropped by parseConstraintSpec anyway
("a list that resolves to the WHOLE vocabulary is not a constraint"), so it would parse to
nothing and read as a ceiling that exists.
parseConstraintSpec("tmux", {sessions: "single", boundSession: "main"}) returns both fields
(today it returns undefined).
An instance of tmux-operator with sessions: "single" and no bound session refuses tmux_capture_pane with the "you must bind one" wording rather than guessing a pane.
With main bound: tmux_capture_pane {session:"main"} runs; {session:"other"} is refused.
tmux_list_sessions still runs unbound (no session in its schema).
A test asserts every arg in the tmux vocabulary appears in the corresponding tools'
JSON-Schema properties, in the shape terminal.test.ts already uses.
Regression risk
The two live instances are the risk.tmux Operator and Heartfull (tmux) run today with no
ceiling. Shipping the vocabulary changes nothing for them (an absent declaration is an absent
ceiling); shipping a seededsessions:"single" would refuse their next call until someone binds
a session. That is why the migration is separated from the vocabulary above.
enforceConstraints returns its input byte-identically when nothing is declared
(surface-options.ts:443-445, asserted in surface-options.test.ts). A new table key must not
disturb that for any connector, tmux included.
Files: workers/api/src/lib/surface-options.ts:154-212,236-260,440-505, workers/api/src/lib/connectors/tmux.ts:29-160, workers/api/src/lib/tool-registry.ts:467-486, workers/api/src/routes/instances-terminal.ts:32-95, workers/api/src/lib/agent-capabilities.ts:561-584, workers/api/migrations/0099_tmux_operator_backend_exclusive_tools.sql, workers/api/migrations/0104_operator_backend_ceilings.sql, docs/capability-constraints.md.
Related: #249 (the assessment that asks for this, recommendation item 1), #402/#404 (the
ceiling and the binding), #403 (which moved the published agent onto the connector with no
vocabulary), #441 (the three other residuals, which explicitly names this blocker), #348
(why an Operator's drives are unmetered, and therefore why governing them matters).
The one published Operator is the one that cannot be bound to a pane —
CONNECTOR_CONSTRAINTShas notmuxentryFiled from the #249 assessment, whose recommendation item 1 is "a
tmuxentry inCONNECTOR_CONSTRAINTSwith atargetsbinding" and which says plainly: "Needs an issue — I havenot filed one." #249 cannot close without it, because closing #249 "no tmux Coder backend" rests on
"use the Operator family if you want attach", and that argument is only honest once the Operator
path can be bound.
The gap, and how two correct decisions produced it
#403 (correct) moved the published
tmux-operatoroff the genericterminal_*six and onto thesix backend-exclusive
tmux_*tools, so it is a tmux agent by construction rather than by name.Verified live —
GET /v1/agents/my/agents:#402/#404 (correct) put the capability ceiling in
CONNECTOR_CONSTRAINTS, which has exactlyone key, and says so (
workers/api/src/lib/surface-options.ts:186-190):Those two compose into a hole. The ceiling vocabulary is keyed by connector id
(
parseConstraintSpec(id, raw)→CONNECTOR_CONSTRAINTS[id],:236), and#403moved thepublished agent onto a connector that has no entry. So:
kitty-operatoranditerm-operator— drafts, zero instances — carry{"terminal":{"backends":["kitty"]}}/["iterm2"]and are gated at dispatch.tmux-operator— published, with two live instances (tmux Operator,Heartfull (tmux)) —carries no
surfaceOptionsat all, and could not carry a useful one if someone wrote it: atmuxkey is dropped byparseConstraintSpecbecause the vocabulary has no such connector.Verified today across all 39 agents on the operator account: exactly three carry
surfaceOptions,and they are the three above.
What that leaves ungoverned.
tmux_run_commandisscope:"write"arbitrary shell on the user'smachine (
connectors/tmux.ts:79-101), gated by one per-connector write-consent row that alsolegitimises its five read-only siblings. A named operator — "Heartfull (tmux)" — is exactly the case
#402 wrote
targets: "single"for: "One pane, one job. A mis-selected target here is a shellcommand on a real machine." It is the one shape that cannot be declared.
Why this is not #441
#441 §3 reports the same absence from the other end — "
targets: "single"has never been exercisedend to end" — and explicitly rejects the workaround:
So #441 names this blocker and routes around it (declare on a draft
terminalagent instead). Thisticket is the blocker itself. They are complementary and neither subsumes the other: #441 fixes the
gate's behaviour and exercises the binding on the connector that already has a vocabulary; this makes
the published agent able to declare one.
What to do
Add a
tmuxentry toCONNECTOR_CONSTRAINTS(surface-options.ts:191), one bindingfield, no value field:
Check
argagainst the tools' own schemas. All sixtmux_*tools takesession(
connectors/tmux.ts:60,88,114,142,…);tmux_list_sessionstakes no arguments at all(
jsonSchema: {type:"object", properties:{}}) and is therefore correctly ungated — a toolwithout the arg in its schema is not gated, by design. Add the same schema-vs-vocabulary
assertion
terminal.test.tsalready makes, so the two cannot drift.Generalise the binding write route, or add a sibling.
routes/instances-terminal.ts:89-91hardcodesCONNECTOR = "terminal",FIELD = "targets",BIND_FIELD = "boundTarget". The handler body is already connector-agnostic, so the cheapestcorrect shape is to parameterise those three and mount a second path — or to make the path
itself carry the connector. This is the decision in the ticket, and I would parameterise
rather than copy: two near-identical handlers writing
surfaceOptionsis how the two get todisagree about the read-merge-write at
:75-80, which is the part that must not be duplicated.Declare it on
tmux-operator, as a migration in0099's shape (json_seton$.capabilities.surfaceOptions.tmux,WHERE slug = 'tmux-operator'). Whether the seeded valueis
{"sessions":"single"}is the owner's call: it makes an unbound instance refuse ratherthan guess, which is the safe default but is a behaviour change for the two live instances.
My recommendation: ship the vocabulary and the route first, leave the agent undeclared, and let
the owner opt in per instance — the capability is what is missing, not the policy.
Alternatives considered and rejected
tmux_*alone and tell users to subscribe to aterminal-connector agent instead.Rejected: that undoes [chore] The tmux, kitty and iTerm2 Operators are one agent wearing three names — declare backend-exclusive tools (no code) #403. The generic
terminal_*tools default tobackend: "all", which isthe disclosure [design] A "tmux Operator" can drive iTerm2 — a creator cannot declare which backend an agent may touch, or that it owns exactly one terminal #402 was filed for — a "tmux Operator" reading the owner's iTerm2 windows.
tmuxavaluesconstraint too, for symmetry. Rejected: there is nothing to constrain.A single-valued list covering the whole vocabulary is dropped by
parseConstraintSpecanyway("a list that resolves to the WHOLE vocabulary is not a constraint"), so it would parse to
nothing and read as a ceiling that exists.
instead of one, and
runRegistryTool's single dispatch gate is the property [design] Capability constraints — declare what an agent may PASS, and enforce it at the tool boundary #404 exists toestablish.
is one entry in a table that already exists and is already the reviewed vocabulary. [coordination][deferred] Retire the workflow closed enum → declarative behavior #160 would
change how the table is authored, not whether
tmuxbelongs in it.Acceptance criteria
parseConstraintSpec("tmux", {sessions: "single", boundSession: "main"})returns both fields(today it returns
undefined).tmux-operatorwithsessions: "single"and no bound session refusestmux_capture_panewith the "you must bind one" wording rather than guessing a pane.mainbound:tmux_capture_pane {session:"main"}runs;{session:"other"}is refused.tmux_list_sessionsstill runs unbound (nosessionin its schema).argin thetmuxvocabulary appears in the corresponding tools'JSON-Schema properties, in the shape
terminal.test.tsalready uses.Regression risk
tmux OperatorandHeartfull (tmux)run today with noceiling. Shipping the vocabulary changes nothing for them (an absent declaration is an absent
ceiling); shipping a seeded
sessions:"single"would refuse their next call until someone bindsa session. That is why the migration is separated from the vocabulary above.
enforceConstraintsreturns its input byte-identically when nothing is declared(
surface-options.ts:443-445, asserted insurface-options.test.ts). A new table key must notdisturb that for any connector,
tmuxincluded.prefix handling does not reach the
tmuxentry, which has no prefix args at all.Files:
workers/api/src/lib/surface-options.ts:154-212,236-260,440-505,workers/api/src/lib/connectors/tmux.ts:29-160,workers/api/src/lib/tool-registry.ts:467-486,workers/api/src/routes/instances-terminal.ts:32-95,workers/api/src/lib/agent-capabilities.ts:561-584,workers/api/migrations/0099_tmux_operator_backend_exclusive_tools.sql,workers/api/migrations/0104_operator_backend_ceilings.sql,docs/capability-constraints.md.Related: #249 (the assessment that asks for this, recommendation item 1), #402/#404 (the
ceiling and the binding), #403 (which moved the published agent onto the connector with no
vocabulary), #441 (the three other residuals, which explicitly names this blocker), #348
(why an Operator's drives are unmetered, and therefore why governing them matters).