Skip to content

Architecture

A class name tells you where its file is. ActiveCli.Adapter.Claude.ClaudeAdapter is src/ActiveCli/Adapter/Claude/ClaudeAdapter.ts, one class per file.

ActiveCli/
Command/ Command — the public surface: accumulate, inspect, send
Provider/ A CLI as an identity a caller can name; Claude, and ask()
Auth/ claude auth: status, and a live sign-in flow
Mcp/ claude mcp: list, get, add, remove, and the output parser
Adapter/ AbstractAdapter and one subclass per CLI
Claude/ The claude CLI: argv, permission flags, auth env, the adapter
Event/ What a CLI told us, as instances
Message/ What we tell a CLI: turns, attachments, control requests
Path/ A path that knows its own flavour
Process/
Exec/ Short commands, quoted so no shell rewrites them
Job/ Windows Job Objects, for descendants that detach
Launcher/ Finding the executable that will actually run
Path/ The PATH a GUI process should have had
Tree/ Killing everything a CLI spawned
Watchdog/ Noticing that the host died
Registry/ On-disk record of live CLIs, for orphans a crash left behind
Session/ A live conversation
Stream/ Reassembling JSON lines out of pipe chunks

src/index.ts is the only public surface. There are no barrel files inside the tree; every internal import names the file it wants.

What a caller holds, and what is underneath

Section titled “What a caller holds, and what is underneath”

The library has one public entry point and several layers below it. A consumer reaches only the top row; everything under it is reachable but is not where the API is aimed.

