Esc and Ctrl-C are a per-tab turn interrupt

Esc and Ctrl-C unwind only the focused tab’s current turn — they never cascade to a subtree and never kill an agent. The two keystrokes had fused two unrelated gestures onto one press: interrupting a turn (“stop what this tab is doing now”) and ending an agent (“tear down this delegation tree”). A cancel key must only ever do the first, and only to the tab under your eyes. Lifecycle death stays explicit and separate — /quit, /clear, the reaper ceiling, and the agent_cancel tool are the only ways an agent dies, and the subtree-owning ones keep cascading (agents).

This generalises esc-non-escalating-interrupt’s rule — “an Esc cancels a turn, not the agent” — which the code delivered only for a Held agent (the trunk), from that one node to every tab. A non-Held parked agent used to die the instant it observed a tripped token, so interrupt was death for exactly the agents a human was not watching.

The cause on the token

The drive-loop cancel::Token was a bare flag; it now carries a CancelCause discriminant (0 = uncancelled) and is read two-valuedly at the park — interrupt versus terminate (signals-are-causes gives the cause vocabulary, Interrupt < … < Terminate):

  • is_cancelled() — true for any cause — still unwinds the in-flight turn at ral’s next poll point, so both an interrupt and a terminate drop the current turn.
  • terminated() — new: true for any cause but Interrupt — is what the park reads. A non-Held agent ends only on a terminate-cause cancel; an interrupt-cause cancel drops the turn and the agent re-parks.

So the kill that used to fire whenever a non-Held parked agent saw a tripped token now fires only for a lifecycle terminate. The cascade terminators (agent_cancel, the ceiling reaper, /clear) pass a terminate cause through cancel_entry; raise() and the frontend interrupt store Interrupt. A real SIGTERM/SIGHUP still escalates through core’s separate handler, untouched.

One entry, no walk

The Cancel arm is now trunk-versus-not, and both keys keep returning Cancel, so the key layer needs no split:

  • Off the trunk it routes to AgentRegistry::interrupt(id) — the single-entry, non-cascading turn cancel. It trips one entry’s token and eval-root with Interruptno descendant walk, no deregistration — unwinding one turn and nothing else.
  • On the trunk raise_interrupt() already is the trunk’s single-turn interrupt (its published foreground slot plus the ral foreground child, per-agent-eval-cancel), so the old unconditional cancel(focused) cascade beside it was simply deleted.

A focused agent parks Held, so the tab you just interrupted is always left alive and steerable — the whole point. A defocused idle sub-agent you interrupt and then TAB away from still quiesces and reaps, but that is the ordinary idle-reclaim lifecycle, not the keystroke, and it is unchanged.

Why an interrupt cannot orphan

An interrupt never removes or settles an entry, so it can never strand a settled parent over live registered descendants — the failure the no-orphans invariant forbids. That invariant keeps riding on the existing reply-path cancel_descendants reap (agents), untouched; the lifecycle terminators already cascade, so none of them orphans either. No reap moved.

See also

esc-non-escalating-interrupt (the prior Esc semantics this generalises), signals-are-causes (the cause vocabulary the token now carries), per-agent-eval-cancel (the token + eval-root layering an interrupt trips), agents (the no-orphans invariant and the surviving terminator cascade). Maps: frontend, agent.