* fix(runner): prevent ghost sessions from orphaned spawn webhooks
spawnSession() registers a tracked session entry before the child's
"Session started" webhook arrives. When the 15s webhook timeout fires,
only pidToAwaiter / pidToErrorAwaiter are cleared; the
pidToTrackedSession entry is left in place and the detached child
keeps running. If the child eventually starts and reports its webhook,
onHappySessionWebhook() still finds the stale tracking entry and
promotes the orphan into a normal runner-managed session, surfacing
a message-less "ghost session" in the web UI.
This is easy to reproduce on opus[1m] --resume: observed real-world
case where four rapid "resume" clicks produced four orphan children
that all reported their webhooks ~60 minutes later, creating four
ghost sessions on the dashboard.
Three-part fix:
1. Make the webhook timeout configurable via
HAPI_RUNNER_WEBHOOK_TIMEOUT_MS (default unchanged at 15_000). Users
on slow models / large resumes can raise the ceiling so the
timeout never fires in the first place.
2. On timeout, also delete the pidToTrackedSession entry and SIGTERM
the child, so a late webhook cannot promote the orphan.
3. Defence in depth: if onHappySessionWebhook() receives a webhook
from a PID that is not tracked but whose payload claims
startedBy: 'runner', ignore it and SIGTERM the child. Genuine
terminal-launched children correctly report
startedBy: 'terminal', so this branch cannot false-positive on
them.
* fix(runner): use tree-kill on timeout and clean up worktree for orphans
Address review feedback on the kill path and worktree cleanup:
1. Timeout handler: replace bare `happyProcess.kill('SIGTERM')` with
`killProcessByChildProcess(happyProcess)` so the entire process tree
(wrapper + detached agent grandchildren) is reaped, matching the
existing `stopSession()` behaviour.
2. Orphan webhook handler: replace bare `process.kill(pid, 'SIGTERM')`
with `killProcess(pid)` for proper SIGTERM → SIGKILL escalation.
A ChildProcess reference is unavailable here (tracking entry already
removed), so tree-kill is not possible — but the timeout handler
should have already tree-killed the group; this is defence-in-depth.
3. Worktree leak: when a worktree session times out, register a
one-shot `exit` listener on the child process to run
`cleanupWorktree()` after the child actually exits. Previously
`maybeCleanupWorktree('spawn-error')` would skip cleanup because
the child was still alive at that point, and `onChildExited()` had
no worktree awareness after the tracking entry was deleted — so the
worktree leaked permanently.
---------
Co-authored-by: fengtian <fengtian@users.noreply.github.com>
HAPI CLI Runner: Control Flow and Lifecycle
The runner is a persistent background process that manages HAPI sessions, enables remote control from the mobile app, and handles auto-updates when the CLI version changes.
1. Runner Lifecycle
Starting the Runner
Command: hapi runner start
Control Flow:
src/index.tsreceivesrunner startcommand- Spawns detached process via
spawnHappyCLI(['runner', 'start-sync'], { detached: true }) - 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 - State persistence: writes PID, version, HTTP port, mtime to runner.state.json
- HTTP server: starts Fastify on random port for local CLI control (list, stop, spawn)
- 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 CLI binary changes (e.g., after npm upgrade hapi):
- At startup, records
startedWithCliMtimeMs(file modification time of CLI binary) - Heartbeat compares current CLI mtime with recorded mtime via
getInstalledCliMtimeMs() - If mtime changed:
- Clears heartbeat interval
- Spawns new runner via
spawnHappyCLI(['runner', 'start']) - Waits 10 seconds to be killed by new runner
- New runner starts, sees old runner running with different mtime
- New runner calls
stopRunner()which tries HTTP/stop, falls back to SIGKILL - New runner takes over
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
Control Flow:
stopRunner()incontrolClient.tsreads runner.state.json- 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 spawning sessions with different AI agents:
| Agent | Command | Token Environment |
|---|---|---|
claude (default) |
hapi claude |
CLAUDE_CODE_OAUTH_TOKEN |
codex |
hapi codex |
CODEX_HOME (temp directory with auth.json) |
gemini |
hapi gemini |
- |
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-*, writes token toauth.json, setsCODEX_HOME - 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():- Validates/creates directory (with approval flow)
- Configures agent-specific token environment
- Spawns detached HAPI process with
--hapi-starting-mode remote --started-by runner - Adds to
pidToTrackedSessionmap - Sets up 15-second awaiter for session webhook
- 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 runs hapi directly:
- CLI auto-starts runner if configured
- HAPI process calls
notifyRunnerSessionStarted() - 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()finds session byhappySessionIdorPID-{pid}format- Sends termination request via
killProcessByChildProcess()orkillProcess()(Windows usestaskkill /T) on('exit')handler removes from tracking map
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):
{ "success": true }
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
All data is plain JSON over TLS; authentication is CLI_API_TOKEN (no end-to-end encryption).
7. Process Discovery and Cleanup
Doctor Command
hapi doctor uses ps aux | grep to find all HAPI processes:
- Production: matches
hapibinary,happy-coder - Development: matches
src/index.ts(run viabun) - Categorizes by command args: runner, runner-spawned, user-session, doctor
Clean Runaway Processes
hapi doctor clean:
findRunawayHappyProcesses()filters for likely orphanskillRunawayHappyProcesses():- Sends SIGTERM
- Waits 1 second
- Sends SIGKILL if still alive
8. Integration Testing
Test Environment
- Requires
.env.integration-test - Uses local hapi-hub (http://localhost:3006)
- Separate
~/.hapi-dev-testhome directory
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)
// 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
Checks if machine ID exists in settings:
- If not: creates ID locally only (so sessions can reference it)
- Does NOT create machine on hub - that's runner's job
- CLI doesn't manage machine details - all API & schema live in runner subpackage
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
When runner state changes:
// Server -> Mobile/Web clients
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: No end-to-end encryption (TLS only); CLI auth is a shared secret
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)
Improvements
-
runner.state.json file is getting hard removed when runner exits or is stopped. We should keep it around and have 'state' field and 'stateReason' field that will explain why the runner is in that state
-
If the file is not found - we assume the runner was never started or was cleaned out by the user or doctor
-
If the file is found and corrupted - we should try to upgrade it to the latest version? or simply remove it if we have write access
-
posts helpers for runner do not return typed results
-
I don't like that runnerPost returns either response from runner or { error: ... }. We should have consistent envelope type
-
we loose track of children processes when runner exits / restarts - we should write them to the same state file? At least the pids should be there for doctor & cleanup
-
the runner control server binds to
127.0.0.1on a random port; if we ever expose it beyond localhost, require an explicit auth token/header