Skip to content

feat(interactive): add interactive Claude REPL provider with origin-based routing - #156

Draft
ryuhaneul wants to merge 2 commits into
PleasePrompto:mainfrom
ryuhaneul:feat/interactive-claude-provider
Draft

feat(interactive): add interactive Claude REPL provider with origin-based routing#156
ryuhaneul wants to merge 2 commits into
PleasePrompto:mainfrom
ryuhaneul:feat/interactive-claude-provider

Conversation

@ryuhaneul

Copy link
Copy Markdown
Contributor

Motivation

As of June 15, 2026, claude -p / Agent SDK calls draw a separate monthly
credit rather than the Claude plan's interactive limits (see #154). Human chat
in Ductor is inherently interactive — a person sends each message on demand —
so this routes only that path through a persistent interactive claude REPL
instead of headless -p. Unattended automation (cron, heartbeat, webhooks,
background tasks, memory flush, inter-agent injection) deliberately stays
headless. This is an explicit boundary, not a limit workaround.

Background. I've been running a version of this on my own Ductor instance
since Anthropic's May 2026 announcement about Agent SDK / claude -p billing.
As upstream is moving in the same direction, I've refactored it to match
upstream conventions and am submitting it here for consideration.

Note (June 16, 2026). Anthropic has since indicated the SDK-credit
transition will not be enforced immediately — headless -p still draws the
normal subscription limits for now. So there's no urgency to adopt interactive
mode today; I'm opening this for direction/discussion rather than as a
time-sensitive change.

Scope (origin-based routing)

A turn uses the interactive REPL only when all hold:

  • origin is a human chat message,
  • provider is claude,
  • claude_interactive is enabled in config.

Everything else stays on -p. Opt-in, default off, fail-closed.

What it adds

  • Persistent tmux-backed claude REPL pool (spawn / send / kill / idle-evict),
    keyed per (transport, chat, topic).
  • Turn completion via a Stop hook + transcript nonce extraction.
  • /interactive on|off command (hot-reload toggle).
  • A dedicated claude:i session bucket, isolated from the -p claude bucket.
  • The REPL uses the same Claude config dir and the same DUCTOR_*
    environment
    as the -p path, so authentication and the agent tooling /
    inter-agent helpers behave identically inside an interactive turn.
  • Fail-fast on fatal REPL screens (rejected/inaccessible model, invalid key,
    not-logged-in / 401) instead of blocking until timeout.
  • env -i allowlist for the REPL subprocess (no host environment leak).

Validation

Not fully validated, but verified working in the following:

Automated (test suite): added targeted tests for origin-based routing /
human-only gating, claude:i bucket isolation, Stop-hook + transcript nonce
extraction (including the tool-result boundary), fatal-screen fail-fast
detection, Claude config-dir parity with -p, DUCTOR_* env injection
(host + container) with the env -i no-leak guarantee, per-(transport, chat, topic) session identity, and transport-scoped abort. Full suite green.

Manual end-to-end (a live single-agent instance):

  • an interactive turn authenticates from the same dir as -p (no separate login);
  • multi-turn context continuity within an interactive session;
  • /interactive on|off toggles REPL ↔ -p;
  • tool-using turns and sub-agent creation via the agent tools (correct
    DUCTOR_HOME resolution);
  • synchronous and asynchronous inter-agent calls route correctly (correct
    DUCTOR_AGENT_NAME / interagent port; async results return to the
    originating chat/topic);
  • fail-fast surfaces an error to the user instead of hanging on an
    auth-failed / rejected-model screen.

Not yet exercised: long-running soak, multi-transport concurrency, and the
toggle-unification path below.

Status / limitations

This is not a complete/exhaustive implementation — it covers the
human-chat path and is offered for discussion:

  • Toggling /interactive starts a separate conversation thread: the
    interactive (REPL) and headless (-p) sessions are intentionally isolated,
    so context does not carry across the toggle. Unifying them is future work.
  • Automation paths are intentionally excluded (see Scope).

@ryuhaneul
ryuhaneul force-pushed the feat/interactive-claude-provider branch from 5cd132a to 0161037 Compare June 16, 2026 02:49
@PleasePrompto

PleasePrompto commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Ha, love this — you're ahead of all of us here.

Quick heads up: Anthropic paused the -p billing change on the 15th, so headless still runs on the normal subscription for now. No rush to merge.

But I'm keeping it open. claude -p is too useful to lose, and the day they actually pull it, this is exactly how we keep human chat on the plan. Your boundary is spot on — humans through the REPL, cron/webhooks/background stay headless. Opt-in + fail-closed, perfect.

