Interrupting or waking another session (the two confirmed escalations)
Two opt-in escalations when one agent messages another: kill a busy target's turn instead of waiting for it to finish, and wake a paused, archived, ended or errored target instead of being refused. Both are off by default because both have real consequences, and both must be asked for explicitly. Also the per-target circuit breaker, which stops a sender looping against one target after three terminal refusals and tells it to re-route instead.
What it is
Two opt-in, confirmed escalations on
POST /sessions/:id/peer-message: kill a busy target's in-flight turn instead of waiting for it (confirmInterruptTurn), and wake a paused / archived / ended / errored target instead of being refused (confirmInactiveTarget). Both are shipped, tested, and already live — this page exists because neither was documented anywhere before now.
Where to find it
Agent-facing rather than user-facing: these are two fields an agent adds to the peer-message route on the local control server. Nothing in the UI exposes them, deliberately — a session interrupts or wakes another one on purpose, from code.
How it behaves
The short version
| You want to… | Send | Default behavior without it |
|---|---|---|
| Interrupt a target mid-turn right now | confirmInterruptTurn: true |
Message is held on the turn-boundary queue, delivered when the turn ends |
| Wake a paused / archived / ended / errored target | confirmInactiveTarget: true |
409 inactive_target_needs_confirmation — nothing is touched |
Both fields go on the same route, POST /sessions/:id/peer-message —
see that page for the base mechanics (auth, provenance header, rate limiting, idempotency). This
page covers only the two escalations and one sharp edge.
Auth reminder: you send this as your own session, with your scoped $AMC_CLI_TOKEN and your
own X-AMC-Source-Session-Id. The route admits the agent-session tier (admitAgentSession +
allowAgentSession) because a session reaching a peer is the whole point of it.
The reverse is what is refused: the GLOBAL ~/.amc/cli-token acting as a named session — an
owner-file caller that declares a source session id is turned away here. That pairing is what let a
caller send a message wearing a live session's identity, attributed to it and billed to it, so the
route's own guard rejects it.
The owner credential cannot use this route at all — in either shape. The obvious workaround is
to drop the header and call as the owner alone, and it does not work: this route also requires an
identified sender, and a header-less owner proves no session, so it is refused 400 as source-less.
The two refusals point at each other, and that is deliberate — a cross-session delivery that spends
a paid turn must be attributable to a real session. A session reaches a peer with its own token,
which is why the scoped tier is admitted here in the first place.
1. Interrupt a busy target — confirmInterruptTurn
By default, peer-message never interrupts a target that is mid-turn: the message is written to
that session's durable turn-boundary queue and the call returns 200 { queued: true }. That is
correct almost all of the time — nothing is lost, nothing is destroyed. confirmInterruptTurn: true
is the deliberate override for the rare case where your message matters more than the work the
target is doing right now: it SIGKILLs the in-flight turn and respawns with your message, exactly
like a human clicking "Send now (interrupts the current step)" in the composer. If messages are
already queued for the target — including the held copy of this one from a first plain send — the
interrupt delivers them in the same turn, oldest first and yours last: a message never jumps the
queue.
Native targets only. An external-engine target (gemini/codex/pi/openclaw/…) has no kill-and-respawn path, so a busy one still queues even with the flag set.
Recipe
TOKEN=$AMC_CLI_TOKEN
# Step 1 — a plain send. If the target is mid-turn, this is HELD, not dropped.
curl -s -X POST "http://127.0.0.1:19519/sessions/<target-id>/peer-message" \
-H "Authorization: Bearer $TOKEN" \
-H "X-AMC-Source-Session-Id: $AMC_SESSION_ID" \
-H "Content-Type: application/json" \
-d '{"text":"Master moved under you — rebase before you tag ready-to-merge."}'
# → 200 { "ok": true, "delivered": true, "wokenFrom": null, "queued": true,
# "detail": "...re-send with confirmInterruptTurn: true..." }
# Step 2 — only if it genuinely cannot wait: kill the in-flight turn and deliver now.
curl -s -X POST "http://127.0.0.1:19519/sessions/<target-id>/peer-message" \
-H "Authorization: Bearer $TOKEN" \
-H "X-AMC-Source-Session-Id: $AMC_SESSION_ID" \
-H "Content-Type: application/json" \
-d '{"text":"Master moved under you — rebase before you tag ready-to-merge.","confirmInterruptTurn":true}'
# → 200 { "ok": true, "delivered": true, "wokenFrom": null }
A queued: true response is a success — do not retry the plain call. Only send Step 2 when you
have decided the interruption is worth it.
Idempotency caution. If you attach X-Client-Request-Id to Step 1, its queued: true response
is a success and is cached for ~10 minutes. Reusing that same key on the Step 2 (confirmed)
call replays the cached queued result instead of actually interrupting — mint a fresh
X-Client-Request-Id for Step 2. (This is the opposite of the wake recipe below, where reusing the
same key on the confirmed retry is explicitly safe — the difference is that Step 1 here succeeds,
while a dormant-target refusal does not.)
The sharp edge: a second route interrupts with no flag at all
POST /session/:id/message — the "send the user's next turn" route — has no interrupt guard at
all. It calls sendResponse with isAutoResponse: false, and the class-wide mid-turn guard that
protects every mechanical sender only fires when isAutoResponse is true. So this route always
behaves like a human hitting "Send now": if the target is mid-turn, it kills and respawns it
immediately, unconditionally — no confirmInterruptTurn, no queueing, no opt-out.
This is intentional for a human-typed message (that IS what "Send now" means), but it means an
agent calling POST /session/:id/message gets the destructive behavior by default, while
peer-message requires you to opt in. If you want the safe-by-default behavior (hold until the
turn ends, unless you explicitly say otherwise), use peer-message, not this route. This asymmetry
is a known, accepted design tradeoff — not a bug, and not something this page changes.
2. Wake a dormant or archived target — confirmInactiveTarget
peer-message's target can be in any status. For a target that is paused, archived, ended,
or error (collectively "dormant"), waking it is a real, visible side effect — it re-enters the
live session list and starts spending money again — so by default the call refuses:
409 { "ok": false, "error": "inactive_target_needs_confirmation",
"targetStatus": "archived",
"detail": "\"<name>\" (<id>) is ARCHIVED — delivering will pull it back into the live session list and it will start working again. Nothing has been sent. If you meant to wake it, re-send the same request with \"confirmInactiveTarget\": true." }
Nothing is unpaused, unarchived, spawned, or written on the refusal. Re-sending the identical
request with confirmInactiveTarget: true proceeds and wakes it.
Recipe
TOKEN=$AMC_CLI_TOKEN
# Step 1 — plain send to a target you believe may be dormant.
curl -s -X POST "http://127.0.0.1:19519/sessions/<target-id>/peer-message" \
-H "Authorization: Bearer $TOKEN" \
-H "X-AMC-Source-Session-Id: $AMC_SESSION_ID" \
-H "Content-Type: application/json" \
-d '{"text":"New findings on the auth bug — can you take another look?"}'
# → 409 { "ok": false, "error": "inactive_target_needs_confirmation", "targetStatus": "archived", "detail": "..." }
# Step 2 — confirm and proceed. Safe to reuse the SAME X-Client-Request-Id here if you used
# one on Step 1 — a 409 releases the idempotency claim (unlike the interrupt recipe above).
curl -s -X POST "http://127.0.0.1:19519/sessions/<target-id>/peer-message" \
-H "Authorization: Bearer $TOKEN" \
-H "X-AMC-Source-Session-Id: $AMC_SESSION_ID" \
-H "Content-Type: application/json" \
-d '{"text":"New findings on the auth bug — can you take another look?","confirmInactiveTarget":true}'
# → 200 { "ok": true, "delivered": true, "wokenFrom": "archived" }
wokenFrom tells you what you just did: "paused" (unpaused), "archived" (unarchived),
"ended" / "error" (a fresh CLI process was spawned). null means the target was already live
— or that a PAUSED target HELD your message instead of being woken. queued: true tells those
two apart, and it is the only difference between "the receiver has it now" and "the receiver will
get it when a person unpauses it". See the pause rule below.
A paused target is HELD, not woken (2026-09-28)
Your confirmation is not enough to unpause a session. A pause on this box is a person's own
command — nothing automatic writes it, and the reaper's floors, the adopt gate and the stranded
triage all read it as a standing human hold. So a delivery from an agent, a cron or a machine
notice to a paused target is written to that session's OWN turn-boundary queue and handed over
when a person unpauses it:
# → 200 { "ok": true, "delivered": true, "queued": true, "wokenFrom": null }
confirmInactiveTarget: true does not change that — it only clears the 409. Nothing is lost, so
do not retry: the row is durable and drains on the unpause. Only a PERSON's own send (the
composer, or an operator broadcast) still unpauses a paused target.
Why: the wake used to be unconditional, so any machine traffic undid a person's pause — measured 2026-09-28, 25 sessions were paused at 05:15Z and three were running again 1.5 to 26 minutes later with no human resume, each flipped by its own next gate verdict or sibling message. A pause that holds only until the next notification is not a load lever.
For a Claude target, the 200 comes back once the message is accepted — not once the target is
running again (2026-09-26). Waking a target with no running process means restarting its CLI,
which can take a couple of minutes on a busy machine, so the route no longer holds your call open
for it. If that restart then fails, you are still told: a "was NOT delivered" row lands in your own
conversation and the full text is kept (readable from GET /peer-dead-letters), exactly as for a
send that fails outright.
Two exemptions, and one internal override, skip the confirmation gate
- Sending to yourself (
senderSessionId === targetSessionId) — the confirmation this gate asks for can never arrive from a session that is asleep, so a self-wake (a wake schedule, a heartbeat, an in-session cron addressing its own id) delivers to a paused, ended or errored session. - Replying to a session that messaged you first — if the target sent you a peer-message within the last 24 hours, your reply wakes it without confirmation. Provable only from your own transcript (the target's session id must appear there), so this can't be talked into by a caller-supplied claim.
Neither exemption reaches an ARCHIVED session (the owner's rule, 2026-09-24: "Agents can force
through if important otherwise block"). A reply or a self-send to an archived session gets the same
409 inactive_target_needs_confirmation as any other send, and so does POST /session/:id/message.
Archived means the conversation is over — re-send with confirmInactiveTarget: true only when it
genuinely matters.
- The statuses a mechanical sender has ALREADY confirmed (
wakeConfirmedInactiveStatuses) — an internal option, not in the route's request body, so you cannot set it frompeer-message. It names dormant statuses the caller has decided to wake, and the guard skips exactly those. It exists for the one sender that can never answer the confirmation: the Review Service delivering a finished review's whole result back to the session that asked for it (review-service/deliver-review.ts). There the sender is the app, every reviewer behind the result is already archived, and the delivery is attempted once — so a refusal is not a prompt, it is a silent loss. It namesarchived,endedanderror— a finished review wakes the session that asked for it however it went to sleep — and neverpaused, which on this box is always a person's command. A named list is that caller's whole policy: the self-send and reply exemptions above never widen it.
Two guards that beat even a confirmed request
user_closedalways wins. If a person closed the target session,confirmInactiveTargetdoes not reopen it — you get409 { "error": "user_closed" }regardless. (A different opt-in,allowReopenUserClosed, exists for that specific case and is currently reserved for the Auto-lander's hand-back flow.)noWakeFromArchivedhard-refuses an archived target with{ "error": "session_archived" }. You cannot set this yourself frompeer-message— it isn't in the route's request body. It's an internal option several automated senders set on themselves (the broadcast fan-out, the cron wake adapter, worktree-ledger nudges) so routine automation can never resurrect a session someone deliberately archived. If you're callingpeer-messagedirectly withconfirmInactiveTarget: true, this guard does not apply to you. The Review Service's own delivery deliberately does not set it — a finished review has to reach the session that asked for it, and that session is routinely one the app archived after it finished; that caller uses the per-status list above instead, which is what keeps apausedsession out of its reach.
A guard that beats REPEATED attempts: target_circuit_open
Sending to a target that refuses you is a normal, recoverable event — a target can be busy, dormant, or closed. Sending to it three times in a row and getting a terminal refusal every time is a loop, and it is the one failure this route will not keep carrying for you.
Once your session has collected three terminal refusals (user_closed, session_archived,
session_ended, target_gone, session_not_found, or an unconfirmed dormant target) against one
target inside two hours, the next send is refused before anything is attempted:
409 { "ok": false, "error": "target_circuit_open",
"detail": "3 messages in a row to that session have now been refused, the last because that session was closed. Stop re-sending: send this to a different session, or raise it with your overseer. That session will keep refusing until 2026-09-21T15:00:00.000Z. Nothing was sent this time, and nothing was lost — every earlier attempt is kept in full, so the text does not need re-sending to keep it.",
"circuit": { "count": 3, "lastReason": "user_closed", "resetsAt": "2026-09-21T15:00:00.000Z" } }
What to do when you see it:
- Do not re-send. Every attempt until
circuit.resetsAtis refused the same way, and the refusal is written into your own transcript so a later reading of it cannot mistake the send for a success. - Re-route the work to a session that is actually reading, or raise it with your overseer — the two moves the refusal names.
- Nothing is lost. Every attempt the count is made of was preserved in full, so the text does not need re-sending anywhere to keep it. The refusal itself adds no new copy.
The count is per SENDER and per TARGET: another agent's failures against that session do not count against you, and your failures against other sessions do not count against this one. A wait that resolves on its own — a busy target, a stalled one, a Do-Not-Disturb hold, an over-full queue — is never counted, and a message that does get through clears the count back to zero. The breaker closes by itself when the oldest counted refusal leaves the two-hour window.
For agents
Implementation notes (for agents touching this code)
confirmInterruptTurn: field + doc block in peer-message-service.ts (DeliverPeerMessageOpts, around line 280); normalised intoallowInterruptInFlightTurnand logged atlog.warnindeliverPeerMessage(~688-697). (It used to also bypass aneeds_youinbox-card dwell hold; that hold was retired 2026-09-08 — an idle target is delivered to immediately again. See message-queue-contract I15.) Route schema + wiring in cli-server-peer-routes.ts (schema ~165, wiring ~675); the200 { queued: true }response'sdetailthat advertises the flag is built at ~493-501.confirmInactiveTarget:DORMANT_TARGET_STATUSESand the confirmation gate in peer-message-service.ts (statuses ~37, gate ~1179-1197, exemptions ~947-971 and ~1179,user_closed~999-1009,noWakeFromArchivedenforcement ~1231-1241). Route schema + inversion intorequireInactiveTargetConfirmationin cli-server-peer-routes.ts (schema ~158, inversion ~670); the release-on-refusal idempotency behavior is documented in that file's header comment (~88-98).- The sharp edge:
POST /session/:id/message'ssendResponsecall withisAutoResponse: falseis in cli-server-session-action-routes.ts (~410-427); the class-wide guard it skips is in user-message-router.ts (~211-223), with the kill-and-respawn mechanism it protects (killInFlightTurnForNewMessage) described just above it. - Contract: agent-messaging-contract.md
—
never-destroys-a-turnandnever-resurrectsname both of these as the sanctioned, explicit, recorded exceptions to the default "never interrupt, never resurrect" behavior.
Related
See also
- cross-session-messaging.md — the base
peer-message/peer-broadcastmechanics: auth, provenance, rate limiting, idempotency, dead letters, and how an arrival renders - queue-a-message.md — the human-facing composer equivalent of "don't
interrupt, deliver when free," and
POST /session/:id/queueas a durable alternative to retrying a busy peer - send-a-message.md — the composer's normal send, which interrupts a running
turn the same way
confirmInterruptTurndoes, by design, for a human
Last verified 2026-10-05