Tier Classes A caller touches it
Public surface Command, AbstractProvider / Claude, Answer Always
One-off commands AuthCommands, McpCommands, LoginSession Through command.auth / command.mcp
Conversation Session, SpawnOptions, UserMessage, ControlRequest For inspection, or when writing an adapter
Provider translation AbstractAdapter, ClaudeAdapter, ClaudeArgv, ClaudePermissionFlag, ClaudeAuthEnv Only when writing an adapter
Operating system Process/*, Path/*, Registry/*, Stream/* Rarely — and never from inside an adapter

Session is a live process: constructing one spawns. That is the right shape for what it is, and the wrong shape for a caller who wants to change their mind.

Question Session alone With Command
Change the model before sending Spawn, then a control request — or respawn setModel(); applied at the next send()
Change the permission mode Caller must know it is spawn-time only, and respawn setPermissions(); send() respawns if needed
Switch CLI Build a different adapter and a new session by hand command.provider = Codex
See the command line adapter.buildArgv(options) — assemble the options first command.toArgv()

Each row is a detail the old surface leaked. Command absorbs them by holding intent instead of a process, which is only possible because there is a single execution point to apply it at.

The tree divides cleanly into what is provider-specific and what is universal.

Half Namespaces Grows when
Provider-specific Adapter/*, Provider/* A new CLI is supported
Universal Process/, Path/, Stream/, Registry/, Session/ A new operating-system problem is found
Shared vocabulary Command/, Event/, Message/, Session/PermissionMode A concept proves common to more than one CLI
CLI subcommands Auth/, Mcp/ A CLI subcommand is worth wrapping — currently Claude-shaped, and the first place a second provider will apply pressure

This is the division the adapter contract enforces. A subclass owns everything its CLI spells differently — the argv, the vocabulary of its permission flags, how its output is classified. It does not own finding the executable, augmenting PATH, or killing process trees, because those are the same problem for every CLI.

Layer Role Principal classes
Host-facing What a consumer holds and calls Command, AbstractProvider, Claude, Answer, CliRegistry
Subcommand One-off CLI questions with answers AuthCommands, AuthStatus, LoginSession, McpCommands, McpOutputParser
Conversation The live process and its reassembly state Session, SpawnOptions, UserMessage
Provider Translates intent into one CLI’s spelling AbstractAdapter, ClaudeAdapter, ClaudeArgv, ClaudePermissionFlag, ClaudeAuthEnv
Protocol The vocabulary in both directions AbstractEvent subclasses, EventParser, ControlRequest, attachments
Transport Turning pipe chunks into lines NdjsonBuffer
Operating system Launching, locating, containing, killing AbstractLauncher, AugmentedPath, JobSpawner, AbstractProcessTree, ParentWatchdog
Value Types that answer a question by being themselves AbstractPath subclasses, PermissionMode, SessionId

Dependencies point downward and inward: host-facing classes depend on provider and OS classes, and value classes depend on nothing.

graph TD
Host["Host application"] --> Command
Host --> Registry["Registry.CliRegistry"]
Command --> Provider["Provider.AbstractProvider"]
Command --> Session
Command --> Auth["Auth.AuthCommands"]
Command --> Mcp["Mcp.McpCommands"]
Command --> Options["Adapter.SpawnOptions"]
Command --> Message["Message.UserMessage"]
Provider --> Adapter["Adapter.AbstractAdapter"]
Auth --> Runner["Process.Exec.CommandRunner"]
Mcp --> Runner
Mcp --> McpParser["Mcp.McpOutputParser"]
Session --> Adapter
Session --> Stream["Stream.NdjsonBuffer"]
Session --> Tree["Process.Tree.AbstractProcessTree"]
Session --> Control["Message.ControlRequest"]
Adapter --> Event["Event.EventParser"]
Adapter --> Argv["Adapter.Claude.ClaudeArgv"]
Adapter --> AugPath["Process.Path.AugmentedPath"]
Adapter --> Job["Process.Job.JobSpawner"]
Argv --> Mode["Session.PermissionMode"]
Options --> Path["Path.PathParser"]

Four rules hold throughout:

  1. Command is the only class that decides when to spawn. Everything below it either runs when constructed (Session) or runs when called (CommandRunner). Holding that decision in one place is what makes deferral possible at all.
  2. Session never learns which CLI it drives. It holds an AbstractAdapter and asks it to spawn and to parseLine. The adapter it was built from is an implementation detail from there on.
  3. Process/ knows nothing about adapters, events or sessions. It is a self-contained solution to operating-system problems and could be lifted out whole.
  4. Value classes import nothing. AbstractPath, PermissionMode and SessionId sit at the bottom.

Two deliberate inversions are worth naming. PathParser is a separate class rather than a static factory on AbstractPath, so the base class does not have to import its own subclasses. And AbstractProvider.ask() imports Command lazily, because Command reaches back to providers and a static import would make that a cycle.

Only three classes ask process.platform to choose behaviour, and each takes it as a parameter so the answer can be pinned in a test.

Class Decides
LauncherFactory WindowsLauncher or PosixLauncher
ProcessTreeFactory WindowsProcessTree or PosixProcessTree
ClaudeAdapter Whether the spawn goes through a Job Object

PathParser performs the equivalent decision from the path’s own shape rather than from the host platform — a Windows path may well be handed to a process running inside a Linux distro, so the host is the wrong thing to ask.

Two further classes take a platform parameter for their own content: BinDirectories, whose candidate directories differ per platform, and ProcessIdentity, which reads a command line via PowerShell on win32 and ps elsewhere.

sequenceDiagram
participant H as Host
participant C as Command
participant S as Session
participant A as ClaudeAdapter
participant P as CLI process
H->>C: Command.open(Claude, options)
Note over C: nothing spawns yet
H->>C: subscribe / onPermission
H->>C: setModel / setMessage
H->>C: send()
C->>C: toSpawnOptions()
C->>S: Session.start(provider.createAdapter(), options)
S->>A: spawn(options)
A->>A: ClaudeArgv.build(...)
A->>A: AugmentedPath.toEnv(PROTOCOL_ENV + env + authStrip)
A->>P: spawn (detached, or via JobSpawner on win32)
A-->>S: ChildProcess
C->>S: whenStarted()
C->>S: ask(UserMessage)
S->>P: stdin: one JSON line
P-->>S: stdout: pipe chunks
S->>S: NdjsonBuffer.push(chunk)
S->>A: parseLine(line)
A-->>S: AbstractEvent or null
S-->>C: onEvent(event)
C->>C: responder answers a PermissionRequestEvent
C->>S: send(request.approve())
S->>P: stdin: control_response
C-->>H: subscribe listeners
H->>C: send() again
Note over C: same process reused unless<br>provider or permission mode changed
H->>C: close()
S->>P: process tree killed

Four details in that flow are easy to miss and all are deliberate.

  • The turn waits for the spawn. Writing to stdin before the OS has the process running loses the line silently — the pipe exists from the moment spawn() returns, but the process behind it may not. Session.send defers the write until whenStarted() settles, so callers see a synchronous send either way.
  • The permission responder runs before the subscribers. A reply should not wait on whatever a host’s listeners do with the event.
  • A second send() reuses the process unless the provider or the permission mode changed. A permission mode is a spawn-time flag, so changing it means a new process with resume: true.
  • Attachments are cleared once sent, so the next turn does not silently carry them again.
  • On close, the buffer is flushed before listeners are told. A CLI that exits without a trailing newline left its last event in the buffer, and it is still something the CLI said.
  • parseLine returning null means the line was not protocol output — a warning on the same stream. It is skipped, not reported as an error.

A host reaches a CLI in two ways, and they have different guarantees.

Channel Reaches Used for Guarantee
Spawn flags (ClaudeArgv) The next process Session id, permission mode, model Documented CLI surface
Control channel (ControlRequest) A live, responsive process Interrupt, retarget the model Treat as an optimisation

Both paths exist for the model because they cover different moments: SetModel retargets something already running, while --model pins a choice made while nothing was running so the next spawn still honours it. Neither alone is sufficient.

The control channel is deliberately not depended upon. Interrupt has a fallback that always exists — stopping the process — and a host that needs certainty should use it.

A CLI left running after its host is gone keeps consuming quota. Several mechanisms overlap, because each fails in a different way.

Mechanism Catches Fails when
PosixProcessTree (group signal) Every descendant on POSIX Not applicable to win32
WindowsProcessTree (taskkill /T) The live parent-child tree on win32 A process detached itself from that tree
JobSpawner (Job Object) Descendants that detached, regardless of PPID The wrapper asset is missing
ParentWatchdog The host dying without running cleanup Our own process is SIGKILLed
CliRegistry Whatever all of the above missed, after the fact The state directory is unwritable

The last row is the point of the registry. Every in-process guard needs some of our code to run; a hard SIGKILL of the host runs none of it. Writing a file at spawn and removing it at exit is what makes the survivor findable by a later host.

132 tests across 12 files, run with vitest. The structure follows from the architecture.

Technique Where Why it is possible
Real processes Session.test.ts runs node -e <script> as a scripted CLI The adapter contract is small enough to implement in a test
Pinned platform Launcher, tree and adapter tests Platform is a constructor parameter
Pure logic extracted WindowsLauncher.pick(), JobSpawner.buildCommandLine() Made public so the rule is assertable without a Windows host
Substituted collaborators StubIdentity in registry tests Constructor-default injection throughout
Temp directories CliRegistry tests The registry directory is the caller’s to choose
Prose fixtures McpOutputParser.test.ts asserts against real claude mcp list / mcp get output Parsing printed output is a contract worth pinning to samples
Terminal window
npm test # vitest run — 132 tests, 12 files
npm run typecheck
npm run build # tsc, then copy-assets

The copy-assets step exists because tsc emits only what it compiles, so win-job-wrapper.ps1 would be missing from a published package. JobSpawner degrades gracefully when the wrapper is absent — which means a packaging mistake would not fail loudly, it would quietly reopen the orphan gap on Windows. Copying it is therefore a build step that fails when the asset is missing, rather than a hope.