Files
web-terminal/src/session/session.ts
Yaojia Wang d6809c65c4 feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.

Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.

New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).

Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).

Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
2026-06-30 17:42:18 +02:00

222 lines
8.4 KiB
TypeScript

/**
* src/session/session.ts (T12) — single PTY session: lifecycle + ws plumbing.
*
* Owns one node-pty process and its scrollback. Wires:
* - pty.onData → ring buffer + (forward to attachedWs as `output`),
* - pty.onExit → stamp exitedAt/exitCode → (notify attachedWs with `exit`)
* → call the injected onExit so the manager can drop it (L2).
*
* Cross-validated fixes embedded here (ARCHITECTURE §3.4 / §4.4):
* - M4: spawn failure THROWS — createSession does not swallow it; server.ts
* catches and serializes a single `exit(-1, reason)` for that connection.
* - M5: every ws.send is guarded by `ws.readyState === WS_OPEN`.
* - L1: detach-then-exit keeps the session; with no attachedWs the `exit` is
* not delivered now — it is re-sent on the next attach (manager's job).
* - L4: writeInput/resize are silently ignored once the PTY has exited
* (calling write/resize on a dead PTY throws).
*
* The session never holds a reference to the manager; the exit event bubbles
* across that layer purely through the injected `onExit` callback.
*
* Imports src/types.ts (frozen, read-only) and createRingBuffer (T6).
* node-pty's `spawn` is imported here and mocked in tests so no real shell runs.
*/
import { randomUUID } from 'node:crypto';
import { spawn } from 'node-pty';
import type {
Config,
Dims,
IPty,
Session,
SessionMeta,
ServerMessage,
TimelineEvent,
WebSocketLike,
} from '../types.js';
import { WS_OPEN } from '../types.js';
import { serialize } from '../protocol.js';
import { createRingBuffer } from './ring-buffer.js';
import { tmuxName, killSession } from './tmux.js';
/** Send a server message to `ws` only if it is OPEN (M5). Never throws on a
* closed socket; forwarding to a dead ws is simply a no-op. */
function sendIfOpen(ws: WebSocketLike | null, msg: ServerMessage): void {
if (ws !== null && ws.readyState === WS_OPEN) {
ws.send(serialize(msg));
}
}
/** Broadcast a server message to every attached client (multi-device sharing). */
export function broadcast(session: Session, msg: ServerMessage): void {
for (const ws of session.clients) sendIfOpen(ws, msg);
}
/** Resize the PTY if the dims actually changed and it's still alive (L4). */
function applyDims(session: Session, cols: number, rows: number): void {
if (session.exitedAt !== null) return;
if (session.pty.cols === cols && session.pty.rows === rows) return;
session.pty.resize(cols, rows);
}
/**
* Spawn a PTY and wire its data/exit streams. Throws on spawn failure (M4).
*
* @param onExit injected by the manager — invoked after exitedAt is set so the
* session can be removed from the table (L2).
*/
export function createSession(
cfg: Config,
dims: Dims,
now: number,
onExit: (session: Session) => void,
// H1: pass an existing id to RE-ATTACH to a surviving tmux session (e.g. after
// a server restart). Default = a fresh id for a brand-new session.
id: string = randomUUID(),
// M6: spawn directory for a new session ("new tab here"); defaults to homeDir.
cwd?: string,
): Session {
// The id is injected into the shell env so Claude Code hooks know which tab an
// event belongs to ($WEBTERM_SESSION) and where to POST ($WEBTERM_HOOK_URL, H2).
const tName = cfg.useTmux ? tmuxName(id) : null;
// H1: under tmux, the node-pty process is a tmux CLIENT; `new-session -A`
// attaches to web_<id> if it exists (restart survival) or creates it.
const file = tName !== null ? 'tmux' : cfg.shellPath;
const args = tName !== null ? ['new-session', '-A', '-s', tName, cfg.shellPath] : [];
// M4: let a spawn failure (e.g. missing shell / tmux) propagate synchronously.
const pty: IPty = spawn(file, args, {
name: 'xterm-256color',
cols: dims.cols,
rows: dims.rows,
cwd: cwd ?? cfg.homeDir,
env: {
...process.env,
WEBTERM_SESSION: id,
WEBTERM_HOOK_URL: `http://127.0.0.1:${cfg.port}/hook`,
// B2: statusLine scripts POST telemetry here; WEBTERM_NTFY_* vars forwarded
// via ...process.env spread above (H3 — token set in env, not here).
WEBTERM_STATUSLINE_URL: `http://127.0.0.1:${cfg.port}/hook/status`,
},
});
const meta: SessionMeta = {
id,
createdAt: now,
shellPath: cfg.shellPath,
};
const session: Session = {
meta,
buffer: createRingBuffer(cfg.scrollbackBytes),
clients: new Set(),
detachedAt: null,
lastOutputAt: now,
exitedAt: null,
exitCode: null,
claudeStatus: 'unknown',
cwd: cwd ?? null,
tmuxName: tName,
pty,
// v0.7 Walk-away Workbench fields (T-spawn-env):
timeline: Object.freeze([] as TimelineEvent[]),
stuckNotified: false, // A5: re-armed to false by each pty output
telemetry: null, // B2: updated by manager.handleStatusLine
};
// onData: persist to scrollback, refresh liveness, broadcast to all clients.
pty.onData((chunk) => {
session.buffer.append(chunk);
session.lastOutputAt = Date.now();
// A5 §3.4: re-arm the stuck flag on every output so each silent round can
// alert at most once. If we were stuck, recover to 'working' and broadcast
// so clients clear the stuck badge immediately — this is the ONLY reliable
// place to do this (same as lastOutputAt; T-manager never sees raw output).
session.stuckNotified = false;
if (session.claudeStatus === 'stuck') {
session.claudeStatus = 'working';
broadcast(session, { type: 'status', status: 'working' });
}
broadcast(session, { type: 'output', data: chunk });
});
// onExit: record exit, notify every client (L1 if none), then bubble up.
pty.onExit(({ exitCode }) => {
session.exitedAt = Date.now();
session.exitCode = exitCode;
broadcast(session, { type: 'exit', code: exitCode });
onExit(session);
});
return session;
}
/**
* Add `ws` as a client (multi-device sharing): clear the detached stamp, then
* replay the scrollback to THIS client only so it sees the current screen.
* No kicking — devices share the session (invariant #5 relaxed for v0.4).
*
* Attaching does NOT change the PTY size — the device actively using the
* session keeps its size. A client only sets the size once it sends a `resize`
* (the frontend does so when its pane is visible / regains focus).
*/
export function attachWs(session: Session, ws: WebSocketLike): void {
session.clients.add(ws);
session.detachedAt = null;
// Replay the buffered scrollback so the joining client sees the last screen.
sendIfOpen(ws, { type: 'output', data: session.buffer.snapshot() });
}
/**
* Remove one client. NEVER kills the PTY (vibe-coding core). Only when the LAST
* client leaves do we stamp detachedAt (starts the idle-reclaim clock); while
* other clients remain, re-derive the shared size (a leaver may let it grow).
*/
export function detachWs(session: Session, ws: WebSocketLike, now: number): void {
session.clients.delete(ws);
// A client leaving does NOT resize the PTY — whatever device is still actively
// viewing keeps its size. Start the idle clock only when the last client goes.
if (session.clients.size === 0) {
session.detachedAt = now;
}
}
/** Forward input to the PTY. No-op once the PTY has exited (L4). */
export function writeInput(session: Session, data: string): void {
if (session.exitedAt !== null) return;
session.pty.write(data);
}
/**
* Latest-writer-wins: the device that most recently fit/focused drives the PTY
* size, so whichever device you are actively using is full-screen. (A shared
* PTY can only be one size; min-sizing letterboxed the bigger screen.) The
* frontend re-sends dims when a pane is shown or its window regains focus, so
* switching devices reclaims full size. No-op after exit (L4) / when unchanged.
*
* The `ws` argument identifies the requesting client; the size is applied
* directly to the PTY (no per-client dims map — latest writer wins).
*/
export function setClientDims(
session: Session,
_ws: WebSocketLike,
cols: number,
rows: number,
): void {
if (session.exitedAt !== null) return;
applyDims(session, cols, rows);
}
/**
* Kill the session (idle reclaim). Under tmux this ends the actual shell via
* `tmux kill-session` (H1); killing only the client pty would leave the tmux
* session running. Always also kills the client pty.
*/
export function kill(session: Session): void {
if (session.tmuxName !== null) killSession(session.tmuxName);
session.pty.kill();
}