feat(agents): route cohort Claude-plan agents to Claude Code
P11 of the rollout in "Bringing Codex or Claude Code Max plans into Luke" (https://tryluke.dev/stage/plan/89f19bfc-88b5-471a-886a-84b00180aa09). It routes a cohort account's Claude-plan agents to Claude Code. Every other agent keeps taking exactly today's eve path.
HELD: do not merge without the developer's go. This PR touches the code path every agent takes. Auto-merge is not armed.
What the prompt asked
- At Start:
- set
runtimeandpayer: an account inLUKE_NATIVE_AGENT_ACCOUNTS, with a Claude model and aclaude_setup_tokenrow, getsclaude_code+claude_plan; - everything else gets
eve, as today; - the decision is one function, which P24 extends.
- set
- Every entry point picks the
AgentSessionsimplementation byruntime: the composer, Inbox replies, chat notices, GitHub wakes, automations, the agent tools, Stop, delete, Start, Fork and Continue. - Claude Code instructions: drop Luke's dropped tools and point at Claude Code's own.
- What P08, P08b and P09 left for this PR:
nextSequencefromreadNativeSequence;- the session id stored in
runtime_session_id; - Luke's message id passed as
lineId; - the route functions import
runner-bundle.ts; - a turn-scoped Stop;
- a 425 from
open(IMAGE_PREPARING) retried rather than failing the agent.
- One admission check for who may write to a plan-funded agent.
PRIVACY.md: the sandbox disk holds the checkout and Claude Code's session files, deleted with the task.
How it works
The router (coder-host/runtime-sessions.ts)
Every place that composes eve's client for the coder mount now wraps it in routedSessions / sessionsRouter. That covers:
stop.ts'seveFor, used by the composer, Inbox, Stop, delete and Start routes;deployment-eve.ts, used by GitHub wakes, the sweep's automations and token-PR polls, and chat notices from a person;- the agent doors, for agent tools and agent chat notices;
- the planner's tools in
brain-host/host.ts.
How each call is routed:
openandsendcarry the conversation. The router reads its agent andruntime:- eve, null, or not a coding agent goes to eve's client, handed exactly what it was handed before. Luke's
lineIdis stripped before eve sees it. claude_codegoes to the harness, acting for the agent's starter, whose plan pays. A session it opens claimsruntime_session_id, as eve's door claims eve's.
- eve, null, or not a coding agent goes to eve's client, handed exactly what it was handed before. Luke's
cancel,resetandstreamname only a session. They go to whichever implementation minted it, told apart by prefix (native-runtime.ts). eve's sessions go to eve unchanged.- The tasks mount, used by the planner's own conversations, is not wrapped at all.
The native harnesses are a service, NativeAgentSessions (native-agent-sessions.ts). It is provided only by the web functions that ship the runner (apps/web/server/native-agents.ts):
- the default function: oRPC, the GitHub webhook, the automations webhook and runner-events;
luke-mcp;maintenance-sweep.
Importing that module reaches runner-bundle.ts, so bundle-functions.ts copies the bundle into those three .funcs. The image comes from P08b's agentSandboxImageFromTemplate(). The events and MCP URLs come from the deployment's own origin. nextSequence is readNativeSequence + 1, or 0 before any event.
The runtime decision (coder-host/agent-runtime-choice.ts)
agentRuntimeChoice(userId, model) answers claude_code + claude_plan only when all of these hold:
- the account is in the cohort;
- the model is a Claude model and not a fast variant;
- the function provides
NativeAgentSessions; - the account has an Anthropic
claude_setup_tokenrow.
Otherwise it answers eve with no payer, as before. startCodingAgent writes the answer on the new agent and its conversation. P24 adds Codex in this one function.
Turn-scoped Stop
- Native turns now record their own store id as
eve_turn_idwhen the events route queues them. Before this,stoppablenever matched a native turn, so Stop could not stop one at all. - The harness's
cancelwritesstoponly if the named turn is still the conversation's latest running turn, read from P09's turns. - It writes to the command of the launch that is running, and the line names that launch:
{type: "stop", launch: <firstSequence>}. - The runner (both the Claude Code and Codex modes) skips a stop that names another launch. One launch runs one Luke turn, so a delayed Stop can't interrupt the next turn.
Image still preparing (425)
- The router tags a harness's 425 as
runtimeRefusal: image_preparing. eve outcomes are never reinterpreted. - Start, and a message that relaunches the runner, answer a named refusal,
agent-image-preparing(503). A Start leaves no agent behind, exactly as a failed Start does today. - A native launch answers the 425 at once. P08b's seam takes its wait as a parameter now; the web edge passes zero, and the default and P08b's tests are unchanged. A Start's 120 s client deadline, a message's 10 s deadline and the 30 s dispatch fence each get the named refusal in time.
- The panel (
panel/src/tasks/image-preparing.ts) asks again under the same key and shows "Preparing the agent's image…" beside Start.- It pauses 15 s between tries. Each pause is its own fiber on the renderer's runtime edge (
rendererPause), so overlapping retries never cut each other short. - It stops after 40 tries (10 minutes) and shows "The agent's image is taking too long to prepare. Try again in a few minutes."
- It pauses 15 s between tries. Each pause is its own fiber on the renderer's runtime edge (
- The agent tools answer in words.
Mentioned servers on a native turn (after P07b)
eve hands a switched-off server that was mentioned (@Linear) to the next turn alone. Native agents now do the same:
- When the events route queues a new native turn, it takes the agent's mentioned servers (
takeCodingAgentMentionedServers, under the projection's lock) and keeps them on the projection's turn. - The integrations door at the MCP server hands them on top of the agent's switches (a new
turnMentionsargument onturnServers; eve doesn't pass it). - A store read,
readNativeProjection, has its own isolation test.
Admission (agent-reach.ts): decided by the developer, "same as today"
Any workspace member can message, steer, continue or stop a claude_plan or chatgpt_plan agent, as with an agent on any other payer. The calls are billed to the starter's plan: the harness always acts for the starter. Everyone can still fork one, and a fork runs on the forker's own payer.
The policy is one switch, PLAN_AGENT_WRITER_POLICY = PLAN_AGENT_WRITERS.WORKSPACE. PLAN_AGENT_WRITERS.STARTER (the starter and the agents the starter started) remains available as a one-line change. writesToAgent / admitsPlanWriter is checked in:
steerCodingAgent, which covers the composer, Inbox, chat notices, wakes, automations andmessage_agent;stoppedTurn, which covers Stop and task archive or delete;stop_agent;- a Continue's source in
startCodingAgent.
Under the current setting, none of them refuses anyone the existing checks admit.
Claude Code instructions (instructions.ts)
coderInstructions(…, harness) takes a per-harness TOOL_WORDS table:
- eve's entries are the old strings moved verbatim. The output for all 256 combinations of the existing parameters is byte-identical to
main's, checked by dumping both. - Claude Code's entries name Bash, Read/Edit/Write/Glob/Grep, WebSearch/WebFetch, the Skill tool, TodoWrite, the Agent tool, and
run_in_background+ Monitor/ScheduleWakeup for CI. - Luke's own tools keep their names: inbox,
start_agent,show_imageand the rest. - The checkout line no longer says the checkout is one commit deep, because a native
opendeepens it.
Session ids
A native session id is now <prefix><mint time in fixed-width hex>-<uuid>. Before, it was <prefix><uuid>. Without this, a session reopened after a retire could lose the forward-only claimRuntimeSession (lt) to the older random id. That would leave the conversation on the dead session and open a new sandbox on every message.
What stays out, and consequences (P11b)
The eve apps don't provide NativeAgentSessions: the planner's tools in apps/luke, and an eve agent's tools in apps/dev. Until P11b (feat(agents): native agents reachable from the planner and eve agents), from there:
- a message, Stop or reset to a Claude Code agent is refused by name (
native-agent-not-here) and never falls through to eve; - a Start decides eve, as today, with no payer. So a cohort account's planner or eve agent starts Claude agents on eve.
There's a TODO at the router. Starts from the panel, and from native agents over MCP, route normally.
Out of scope: the model chip and the list_models/update_agent rules (P12), Start's payer UI (P14), statuses (P15), Codex routing (P24).
How the eve path is shown unchanged
runtime-sessions.test.tscovers, for an eve agent beside a Claude Code agent in one workspace:- every session call, through the router and through each composition: a route's bearer, a route's cookie, the deployment's HTTP client, and the router the doors and planner use;
- a Start's runtime decision, across five cases;
- a composer or Inbox message;
- a Stop;
- admission;
- the no-harness case.
- In those tests, eve receives the identical message (no
lineId) and the harness hears nothing for the eve agent. The reverse holds for the Claude Code agent. - The existing suites pass unchanged, apart from three that now run over a PGlite test DB, because the router reads
runtimefrom the store:stop.test.ts,deployment-eve.test.tsandretire-sessions.test.ts. Their conversations are no agent's, so every call still reaches eve. - The eve instructions are byte-identical (see above).
Tests added
runtime-sessions.test.ts: routing, compositions, steer, Stop, admission (a teammate's message reaches the harness acting for the starter; the starter-only setting refuses the teammate), the no-harness refusal, the Start runtime and payer decision, and a 425 Start refused and then taken under its key.claude-code-sessions.test.ts:stopnames its launch;- a Stop of a turn no longer under way writes nothing.
codex-sessions.test.ts: the cancel stays turn-scoped.runner.test.ts: a stop for another launch is skipped and the right one is taken.coder-agent.test.ts: a Claude Code agent's instructions name Claude Code's tools and none of Luke's dropped ones.panel/image-preparing.test.ts: retry, the cap, and no retry for other refusals.connection-door.test.ts: a switched-off Linear that was mentioned is refused before the native turn, handed during it, and refused again in the next turn.native-projection-store.test.ts: the projection reads back to its own account only.
Checks
CI is green on every check. Locally I ran typecheck for service, web, panel and database, plus biome, oxlint and the affected suites. The full local pnpm check:affected run is slow in this sandbox, so CI is the gate.
Gate B (the developer's, later): the P11 workflow on Preview for one internal account in test mode on an API key. One thing to watch then: on a protected Preview, the runner POSTs to the deployment's own origin for events and MCP.
Made with Luke