SLOPSHOPPER

wmux-turn-signals

Reports an interrupted Claude Code turn (Esc / Ctrl+C), which fires no classic Stop hook, to wmux so the pane settles instead of staying 'running'. Requires…

newprocesstimer
★ 429v0.1.0MITupdated 2026-10-12openwong2kim/wmux/integrations/claude-mod
A shopper browsing a rack in a slop shop
README

wmux-turn-signals

A Claude Code mod (function hooks, Claude Code 2.1.296+) that closes one gap the classic hook bridge cannot: an interrupted turn.

When you press Esc (or Ctrl+C) mid-turn, Claude Code fires no Stop hook. A pane wmux tracks through hooks then stays running until the next prompt. Claude Code's own turn.complete event does fire there, with reason: 'aborted'. This mod replays the missing Stop through the same bridge script the classic hooks use, marked wmux_turn_reason: 'aborted'. wmux then settles the pane without a "work finished" alarm, because you stopped the turn yourself.

Turns that end normally, and turns a background task starts, already get their classic UserPromptSubmit / Stop (measured on 2.1.296). The mod sends nothing for them. Nor does it send anything when a queued prompt starts the next turn right after the interrupt (it waits 1 s to see): that turn owns the pane, and its own Stop closes it.

Install

The classic hooks must already be installed (wmux setup-hooks, or the wmux-claude-integration plugin). This mod is an addition, not a replacement. Then, inside Claude Code:

/plugin marketplace add openwong2kim/wmux
/plugin install wmux-turn-signals@wmux

The mod runs the bridge at ~/.wmux/hooks/wmux-bridge.mjs, where wmux setup-hooks installs it. If yours lives elsewhere (for example, only the marketplace plugin is installed), set WMUX_BRIDGE_PATH to its path. Outside a wmux pane (no WMUX_PTY_ID) the mod does nothing.

Limits

  • Mods are early access in Claude Code; the API may change between releases.
  • $.process is CLI-only, so the mod does nothing under SDK or desktop hosts.
  • A turn that dies on an API error is not filled in. The classic StopFailure covers rate limits, and with no way to tell whether it fired, a second report could overwrite its rate_limit reason.
  • A reload of the mod (an edit under --plugin-dir, or an update) forgets the turn in flight, so interrupting that one turn is not reported.
  • On a Team organization, Claude Code's default security plugin bypasses a user mod's hooks on classic.* and prompt.compose. The mod relies on neither of them.

Develop

claude plugin validate integrations/claude-mod
claude plugin test integrations/claude-mod
claude --plugin-dir integrations/claude-mod   # a live session with the mod loaded

Loading the mod once writes its API types to .claude-plugin/types/ (ignored by git); after that, tsc -p integrations/claude-mod type-checks it.

Source 1 files
hooks/register.ts 100 lines
1// wmux × Claude Code mod: the turn end classic hooks never fire.
2//
3// The classic bridge (integrations/claude, or `wmux setup-hooks`) stays the
4// signal path for every turn edge it covers. One it cannot: an interrupted
5// turn (Esc / Ctrl+C) fires no Stop (measured on Claude Code 2.1.296), so a
6// hook-governed pane stays "running" until the next prompt. The engine's own
7// `turn.complete` does fire there, with `reason: 'aborted'`, so this mod
8// replays the missing Stop through the SAME bridge script: one wire format
9// and one daemon path. `wmux_turn_reason` tells the daemon the turn was
10// interrupted, which settles the pane without a "work finished" alarm.
11//
12// An interrupt with a prompt queued behind it is not a stop: the engine
13// starts the queued turn at once (its UserPromptSubmit already fired at
14// Enter) and does not wait on this hook. A Stop sent then would land inside
15// that turn and close its completion gate, so the mod waits a beat and sends
16// nothing when another turn has started. A prompt typed just after that beat
17// can still beat the Stop to wmux, so the Stop carries the interrupt time and
18// the daemon drops it when a newer prompt already landed.
19//
20// Deliberately not done here: observing the classic hooks (`classic.*`) or
21// adding a system prompt section (`prompt.compose`). A Team organization
22// seats Claude Code's default security plugin outermost, and it bypasses a
23// user plugin's hooks on those events, so neither can be relied on.
24
25import type { EngineInterface, Register } from 'claude-code'
26
27/** Hard cap on the bridge run so a hung child never piles up. */
28const BRIDGE_TIMEOUT_MS = 10_000
29/** How long after an interrupt a queued turn has to start before the pane
30 *  counts as stopped. The engine starts a queued turn within milliseconds. */
31export const QUEUED_TURN_GRACE_MS = 1_000
32
33async function bridgePath($: EngineInterface): Promise<string | undefined> {
34  const override = await $.env.get('WMUX_BRIDGE_PATH')
35  if (override) return override
36  // `wmux setup-hooks` (and the app's first-run hook install) copies the
37  // bridge here so it survives app updates.
38  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
39  return home ? `${home}/.wmux/hooks/wmux-bridge.mjs` : undefined
40}
41
42async function replayInterruptedStop(
43  $: EngineInterface,
44  answer: string,
45  endedAt: number,
46  stillCurrent: () => boolean,
47): Promise<void> {
48  // Outside a wmux pane there is nobody to tell; the bridge would drop it.
49  if (!(await $.env.get('WMUX_PTY_ID'))) return
50  const bridge = await bridgePath($)
51  if (!bridge || !(await $.fs.exists(bridge))) return
52  // No session_id: the bridge would rebind the pane's resume target to it,
53  // and after a resume the engine's id is not the transcript the pane resumes
54  // (the classic hooks derive that from transcript_path, which mods lack).
55  const payload = {
56    hook_event_name: 'Stop',
57    cwd: await $.session.cwd(),
58    last_assistant_message: answer,
59    wmux_source: 'mod',
60    wmux_turn_reason: 'aborted',
61    wmux_turn_ended_at: endedAt,
62  }
63  // The lookups above yield; a turn may have started meanwhile.
64  if (!stillCurrent()) return
65  await $.process.run(['node', bridge, 'Stop'], {
66    stdin: JSON.stringify(payload),
67    timeoutMs: BRIDGE_TIMEOUT_MS,
68  })
69}
70
71export const register: Register = on => {
72  // The main loop's latest turn: an interrupted turn still current after the
73  // grace period had nothing queued behind it.
74  const state: { currentTurnId: string | undefined } = { currentTurnId: undefined }
75
76  on('turn.start', async ($, e, next) => {
77    const started = await next(e)
78    state.currentTurnId = started.turnId
79    return started
80  })
81
82  on('turn.complete', async ($, e, next) => {
83    const done = await next(e)
84    // Subagent runs end with SubagentStop; only the main loop's interrupt is a gap.
85    if (e.agentId === undefined && e.reason === 'aborted' && state.currentTurnId === e.turnId) {
86      const interrupted = e.turnId
87      const answer = e.answer
88      const endedAt = await $.clock.now()
89      const stillCurrent = (): boolean => state.currentTurnId === interrupted
90      $.clock.after(QUEUED_TURN_GRACE_MS, () => {
91        if (!stillCurrent()) return
92        replayInterruptedStop($, answer, endedAt, stillCurrent).catch(err =>
93          $.ui.log(`wmux: Stop replay failed: ${String(err)}`, { to: 'debug' }),
94        )
95      })
96    }
97    return done
98  })
99}
100