Skip to content

Claude provider

Claude is the only provider implemented today. Everything below it is what a second provider will be measured against.

import { Claude, Command } from 'activecli';
const command = Command.open(Claude, { workingDir: '/proj' });
await command.setMessage('fix the failing test').send();

The provider itself is nine lines — a name and a factory. The work is in the four classes under Adapter/Claude/.

Class What it owns
ClaudeAdapter Which flag resumes a session, when a spawn needs a Job Object, and the environment that keeps output parseable
ClaudeArgv The argument vector, built as an object so flag composition can be asserted without starting a process
ClaudePermissionFlag Translation between PermissionMode and the CLI’s own flag names, in both directions
ClaudeAuthEnv Which inherited credentials must not reach the spawned CLI

ClaudeAdapter composes rather than implements: argv from ClaudeArgv, PATH from AugmentedPath, orphan containment from JobSpawner, output classification from EventParser. What is left is the part that is genuinely Claude-specific.

ClaudeArgv.STREAM_FLAGS is constant — every spawn carries all of it. These are the flags that make the CLI a controllable stream rather than a terminal UI.

private static readonly STREAM_FLAGS = [
'-p',
'--output-format',
'stream-json',
'--input-format',
'stream-json',
'--verbose',
'--include-partial-messages',
'--permission-prompt-tool',
'stdio',
];
Flag What breaks without it
-p The CLI draws its interactive terminal UI instead of running as a driven process
--output-format stream-json Output arrives as prose meant for a human to read, not as entries a parser can classify
--input-format stream-json stdin does not stay open, so the session ends after one answer instead of continuing — this is the flag that makes the conversation two-way
--verbose Entries the host needs in order to follow the turn are never emitted
--include-partial-messages Text arrives only when a turn completes, so nothing can be streamed to a user as it is produced
--permission-prompt-tool stdio Approval requests are not routed to the channel the host is listening on, so there is no way to answer them

The last pair is what makes permission handling possible at all: --input-format stream-json gives the host a channel to speak on, and --permission-prompt-tool stdio points the CLI’s approval requests at it.

Three more are appended only when something asked for them, and each omission is a decision.

Flag When it appears Why it is conditional
--session-id / --resume Always one of the two Starting a new conversation and continuing an existing one use different flags, and only the caller knows which this is
--no-session-persistence ephemeral runs A probe should not appear in the user’s history: they did not ask for the turn, and a transcript full of them is noise they cannot act on
--permission-mode A mode was established Absent means nothing has established one yet, so the CLI reads its own configured default
--model A model was pinned, and it is not default default is the CLI’s own alias for “no pinned model”, so passing it would be a no-op dressed up as a choice

Two of those carry rules worth stating plainly, because both are easy to get wrong:

Rule Reason
Omit a flag entirely when the caller expressed no preference Passing the CLI’s word for “the default” overrides the user’s configured setting rather than deferring to it. --permission-mode default names a specific mode; it does not mean “follow the user’s settings”.
Pin the model explicitly when one was chosen A control-channel retarget only reaches a live process. Without the flag, a model chosen while idle is lost at the next spawn.

ClaudeAdapter holds two flags and picks between them:

private static readonly RESUME_FLAG = '--resume';
private static readonly NEW_SESSION_FLAG = '--session-id';

The choice comes from options.isResuming(), which is resume && sessionId !== null — not from the id merely being present.

Every spawn carries an id, because a conversation that cannot be resumed is not a session. buildArgv throws without one; there is no correct case for omitting it.

private static readonly PROTOCOL_ENV: NodeJS.ProcessEnv = {
TERM: 'dumb',
CI: 'true',
CLAUDECODE: undefined,
};
Variable Why
TERM=dumb A CLI that thinks it is attached to a capable terminal decorates its output with ANSI escapes, and those land on the same stream as the protocol. Prepending \x1b[1 q to a JSON line makes it fail to parse — exactly the breakage other tools have hit when launching agents through a login shell.
CI=true Asks for non-interactive behaviour for the same reason: nothing here is going to answer a prompt drawn on the terminal.
CLAUDECODE cleared It marks “you are running inside Claude Code”. Inheriting it from a parent that was would make the CLI behave as a nested run when it is not one.

Order matters when these are merged. The protocol env comes first as a floor, then the caller’s own — they may know something the library does not — and the auth strip last, because an inherited token would pin the CLI to something it cannot refresh no matter who passed it down.

ClaudeAuthEnv — the 401 loop it prevents

Section titled “ClaudeAuthEnv — the 401 loop it prevents”

When a host inherits OAuth tokens from whatever launched it — a desktop app, an IDE, a stale shell — passing them through pins the CLI to that token. The CLI takes an explicit token as final and never runs its keychain-based refresh, so the moment the inherited one expires, every call returns 401 and keeps returning it. Removing them lets the CLI fall back to auth it can renew.

private static readonly STRIPPABLE = [
'CLAUDE_CODE_OAUTH_TOKEN',
'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR',
] as const;

Two absences from that list are deliberate.

ANTHROPIC_API_KEY is left alone. It does not expire and has no refresh flow, so it cannot cause the loop above — the only reason to strip anything. Stripping it broke exactly one group of users: those who authenticate by exporting it in their shell rather than pinning it in settings. The claude CLI honours that variable directly, so anything driving the CLI must too, or the same command that works in a terminal fails under a GUI.

A key set in the user’s own Claude settings wins. A token placed there is a deliberate override — a dev or staging credential, chosen on purpose. Removing it would override the user, so a key present there is left in place.

Variables are removed by merging { KEY: undefined }, because that is what child_process treats as “omit this variable”. An empty string would still be a value the CLI reads and trusts.

PermissionMode is the shared vocabulary; the CLI has its own spelling.

PermissionMode Claude’s flag
PLAN plan
ASK_BEFORE_EDIT default
AUTO_EDIT acceptEdits
AUTO auto
BYPASS bypassPermissions

The reverse map is derived from the forward one, so the two cannot drift.

Both directions are needed. The library spawns with the flag, and the CLI then announces its mode using that same vocabulary — on system/init at spawn, and again on system/status when it changes the mode itself. That is how an approved plan leaving plan mode becomes observable without inspecting the tool call, and it is what makes Session.mode and Session.requiresRestartFor() work.

Note that ASK_BEFORE_EDIT maps to the flag literally named default. This is precisely why omitting the flag is not the same as passing it: --permission-mode default selects ask-before-edits, while omitting it defers to whatever the user configured.

On win32 the spawn goes through a Job Object, because a long-lived session is exactly the case where git-bash workers detach and survive a tree-kill. Elsewhere the CLI is spawned detached, so it leads a process group that PosixProcessTree can signal as a whole.

A working directory is only handed over when isSpawnableAsCwd() says it can be one. A WSL UNC path that refuses fails the spawn with ENOENT naming the directory, which reads like a missing binary — so it is left off entirely instead.