Kookr terminal backend
How Kookr runs agent terminal sessions — TerminalBackend interface, dtach persistence, KOOKR_BACKEND env var, SessionBridge routing, legacy tmux paths. Use when touching adapter launch code, session I/O, stuck detection's paneContent, reconciliation, or the circuit-breaker-registry terminal entry.From its SKILL.md
npx -y skills add kookr-ai/kookr --skill kookr-terminal-backendAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 3 stars3 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
9.8 KB, as published. Nobody here has run it
Kookr Terminal Backend
Kookr runs agents (Claude Code, Codex CLI) in persistent terminal sessions so the agent survives Kookr restart. Two backends coexist in the code:
- dtach (
LocalDtachBackendatsrc/adapters/local-dtach-backend.ts) — current default as of v7 Main B.b (#343, 2026-04-21). Byte-transparent. Unix-socket-based. Selected whenKOOKR_BACKEND !== 'tmux'. - tmux (
TmuxTerminalManageratsrc/adapters/tmux-terminal-manager.ts) — legacy. Still constructed at startup. Kept for theKOOKR_BACKEND=tmuxrollback path. Scheduled for deletion in V8 (docs/rfc/rfc-v8-tmux-removal.md).
Two interface shapes (know which one you're holding)
TerminalManager (legacy, tmux-shaped)
├── createSession(name, command, options)
├── sendKeys(name, keys) // text + Enter
├── pasteText(name, text) // bracketed paste (Codex only)
├── sendKeystroke(name, key) // single key, no Enter
├── capturePane(name, lines?) // rendered screen text
├── isAlive(name)
├── killSession(name)
├── listSessions(prefix?)
└── setSessionOption(name, opt, val) // tmux-only
TerminalBackend (new, backend-agnostic)
├── createSession(spec: SessionSpec): Promise<SessionHandle>
├── attachSession(id): Promise<SessionHandle>
├── listSessions(): Promise<SessionId[]>
├── isAlive(id)
└── killSession(id)
SessionHandle (returned by createSession/attachSession)
├── write(data: Uint8Array) // byte-transparent
├── resize(cols, rows)
├── onData(cb) // subscribe to child PTY bytes
├── onExit(cb)
└── dispose() // detach; child survives
Backend selection at startup
src/server/start.ts:42:
const BACKEND_KIND: 'tmux' | 'dtach' = process.env.KOOKR_BACKEND === 'tmux' ? 'tmux' : 'dtach';
BACKEND_KIND === 'dtach'→LocalDtachBackendconstructed + passed to adapters asoptions.backend.TmuxTerminalManageris always constructed (start.ts:180) and passed asterminal, regardless of BACKEND_KIND. This is intentional — the adapters' legacy method paths still thread through it.
The critical seam: adapter partial wiring
ClaudeCodeAdapter / CodexCliAdapter receive both terminal and backend:
// launch() — uses backend when set:
if (this.backend) {
await this.backend.createSession({...}); // dtach path
} else {
await this.terminal.createSession(...); // tmux path
}
// sendInput / sendKeystroke / stop / captureDisplay — ALWAYS use terminal:
await this.terminal.sendKeys(tmuxName, text);
Consequence: when KOOKR_BACKEND=dtach, sessions live in dtach, but per-session ops still call tmux send-keys -t kookr-XXXX → throws "no such session" → breaker increments.
V8 fixes this by porting per-session ops onto the backend (new backend.write(id, bytes) + writeSequence(id, payloads[])).
The CircuitBreaker (currently named 'tmux')
src/server/index.ts:220:
const tmuxBreaker = new CircuitBreaker({ name: 'tmux', failureThreshold: 5, failureWindowMs: 60_000, resetTimeoutMs: 15_000 });
const wrappedTerminal = new CircuitBreakerTerminalManager(terminal, tmuxBreaker);
- Only wraps
TerminalManagercalls (the legacy path). - Does not wrap dtach backend calls.
- The "tmux" label is correct re: implementation but misleading re: operator intent — failures it catches are always adapter-legacy-path calls failing for missing tmux sessions.
Dashboard symptom: breaker showing open state with 3+ failures when KOOKR_BACKEND=dtach is the default = not a tmux bug, it's the adapter-partial-wiring bug. RFC v8 deletes the breaker entirely in favor of backend-emitted structured BackendError events.
WebSocket routing: three bridge kinds
src/server/index.ts:745-786 — when a browser xterm.js connects to /ws/terminal/<session>:
- FakeTerminalBridge — E2E / demo mode (
useFakeTerminalBridge). - SessionBridge (new, byte-transparent) — if
terminalBackend?.isAlive(id)returns true. Usesbackend.attachSession()+ ring buffer. - TerminalBridge (legacy, tmux) — fallback. Spawns
tmux attach -t <id>via node-pty. Used when dtach doesn't know the session (e.g., pre-V7 tmux-era session, or a stale session ID from task store).
Sessions with kind=terminal bridge opens in the log are the tell-tale sign of a task whose session ID points to a session that dtach does not have.
dtach on-disk layout
- Sockets:
/tmp/kookr-dtach/<uid>/<instanceId>/<sessionId>.sock(e.g./tmp/kookr-dtach/1000/port-4800/kookr-c1c8bcc4.sock). - Manifest:
/tmp/kookr-dtach/<uid>/<instanceId>/manifest.json— array of{sessionId, pid, startedAt, status, sock}. Written atomically via temp+rename.LocalDtachBackendowns this exclusively. - Per-instance isolation:
instanceId = port-${PORT}.kookr-prod(port 4800) and dev (4801) never collide. - Session ID cap 40 chars — keeps socket paths under Linux UDS 107-byte limit.
Reconciliation
src/server/reconciliation.ts:
- Takes a
TerminalManager(NOT wrappedTerminal — not breaker-guarded). Callsterminal.listSessions('kookr-'). - Problem for dtach: tmux's list returns
[]when there's no tmux server running; this silently makes ALL task sessions look dead to reconcile. - Fixed partially in PR #346 (commit
a42ccfd— "reconcile queries dtach backend") but reconcile still callsTerminalManager. - V8 changes the signature to
reconcile(taskStore, backend: TerminalBackend). reconcile()only COMPUTES the orphan set (sessions the backend reports live that no task accounts for — including a session whose OWN record already carrieslastStatus: 'completed'/'aborted', since the loopcontinues past those without marking them accounted-for). It never acts on it.
Session reaping (issue #1720)
src/server/session-reaper.ts (SessionReaperService) is the separate "act on it" step run after every reconcile() call (boot + periodic liveness tick, wired from index.ts / lifecycle-timers.ts):
- Cross-references every backend-reported live session against the TaskStore itself (not reconcile's output) and classifies each with the pure
src/core/session-reap-policy.tslogic intounowned(leak class 1 — the original orphan report) orterminal-task-leak(leak class 2 — task alreadycompleted/terminated/cancelled, or its session record already terminal, but the process tree is still resident; e.g.completeTask's fire-and-forgetadapter.stop()silently failed). - Reaps via the backend's existing
killSession(TERM → grace → KILL + socket removal) once past an age/grace threshold (KOOKR_REAP_ORPHAN_AGE_MSdefault 24h for unowned sessions — never race a mid-launch session per #1537 item 2 — or the shorterKOOKR_REAP_ORPHAN_AGE_UNDER_PRESSURE_MSdefault 2h when dtach count ≥ soft bound 20, issue #2081;KOOKR_REAP_TERMINAL_TASK_GRACE_MSdefault 60s for owned leaks, since the owning task is already known to be done). Master flagKOOKR_REAP_ORPHAN_SESSIONS(default on). Each sweep logseffectiveOrphanAgeMsand surfaces it on/api/health→sessionReaper. - Boot-only
runStaleAttacherSweep(backed bysrc/adapters/dtach-attach-reaper.ts) kills leakeddtach -aattach-client processes from dead server generations (leak class 3), scoped toLocalDtachBackend.getInstanceDir()so it never touches another port's sessions or a user's own terminal. - Every reap is logged to
audit.jsonland surfaced cheaply (cached counters, no re-scan) onGET /api/health'ssessionReaperblock.
Gotchas
tmuxNameparameter name is load-bearing in logs but has nothing to do with tmux post-v7. Leaky abstraction. Planned for rename in a follow-up PR.session.tmuxSessionfield in persistedtasks.json— same story. Renaming requires a schema-migration step.- TmuxTerminalManager swallows some errors, throws others.
listSessions,killSession,isAlive,setSessionOptionswallow "no such session."sendKeys,capturePane,pasteText,sendKeystrokethrow it. Breaker sees only the throwing paths. - TerminalBridge spawns
tmux attachvia node-pty directly — does NOT go through the breaker. So bridge open failures don't count toward breaker. - Cutover detection runs on every dtach startup (
start.ts:124-164) — runstmux list-sessions, warns about stalekookr-*sessions. Noise after V7 has baked; V8 removes this.
Where to make changes
| Task | File |
|---|---|
| Change how sessions are spawned | ClaudeCodeAdapter.launch / CodexCliAdapter.launch |
| Change per-session I/O | Same adapters' sendInput/sendKeystroke/stop/captureDisplay |
| Change backend selection / startup | src/server/start.ts:42, 108-167, 180 |
| Change WS routing | src/server/index.ts:733-794 |
| Change reconciliation | src/server/reconciliation.ts + src/server/index.ts:409, 419-421 |
| Change breaker | src/server/index.ts:220, 224, 237 + src/adapters/circuit-breaker-terminal-manager.ts |
References
- V7 RFC:
docs/rfc/rfc-claude-code-terminal-parity.md - V8 RFC:
docs/rfc/rfc-v8-tmux-removal.md - ADR-014 local-dtach-backend.
- ADR-007 managed-terminal-sessions (to be superseded).