Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: CI

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- run: deno task check
- run: deno task lint
- run: deno task test
13 changes: 6 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,13 +382,12 @@ a start/stop cycle around a picker opening makes the next open pay full price.
every `console` method through the plugin logger before any plugin code runs.
A single `console.log` reaching stdout is read by the host as a corrupt frame
and takes the plugin down.
- **ACP payloads are never parsed** on the bridge. Frames are re-framed between
Ora's binary envelope and the adapter's NDJSON and otherwise passed through
verbatim — this is also what lets Claude Code's ACP extensions (session modes,
steering, its `_meta` capabilities) reach Ora without this plugin knowing they
exist. `handlers/effects.ts` is the one exception, and a deliberately narrow
one: it reads `method` and `id` off the envelope to track turns, and never
looks at `params`.
- **ACP routing** stays opaque for legacy starts and unrelated frames. When
`modelContext` is present, `services/model-adapter.ts` owns the model/routing
fields and safe error envelopes; read
[Platform model bindings](README.md#platform-model-bindings) before changing
this seam. Effect tracking reads only envelope `method`/`id`. Keep payload
values out of logs; private provider diagnostics stay suppressed.
- **Throw the right error code.** `AGENT_NOT_INSTALLED` (`-32001`) is retried
quietly by Ora as expected local configuration; `AGENT_UNUSABLE` (`-32002`) is
reported once and not retried; `-32000` is how an Effect Consumer says "not
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,27 @@ takes care of getting Claude Code to pick them up — no manual restart needed.
| -------------------- | ---------------------------------------------------------------------------- |
| `ORA_CLAUDE_ACP_BIN` | Pin an exact path to the Claude ACP adapter binary, bypassing `PATH` lookup. |

## Platform model bindings

The SDK 0.11 startup contract can supply one frozen `modelContext`. This mode
requires the bundled adapter/native CLI pair and a private execution HOME from
the host. It uses only Anthropic Messages, pins the primary model and every
model alias, disables fallback, and overrides query settings sources and routing
before the first prompt. Discovery returns only the bound model. Legacy starts
omit the context and retain their existing behavior.

The temporary token is supplied only in the child environment. Programmatic
settings contain no token literal: the pinned SDK serializes them into CLI
arguments. Provider/authentication replacement and another model are refused;
private diagnostics are suppressed while protocol error codes remain intact.
Node owns renewal, revocation and process cleanup. Settings are validated JSON
text; this declaration currently contributes no additional options.

Declaration packaging remains an explicit `--agent-configuration` opt-in after
compatible hosts are deployed. Every target and the published static copy must
contain identical declaration bytes. Keep the default publication gate off
during host rollout.

## License

Apache-2.0
6 changes: 3 additions & 3 deletions deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ora-space/claude-agent",
"version": "0.3.23",
"version": "0.3.24",
"exports": "./src/main.ts",
"minimumDependencyAge": 0,
"imports": {
Expand All @@ -14,10 +14,10 @@
"@zip-js/zip-js": "jsr:@zip-js/zip-js@^2.8.61"
},
"tasks": {
"check": "deno check src/main.ts tests/main_test.ts tests/host-simulator.ts scripts/package.ts scripts/sync-upstream.ts",
"check": "deno check src/main.ts tests/main_test.ts tests/model_adapter_test.ts tests/host-simulator.ts scripts/package.ts scripts/sync-upstream.ts",
"lint": "deno lint src scripts tests bundle.config.ts",
"format": "deno fmt src scripts tests bundle.config.ts deno.json package.json README.md AGENTS.md",
"test": "deno test --allow-read --allow-env --allow-write tests/main_test.ts",
"test": "deno test --allow-read --allow-env --allow-write tests/main_test.ts tests/model_adapter_test.ts",
"simulate": "deno run --allow-run --allow-read --allow-env --allow-net tests/host-simulator.ts",
"dev": "deno run --no-prompt --allow-run --allow-read --allow-env --allow-net src/main.ts",
"build": "deno bundle src/main.ts -o dist/main.js",
Expand Down
2 changes: 1 addition & 1 deletion orax.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title = "Claude Code"
identifier = "ora-space.claude"
namespace = "official"
kind = "agent"
version = "0.3.23"
version = "0.3.24"
description = "Ora Space Claude Code Agent"
homepage = "https://github.com/ora-space/claude-code-agent"
license = "Apache-2.0"
7 changes: 7 additions & 0 deletions scripts/adapter-entry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ const BIN_ENV_VAR = "ORA_CLAUDE_BIN";
* machine that has it.
*/
function resolveClaudeCli() {
if (process.env.ORA_CLAUDE_PLATFORM_MODEL === "1") {
const bundled = join(dirname(process.execPath), CLAUDE_BINARY_NAME);
if (!isExecutableFile(bundled) || isUnrunnableHere(bundled)) {
throw new Error("platform model requires the bundled CLI");
}
return { path: bundled, source: "bundled" };
}
const pinned = process.env[BIN_ENV_VAR]?.trim();
if (pinned) {
if (!isExecutableFile(pinned)) {
Expand Down
6 changes: 4 additions & 2 deletions src/base/agent-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
AGENT_METHODS,
type AgentEffectDefinition,
type AgentModel,
type AgentModelApplied,
type AgentStartContext,
createHostProcesses,
defineAgent,
Expand Down Expand Up @@ -93,7 +94,7 @@ export abstract class AgentPlugin {
abstract onStart(
context: AgentStartContext,
send: AcpSender,
): void | Promise<void>;
): void | AgentModelApplied | Promise<void | AgentModelApplied>;

/** [agent/acp] Receives one ACP frame the host is forwarding to the agent. */
abstract onAcp(frame: JsonValue): void | Promise<void>;
Expand Down Expand Up @@ -145,7 +146,8 @@ export async function runAgentPlugin(
() =>
invoke(routes, AGENT_METHOD_ROUTES.onStart, startContext, send) as
| void
| Promise<void>,
| AgentModelApplied
| Promise<void | AgentModelApplied>,
),
stop: () =>
traced(
Expand Down
37 changes: 33 additions & 4 deletions src/handlers/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import type { AgentStartContext } from "@ora-space/plugin-sdk";
import { INVALID_PARAMS, PluginMethodError } from "@ora-space/plugin-sdk";
import type {
AgentModelApplied,
AgentStartContext,
} from "@ora-space/plugin-sdk";
import {
AGENT_UNUSABLE,
INVALID_PARAMS,
PluginMethodError,
} from "@ora-space/plugin-sdk";
import type { ClaudeClient } from "../services/claude-client.ts";
import { logger } from "../services/log.ts";

Expand All @@ -15,18 +22,40 @@ const log = logger("lifecycle");
export async function startClaude(
client: ClaudeClient,
context: AgentStartContext,
): Promise<void> {
): Promise<void | AgentModelApplied> {
if (context.cwd.trim() === "") {
log.warn("agent/start refused: empty cwd");
throw new PluginMethodError(
INVALID_PARAMS,
"agent/start requires a non-empty cwd",
);
}
await client.start(context.cwd);
try {
client.setModelContext(context.modelContext);
await client.start(context.cwd);
} catch (error) {
await client.stop();
if (context.modelContext) {
throw new PluginMethodError(
error instanceof PluginMethodError && error.code === AGENT_UNUSABLE
? AGENT_UNUSABLE
: INVALID_PARAMS,
"claude_model_adapter_failed",
);
}
throw error;
}
log.info("Claude ACP adapter started for agent/start", {
context: { cwd: context.cwd },
});
const frozen = context.modelContext?.configuration;
if (frozen) {
return {
adapterVersion: frozen.adapterVersion,
declarationFingerprint: frozen.declarationFingerprint,
configurationRevision: frozen.configurationRevision,
};
}
}

/**
Expand Down
7 changes: 5 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
AcpSender,
AgentModel,
AgentModelApplied,
AgentStartContext,
HostProcesses,
JsonValue,
Expand Down Expand Up @@ -95,7 +96,7 @@ class ClaudeAgentPlugin extends AgentPlugin {
override onStart = async (
context: AgentStartContext,
send: AcpSender,
): Promise<void> => {
): Promise<void | AgentModelApplied> => {
this.#log.info("agent start requested", {
context: { cwd: context.cwd, previousCwd: this.#cwd },
});
Expand All @@ -105,7 +106,7 @@ class ClaudeAgentPlugin extends AgentPlugin {
this.#send = send;
this.#cwd = context.cwd;
invalidateClaudeModels(context.cwd);
await startClaude(this.#client, context);
return await startClaude(this.#client, context);
};

override onStop = async (): Promise<void> => {
Expand All @@ -119,6 +120,8 @@ class ClaudeAgentPlugin extends AgentPlugin {
override onListModels = (
context: AgentListModelsContext,
): Promise<AgentModel[]> => {
const frozen = this.#client.frozenModel;
if (frozen) return Promise.resolve([frozen]);
if (this.#processes === undefined) {
throw new Error(
`${AGENT_METHODS.listModels} was called before activation`,
Expand Down
83 changes: 72 additions & 11 deletions src/services/claude-client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import type { HostProcesses, JsonValue } from "@ora-space/plugin-sdk";
import { spawnClaude } from "./command.ts";
import type {
AgentModel,
AgentModelContext,
HostProcesses,
JsonValue,
} from "@ora-space/plugin-sdk";
import { spawnClaude, spawnClaudeModel } from "./command.ts";
import {
claudeGatewayBase,
claudeModelEnvironment,
pinClaudeModelFrame,
redactClaudeModelError,
} from "./model-adapter.ts";
import { logger } from "./log.ts";
import { decodeLines, encodeLine } from "./ndjson.ts";

Expand Down Expand Up @@ -54,6 +65,21 @@ export class ClaudeClient {
#processes: HostProcesses | undefined;
#running: RunningProcess | undefined;
#expectedExit = false;
#modelContext: AgentModelContext | undefined;

/** A start replaces the execution context; Effect restarts retain that same frozen binding. */
setModelContext(context: AgentModelContext | undefined): void {
if (context) claudeGatewayBase(context);
this.#modelContext = context;
}

/** Active discovery never probes ambient credentials or uses a cache shared by workspace cwd. */
get frozenModel(): AgentModel | undefined {
const model = this.#modelContext?.configuration.model;
return model
? { id: model.id, displayName: model.name, default: true }
: undefined;
}

constructor(options: ClaudeClientOptions = {}) {
this.#spawn = options.spawn ?? ((cwd) => this.#spawnViaHost(cwd));
Expand Down Expand Up @@ -100,7 +126,7 @@ export class ClaudeClient {
throw error;
}
this.#running = { process, stdinWriter: process.stdin.getWriter() };
this.#attach(process);
this.#attach(process, this.#modelContext !== undefined);
log.info("adapter running", { context: { cwd, pid: process.pid } });
}

Expand All @@ -111,6 +137,24 @@ export class ClaudeClient {
* an unbounded queue inside this process.
*/
async writeAcp(frame: JsonValue): Promise<void> {
if (this.#modelContext) {
const pinned = pinClaudeModelFrame(frame, this.#modelContext);
if (pinned === undefined) {
if (
frame !== null && typeof frame === "object" &&
!Array.isArray(frame) &&
(typeof frame.id === "number" || typeof frame.id === "string")
) {
this.#onAcpFrame({
jsonrpc: "2.0",
id: frame.id,
error: { code: -32602, message: "The execution model is frozen" },
});
}
return;
}
frame = pinned;
}
const running = this.#running;
if (running === undefined) {
throw new Error("the Claude agent is not running");
Expand Down Expand Up @@ -149,9 +193,9 @@ export class ClaudeClient {
}

/** Wires stdout, stderr, and exit bookkeeping for one live child. */
#attach(process: SpawnedProcess): void {
void this.#pumpStdout(process);
void this.#pumpStderr(process);
#attach(process: SpawnedProcess, sensitive: boolean): void {
void this.#pumpStdout(process, sensitive);
void this.#pumpStderr(process, sensitive);
void process.exited.then(() => {
// A process that is no longer `#running` was already superseded by a later `start()` (an
// Effect restart, for instance); its death is old news, not a live agent going away, so it
Expand Down Expand Up @@ -184,7 +228,10 @@ export class ClaudeClient {
* rejects non-object frames anyway, and one stray diagnostic line must not end every live session
* on this agent.
*/
async #pumpStdout(process: SpawnedProcess): Promise<void> {
async #pumpStdout(
process: SpawnedProcess,
sensitive: boolean,
): Promise<void> {
try {
for await (const line of decodeLines(process.stdout)) {
let frame: JsonValue;
Expand All @@ -194,7 +241,10 @@ export class ClaudeClient {
// The line itself is logged: it is the adapter's own output on its protocol channel,
// and the only clue to what went wrong with the pairing.
log.warn("dropping a non-JSON stdout line from the adapter", {
context: { pid: process.pid, line: line.slice(0, 512) },
context: {
pid: process.pid,
line: sensitive ? undefined : line.slice(0, 512),
},
});
continue;
}
Expand All @@ -209,6 +259,7 @@ export class ClaudeClient {
log.debug("adapter ACP frame received", {
context: { pid: process.pid, ...summarize(frame) },
});
if (sensitive) frame = redactClaudeModelError(frame);
this.#onAcpFrame(frame);
}
log.debug("adapter stdout reached EOF", {
Expand All @@ -229,11 +280,16 @@ export class ClaudeClient {
* recorded at `info` under its own target rather than guessed at; the host's per-plugin level
* decides whether it is kept.
*/
async #pumpStderr(process: SpawnedProcess): Promise<void> {
async #pumpStderr(
process: SpawnedProcess,
sensitive: boolean,
): Promise<void> {
try {
for await (const line of decodeLines(process.stderr)) {
if (line.length > 0) {
adapterLog.info(line, { context: { pid: process.pid } });
if (!sensitive) {
adapterLog.info(line, { context: { pid: process.pid } });
}
}
}
} catch (error) {
Expand All @@ -254,7 +310,12 @@ export class ClaudeClient {
"ClaudeClient cannot spawn before attachProcesses() runs",
);
}
const child = await spawnClaude(this.#processes, { cwd });
const child = this.#modelContext
? await spawnClaudeModel(this.#processes, {
cwd,
env: claudeModelEnvironment(this.#modelContext),
})
: await spawnClaude(this.#processes, { cwd });
return {
stdin: new WritableStream<Uint8Array>({
write: (chunk) => child.write(chunk),
Expand Down
Loading
Loading