Architecture
Folder structure is the namespace
Section titled “Folder structure is the namespace”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 chunkssrc/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 |
Why Command exists as its own layer
Section titled “Why Command exists as its own layer”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 two halves of the library
Section titled “The two halves of the library”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.
Layers
Section titled “Layers”| 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 |
Dependency direction
Section titled “Dependency direction”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:
Commandis 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.Sessionnever learns which CLI it drives. It holds anAbstractAdapterand asks it tospawnand toparseLine. The adapter it was built from is an implementation detail from there on.Process/knows nothing about adapters, events or sessions. It is a self-contained solution to operating-system problems and could be lifted out whole.- Value classes import nothing.
AbstractPath,PermissionModeandSessionIdsit 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.
Where platform is decided
Section titled “Where platform is decided”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.
The life of a command
Section titled “The life of a command”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 killedFour 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.senddefers the write untilwhenStarted()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 withresume: 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.
parseLinereturningnullmeans the line was not protocol output — a warning on the same stream. It is skipped, not reported as an error.
Two channels, and why both exist
Section titled “Two channels, and why both exist”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.
Defence in depth against orphans
Section titled “Defence in depth against orphans”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.
Testing shape
Section titled “Testing shape”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 |
npm test # vitest run — 132 tests, 12 filesnpm run typechecknpm run build # tsc, then copy-assetsThe 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.