Host daemon (hostd)
Singleton host-stage daemon, three trust channels, the control protocol, portbroker internals
src/hostd/ is a singleton host-stage daemon owning host-side capabilities the running container needs: the supervisor (restart), the port broker (delegated to src/portbroker/), and the KakaoTalk renewal cron. Singleton per host, not per agent. Only persistent host-side process; only persistent host-stage state (~/.typeclaw/). New host-side capabilities plug in as src/hostd/<capability>-manager.ts glue + a src/<capability>/ module + an optional DaemonOptions callback. Don't add new daemons.
Why it exists
Two cross-stage problems converge on a long-lived host process:
- Port forwarding. Docker can't publish new ports on a running container (create-time-only
HostConfig.PortBindings). Many dev servers also bind127.0.0.1inside the netns, whichdocker run -pcan't reach. The portbroker proxies LISTEN events from the container with the upstream side of every connection running inside the container's typeclaw process, whereBun.connect('127.0.0.1', port)reaches loopback naturally. - Container self-restart. The container has no Docker access. When the agent updates the CLI source or hits a
restart-requiredfield, the only path back is through a host-stage process.
Process topology
host:
typeclaw start/stop → short-lived clients of hostd
(Unix socket ~/.typeclaw/run/hostd.sock)
typeclaw _hostd (singleton)
├─ supervisor (restart capability)
├─ portbroker-manager (one Broker per container)
│ └─ ws → /portbroker on each agent
├─ kakao-renewal-manager (daily tick per container)
└─ persistent registry: ~/.typeclaw/run/registrations/<name>.json
container: typeclaw run
├─ / TUI WS protocol
├─ /portbroker hostd port-forward broker
├─ /tunnel-logs tunnel logs (typeclaw tunnel logs)
└─ agent's `restart` tool → HTTP host.docker.internal:8974 (Bearer auth)The daemon is typeclaw _hostd (hidden subcommand). First container launch spawns it detached via Bun.spawn + proc.unref(). This is the only detached spawn in the codebase. Subsequent start calls connect, probe the daemon's source fingerprint via the version RPC, and respawn on mismatch via shutdown RPC (never SIGTERM by pidfile — that's what makes PID-reuse safe). A 30s GC tick deregisters containers that no longer exist.
Container ↔ host channels
Three channels, each scoped:
- Unix socket on
~/.typeclaw/run/hostd.sock(CLI ↔ daemon). NDJSON RPC. Never bind-mounted (macOS 9p/virtiofs canstatsocket files but can't passconnect()through). - HTTP on
host.docker.internal:8974(container → daemon). Carriesrestartonly. Per-containerrestartToken(32 bytes, base64url) for Bearer auth. Preferred port falls back to ephemeral onEADDRINUSE. Used instead of bind-mounted socket because of the macOS limitation above.host.docker.internalinstead of bridge IP because Docker Desktop on macOS doesn't route the bridge subnet. - WebSocket on
ws://127.0.0.1:${hostPort}/portbroker(hostd-initiated). Multiplexed portbroker protocol. Auth:brokerTokenvia env var; container rejects mismatchingbroker-hello. Token regenerates per register.
Container name plumbed via TYPECLAW_CONTAINER_NAME. Inside the container, cwd is /agent and the host folder is otherwise unrecoverable.
Control protocol
NDJSON over the Unix socket. Connection-per-request. Kinds: register, deregister, list, status, restart, http-info, version, shutdown. register is idempotent at the cwds level but always (re)spawns the broker (handles TOCTOU port-retry). Payload persisted atomically to registrations/<name>.json (0600); replayed on boot before opening the socket. restart and shutdown ACK then execute asynchronously so the calling container/CLI sees success before the listener disappears. Adding a new request kind: extend both Request in protocol.ts and the dispatch switch.
Components
src/hostd/:
daemon.ts— singleton process. Owns the cwds/token registries, socket server, HTTP control surface, RPC dispatch, GC tick. Replays registrations on boot. SIGTERM/SIGINT → orderly shutdown.restart/portbroker.start/kakaoRenewal.startare honored only when the correspondingDaemonOptionscallback is set — tests omit them.supervisor.ts— restart. ACK-then-execute. Wired bysrc/cli/hostd.tsviaDaemonOptions.restartsodaemon.tsstays free of@/containerimports.portbroker-manager.ts— glue. OneBrokerper container.resolveHostPortcallback re-queries Docker on reconnect (host port may change across restarts).kakao-renewal-manager.ts— daily tick per container.shouldRenewgate (CLI plumbs a predicate that readstypeclaw.json#channels.kakaotalk).onRenewalOkrestarts the container so fresh tokens propagate into the live LOCO client.client.ts—isDaemonReachable(),send(req)(Unix socket),sendHttp(req, opts)(HTTP from container).spawn.ts—ensureDaemon(). Lockfile-arbitrated spawn race; version-drift detection viaversion+shutdown.version.ts— sha256 fingerprint oversrc/**/*.ts(tests excluded).UNVERSIONED_SENTINELfallback whensrc/ancestor isn't findable.paths.ts—~/.typeclaw/paths. HonorsTYPECLAW_HOME.
src/portbroker/:
policy.ts—shouldForward({ policy, port }),brokerEnabled(policy). Always implicitly excludesCONTAINER_PORT.proc-net-tcp.ts— pure parser. Prefers127.0.0.1on IPv4/IPv6 dedup. Never throws on garbage input.container-server.ts— container side. PortWatcher polls/proc/net/tcp[6]every 500ms. Perrelay-open, opensBun.connect('127.0.0.1', port).hostd-client.ts— host side. Per allowed LISTEN port,Bun.listen(127.0.0.1, port). Bun's TCPdatacallback reuses its buffer — copy chunks into freshUint8Arraybefore queuing.- Reconnect on disconnect: tear down forwarders, then backoff loop (1s/2s/4s/10s) with
resolveHostPort()re-resolution per attempt.
Rules of thumb
portForwarddefaults to{ allow: '*' }at the schema layer, butstart()defaults to no broker when called withoutcliEntry(test isolation). The CLI passesprocess.argv[1]explicitly. Tests omit it.- The supervisor is on whenever the daemon is on; the broker is on whenever
portForward.allow !== [].restartis always available without separate opt-in. portForwardisrestart-required. Captured at register time.- Host port equals container port for forwarded ports. No random-port fallback — predictable URLs are the point. Collisions log
port-forward-failedand emit a TUI broadcast. port-forward-resultis sent for everyport-listen-opened, including policy-excluded. Consumers (e.g.bindWithForward) wait on this for cross-container collision detection.- Forward goes through
127.0.0.1, not the bridge IP. macOS doesn't route the bridge subnet; many dev servers bind loopback anyway. - Wire-protocol changes to
/portbrokerrequire a container restart. Bun has no hot-reload. Same as TUI protocol. - CLI never SIGTERMs by PID.
stopderegisters via socket; pidfile is a discovery hint. Drift respawn usesshutdownRPC + socket-file polling. If it doesn't honor shutdown within 5s, hard failure — never escalate to signals. - Daemon-source drift is detected, not assumed.
_hostdloadssrc/once at boot; without the version probe, every fix to daemon logic was silently a no-op until manualpkill. Portbroker source is part of the fingerprint. - Trust boundaries scale per channel. Unix socket → UID via 0600. HTTP → containerName + Bearer token. WS → brokerToken. New cross-container capabilities need new RPC kinds with explicit auth, not wider mounts.
- Persistence is per-container. One file per registration; corrupt files skip one container, not all. CLI never writes these — only hostd does, inside
runSerially(name).