@alook/daemon
v0.0.155
Published
Alook agent daemon — host-side runtime backend, process manager, credential proxy, and control plane.
Readme
@alook/daemon
A pure, host-neutral agent-runtime "driver" abstraction in TypeScript.
It documents — as compilable code — how a host adapts many different AI coding runtimes (Claude Code, Codex, Gemini, Kimi, Pi, Copilot, Cursor, OpenCode, Antigravity) behind one uniform interface, and how it delivers messages into a running agent (the "steering" mechanism).
The backend hardcodes no specific host platform. The agent always invokes a
stable CLI name (alook); cliTransport.ts writes a small wrapper by that
name into a per-launch state dir, prepends it to PATH, and forwards to the
host's real targetCommand — decoupling the agent-facing name from the host's
real binary (the host can rename/relocate its CLI without touching the agent
surface). It also injects an ALOOK_* env contract. The default
MOCK_CLI_CONFIG wires no targetCommand (the wrapper errors if invoked); a
real deployment passes its own CliTransportConfig (CLI name, env prefix,
targetCommand) and its own system-prompt communication guide. The abstraction
is what's reusable — plug in whatever host you like.
Provenance. The shapes, protocols, flag strings, and control flow here were derived by studying how production agent-runtime daemons drive these CLIs, then re-expressed as a tidy, generic abstraction. It is original code, not a copy of any vendor source, and carries no platform-specific glue.
The big idea
The daemon never speaks a runtime's native protocol directly. Each runtime is
wrapped by a Driver (src/types.ts) that knows how to:
spawn(orcreateSession) — launch the runtime,encodeStdinMessage— encode an outgoing user message for the runtime's input channel,parseLine— normalize the runtime's output into a tiny shared event vocabulary (ParsedEvent:session_init,thinking,text,tool_call,tool_output,compaction_*,turn_end,error,telemetry).
A generic session host then runs the process and fans ParsedEvents out to
the daemon:
ChildProcessRuntimeSession(src/runtime/runtimeSession.ts) for CLI runtimes.SdkRuntimeSession(src/runtime/sdkRuntimeSession.ts) for in-process SDK runtimes (pi).
Because everything downstream consumes the same ParsedEvent stream and the
same Driver capability flags, the rest of the daemon is transport-agnostic.
Two delivery models (the heart of it)
When a message arrives, what happens depends on the runtime's lifecycle:
Persistent runtimes (claude, codex, kimi, pi)
One long-lived process spans many turns. A new message is written onto the still-open input channel — no restart. This is "steering."
direct(codex, kimi, pi): write immediately; the runtime tolerates injection any time.gated(claude): a raw write mid-stream could collide with an active signed thinking block, so writes are held until a safe boundary. Two state machines implement this:RuntimeTurnState(src/runtime/turnState.ts) — the instantaneous gate:canSteerBusy = currentTurnId && !steeringGateActive. Tool boundary closes the gate; progress / turn-start reopens it.apmStateMachine(src/runtime/apmStateMachine.ts) — the policy reducers that decide when to flush queued inbox notices and emit the concretenotify_stdin/deliver_stdineffects (clauseSMR-002). Flushing is blocked whileoutstanding_tool_uses > 0, whilecompacting, whilereviewing(Codex review mode), or whentool_boundary_flush_disabled. Compaction and review exit are treated as safe flush boundaries.
For Claude specifically, the input channel is stream-json: the process is
launched with --input-format stream-json --output-format stream-json
--include-partial-messages, and each message is one NDJSON line
{"type":"user","message":{"role":"user","content":[{"type":"text","text":…}]}}.
Per-turn runtimes (gemini, copilot, cursor, opencode, antigravity)
The process handles exactly one turn and exits. supportsStdinNotification is
false and encodeStdinMessage returns null. A new message means a brand-new
process; the agent re-checks the inbox each wake. This lifecycle
(lifecycle.kind: "per_turn") drives the ## Message Notifications section
generated by buildCliSystemPrompt — no driver hand-writes this reminder.
opencode is a special per-turn case: it defers spawning until a concrete
message and terminates the process on turn end.
Runtime comparison
| Runtime | Lifecycle | Transport / protocol | Steering | Initial input | Output format |
|---|---|---|---|---|---|
| claude | persistent | child process, stream-json NDJSON | gated | {type:"user",…} line on stdin | stream-json |
| codex | persistent | child process, JSON-RPC 2.0 (app-server --listen stdio://) | direct (turn/steer) | initialize → thread/start/resume | JSON-RPC notifications |
| kimi | persistent | child process, JSON-RPC "wire" | direct (steer) | initialize → prompt | JSON-RPC events |
| pi | persistent | in-process SDK (@earendil-works/pi-coding-agent), multi-provider | direct | session.prompt() | SDK event callback |
| gemini | per-turn | child process, stream-json | none | prompt on stdin, then close | stream-json |
| copilot | per-turn | child process, JSON | none | prompt as -p arg | JSON events |
| cursor | per-turn | child process, stream-json | none | prompt as trailing arg | stream-json |
| opencode | per-turn (defer-spawn, terminate-on-end) | child process, JSON | none | prompt as -- <arg> | JSON events |
| antigravity | per-turn | child process, plain text | none | prompt on stdin, then close | plain text lines |
(Each driver's exact launch flags live in its file under src/drivers/.)
Layout
src/
types.ts # Driver interface + ParsedEvent + lifecycle/capability types
index.ts # public entry point
logger.ts # tiny structured logger (ALOOK_LOG_LEVEL); wired through
# wsControlChannel.ts/agentRouter.ts/managerRuntime.ts too, not just cli/daemonStart.ts
drivers/
index.ts # getDriver(runtimeId) registry ← start here
cliTransport.ts # shared: state dir, token, decoupling cliName wrapper -> targetCommand, env
systemPrompt.ts # shared: standing-prompt assembly
probe.ts # CLI/binary detection + version
claude.ts # Claude Code driver
claudeLaunch.ts # args / command / spawn-spec / prompt-file
claudeProviderIsolation.ts # custom-provider HOME/config isolation
claudeEventNormalizer.ts # stream-json → ParsedEvent
codex.ts / codexEventNormalizer.ts / codexTelemetrySidecar.ts
gemini.ts copilot.ts cursor.ts opencode.ts antigravity.ts
kimi.ts # child-process Kimi (JSON-RPC wire)
pi.ts # in-process Pi SDK (multi-provider)
runtime/
runtimeSession.ts # ChildProcessRuntimeSession + descriptor
sdkRuntimeSession.ts # SdkRuntimeSession (in-process)
turnState.ts # instantaneous steering gate
apmStateMachine.ts # gated-delivery policy reducers (SMR-002)
progressState.ts # liveness / stall detection
notificationState.ts # inbox-notice de-dup + batching
errorDiagnostics.ts # runtime-error classification + scrubbing
inbox/
projection.ts # bucket pending messages by target → notice snapshots
stateMachine.ts # pre-action freshness guard (forward / hold / bypass)
manager/
managerPolicy.ts # pure reducer: single-flight, wake/sleep, queue+coalesce, stalled
managerRuntime.ts # AgentProcessManager: applies effects to real sessions
server/
contract.ts # ServerApi (data) + EnrollmentApi (machine→runner key) + HostControlChannel (control) + AdminApi
wsControlServer.ts # server end of the control plane over ws (machine-key authed)
wsControlChannel.ts # host end: WebSocket HostControlChannel (injectable socket, reconnect + heartbeat + resync)
daemon/
createDaemon.ts # runtime-agnostic daemon factory (driver/sessionFactory/runtimes INJECTED; no test code)
cli/
index.ts # the `alook` agent CLI skeleton (message send / inbox pull)
proxyServerApi.ts # agent data-plane client: voucher → proxy → server (identity from the voucher)
credentials/
credentialProxy.ts # CredentialBroker (mint/revoke/check per-voucher vouchers) + local key-swapping proxy
scripts/
daemon.ts # DAEMON process: thin launcher — wraps `alook daemon start` for local devHost orchestration (manager + server)
Beyond driving a single runtime, the backend includes the host-side orchestration so it can run agents end-to-end:
manager/—AgentProcessManagerschedules processes. Decisions live in a pure reducer (managerPolicy.ts: single-flight one-process-per-agent, wake/sleep, message queue + coalesce, stalled detection →spawn/send/stop/terminate_stalledeffects); a thin executor (managerRuntime.ts) applies them to real sessions and feeds runtime events back in.AgentRouterconsumes the control plane (below).server/— the agent ⇄ server boundary, split into two planes that share one contract (contract.ts):- data plane (
ServerApi): what thealookCLI calls —send/inboxPull/ack/read/listChannels, addressed by path-styleChannelRefs. - control plane (
HostCommand+HostControlChannel): server → host commands (agent:wake/agent:stop; plusbot:*lifecycle frames).agent:wakecarries a bodilessUnreadNotice(channel + latest seq, no message content) — the DAEMON (AgentRouter/AgentProcessManager), not the server, decides whether to spawn a process, notify an already-running one, or coalesce the notice for the next turn. There is no in-process fixture standing in for the server:wsControlServer.ts(server end) andwsControlChannel.ts(host end, injectable socket + exponential-backoff reconnect + heartbeat) carry theHostCommandframes over a real WebSocket (ws://127.0.0.1in local dev, a realws-doURL in production). A real server connection is the sameWsControlChannelpointed at a real URL — nothing upstream changes.
- data plane (
tests/integration/daemon/control-plane.test.ts wires a real WsControlChannel
against a running ws-do dev server into a complete loop: seed a server/bot/
machine, post a message as a human owner over real HTTP, and assert the daemon's
control channel receives the resulting agent:wake HostCommand — then, acting
as the agent (no CLI spawned), replies over the real credential chain
(enroll → voucher → proxy → X-Agent-Id) and asserts the reply is readable back
over real HTTP. See "Point a daemon at real infra locally" below for how to run
the pieces this test drives by hand.
Credentials (zero-trust isolation)
A spawned runtime needs some credential to call back to the server, but handing
it the real API key means the agent process (and any tool it runs) can read it,
can't be scoped, and a leak forces a rotation. credentials/credentialProxy.ts
implements the voucher pattern instead — the same shape a production daemon
uses:
- The host starts one local key-swapping proxy on
127.0.0.1(loopback only). - For each launch, a
CredentialBrokermints a per-launch voucher (vch_…) into a 0600 file. The child is given<PREFIX>_PROXY_URL+<PREFIX>_PROXY_TOKEN_FILE+ its capability set — never the real key. - The child's CLI calls the proxy with
Authorization: Bearer vch_…. - The proxy validates the voucher, swaps in the real
Bearer <hostApiKey>, stampsX-Agent-Id/X-Client/X-Agent-Active-Capabilities, and forwards to the host-supplied upstream.
This buys credential isolation (the agent only ever holds a voucher),
capability scoping (the proxy rejects endpoints outside the voucher's caps),
and revocability (vouchers are per-launch and individually revocable; rotating
a leaked voucher never touches the real key). This is the only credential path
— cliTransport requires ctx.credentialProxy (a running broker + proxy URL) and
throws without it; there is no plaintext fallback that would hand the agent a real
key. Everything is host-neutral: the real key, upstream URL, voucher prefix, and
header names all come from the host via CredentialBrokerConfig.
src/credentials/credentialProxy.test.ts covers the swap, voucher rejection
(invalid local agent proxy token), capability scoping, and revocation against a
throwaway upstream.
Three key tiers (and where each lives)
A production daemon keeps three credential tiers, not two — important for
understanding what hostApiKey should be:
- Machine master key — authenticates the daemon to the server and is used to
mint per-agent credentials. Held in daemon memory only (not persisted to
disk or keychain). ⚠️ In the current daemon it is passed as a process argv
(
--api-key …), so any local process can read its full value viaps aux. Mitigation: source it from an env var, a 0600 file, or the OS keychain instead of argv. This proxy never forwards the master key. - Per-agent runner credential — the server mints one per agent (scoped to
that agent, individually revocable) when the daemon asks with the master key.
This is what the proxy actually swaps in and forwards upstream, and what
belongs in
CredentialBrokerConfig.hostApiKey. Minting it from the master key is host-side orchestration and is intentionally out of scope for this backend. vch_voucher — minted byCredentialBrokerper launch, written to a 0600 file, and the only credential the agent process ever sees.
Inbox layer (freshness)
Two pieces govern how messages reach the agent and how outgoing actions are gated:
inbox/projection.ts—projectAgentInboxSnapshotbuckets pending messages by target (channel / DM / thread) and projects each into the metadata-only summary surfaced as an[inbox notice: …](count, first, latest, sender, flags). No bodies — the agent pulls those withalook inbox pull.inbox/stateMachine.ts—planAgentInboxSideEffectis the "don't reply on stale context" guard. Before an outward action (send/task_claim/task_update) it compares the model's seenseqboundary against pending messages and returns forward (let it through), held (hold the action, surface the latest few unseen messages as held context to reconcile first — this is the "Freshness hold → saved as a draft" you can hit when messaging a busy agent), or bypass (explicitcontinueAnyway). It is a pure function producing a stableproducerFactId; clause idsSMR-002(consume) andSMR-006(held envelope) are preserved.
Build & run
This project uses pnpm (see the packageManager field).
pnpm install
pnpm run typecheck # tsc --noEmit (passes clean)
pnpm test # vitest — unit tests only (drivers/manager/inbox/credentials/server)
pnpm run test:integration # real infra: requires wrangler dev + ws-do dev + wake-worker dev (see below)Unit behavior is covered by src/**/*.test.ts. Full control-plane and
credential-chain behavior against real infra is covered by
tests/integration/daemon/ — see that
directory for the executable reference of how the pieces fit together over
the network (real WsControlChannel over a real WebSocket, real
enroll-agent/credential-proxy HTTP calls, no in-process shortcuts).
Point a daemon at real infra locally
There's no mock server to stand in for src/web/src/ws-do — a daemon
always talks to the real thing. To drive one by hand in local dev, run the
real servers and walk the real credential chain (cmt_ pairing token →
cmk_ daemon credential → crk_ per-agent runner key):
Terminal 1 — the web app + control-plane DO:
pnpm --filter @alook/web dev # http://localhost:3000
pnpm --filter @alook/ws-do dev # ws://localhost:8789Terminal 2 — pair + activate a machine, then start a daemon with the
resulting cmk_... credential:
# 1. As a signed-in user, create a pairing token:
curl -s -X POST http://localhost:3000/api/community/machines/pair \
-H "Cookie: <session-cookie>" | tee /tmp/pair.json
# → { "tokenId": "cmt_…", "expiresAt": "…" }
# 2. Exchange it for a daemon credential:
curl -s -X POST http://localhost:3000/api/community/daemon/activate \
-H "Authorization: Bearer $(jq -r .tokenId /tmp/pair.json)" \
-H "content-type: application/json" \
-d '{"hostname":"my-laptop","platform":"darwin","arch":"arm64"}'
# → { "credential": "cmk_…", "machineId": "cm_…", "expiresAt": null }
# 3. Start the daemon with that credential:
ALOOK_MACHINE_KEY=cmk_… \
ALOOK_SERVER_URL=http://localhost:3000 \
ALOOK_SERVER_WS_URL=ws://localhost:8789 \
pnpm run daemonA bot bound to this machine (via the community UI/API) can now be woken by
posting a message in a channel it's a member of — the real wake-producer path
(src/web → src/wake-worker → src/ws-do) delivers agent:wake over the
daemon's real WsControlChannel.
Run the source, not
dist/(yet).pnpm run buildemits JS, but the current Bundler-resolution emit produces extensionless relative imports that Node's native ESM rejects (Cannot find module './types'). Until that's switched to NodeNext or bundled, consume the library through a TS toolchain (tsx / ts-node / vite / esbuild / webpack).
Usage sketch
import { getDriver, createChildProcessRuntimeSession } from "@alook/daemon";
const driver = getDriver("claude");
const session = createChildProcessRuntimeSession(driver, ctx);
session.on("runtime_event", (e) => handleParsedEvent(e));
await session.start({ text: initialPrompt });
// Later, a message arrives while the agent is working:
session.send({ text: "new message from #general", mode: "busy" }); // steer