Skip to content

Commit b2e3811

Browse files
authored
Use host Claude Code throughout the retained ACP integration (#1082)
Resolve Claude Code from the host environment, omit bundled platform executables from fresh adapter materializations, and retain the approved early backend identity confirmation with joined startup cleanup. Fixes #1080.
1 parent d8588d8 commit b2e3811

29 files changed

Lines changed: 3888 additions & 2819 deletions

‎architecture.md‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3701,7 +3701,13 @@ Identity provenance records who chose a provider-native session identity: the pr
37013701
returned it, or the client allocated it before the provider existed. This provenance is
37023702
retained rather than inferred, because after the fact both are just a string in the
37033703
record. A released record written before the distinction existed reads as
3704-
provider-returned, which is the only compatibility inference.
3704+
provider-returned, which is the only compatibility inference. Backend confirmation
3705+
is separate from allocation: Claude's native route remains client-allocated when
3706+
the retained adapter confirms that same conversation on `_meta.agentSessionId`.
3707+
The adapter reads its owned SDK Query before answering session creation or
3708+
reattachment. Only a successful SessionStart lifecycle response confirms the
3709+
identity; a requested UUID or ACP session name does not. This confirmation stays
3710+
private until explicit first-turn acceptance and the asserting provider save.
37053711

37063712
#### Proving release
37073713

@@ -4232,7 +4238,10 @@ asked at all.
42324238

42334239
The workflow profile selects ACP-only capability explicitly: it advertises
42344240
neither native launch nor client-native attachment. It supplies no machine
4235-
session coordinator, construction-route store or executable observer. A workflow
4241+
session coordinator, construction-route store or executable observer. The host
4242+
selects its installed Claude launcher separately and supplies the path only to
4243+
the retained adapter child's transient environment. This selection grants no
4244+
native-session capability and performs no executable-build admission. A workflow
42364245
session belongs to a run; the machine-wide ownership account describes a
42374246
different boundary. Deleting a run removes its mapping row and provider-session
42384247
directory and reports the categories removed.

‎packages/acp/src/adapter-snapshots.ts‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727
import { ensureDir, exists, readTextFile, rm, writeTextFile } from "@effectionx/fs";
2828
import { readdir, readFile, rename as renamePath, rm as rmPath, writeFile } from "node:fs/promises";
29-
import { exec, useQuietProcessOutput } from "@executablemd/runtime";
29+
import { exec, hostClaudeExecutable, useQuietProcessOutput } from "@executablemd/runtime";
3030
import { createAgentRegistry } from "./acpx-runtime.ts";
3131
import type { AcpAgentRegistry } from "./acpx-runtime.ts";
3232
import type { AcpxProviderDependencies } from "./provider.ts";
@@ -243,8 +243,8 @@ function* contentDigest(directory: string): Operation<string> {
243243
*
244244
* A `file:` dependency rather than an unpacked tree, so the adapter's own
245245
* runtime dependencies are resolved the way they are for anybody installing it
246-
* from the registry. Provider CLIs are among those dependencies and are not
247-
* carried here: this repository vendors the adapter, never the agent.
246+
* from the registry. Claude uses the host CLI; its SDK library remains installed
247+
* while platform executable optional dependencies are omitted.
248248
*/
249249
function* install(
250250
directory: string,
@@ -273,7 +273,15 @@ function* install(
273273
const installed = yield* scoped(function* () {
274274
yield* useQuietProcessOutput();
275275
return yield* exec({
276-
command: ["npm", "install", "--omit=dev", "--no-audit", "--no-fund", "--loglevel=error"],
276+
command: [
277+
"npm",
278+
"install",
279+
"--omit=dev",
280+
...(snapshot.provider === "claude" ? ["--omit=optional"] : []),
281+
"--no-audit",
282+
"--no-fund",
283+
"--loglevel=error",
284+
],
277285
cwd: directory,
278286
});
279287
});
@@ -702,9 +710,19 @@ export function carriesEmbeddedAdapter(adapters: EmbeddedAdapters, agentName: st
702710
*/
703711
export function embeddedAdapterDependencies(
704712
adapters: EmbeddedAdapters,
705-
): Pick<AcpxProviderDependencies, "agentRegistry" | "prepareAgent"> {
713+
): Pick<AcpxProviderDependencies, "agentRegistry" | "prepareAgent" | "agentProcessEnv"> {
706714
return {
707715
agentRegistry: overlaidAdapterRegistry(adapters),
716+
*agentProcessEnv(agentName, agentCommand) {
717+
if (
718+
agentName !== "claude" ||
719+
!carriesEmbeddedAdapter(adapters, agentName) ||
720+
agentCommand !== adapters.command(agentName)
721+
) {
722+
return undefined;
723+
}
724+
return { CLAUDE_CODE_EXECUTABLE: yield* hostClaudeExecutable() };
725+
},
708726
*prepareAgent(agentName: string): Operation<void> {
709727
if (!carriesEmbeddedAdapter(adapters, agentName)) {
710728
return;

‎packages/acp/src/native-launch.ts‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -235,9 +235,6 @@ const ADAPTERS: Readonly<Record<string, NativeAdapter>> = {
235235
binding: {
236236
command: "claude",
237237
version: claudeVersion,
238-
// The version #561's gates are proven against. Raising it is a new proof,
239-
// not a version bump.
240-
adapterCommand: "npx -y @agentclientprotocol/claude-agent-acp@0.70.0",
241238
// The first thing the Claude ACP adapter consults when deciding which
242239
// Claude to run. Without it the adapter resolves the build shipped with
243240
// the Agent SDK it pins, which is not the build that created the session.

‎packages/acp/src/provider.ts‎

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,11 @@ export interface AcpxProviderDependencies {
268268
* asks for one prepares nothing.
269269
*/
270270
prepareAgent?: (agentName: string) => Operation<void>;
271+
/** Child-only host inputs for the selected command; never session configuration. */
272+
agentProcessEnv?: (
273+
agentName: string,
274+
agentCommand: string,
275+
) => Operation<Record<string, string> | undefined>;
271276
/**
272277
* The native adapters this host has proven and is therefore willing to hand
273278
* a session to. Absent means none: knowing an adapter's command shape is not
@@ -1204,7 +1209,7 @@ function* useAcpxProviderState(
12041209
};
12051210
}
12061211

1207-
function* runtimeOptions(): Operation<AcpRuntimeOptions> {
1212+
function* runtimeOptions(agentName?: string): Operation<AcpRuntimeOptions> {
12081213
const dir = yield* agentCwd();
12091214
const options: AcpRuntimeOptions = {
12101215
cwd: dir,
@@ -1213,6 +1218,15 @@ function* useAcpxProviderState(
12131218
permissionMode: providerOptions.permissionMode,
12141219
nonInteractivePermissions: "deny",
12151220
};
1221+
if (agentName !== undefined && dependencies?.agentProcessEnv !== undefined) {
1222+
const environment = yield* dependencies.agentProcessEnv(
1223+
agentName,
1224+
resolveAgentCommand(registry, agentName),
1225+
);
1226+
if (environment !== undefined) {
1227+
options.agentProcessEnv = { ...environment };
1228+
}
1229+
}
12161230
if (mcpServers !== undefined) {
12171231
options.mcpServers = [...mcpServers];
12181232
}
@@ -1253,8 +1267,8 @@ function* useAcpxProviderState(
12531267
* step: an election that suspends part-way through is an election another
12541268
* operation can act between.
12551269
*/
1256-
function* runtimeBlueprint(build?: BoundBuild): Operation<AcpRuntimeOptions> {
1257-
const base = yield* runtimeOptions();
1270+
function* runtimeBlueprint(build?: BoundBuild, agentName?: string): Operation<AcpRuntimeOptions> {
1271+
const base = yield* runtimeOptions(build === undefined ? agentName : undefined);
12581272
const options: AcpRuntimeOptions = {
12591273
...base,
12601274
// The acpx callback boundary: `scope.run` returns a Promise-compatible
@@ -1284,12 +1298,18 @@ function* useAcpxProviderState(
12841298
* map no longer names and the next operation builds a second child for the
12851299
* same build. Here the entry is published already reserved.
12861300
*/
1287-
function electRuntime(partition: string | undefined, options: AcpRuntimeOptions): RuntimeEntry {
1301+
function electRuntime(
1302+
partition: string | undefined,
1303+
options: AcpRuntimeOptions | undefined,
1304+
): RuntimeEntry {
12881305
const held = partition === undefined ? unbound : runtimes.get(partition);
12891306
if (held) {
12901307
held.active++;
12911308
return held;
12921309
}
1310+
if (options === undefined) {
1311+
throw new Error("the selected ACP runtime was released during preparation");
1312+
}
12931313
const entry: RuntimeEntry = {
12941314
runtime: createRuntime(options),
12951315
partition,
@@ -1419,10 +1439,6 @@ function* useAcpxProviderState(
14191439
toSession: (handle: AcpRuntimeHandle, entry: RuntimeEntry) => ManagedSession,
14201440
): Operation<ManagedSession> {
14211441
return scoped(function* (): Operation<ManagedSession> {
1422-
const partition = build ? partitionOf(build) : undefined;
1423-
// Every suspension this needs, taken before anything is decided. What
1424-
// follows must not yield, so nothing it depends on may.
1425-
const options = yield* runtimeBlueprint(build);
14261442
let claimed: RuntimeEntry | undefined;
14271443
let pending: Promise<AcpRuntimeHandle> | undefined;
14281444
let settled = false;
@@ -1470,6 +1486,16 @@ function* useAcpxProviderState(
14701486
yield* abandonHandle(late, "cancelled before the session was established");
14711487
});
14721488

1489+
const partition = build
1490+
? partitionOf(build)
1491+
: dependencies?.agentProcessEnv === undefined
1492+
? undefined
1493+
: `host\u0000${input.agent}\u0000${resolveAgentCommand(registry, input.agent)}`;
1494+
const held = partition === undefined ? unbound : runtimes.get(partition);
1495+
const options =
1496+
build !== undefined || dependencies?.agentProcessEnv === undefined || held === undefined
1497+
? yield* runtimeBlueprint(build, input.agent)
1498+
: undefined;
14731499
// One synchronous step: the map is read, an entry is published already
14741500
// claimed if this is the first work to want one, and the ensure is
14751501
// started. Nothing yields in here, so no sibling runs between the
@@ -1533,7 +1559,7 @@ function* useAcpxProviderState(
15331559
// client-native session is created by a native process, and where ACP does
15341560
// serve one, the establishment itself reports being unable to.
15351561
if (!validatedAgents.has(selected) && !boundSessions(selected)) {
1536-
const base = yield* runtimeOptions();
1562+
const base = yield* runtimeOptions(selected);
15371563
const probe = createRuntime({ ...base, probeAgent: selected });
15381564
const report = yield* until(probe.doctor());
15391565
if (!report.ok) {

‎packages/acp/tests/adapter-materialization.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -831,3 +831,30 @@ describe("Tier AM — embedded adapter materialization", () => {
831831
expect(yield* exists(adapters.executablePath("codex"))).toBe(true);
832832
});
833833
});
834+
835+
describe("HC fresh Claude materialization", () => {
836+
it("HC5: the SDK library and required packages survive without platform executables", function* () {
837+
const root = yield* useRoot();
838+
const adapters = createEmbeddedAdapters(root);
839+
yield* adapters.materialize("claude");
840+
const names: string[] = [];
841+
function* walk(directory: string): Operation<void> {
842+
for (const entry of yield* until(readdir(directory, { withFileTypes: true }))) {
843+
if (entry.isDirectory()) {
844+
yield* walk(join(directory, entry.name));
845+
} else if (entry.name === "package.json") {
846+
const value = JSON.parse(yield* until(readFile(join(directory, entry.name), "utf8")));
847+
if (typeof value.name === "string") {
848+
names.push(value.name);
849+
}
850+
}
851+
}
852+
}
853+
yield* walk(join(root, digestOf("claude"), "node_modules"));
854+
expect(names).toContain("@agentclientprotocol/claude-agent-acp");
855+
expect(names).toContain("@anthropic-ai/claude-agent-sdk");
856+
expect(names).toContain("@agentclientprotocol/sdk");
857+
expect(names).toContain("zod");
858+
expect(names.filter((name) => name.startsWith("@anthropic-ai/claude-agent-sdk-"))).toEqual([]);
859+
});
860+
});

0 commit comments

Comments
 (0)