feat(agents): route cohort Claude-plan agents to Claude Code

Merged
CH
5d ago
+2693-464

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 runtime and payer: an account in LUKE_NATIVE_AGENT_ACCOUNTS, with a Claude model and a claude_setup_token row, gets claude_code + claude_plan;
    • everything else gets eve, as today;
    • the decision is one function, which P24 extends.
  • Every entry point picks the AgentSessions implementation by runtime: 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:
    • nextSequence from readNativeSequence;
    • 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's eveFor, 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:

  • open and send carry the conversation. The router reads its agent and runtime:
    • eve, null, or not a coding agent goes to eve's client, handed exactly what it was handed before. Luke's lineId is stripped before eve sees it.
    • claude_code goes to the harness, acting for the agent's starter, whose plan pays. A session it opens claims runtime_session_id, as eve's door claims eve's.
  • cancel, reset and stream name 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_token row.

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_id when the events route queues them. Before this, stoppable never matched a native turn, so Stop could not stop one at all.
  • The harness's cancel writes stop only 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."
  • 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 turnMentions argument on turnServers; 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 and message_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_image and the rest.
  • The checkout line no longer says the checkout is one commit deep, because a native open deepens 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.ts covers, 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 runtime from the store: stop.test.ts, deployment-eve.test.ts and retire-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:
    • stop names 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


Devin Review