Skip to content

Proposal: an OpenRig Claude Code mod in each Claude seat, so delivery and status don't go through the terminal #728

Description

@gopinathrimc

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:

Open issue What a mod can do inside the seat
#48, #14, #498: a send lands in the operator's half-typed draft, reports ok while 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.
#81: a seat waiting at a permission prompt raises no attention Hooks on classic.PermissionRequest and classic.Notification see the prompt the moment it opens.
#421, #64: hooks and the status line written into project-local settings that several seats share; one shared role block A per-seat --plugin-dir writes nothing into the project. A prompt.compose hook can add a per-seat system-prompt section keyed on OPENRIG_SESSION_NAME.
#86, #273: identity read from the process name $.session.id() from inside the seat. This complements #724 rather than replacing it.
#161: polling cost Activity is pushed from the seat, so there's less to poll.

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 with claude plugin validate and claude 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?

Activity

  1. mvschwarz commented on Oct 4, 2026

    @mvschwarz
    Owner

    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.

  2. mvschwarz commented on Oct 4, 2026

    @mvschwarz
    Owner

    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?

  3. gopinathrimc commented on Oct 4, 2026

    @gopinathrimc
    Author

    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.every poller in session.start. When the test writes a trigger file, the poller calls $.prompt.fill and then $.prompt.submit. The mod logs every turn.start, turn.complete, prompt.submit and 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.fill is refused outright with refusal: "dialog".

    Other cases from the same session:

    Case $.prompt.fill $.prompt.submit
    Idle filled the box turn started in 25 ms
    Normal turn running, no dialog filled the box queued; own turn 19 ms after turn.complete
    Dialog open, then answered refused, dialog queued (trace above); own turn 23 ms after turn.complete
    Dialog open, then cancelled with Esc refused, dialog queued; 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 separate C-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.call hook 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.

  4. gopinathrimc commented on Oct 4, 2026

    @gopinathrimc
    Author

    A correction to my baseline above. I reproduced only the tmux adapter's paste and C-m, not rig send itself. I've since read 0.6.4's send path. classifySendReadiness refuses with target_needs_input when the screen shows a picker or a permission question, and classifyPaneActivity flags the dialog screens I captured as selection_prompt (❯ 1. Red). So rig send would 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-east exactly on line 12.
    • One more line defeats it. Adding one line to that capture, as a wrapped description would, makes classifyPaneActivity return unknown / no_activity_signal. On the default path unknown sends with an advisory. A narrow tiled pane makes wrapping likely.
    • The hooks cover it, when they're wired. In default permission mode, AskUserQuestion fires PermissionRequest 40 ms after PreToolUse, then a permission_prompt Notification 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.

  5. mvschwarz commented on Oct 4, 2026

    @mvschwarz
    Owner

    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.

  6. mvschwarz commented on Oct 4, 2026

    @mvschwarz
    Owner

    Thanks again for correcting your baseline, @gopinathrimc. As you noted, rig send refuses at the picker in your original capture, so the open case is the taller one you found, where one added line made the classifier return unknown. 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.

  7. mvschwarz commented on Oct 4, 2026

    @mvschwarz
    Owner

    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 unknown and an ordinary rig send would 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-idle are 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.

  8. gopinathrimc commented on Oct 5, 2026

    @gopinathrimc
    Author

    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 cancel
    

    As captured, 0.6.4's classifyPaneActivity returns selection_prompt on ❯ 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 footer Enter to select · ↑/↓ to navigate · Esc to cancel as 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-idle send after a resume goes through.

  9. mvschwarz commented on Oct 5, 2026

    @mvschwarz
    Owner

    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.

  10. mvschwarz commented on Oct 5, 2026

    @mvschwarz
    Owner

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions