---
title: Interrupting or waking another session (the two confirmed escalations)
---

# Interrupting or Waking a Peer Session (confirmed escalations)

## 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`](cross-session-messaging.md) —
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

```bash
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**:

```json
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

```bash
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 from `peer-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](../../src/main/services/dev-pipeline/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 names
  `archived`, `ended` and `error` — a finished review wakes the session that asked for it however it
  went to sleep — and never `paused`, 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_closed` always wins.** If a person closed the target session, `confirmInactiveTarget`
  does not reopen it — you get `409 { "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.)
- **`noWakeFromArchived` hard-refuses an archived target with `{ "error": "session_archived" }`.**
  You cannot set this yourself from `peer-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 calling `peer-message` directly with `confirmInactiveTarget:
  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 a `paused` session 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*:

```json
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.resetsAt` is 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](../../src/main/services/peer-message-service.ts) (`DeliverPeerMessageOpts`,
  around line 280); normalised into `allowInterruptInFlightTurn` and logged at `log.warn` in
  `deliverPeerMessage` (~688-697). (It used to also bypass a `needs_you` inbox-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](../../src/main/services/cli/cli-server-peer-routes.ts) (schema ~165,
  wiring ~675); the `200 { queued: true }` response's `detail` that advertises the flag is built at
  ~493-501.
- `confirmInactiveTarget`: `DORMANT_TARGET_STATUSES` and the confirmation gate in
  [peer-message-service.ts](../../src/main/services/peer-message-service.ts) (statuses ~37, gate
  ~1179-1197, exemptions ~947-971 and ~1179, `user_closed` ~999-1009, `noWakeFromArchived`
  enforcement ~1231-1241). Route schema + inversion into `requireInactiveTargetConfirmation` in
  [cli-server-peer-routes.ts](../../src/main/services/cli/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`'s `sendResponse` call with `isAutoResponse: false` is
  in [cli-server-session-action-routes.ts](../../src/main/services/cli/cli-server-session-action-routes.ts)
  (~410-427); the class-wide guard it skips is in
  [user-message-router.ts](../../src/main/process/user-message-router.ts) (~211-223), with the
  kill-and-respawn mechanism it protects (`killInFlightTurnForNewMessage`) described just above it.
- Contract: [agent-messaging-contract.md](../../.claude/memory/contracts/agent-messaging-contract.md)
  — `never-destroys-a-turn` and `never-resurrects` name both of these as the sanctioned, explicit,
  recorded exceptions to the default "never interrupt, never resurrect" behavior.

## Related

### See also

- [cross-session-messaging.md](cross-session-messaging.md) — the base `peer-message` /
  `peer-broadcast` mechanics: auth, provenance, rate limiting, idempotency, dead
  letters, and how an arrival renders
- [queue-a-message.md](queue-a-message.md) — the human-facing composer equivalent of "don't
  interrupt, deliver when free," and `POST /session/:id/queue` as a durable alternative to
  retrying a busy peer
- [send-a-message.md](send-a-message.md) — the composer's normal send, which interrupts a running
  turn the same way `confirmInterruptTurn` does, by design, for a human

