Agent Daemon — Design
Overview
A Haskell WebSocket server that manages Claude Code agent sessions. Runs on a single machine reachable via Tailscale, providing full terminal access to tmux sessions through xterm.js in the browser.
System Context
graph LR
Browser["Browser<br/>(gh-dashboard)"]
Daemon["agent-daemon<br/>(Haskell)"]
Tmux["tmux sessions"]
Git["git worktrees"]
GH["GitHub API"]
Browser -- "WebSocket<br/>terminal I/O" --> Daemon
Browser -- "REST<br/>session mgmt" --> Daemon
Daemon -- "PTY attach" --> Tmux
Daemon -- "git worktree<br/>add/remove" --> Git
Daemon -- "gh issue view" --> GH
Session Lifecycle
sequenceDiagram
participant D as Dashboard
participant A as agent-daemon
participant T as tmux
participant C as Claude Code
D->>A: POST /sessions {repo, issue}
A->>A: git worktree add /code/<repo>-issue-<N>
A->>T: tmux new-session -d -s <repo>-<N>
A->>T: send-keys "claude 'Work on owner/repo#N...'"
A-->>D: 201 {session_id, state: creating}
D->>A: WS /sessions/<id>/terminal
A->>T: PTY attach to tmux session
T-->>A: PTY stream
A-->>D: terminal I/O (bidirectional)
Note over D,C: User disconnects (closes tab)
Note over T,C: tmux + Claude keep running
D->>A: WS /sessions/<id>/terminal
A->>T: PTY attach (reconnect)
A-->>D: terminal I/O (resumed)
D->>A: DELETE /sessions/<id>
A->>T: tmux kill-session -t <repo>-<N>
A->>A: git worktree remove
A-->>D: 200 {status: stopped}
Component Architecture
graph TB
subgraph "agent-daemon process"
HTTP["Warp HTTP Server"]
WS["WebSocket Handler"]
SM["Session Manager<br/>(TVar Map)"]
TM["Tmux Manager"]
WM["Worktree Manager"]
REC["Recovery"]
HTTP --> SM
WS --> SM
SM --> TM
SM --> WM
REC --> SM
REC --> TM
end
subgraph "OS layer"
TMX["tmux"]
GIT["git"]
PTY["PTY pairs"]
end
TM --> TMX
TM --> PTY
WM --> GIT
Components
- Warp HTTP Server — REST endpoints for session CRUD, static file serving
- WebSocket Handler — bridges xterm.js to tmux PTY streams with bidirectional binary frames
- Session Manager — in-memory
TVar (Map SessionId Session), thread-safe, not persisted to disk - Tmux Manager — creates, attaches, kills tmux sessions
- Worktree Manager — creates and removes git worktrees, branches as
feat/issue-<N> - Recovery — on startup, scans for existing tmux sessions and reconstructs state from worktree directories and git remote URLs
Session State Machine
stateDiagram-v2
[*] --> Creating: POST /sessions
Creating --> Running: worktree + tmux ready
Creating --> Failed: error
Running --> Attached: WS connect
Attached --> Running: WS disconnect
Running --> Stopping: DELETE /sessions/<id>
Attached --> Stopping: DELETE /sessions/<id>
Stopping --> [*]: cleanup done
Failed --> [*]: reported
Data Model
classDiagram
class Session {
+SessionId id
+Repo repo
+IssueNumber issue
+WorktreePath worktree
+TmuxSession tmuxName
+SessionState state
+UTCTime createdAt
}
class SessionState {
<<enumeration>>
Creating
Running
Attached
Stopping
Failed Text
}
class Repo {
+Text owner
+Text name
}
Session --> SessionState
Session --> Repo
Naming Conventions
| Entity | Pattern | Example |
|---|---|---|
| Worktree path | /code/<repo>-issue-<N>/ |
/code/cardano-utxo-csmt-issue-42/ |
| tmux session | <repo>-<N> |
cardano-utxo-csmt-42 |
| Branch | feat/issue-<N> |
feat/issue-42 |
| Session ID | <repo>-<N> |
cardano-utxo-csmt-42 |
REST API
Launch session
POST /sessions
Content-Type: application/json
{
"repo": { "owner": "cardano-foundation", "name": "cardano-utxo-csmt" },
"issue": 42
}
→ 201 Created (new session)
→ 200 OK (session already exists, idempotent)
{
"id": "cardano-utxo-csmt-42",
"repo": { "owner": "cardano-foundation", "name": "cardano-utxo-csmt" },
"issue": 42,
"worktree": "/code/cardano-utxo-csmt-issue-42",
"tmuxName": "cardano-utxo-csmt-42",
"state": "creating",
"createdAt": "2026-03-13T10:30:00Z"
}
List sessions
GET /sessions
→ 200 OK
[
{
"id": "cardano-utxo-csmt-42",
"repo": { "owner": "cardano-foundation", "name": "cardano-utxo-csmt" },
"issue": 42,
"state": "running",
"createdAt": "2026-03-13T10:30:00Z"
}
]
Stop session
DELETE /sessions/cardano-utxo-csmt-42
→ 200 OK
{ "status": "stopped" }
Kills the tmux session and removes the git worktree.
Terminal attach
GET /sessions/cardano-utxo-csmt-42/terminal
Upgrade: websocket
↔ bidirectional binary frames (terminal I/O)
Terminal resize is sent as a protocol message: \x01cols;rows.
Static files
Any path not matching the API routes serves from --static-dir,
falling back to index.html (SPA support).
CORS
All responses include permissive CORS headers (any origin, GET/POST/DELETE/OPTIONS).
Issue Context Injection
When a session launches, the daemon sends a bootstrap command to tmux:
claude 'Work on owner/repo#N. Start by running: gh issue view N -R owner/repo'
Claude receives the issue context by running gh issue view interactively.
No environment variables or prompt files are used.
Session Recovery
On startup the daemon reconstructs state from existing tmux sessions:
- Run
tmux list-sessionsto find active sessions - Parse session names (split on last hyphen:
repo-name-N→ repo=repo-name, issue=N) - Check for worktree directory at
baseDir/repoName-issue-N - Read git remote URL from the worktree to recover
repoOwner - Create
Sessionrecords withRunningstate in the in-memory map
This means the daemon can be restarted without losing track of running agent sessions — tmux and worktrees are the persistent state.
Network Topology
graph LR
subgraph "User machine"
Browser["Browser<br/>gh-dashboard"]
end
subgraph "Tailscale network"
TS["tailscale serve<br/>TLS termination<br/>:8443 → :8080"]
end
subgraph "Server"
AD["agent-daemon<br/>127.0.0.1:8080"]
T1["tmux: csmt-42"]
T2["tmux: wallet-15"]
W1["/code/csmt-issue-42/"]
W2["/code/wallet-issue-15/"]
end
Browser -- "wss://" --> TS --> AD
AD --> T1
AD --> T2
T1 -.-> W1
T2 -.-> W2
The daemon binds to 127.0.0.1 (localhost only). tailscale serve
provides TLS termination on port 8443, accessible only within the
tailnet. See README.md for setup instructions.
Authentication
Currently relies on Tailscale ACLs — only machines on the tailnet can reach the service. No application-level authentication (tokens, API keys).
Current Limitations
- No resource limits — unbounded concurrent sessions, no memory or CPU guards
- No completion detection — sessions run until explicitly stopped; no webhook or polling for PR creation
- In-memory state — session metadata is not persisted to disk (recovered from tmux on restart)
- Single WebSocket per session — no multi-attach tracking