- spawned members inherit the caller's tool/model/thinking level/permission (spawn request schema + hub runtime + spawn_peer tool); explicit overrides win - replies from turns triggered by teammates (not by the human) no longer vanish: system prompt and ACP briefs now require an explicit team_send(to=human) for human-facing conclusions produced on those turns - task loop: team_task tool (list/update), dependsOn gating, deliverable evidence required when a member marks done, auto broadcast on change, web shows deps and deliverable on the task board - team-scoped agent tokens (hapi_team_*): issued per team, injected as HAPI_TEAM_TOKEN at spawn, restricted by the auth middleware to this team's messages/tasks/status and audited via [TeamToken] hub log lines
HAPI CLI Runner: Control Flow and Lifecycle
The runner is a persistent background process that starts and manages HAPI sessions from web/phone. After you update the CLI, it can restart itself onto the new binary; it does not download or install updates.
Source paths below are relative to cli/ unless prefixed with another package.
1. Runner Lifecycle
Starting the Runner
Command: hapi runner start
Control Flow:
src/commands/runner.tshandlesrunner start, stopping any existing runner first so new flags/environment take effect- Spawns detached
runner start-sync, forwarding configured workspace roots - New process calls
startRunner()fromsrc/runner/run.ts startRunner()performs startup:- Sets up shutdown promise and handlers (SIGINT, SIGTERM, uncaughtException, unhandledRejection)
- Version check:
isRunnerRunningCurrentlyInstalledHappyVersion()compares CLI binary mtime - If version mismatch: calls
stopRunner()to kill old runner before proceeding - If same version running: exits with "Runner already running"
- Lock acquisition:
acquireRunnerLock()creates exclusive lock file to prevent multiple runners - Direct-connect setup:
authAndSetupMachineIfNeeded()ensuresCLI_API_TOKENis set andmachineIdexists - HTTP server: starts Fastify on random port for local CLI control (list, stop, spawn)
- State persistence: writes PID, version, HTTP port, mtime to runner.state.json
- WebSocket: establishes persistent connection to backend via
ApiMachineClient - RPC registration: exposes
spawn-happy-session,stop-session,stop-runnerhandlers - Heartbeat loop: every 60s (or
HAPI_RUNNER_HEARTBEAT_INTERVAL) checks for version updates, prunes dead sessions, verifies PID ownership
- Awaits shutdown promise which resolves when:
- OS signal received (SIGINT/SIGTERM) - source:
os-signal - HTTP
/stopendpoint called - source:hapi-cli - RPC
stop-runnerinvoked - source:hapi-app - Uncaught exception occurs - source:
exception
- OS signal received (SIGINT/SIGTERM) - source:
- On shutdown,
cleanupAndShutdown()performs:- Clears heartbeat interval
- Updates runner state to "shutting-down" on backend with shutdown source
- Disconnects WebSocket
- Stops HTTP server
- Deletes runner.state.json
- Releases lock file
- Exits process
Version Detection & Auto-Update
The runner detects when the CLI binary changes (e.g., after npm update -g @twsxtd/hapi):
- At startup, records
startedWithCliMtimeMs(file modification time of CLI binary) - Heartbeat compares current CLI mtime with recorded mtime via
getInstalledCliMtimeMs() - Replays the original runner arguments (including workspace roots), marking the replacement as an authorized handoff child
- Releases the lock and waits up to 30 seconds for a different live runner PID in the state file
- On confirmation, the old runner exits. On failure, it tries to reacquire the lock and stays online for a later retry; it exits if another process holds the lock
HAPI_DISABLE_VERSION_HANDOFF=1 disables this automatic replacement, not
the rest of the heartbeat. A foreground supervisor should run
hapi runner start-sync; advertise HAPI_RUNNER_SUPERVISED=1 only when it
will restart the process after exit.
Heartbeat System
Every 60 seconds (configurable via HAPI_RUNNER_HEARTBEAT_INTERVAL):
- Guard: Skips if previous heartbeat still running (prevents concurrent heartbeats)
- Session Pruning: Checks each tracked PID with
isProcessAlive(pid), removes dead sessions - Version Check: Compares CLI binary mtime, triggers self-restart if changed
- PID Ownership: Verifies runner still owns state file, self-terminates if another runner took over
- State Update: Writes
lastHeartbeattimestamp to runner.state.json
Stopping the Runner
Command: hapi runner stop
This stops the runner, not its detached agent sessions. Use stop-session
to stop an individual session, or doctor clean for broader process cleanup.
Control Flow:
stopRunner()incontrolClient.tsreads runner.state.json and verifies the PID still belongs to a HAPI runner before contacting or signaling it- Attempts graceful shutdown via HTTP POST to
/stop - Runner receives request, triggers shutdown with source
hapi-cli cleanupAndShutdown()executes:- Updates backend status to "shutting-down"
- Closes WebSocket connection
- Stops HTTP server
- Deletes runner.state.json
- Releases lock file
- If HTTP fails, falls back to
killProcess(pid, true)(usestaskkill /T /Fon Windows)
2. Multi-Agent Support
The runner supports the current agent catalog.
If a spawn request omits agent, its fallback is still Claude; this is
separate from the interactive hapi picker, which waits for your choice
instead of launching Claude implicitly.
Examples of agent authentication:
| Agent | Command | Token Environment |
|---|---|---|
claude |
hapi claude |
Agent's existing login, or supplied CLAUDE_CODE_OAUTH_TOKEN |
codex |
hapi codex |
Existing Codex home; a supplied token gets a temporary CODEX_HOME with auth.json and a copy of user config.toml |
grok |
hapi grok |
Grok CLI login or XAI_API_KEY |
opencode |
hapi opencode |
OpenCode config (no token injection) |
Token Authentication
When spawning a session with a token:
- Claude: Sets
CLAUDE_CODE_OAUTH_TOKENenvironment variable - Codex: Creates temp directory at
os.tmpdir()/hapi-codex-*, copies the user'sconfig.tomlwhen present, writes the token toauth.json, and setsCODEX_HOME. Only the config file is copied so user MCP settings survive without copying unrelated Codex state; the copied config is cleaned up when the child exits or fails to start. Windows package-manager MCP commands are proxied by the Codex launcher path; listedenv_varsremain the source of external MCP credentials. - Grok Build: No token injection; relies on Grok CLI login or
XAI_API_KEYin the runner environment - OpenCode: No token injection; relies on OpenCode's own configuration
3. Session Management
Runner-Spawned Sessions (Remote)
Initiated by mobile app via backend RPC:
- Backend forwards RPC
spawn-happy-sessionto runner via WebSocket ApiMachineClientinvokesspawnSession()handlerspawnSession():- Checks agent availability and workspace-root boundaries, then validates/creates the directory
- Configures agent-specific token environment
- Spawns detached HAPI process with
--hapi-starting-mode remote --started-by runner - Adds to
pidToTrackedSessionmap - Waits for the session-start webhook (15 seconds by default;
HAPI_RUNNER_WEBHOOK_TIMEOUT_MSoverrides)
- New HAPI process:
- Creates session with backend, receives
happySessionId - Calls
notifyRunnerSessionStarted()to POST to runner's/session-started
- Creates session with backend, receives
- Runner updates tracking with
happySessionId, resolves awaiter - RPC returns session info to mobile app
Terminal-Spawned Sessions
User starts an agent from the terminal:
- Session bootstrap registers with the hub; a runner is not required for terminal use
- HAPI process calls
notifyRunnerSessionStarted()if it can reach the local runner control server - Runner receives webhook, creates
TrackedSessionwithstartedBy: 'hapi directly - likely by user from terminal' - Session tracked for health monitoring
Directory Creation Approval
When spawning a session, directory handling:
- Check if directory exists with
fs.access() - If missing and
approvedNewDirectoryCreation = false: returnsrequestToApproveDirectoryCreation(HTTP 409) - If missing and approved: creates directory with
fs.mkdir({ recursive: true }) - Error handling for directory creation:
EACCES: Permission deniedENOTDIR: File exists at pathENOSPC: Disk fullEROFS: Read-only filesystem
Session Termination
Via RPC stop-session or HTTP /stop-session:
stopSession()locates the session, including persisted resume-process records- Stops its process tree and verifies exit; shared Codex uses a root-scoped stop so sibling conversations are not killed
- Returns
stopped,already_gone, orstill_alive; uncertainty is not reported as successful termination
4. HTTP Control Server (Fastify)
Local HTTP server using Fastify with fastify-type-provider-zod for type-safe request/response validation.
Host: 127.0.0.1 (localhost only) Port: Dynamic (system-assigned)
Endpoints
POST /session-started
Session webhook - reports itself after creation.
Request:
{ "sessionId": "string", "metadata": { ... } }
Response (200):
{ "status": "ok" }
POST /list
Returns all tracked sessions.
Response (200):
{
"children": [
{ "startedBy": "runner", "happySessionId": "uuid", "pid": 12345 }
]
}
POST /stop-session
Terminates a specific session.
Request:
{ "sessionId": "string" }
Response (200):
{ "status": "stopped" }
status is stopped, already_gone, or still_alive.
POST /spawn-session
Creates a new session.
Request:
{ "directory": "/path/to/dir", "sessionId": "optional-uuid" }
Response (200) - Success:
{
"success": true,
"sessionId": "uuid",
"approvedNewDirectoryCreation": true
}
Response (409) - Requires Approval:
{
"success": false,
"requiresUserApproval": true,
"actionRequired": "CREATE_DIRECTORY",
"directory": "/path/to/dir"
}
Response (500) - Error:
{ "success": false, "error": "Error message" }
POST /stop
Graceful runner shutdown.
Response (200):
{ "status": "stopping" }
5. State Persistence
runner.state.json
{
"pid": 12345,
"httpPort": 50097,
"startTime": "8/24/2025, 6:46:22 PM",
"startedWithCliVersion": "0.9.0-6",
"startedWithCliMtimeMs": 1724531182000,
"lastHeartbeat": "8/24/2025, 6:47:22 PM",
"runnerLogPath": "/path/to/runner.log"
}
Lock File
- Created with O_EXCL flag for atomic acquisition
- Contains PID for debugging
- Prevents multiple runner instances
- Cleaned up on graceful shutdown
6. WebSocket Communication
ApiMachineClient handles bidirectional communication:
Runner to Server:
machine-alive- 20-second heartbeatmachine-update-metadata- static machine info changesmachine-update-state- runner status changes
Server to Runner:
rpc-requestwith methods:spawn-happy-session- spawn new sessionstop-session- stop session by IDstop-runner- request shutdown
Application payloads are plain JSON authenticated with CLI_API_TOKEN.
Transport protection depends on the hub URL: use HTTPS for remote access;
the built-in network relay protects traffic with WireGuard + TLS.
7. Process Discovery and Cleanup
Doctor Command
hapi doctor uses ps-list to find HAPI processes:
- Production: matches
hapi/hapi.exe - Development: matches
src/index.ts(run viabun) - Categorizes by command args: runner, runner-spawned, user-session, doctor
Clean Runaway Processes
hapi doctor clean:
findRunawayHappyProcesses()selects runner and runner-spawned process categories (not only proven orphans); use with carekillRunawayHappyProcesses():- Sends SIGTERM
- Waits 1 second
- Sends SIGKILL if still alive
8. Integration Testing
Test Environment
- Run
bun run test:cli:integrationfrom the repo root (separate serial Vitest project) - Global setup starts an isolated hub on a free loopback port, with a temporary home/database and generated token
- No
.env.integration-testor running user hub is required - Real detached process trees are owned and cleaned up by the test harness; stress coverage is opt-in via
HAPI_RUN_STRESS_TESTS=true
Key Test Scenarios
- Session listing, spawning, stopping
- External session webhook tracking
- Graceful SIGTERM/SIGKILL shutdown
- Multiple runner prevention
- Version mismatch detection
- Directory creation approval flow
- Concurrent session stress tests
Machine Sync Architecture - Separated Metadata & Runner State
Direct-connect note: the "hub" is
hapi-hub, payloads are plain JSON (no base64/encryption), and authentication usesCLI_API_TOKEN(RESTAuthorization: Bearer ...+ Socket.IOhandshake.auth.token).
Data Structure (Similar to Session's metadata + agentState)
Simplified excerpts; the complete wire schemas live in shared/src/schemas.ts.
// Static machine information (rarely changes)
interface MachineMetadata {
host: string; // hostname
platform: string; // darwin, linux, win32
happyCliVersion: string;
homeDir: string;
happyHomeDir: string;
happyLibDir: string; // runtime path
}
// Dynamic runner state (frequently updated)
interface RunnerState {
status: 'running' | 'shutting-down' | 'offline';
pid?: number;
httpPort?: number;
startedAt?: number;
shutdownRequestedAt?: number;
shutdownSource?: 'hapi-app' | 'hapi-cli' | 'os-signal' | 'exception';
}
1. CLI Startup Phase
Authentication/bootstrap ensures a machine ID exists in settings. Session bootstrap also creates/loads that machine on the hub with its metadata; the runner supplies live runner state, heartbeats, and machine-scoped RPCs.
2. Runner Startup - Initial Registration
REST Request: POST /cli/machines
{
"id": "machine-uuid-123",
"metadata": {
"host": "MacBook-Pro.local",
"platform": "darwin",
"happyCliVersion": "1.0.0",
"homeDir": "/Users/john",
"happyHomeDir": "/Users/john/.hapi",
"happyLibDir": "/usr/local/lib/node_modules/hapi"
},
"runnerState": {
"status": "running",
"pid": 12345,
"httpPort": 8080,
"startedAt": 1703001234567
}
}
Server Response:
{
"machine": {
"id": "machine-uuid-123",
"metadata": { "host": "...", "platform": "...", "happyCliVersion": "..." },
"metadataVersion": 1,
"runnerState": { "status": "running", "pid": 12345 },
"runnerStateVersion": 1,
"active": true,
"activeAt": 1703001234567,
"createdAt": 1703001234567,
"updatedAt": 1703001234567
}
}
3. WebSocket Connection & Real-time Updates
Connection Handshake:
io(`${botUrl}/cli`, {
auth: {
token: "CLI_API_TOKEN",
clientType: "machine-scoped",
machineId: "machine-uuid-123"
},
path: "/socket.io/",
transports: ["websocket"]
})
Heartbeat (every 20s):
// Client -> Server
socket.emit('machine-alive', {
"machineId": "machine-uuid-123",
"time": 1703001234567
})
4. Runner State Updates (via WebSocket)
When runner status changes:
// Client -> Server
socket.emit('machine-update-state', {
"machineId": "machine-uuid-123",
"runnerState": {
"status": "shutting-down",
"pid": 12345,
"httpPort": 8080,
"startedAt": 1703001234567,
"shutdownRequestedAt": 1703001244567,
"shutdownSource": "hapi-app"
},
"expectedVersion": 1
}, callback)
// Server -> Client (callback)
// Success:
{
"result": "success",
"version": 2,
"runnerState": { "status": "shutting-down" }
}
// Version mismatch:
{
"result": "version-mismatch",
"version": 3,
"runnerState": { "status": "running" }
}
Machine metadata update (rare):
// Client -> Server
socket.emit('machine-update-metadata', {
"machineId": "machine-uuid-123",
"metadata": {
"host": "MacBook-Pro.local",
"platform": "darwin",
"happyCliVersion": "1.0.1",
"homeDir": "/Users/john",
"happyHomeDir": "/Users/john/.hapi"
},
"expectedVersion": 1
}, callback)
5. Mini App RPC Calls (via hapi-hub)
The Telegram Mini App calls REST endpoints on hapi-hub (for example POST /api/machines/:id/spawn).
hapi-hub then relays those requests to the runner via Socket.IO rpc-request on the /cli namespace.
RPC method naming (machine-scoped) uses a ${machineId}: prefix, for example:
${machineId}:spawn-happy-session
6. Server Broadcasts to Clients
The Socket.IO examples below are for CLI machine subscribers. Web/native
clients instead receive machine-updated via SSE and refetch /api/machines
when the event has no machine data. Do not feed the CLI update envelope
directly into a native client's SSE decoder.
When runner state changes:
// Server -> CLI machine subscribers
socket.emit('update', {
"id": "update-id-xyz",
"seq": 456,
"body": {
"t": "update-machine",
"machineId": "machine-uuid-123",
"runnerState": {
"value": { "status": "shutting-down" },
"version": 2
}
},
"createdAt": 1703001244567
})
When metadata changes:
socket.emit('update', {
"id": "update-id-abc",
"seq": 457,
"body": {
"t": "update-machine",
"machineId": "machine-uuid-123",
"metadata": {
"value": { "host": "MacBook-Pro.local" },
"version": 2
}
},
"createdAt": 1703001244567
})
7. GET Machine Status (REST)
Request: GET /cli/machines/machine-uuid-123
Authorization: Bearer <CLI_API_TOKEN>
Response:
{
"machine": {
"id": "machine-uuid-123",
"metadata": { "host": "...", "platform": "...", "happyCliVersion": "..." },
"metadataVersion": 2,
"runnerState": { "status": "running", "pid": 12345 },
"runnerStateVersion": 3,
"active": true,
"activeAt": 1703001244567,
"createdAt": 1703001234567,
"updatedAt": 1703001244567
}
}
Key Design Decisions
-
Separation of Concerns:
metadata: Static machine info (host, platform, versions)runnerState: Dynamic runtime state (status, pid, ports)
-
Independent Versioning:
metadataVersion: For machine metadata updatesrunnerStateVersion: For runner state updates- Allows concurrent updates without conflicts
-
Security: Plain JSON at the application layer; remote transport uses HTTPS or the encrypted network relay. CLI auth is
CLI_API_TOKEN -
Update Events: Server broadcasts use same pattern as sessions:
t: 'update-machine'with optional metadata and/or runnerState fields- Clients only receive updates for fields that changed
-
RPC Pattern: Machine-scoped RPC methods prefixed with machineId (like sessions)
Operational notes
- Normal shutdown removes
runner.state.json; its absence does not prove the runner has never run. Use logs for shutdown history. - Resume-spawn tracking persists separately in
runner.state.json.resume-processes.json, with process-generation checks before recovery or termination. It is not a complete inventory of every terminal-started process. - The local control server binds to
127.0.0.1on a random port. It has no remote authentication layer; do not expose it through a public proxy.