Repository navigation
Proposal: an OpenRig Claude Code mod in each Claude seat, so delivery and status don't go through the terminal #728
Description
Activity
Thanks for the detailed proposal, @gopinathrimc, and for mapping it to the open issues it touches from a setup you're already running. It's about giving Claude seats a channel for delivery and status that doesn't go through typing into the terminal. We'll look at it and answer here whether a design discussion or a small prototype PR would be more useful.
To answer your question, @gopinathrimc: a design discussion is more useful right now than a prototype PR. The mod surface is real, and what we'd like to understand first is how a submission behaves in the hard cases, like a seat with a question dialog open.
From the mod you already run, could you share one small example or trace of a prompt submitted while an AskUserQuestion dialog is open, showing what happens to it (whether it's resolved, queued or something else), and the Claude Code version it ran on?
Here's the trace. One correction first: the mod we run today only reports usage and never submits a prompt, so I wrote a small probe mod for this rather than citing that one.
Setup: Claude Code 2.1.289 (native installer) on macOS 15.6.1 arm64, interactive TUI in tmux, a throwaway session on Haiku 4.5. The probe mod starts a
$.clock.everypoller insession.start. When the test writes a trigger file, the poller calls$.prompt.filland then$.prompt.submit. The mod logs everyturn.start,turn.complete,prompt.submitand AskUserQuestion call with a timestamp. The model was asked to call AskUserQuestion once (Red / Blue), and the trigger fired while that dialog was open.Trace: dialog open, then answered (UTC, trimmed):
20:06:53.938 turn.start "Use the AskUserQuestion tool once ..." 20:06:55.763 AskUserQuestion open 20:06:58.073 prompt.fill -> { isFilled: false, refusal: "dialog" } 20:06:58.073 prompt.submit called "PROBE-SUBMIT 3: reply with only the words PONG 3." screen: "› Prompt from the ask-probe plugin" and the text, drawn above the open dialog 20:07:24.298 AskUserQuestion answered "Which color do you prefer?"="Blue" (Down, Enter from the test) 20:07:25.136 turn.complete answer "Blue" 20:07:25.159 prompt.submit resolved 20:07:25.160 turn.start "The ask-probe plugin sent a message:\nPROBE-SUBMIT 3: ..." 20:07:25.964 turn.complete answer "PONG 3"So the answer to your question is queued. The prompt didn't touch the dialog, didn't enter the running turn and wasn't lost. It waited 27 s, then started a turn of its own 23 ms after the turn holding the dialog ended.
$.prompt.fillis refused outright withrefusal: "dialog".Other cases from the same session:
Case $.prompt.fill$.prompt.submitIdle filled the box turn started in 25 ms Normal turn running, no dialog filled the box queued; own turn 19 ms after turn.completeDialog open, then answered refused, dialogqueued (trace above); own turn 23 ms after turn.completeDialog open, then cancelled with Esc refused, dialogqueued; the tool result told the model to "STOP ... and wait for the user", the turn ended, and the plugin's prompt started a new turn 39 ms later Baseline, OpenRig's tmux path: same dialog, then a message sent the way the tmux adapter sends it (
load-buffer,paste-buffer -d -r -p, then a separateC-m). Within 0.4 s the dialog was answered "Red", the highlighted option. The message text never reached the model and isn't in the transcript. So today a sender's Enter can answer a person's question for them, and the message itself is lost.Two things that matter for a design:
- Where the submit comes from. In a variant where the timer was started inside the
tool.callhook for AskUserQuestion, the engine rejected the call:HooksError: ask-probe: prompt.submit: called from a tool.call hook, it would wait on the turn this hook is holding; submit from a later event (turn.complete) (host check). Delivery has to run from a session-level timer or event, not from inside a tool hook. - Esc doesn't hold the queue. After a person cancels a dialog, a queued delivery runs straight away, right after the model was told to wait for the user. If that matters, the mod could hold deliveries itself and submit only after a turn that didn't end in a cancelled dialog, or after the person's next prompt.
Also relevant: a plugin's prompt reaches the model framed as "The plugin sent a message:" unless it's submitted with
asUser. The types file marks the mod API as early access that "may change between releases without notice".Limits: one machine, one throwaway session rather than a rig seat, Haiku 4.5, and AskUserQuestion only; permission dialogs (#81) weren't tested. The probe mod is about 80 lines. Happy to share it or run another case you'd like to see.
- Where the submit comes from. In a variant where the timer was started inside the
A correction to my baseline above. I reproduced only the tmux adapter's paste and
C-m, notrig senditself. I've since read 0.6.4's send path.classifySendReadinessrefuses withtarget_needs_inputwhen the screen shows a picker or a permission question, andclassifyPaneActivityflags the dialog screens I captured asselection_prompt(❯ 1. Red). Sorig sendwould have refused there, not answered the dialog. The raw result shows what happens to anything that types without that check.Checking that guard turned up one gap:
- The screen scan is a fixed 12 lines. The picker scan covers the last 12 non-blank lines (
PROMPT_SCAN_LINES). A real four-option AskUserQuestion with one-line descriptions, captured at 160 columns, puts the highlighted❯ 1. us-eastexactly on line 12. - One more line defeats it. Adding one line to that capture, as a wrapped description would, makes
classifyPaneActivityreturnunknown/no_activity_signal. On the default pathunknownsends with an advisory. A narrow tiled pane makes wrapping likely. - The hooks cover it, when they're wired. In default permission mode, AskUserQuestion fires
PermissionRequest40 ms afterPreToolUse, then apermission_promptNotification 6 s later, so a fresh hook signal catches it first. But a seat whose activity hooks are missing, as after the fix: preserve Claude activity hooks across native restore #731 restore bug, relies on the scan alone. I couldn't check which hooks fire under--dangerously-skip-permissions.
One possible direction: also match the dialog's own footer (
Enter to select · ↑/↓ to navigate · Esc to cancel), or scan upward from it instead of using a fixed window. Happy to open this as its own issue if that's easier to track.- The screen scan is a fixed 12 lines. The picker scan covers the last 12 non-blank lines (
Thanks for the trace, @gopinathrimc, with the version and the clear table of cases, for writing a probe mod to get it, and for correcting your own baseline. The trace has gone into the design discussion. We've also confirmed in the code that the picker check scans a fixed 12-line window, and we're looking at that gap.
Thanks again for correcting your baseline, @gopinathrimc. As you noted,
rig sendrefuses at the picker in your original capture, so the open case is the taller one you found, where one added line made the classifier returnunknown. We're looking at it.None of the comments has the raw screen yet, so could you share two things: the minimal raw capture of the tall four-option picker as it appeared (the unedited 160-column pane text), and the exact line you added to it when you saw that result? That would let us test our check for the current dialog against what you actually saw.
- added a commit that references this issue
on Oct 4, 2026 Another update, @gopinathrimc: #745 is merged on main, though it isn't in a release yet. It's for the gap you found, where a tall question picker could push its highlighted choice out of the 12-line check, so the screen read
unknownand an ordinaryrig sendwould have typed into the open question. OpenRig now recognizes a question that's currently on screen by Claude's navigation footer ("Enter to select · ↑/↓ to navigate · Esc to cancel") together with the choices just above it, wherever the highlighted choice sits, and refuses an ordinary send the same way the existing picker check does. It also no longer refuses because of a picker left in history above a later, empty Claude input box. The 12-line window, ordinary sends to working seats and--wait-for-idleare unchanged.Since we didn't have your raw capture, we captured a real tall picker ourselves on Claude Code 2.1.289, at 160 and 100 columns, and built the tests on those screens plus constructed variants. Recognition is tied to that layout, so a different Claude Code version or layout falls back to the previous behavior. And when a fresh activity hook already says a seat is running or idle, OpenRig decides without reading the screen, so this check doesn't apply there. We don't need your capture for the change any more, but if you still have it and the line you added, posting them would let us check the recognition against the exact screen you saw.
Here they are, in case they're still useful for checking #745. The capture is from Claude Code 2.1.289 on macOS, in a 160×50 tmux pane, taken with
tmux capture-pane -p. These are lines 17–33 of the 50, unchanged. Above them are Claude Code's banner and the prompt that asked for the question; below them are blank lines. The separator is the full 160 columns.☐ Region Which deployment region do you prefer? ❯ 1. us-east Eastern United States with low latency for US East Coast users. 2. us-west Western United States providing optimal performance for West Coast customers. 3. eu-central Central Europe offering compliance and fast access for European users. 4. ap-south South Asia delivering low-latency service to the Asia-Pacific region. 5. Type something. ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 6. Chat about this Enter to select · ↑/↓ to navigate · Esc to cancelAs captured, 0.6.4's
classifyPaneActivityreturnsselection_prompton❯ 1. us-east, the 12th non-blank line from the bottom.The line I added sits directly after the
Eastern United States …line, standing in for a description that wraps. It isn't a real Claude render:(wrapped second line of the description)With it, the result was
unknown/no_activity_signal, with the footerEnter to select · ↑/↓ to navigate · Esc to cancelas evidence.Thanks for #745 and #735. When a release includes them, I'll run it on this Mac and report on #273, including whether the first
--wait-for-idlesend after a resume goes through.Thanks, @gopinathrimc, for posting the raw capture and the exact line you added, even after we'd said it was optional. We'll check #745's recognition against that screen and your variant. And thanks for offering to report on #273 once a release includes #745 and #735.
- added a commit that references this issue
on Oct 5, 2026 A quick update, @gopinathrimc: we've checked #745's recognition against your capture and your one-line variant, and both read as an open question picker on main, though that isn't in a release yet. Both are now kept as tests (#765, merged). We'll look for your report on #273 once a release includes it.
Claude Code 2.1.288 added mods (early access): a plugin of function hooks that runs inside the Claude session and can call into it. OpenRig already writes each Claude seat's launch line, so it could load one per seat with
--plugin-dir <openrig-mod>.In #48 you explain that the terminal is effectively the API: wake-ups, handoffs and reminders only arrive because something types them. For Claude seats a mod gives a second channel that doesn't type. It would address several open issues:
okwhile left as a draft, or hits an open AskUserQuestion$.prompt.read()returns the draft.$.prompt.submit()queues a turn that starts once the seat is idle; it is never typed into the box or a dialog, and the model sees it as coming from the plugin.classic.PermissionRequestandclassic.Notificationsee the prompt the moment it opens.--plugin-dirwrites nothing into the project. Aprompt.composehook can add a per-seat system-prompt section keyed onOPENRIG_SESSION_NAME.$.session.id()from inside the seat. This complements #724 rather than replacing it.The same API also exposes the context fill, rate-limit windows and session cost (
$.session.usage()), plus each turn's tokens (turn.complete), without the status line.What we run today: a small mod (for our own seat-status tool, not OpenRig) in 8 live Claude seats. It forwards the classic hook events and each turn's context and cost. It loads through
CLAUDE_CODE_PLUGIN_DIRS, costs about 40 ms per event, and is checked withclaude plugin validateandclaude plugin test.Caveats: the mod API is early access and changes between releases (Claude Code 2.1.288 or later). It covers Claude seats only. OpenRig would keep the terminal path as the fallback for older Claude versions and other runtimes.
Would a design discussion or a small prototype PR be more useful?