export class ClaudeCodeAdapter implements Runtime {
private state: AdapterState | null = null;
constructor(private readonly deps: ClaudeCodeAdapterDeps) {}
spawn(input: SpawnInput): Effect.Effect<void, SpawnFailed, never> {
const toSpawnFailed = (cause: unknown): SpawnFailed => {
const error = cause instanceof Error ? cause : new Error(String(cause));
return new SpawnFailed({
agentName: input.agentName,
cause: error,
message: `Failed to spawn agent "${input.agentName}": ${error.message}`,
});
};
return Effect.gen(this, function* () {
const { stateDir, extDir } = yield* prepareClaudeCodeStateDir(
this.deps,
input,
);
const mcpConfigPath = yield* writeClaudeCodeMcpConfig({
stateDir,
extDir,
serverUrl: input.serverUrl,
apiKey: input.apiKey,
agentName: input.agentName,
});
const logBuffer = { value: "" };
const child = yield* spawnConfiguredClaude({
deps: this.deps,
stateDir,
mcpConfigPath,
logBuffer,
});
this.state = {
process: child,
stateDir,
spawnInput: input,
logBuffer,
tornDown: false,
};
}).pipe(Effect.mapError(toSpawnFailed), Effect.provide(NodeContext.layer));
}
waitUntilReady(timeoutMs: number): Effect.Effect<ReadyOutcome, never, never> {
if (!this.state) {
return Effect.succeed({ _tag: "Ready" as const });
}
const { process: proc, spawnInput, logBuffer } = this.state;
const agentId = spawnInput.agentId;
// The server side of readiness — Ready when ConnectionManager records
// an authenticated connection, Timeout if it never does. Pluggable per
// server-handle implementation (in-process polling vs. out-of-process
// WS-presence subscription).
const serverReady = this.deps.server.awaitAgentReady(agentId, timeoutMs);
const processExit = {
pollExitCode: () => pollClaudeExitCode(proc),
stderr: () => logBuffer.value,
timeoutMs,
};
return pipe(
Effect.race(serverReady, processExitLoop(processExit)),
// Final-check: if the race resolved `Timeout`, the process may have
// exited within the last `exitLoop` tick window — give the adapter one
// last sync poll so a near-deadline exit still surfaces with stderr
// instead of an opaque `Timeout`.
Effect.flatMap((outcome) =>
promoteTimeoutIfProcessExited(outcome, processExit),
),
// Failure outcomes (Timeout, ProcessExited) tear down before returning
// — keeps the Runtime contract that the adapter cleans up after itself.
Effect.tap((outcome) =>
outcome._tag === "Ready" ? Effect.void : this.doTeardown(),
),
);
}
teardown(): Effect.Effect<void, never, never> {
return Effect.suspend(() => this.doTeardown());
}
getLogs(offset: number): LogSlice {
if (!this.state) return { text: "", nextOffset: 0 };
const full = this.state.logBuffer.value;
return { text: full.slice(offset), nextOffset: full.length };
}
getInboundMarker(): string {
// The cc-channel pushes an MCP `notifications/claude/channel` to claude
// for every inbound; that method name appears in `--verbose` stream-json
// output. Used by trace-capture as a coarse "did inbound reach the
// agent" signal.
return "notifications/claude/channel";
}
private doTeardown(): Effect.Effect<void, never, never> {
if (!this.state || this.state.tornDown) return Effect.void;
this.state.tornDown = true;
const { process: proc, stateDir } = this.state;
const removeStateDir = FileSystem.FileSystem.pipe(
Effect.flatMap((fileSystem) =>
fileSystem.remove(stateDir, { recursive: true, force: true }),
),
Effect.provide(NodeContext.layer),
Effect.catchAll((cause) =>
Effect.logWarning(
"failed to remove claude-code adapter state dir",
cause,
),
),
);
// SIGTERM with a timeout; escalate to SIGKILL if SIGTERM doesn't
// reap. Closing `proc.scope` afterward runs Command.start's kill