/** * ONE Claude Agent SDK session, port-injected — wave 2 lane E, rewritten by * door-ctx. * * This module is the SDK loop for `claudeCode()`, and it has TWO homes on * purpose: * * - inside the box, copied into the template as `/opt/vendo-box/claude-turn.mjs` * (`packages/vendo/box/build-template.mjs` stages this package's compiled * `dist/claude-code/claude-turn.js`), driven by the supervisor's session * routes (`packages/vendo/box/turn-routes.mjs`); * - on the host, imported by `claude-code/local.ts` for `machine: "local"`. * * **The tools are the HOST's own MCP door now.** They used to be an in-process * MCP server this file BUILT — every handler round-tripping to the host over an * inverted HTTP bridge the host polled, because the door could not carry a * turn's accountability context. door-ctx taught it to (10-mcp §3b), so the * session simply points at `{ type: "http", url, headers: { Authorization } }` * with a credential scoped to the turn in flight. The door hands each call to * `turn.tools.call()` — one guard, one audit row, one mirror, one commit, * exactly like `vendo()`. Nothing executes box-side, and this file no longer * translates schemas, correlates calls, or knows what a tool IS. * * What died with the projection: the JSON-Schema→zod translation, the * hook/handler correlation queue that made exactly-once hold, the tool listing * itself (the door lists LIVE, so a tool `find_tools` equips mid-conversation * needs no session reopen), and the `callTool` port in both drivers. * * It therefore imports NOTHING — not even a sibling in this package — and, the * rule that matters, it never NAMES the Agent SDK. Whoever supplies the machine * supplies the SDK: the box door loads it from the machine image, `machine: * "local"` loads it from the optional peer that `@vendoai/vendo` declares. * A module that named the package itself was reachable from every composed * host's build graph, and a bundler that folds `import(CONST)` then refused to * build a host that has no reason to install a ~250MB platform binary. Keep it * that way — the emitted `dist/claude-code/claude-turn.js` is copied verbatim * into a machine image. * * There is no local permission system left (design §3, "claudeCode() specifics"; * harness-redesign D1). The session runs in `bypassPermissions` because the two * things that decide are elsewhere: the BOX is the permission for the box's own * hands (copies only, no credentials, domain-filtered egress at the provider's * network layer, reality happens at commit), and the DOOR is the permission for * host tools — the guard decides there, with the turn's own context, and a refusal * arrives as the tool's own in-band error text, which the model narrates and never * a throw. {@link DISALLOWED_TOOLS} is the only local tool law that survives. * * Two limits on how far the box's containment reaches. * * The egress half is weaker than it sounds: the provider filters by DOMAIN, so * an ordinary client is held to the allowlist and a client that omits SNI is * not (measured). The box is filtered, not jailed. * * And the containment is about a BOX at all, which this module's other home — * `machine: "local"` — does not have: there the same bypass is a real shell on * the host's own server, with no network boundary of any kind. The mode is an * explicit deployment opt-in and warns the operator on its first turn * (`claude-code/local.ts`), but nothing in THIS file makes it safe, and reading * the paragraph above as if it did is the mistake to avoid. */ /** The MCP server name our projected tools live under (`mcp__vendo__`). */ export declare const VENDO_MCP_SERVER = "vendo"; /** * Where a beat sits in the arc of making something — a STRUCTURAL MIRROR of * core's `BeatPhase` (contract §3.4), CLOSED at six. * * Restated rather than imported because this file imports nothing (module * header). `claude-code/index.ts` yields these events straight into * `HarnessEvent`, so the compiler already compares the two unions — but in ONE * direction only, and 200 lines away as an inference failure nobody can read. * `BEAT_PHASES` there closes the other direction and names the drift; it is in * production code rather than a test because nothing in this repo typechecks a * test file. */ export type BeatPhase = "understanding" | "planning" | "assembling" | "building" | "checking" | "finishing"; export type ClaudeTurnEvent = { type: "text"; delta: string; } /** * A BEAT — consumer voice, ephemeral, screen only. * * `phase` and `appId` are ADDITIVE (§3.4): a status carrying nothing but a * `label` puts the identical chunk on the wire it always did. `appId` stays * unset here — this loop is never told which app it is building, and the * contract makes the field optional precisely so a producer without one leaves * it off instead of parsing an id out of a file path. */ | { type: "status"; label: string; phase?: BeatPhase; appId?: string; } | { type: "error"; message: string; } | { type: "usage"; inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number; model?: string; } /** Not a `HarnessEvent`: the native session ref the caller puts in `turn.state`. */ | { type: "session"; sessionId: string; }; interface ClaudeSessionInput { /** `Turn.system` — appended to the SDK's own claude_code preset, never replacing * it: the co-training is the reason this harness exists. */ systemPrompt?: string; model?: string; effort?: string; maxTurns?: number; /** The native session to continue — only meaningful on a machine whose disk * still holds it (`turn.state`). */ resume?: string; /** The materialized workspace root on this machine. */ cwd: string; /** `CLAUDE_CONFIG_DIR` included: where the SDK keeps its session file is the * machine's choice, made in the environment and never read back here. */ env: Record; /** * A local PLUGIN root for native skill discovery — the SDK reads * `/skills//SKILL.md`, which is EXACTLY the layout our * `/host` mount already lands (`hostSkillFiles` in core). So the host mount IS * the plugin: no copy, no translation, no second skills mechanism. Omitted, no * plugin is loaded at all. */ pluginPath?: string; /** * Exactly which discovered skills to enable, by name. * * `skills: "all"` enables EVERY skill the engine discovered — which on a host * running `machine: "local"` includes the operator's own `~/.claude/skills` * (measured 2026-08-02: a probe saw `deep-research`, `dataviz`, `claude-api`… * alongside ours). That is the operator's private tooling leaking into a * customer's agent, so the enabled set is OURS by name, never "all". */ skillNames?: readonly string[]; /** * A file this session's work just wrote, from the SDK's NATIVE `PostToolUse` * hook. This is what replaces mid-turn file-watch polling: the host syncs on * WRITE instead of on a timer. `undefined` means a tool that writes without * naming a path (`Bash`), which the host answers with one narrow * collect-by-shape rather than a whole-tree read. */ onFileWritten?: (path: string | undefined) => void | Promise; /** * The host's own MCP door, and a credential for the turn in flight. * * This is the ONLY way anything reaches the world. Absent, the session runs * with the box's own hands and no host tools at all — which is a real * deployment (a host that never opened the door) and never a silent * degradation: `claudeCode()` refuses to open a session it cannot give tools * to when a door exists but has no reachable URL. */ toolDoor?: { url: string; token: string; }; emit: (event: ClaudeTurnEvent) => void; /** * The Agent SDK module, supplied by whoever supplied the machine: the box door * loads it from the image, `machine: "local"` loads it from the optional peer * `@vendoai/vendo` declares (contract build-list item 1). REQUIRED, so * this file never names the package and never lands in a host's build graph * for it. Tests pass a double. */ sdk: SdkModule; } /** * One conversation's live session — held open, chat in / stream out. * * The whole cc-native change is that this object OUTLIVES a turn. `send()` pushes * the user's next message into a session that never stopped, which is what makes * turn 2 cost nothing and remember everything. */ export interface ClaudeSession { /** Push one user message in and settle when THAT message's turn is done. */ send(prompt: string): Promise; /** * Hand the user's words to the turn ALREADY in flight — mid-build steering. * * Same session, same turn, same `send()` still awaiting: the SDK hands the * message to the model at its next step boundary, which is why nothing here * queues. Answers whether the words landed; `false` when no turn is in flight, * because then there would be nobody for the extra `result` to settle and the * caller's own next `send()` is the right home for the message. */ steer(prompt: string): boolean; /** * Stop the turn in flight WITHOUT ending the conversation — the user hit stop, * they did not close the tab. A live session makes this distinction real: * aborting the whole session would throw away everything it remembers. */ interrupt(): Promise; /** Close the input stream and let the SDK's own loop finish. */ end(): Promise; } /** One user message as the SDK's streaming input wants it. */ interface SessionUserMessage { type: "user"; message: { role: "user"; content: string; }; parent_tool_use_id: null; } /** The bits of the SDK this file uses. Narrow on purpose: the real message union * has ~40 members and this file branches on four. */ interface SdkModule { query(params: { prompt: string | AsyncIterable; options: Record; }): AsyncIterable> & { interrupt?: () => Promise; }; } /** * Open ONE live session for a whole conversation. * * `query()` is called exactly once. Its `prompt` is a stream we keep open, so a * second user message is a PUSH rather than a cold start: no re-materialize, no * resume ref, no re-seed. `send()` settles on its own turn's `result`, which is * how the SDK says "this turn is done" while the input stays open. */ export declare function createClaudeSession(input: ClaudeSessionInput): ClaudeSession; export {};