Let's keep it in sync with upstream so we can flip it on when it's needed. I'll do a review pass on the routing + tmux pool. Really nice work 🙏

@PleasePrompto

PleasePrompto commented Jun 16, 2026

Copy link
Copy Markdown
Owner

@ryuhaneul Claude answered it 😂
Just to reiterate from me: thanks for the suggested solution!

I've removed the notice for now; Antrophic sent out emails saying not to do that for the time being! Yay.

https://x.com/i/status/2066608004464861546

If anything changes, I'll get back to you on your PR! Thanks for your work!

@ryuhaneul
ryuhaneul marked this pull request as ready for review June 16, 2026 03:11
@ryuhaneul

Copy link
Copy Markdown
Contributor Author

Thanks — really glad it resonated! And good to hear Anthropic paused it; no rush totally makes sense. I'll keep the branch current if anything shifts on their end.

Honestly, I've tried a lot of the similar agent frameworks out there, and I haven't found anything as pleasant to actually live in day-to-day as Ductor. Thank you for building it — it's genuinely become part of my daily workflow. Happy to help however I can. 🙏

@PleasePrompto

Copy link
Copy Markdown
Owner

Status update after a full review pass (as part of a repo-wide PR triage): keeping this open as agreed — the billing change is paused, so no merge for now.

The review confirmed the architecture is solid (nonce-based stop-hook/transcript extraction instead of TUI scraping, fail-closed origin routing verified across all 11 AgentRequest construction sites, clean env allowlisting). Two blockers to address before we flip it on, whenever that day comes:

  1. Platform guard at enable time: repl_pool.py shells out to tmux/env -i/sh -lc unconditionally — on Windows or a tmux-less Linux, /interactive on currently dies with a swallowed FileNotFoundError deep in the stream path. cmd_interactive / prepare_interactive_runtime should check shutil.which("tmux") + platform and refuse with a clear message.
  2. ReplPool thread safety: self._sessions (OrderedDict) is mutated in _evict_idle/_get_or_spawn/send without a lock, but _send_interactive calls into the pool via asyncio.to_thread — two chats hitting the REPL concurrently can corrupt the dict. Needs a threading.Lock (or a single-thread executor).

Also worth noting for the rebase whenever it happens: main has since merged per-session reasoning effort (#164), append_system_prompt_files (#157), codex stdin prompts (#137) and topic-scoped kills (#166) — cli/service.py/cli/base.py/config.py moved quite a bit.

Thanks again for keeping this in sync 🙏

@ryuhaneul
ryuhaneul marked this pull request as draft July 15, 2026 07:02
@ryuhaneul
ryuhaneul force-pushed the feat/interactive-claude-provider branch from 73de643 to 4cebbe8 Compare July 15, 2026 07:49
@ryuhaneul
ryuhaneul marked this pull request as ready for review July 15, 2026 08:07
…ased routing

As of June 15, 2026, `claude -p` / Agent SDK calls draw a separate monthly
credit rather than the Claude plan's interactive limits (see PleasePrompto#154). Human chat
in Ductor is inherently interactive, so this routes only that path through a
persistent interactive `claude` REPL instead of headless `-p`. Unattended
automation (cron, heartbeat, webhooks, background tasks, memory flush,
inter-agent injection) deliberately stays headless -- an explicit boundary,
not a limit workaround.

Scope (origin-based routing): a turn uses the interactive REPL only when origin
is a human chat message, provider is `claude`, and `claude_interactive` is
enabled. Everything else stays on `-p`. Opt-in, default off, fail-closed.

What it adds:
- Persistent tmux-backed `claude` REPL pool (spawn/send/kill/idle-evict), keyed
  per (transport, chat, topic).
- Turn completion via a Stop hook + transcript nonce extraction.
- `/interactive on|off` command (hot-reload toggle).
- A dedicated `claude:i` session bucket, isolated from the `-p` `claude` bucket.
- The REPL uses the same Claude config dir and the same DUCTOR_* environment as
  the `-p` path, so authentication and the agent tooling / inter-agent helpers
  behave identically inside an interactive turn.
- Fail-fast on fatal REPL screens (rejected/inaccessible model, invalid key,
  not-logged-in / 401) instead of blocking until timeout.
- `env -i` allowlist for the REPL subprocess (no host environment leak).

Not a complete implementation; the `/interactive` toggle starts a separate
conversation thread (interactive and `-p` sessions are intentionally isolated),
and automation paths are intentionally excluded.
@ryuhaneul
ryuhaneul force-pushed the feat/interactive-claude-provider branch from 4cebbe8 to 2b46163 Compare July 15, 2026 08:15
@ryuhaneul
ryuhaneul marked this pull request as draft July 16, 2026 03:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants