Thomas Wolf commited on
Commit
b8df024
·
unverified ·
2 Parent(s): 01a64bf85b01df

Merge pull request #24 from huggingface/feat/libghostty-session-model

Browse files
Dockerfile CHANGED
@@ -10,7 +10,9 @@ RUN npm run build
10
  # Node 22+: required by OpenClaw (22.19+); everything else is version-agnostic.
11
  FROM node:22-bookworm AS runtime
12
 
13
- # System deps: tmux (session durability), git, build tools (node-pty native build),
 
 
14
  # ripgrep (used by the coding CLIs), curl/ca-certs — plus everyday QoL tools
15
  # agents and humans reach for (jq/htop/sqlite3/editors/media, fonts so headless
16
  # Chromium screenshots don't render tofu).
@@ -118,14 +120,14 @@ RUN [ -x /home/node/.local/bin/hermes ] \
118
  || true
119
  USER node
120
 
121
- # App code + built frontend + runtime config (tmux + prompt rcfile).
122
  COPY --chown=node:node server/ server/
123
  # scripts/ is not developer-only: share.js runs scripts/share-session.mjs as a
124
  # child process to build a share bundle off the event loop, so it must ship.
125
  COPY --chown=node:node scripts/ scripts/
126
  COPY --chown=node:node --from=web /web/dist /app/public
127
  COPY --chown=node:node entrypoint.sh /app/entrypoint.sh
128
- COPY --chown=node:node tmux.conf session.bashrc /app/
129
 
130
  ENV PORT=7860 \
131
  DATA_DIR=/data \
 
10
  # Node 22+: required by OpenClaw (22.19+); everything else is version-agnostic.
11
  FROM node:22-bookworm AS runtime
12
 
13
+ # System deps: git, build tools (node-pty native build),
14
+ # tmux is still installed for AGENTS to use if they want it — the app itself no
15
+ # longer runs sessions through it (see server/src/runner.js),
16
  # ripgrep (used by the coding CLIs), curl/ca-certs — plus everyday QoL tools
17
  # agents and humans reach for (jq/htop/sqlite3/editors/media, fonts so headless
18
  # Chromium screenshots don't render tofu).
 
120
  || true
121
  USER node
122
 
123
+ # App code + built frontend + runtime config (prompt rcfile).
124
  COPY --chown=node:node server/ server/
125
  # scripts/ is not developer-only: share.js runs scripts/share-session.mjs as a
126
  # child process to build a share bundle off the event loop, so it must ship.
127
  COPY --chown=node:node scripts/ scripts/
128
  COPY --chown=node:node --from=web /web/dist /app/public
129
  COPY --chown=node:node entrypoint.sh /app/entrypoint.sh
130
+ COPY --chown=node:node session.bashrc /app/
131
 
132
  ENV PORT=7860 \
133
  DATA_DIR=/data \
README.md CHANGED
@@ -18,8 +18,9 @@ file browser, all in your browser. Each agent runs in a workspace folder you
18
  pick at creation (defaulting to where the last agent was created) — names are
19
  just labels, independent of folders, and several agents can share a folder.
20
  Groups are visual: they organize the sidebar and tile agents side by side.
21
- Sessions are
22
- tmux-backed, so they survive disconnects. A **Skills** page distributes reusable
 
23
  `SKILL.md` files to every agent, and a **Usage** page shows tokens/cost and your
24
  5-hour / weekly quota.
25
 
@@ -117,14 +118,28 @@ and `CODEX_HOME` under `/data/state`).
117
  browser (xterm.js panes)
118
  └── WebSocket /ws?session=<id> one connection per visible pane
119
  └── Node backend (Express + ws)
120
- └── node-pty tmux session per agentsurvives disconnect/restart
121
  └── claude | codex | gemini | opencode | hermes | bash
122
  ```
123
 
124
- Each agent is a long-lived `tmux` session (`am-<id>`) that survives browser
125
- disconnects and in-Space server restarts. It does not survive a Space
126
- sleep/rebuild (the container is torn down), but with storage the working dir and
127
- CLI state persist, so a re-opened session resumes its own conversation. Claude
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  sessions are pinned to a per-session conversation id at creation; Codex sessions
129
  are pinned right after first launch (the id is captured from the rollout file
130
  Codex creates) — so agents sharing a folder never resume each other's
@@ -136,7 +151,8 @@ conversations.
136
  |---|---|---|
137
  | `PORT` | `7860` | HTTP + WS port (HF `app_port`) |
138
  | `DATA_DIR` | `/data` | Durable root (mounted private Storage Bucket) |
139
- | `USE_TMUX` | auto | `1`/`0` to force tmux on/off (off = direct PTY, no persistence) |
 
140
  | `ANTHROPIC_API_KEY` | — | Claude Code / opencode / Hermes (Space **secret**) |
141
  | `OPENAI_API_KEY` / `CODEX_API_KEY` | — | Codex (Space secret) |
142
  | `GEMINI_API_KEY` | — | Gemini CLI (Space secret) |
@@ -147,7 +163,7 @@ Logging in interactively inside a terminal works too — credentials are written
147
  ## Local development
148
 
149
  ```bash
150
- # backend (no tmux locally direct-PTY mode; only the Shell CLI works offline)
151
  cd server && npm install && npm run dev
152
 
153
  # frontend (proxies /api and /ws to the backend on :7860)
 
18
  pick at creation (defaulting to where the last agent was created) — names are
19
  just labels, independent of folders, and several agents can share a folder.
20
  Groups are visual: they organize the sidebar and tile agents side by side.
21
+ Sessions live in the
22
+ backend, so they survive disconnects and can be watched from several devices at
23
+ once. A **Skills** page distributes reusable
24
  `SKILL.md` files to every agent, and a **Usage** page shows tokens/cost and your
25
  5-hour / weekly quota.
26
 
 
118
  browser (xterm.js panes)
119
  └── WebSocket /ws?session=<id> one connection per visible pane
120
  └── Node backend (Express + ws)
121
+ └── node-pty per agent + a libghostty-vt gridthe live screen
122
  └── claude | codex | gemini | opencode | hermes | bash
123
  ```
124
 
125
+ Each agent is a PTY held by the backend, with a **libghostty-vt** terminal fed
126
+ from its output. That grid is the authoritative screen, so reopening a pane is a
127
+ canonical serialization of its retained history and styled screen rather than a
128
+ truncated PTY byte replay, and agent state is read from the grid instead of
129
+ shelling out per session. Several browsers can watch the same session, but one
130
+ explicit controller owns input and PTY dimensions; interacting with a watcher
131
+ claims control. This prevents background tabs and small phones from resizing a
132
+ desktop session, and prevents several browser emulators from all answering the
133
+ same terminal query.
134
+
135
+ A resize is a controller request. The backend coalesces window-drag bursts,
136
+ allows Ghostty to perform normal reflow, then tells every viewer the confirmed
137
+ geometry before more PTY output arrives. Full history serialization is reserved
138
+ for attach/reconnect. Browser zoom is presentation-only: it changes cell size
139
+ and pans locally without resizing the PTY. Sessions survive browser disconnects
140
+ but not a backend restart or Space sleep/rebuild; with storage the working
141
+ directory and CLI state persist, so a reopened session resumes its own
142
+ conversation. Claude
143
  sessions are pinned to a per-session conversation id at creation; Codex sessions
144
  are pinned right after first launch (the id is captured from the rollout file
145
  Codex creates) — so agents sharing a folder never resume each other's
 
151
  |---|---|---|
152
  | `PORT` | `7860` | HTTP + WS port (HF `app_port`) |
153
  | `DATA_DIR` | `/data` | Durable root (mounted private Storage Bucket) |
154
+ | `AM_SCROLLBACK_BYTES` | `67108864` | Maximum Ghostty scrollback memory per session |
155
+ | `AM_RESIZE_SETTLE_MS` | `120` | Quiet period before a resize is applied to the PTY |
156
  | `ANTHROPIC_API_KEY` | — | Claude Code / opencode / Hermes (Space **secret**) |
157
  | `OPENAI_API_KEY` / `CODEX_API_KEY` | — | Codex (Space secret) |
158
  | `GEMINI_API_KEY` | — | Gemini CLI (Space secret) |
 
163
  ## Local development
164
 
165
  ```bash
166
+ # backend (needs node >= 20.19 for libghostty-vt; only the Shell CLI works offline)
167
  cd server && npm install && npm run dev
168
 
169
  # frontend (proxies /api and /ws to the backend on :7860)
server/fixtures/ink-tui.mjs ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // A stand-in for an agent TUI built the way Claude Code is: Ink (React) on the
2
+ // PRIMARY screen, re-rendering its whole frame when the terminal resizes.
3
+ //
4
+ // This matters because Ink does not repaint by absolute positioning the way
5
+ // fixtures/repaint-tui.mjs does. It PRINTS its frame — cursor up by the previous
6
+ // frame's height, erase downward, print the lines — so a frame taller than the
7
+ // screen scrolls the excess into scrollback, and a frame whose height changed
8
+ // (which is exactly what a width change does, by rewrapping) erases the wrong
9
+ // region and leaves the difference behind. That is the artifact a real agent pane
10
+ // shows on zoom, and no hand-written repaint reproduces it.
11
+ //
12
+ // Every token is unique (`LLL.CC`), so a token appearing twice in the grid is a
13
+ // real duplicate and not two identical filler lines.
14
+ //
15
+ // NOT wired into resize.test.mjs, and ink is deliberately NOT a dependency:
16
+ // installing it here pruned @coder/libghostty-vt-node's native prebuild and broke
17
+ // every session until `npm install` put it back. Run it out of tree instead —
18
+ //
19
+ // mkdir /tmp/inkbox && cd /tmp/inkbox
20
+ // npm init -y && npm i ink react
21
+ // cp .../fixtures/ink-tui.mjs . && node ink-tui.mjs
22
+ //
23
+ // The finding it produced is recorded where it matters: Ink erases its previous
24
+ // frame by height before reprinting, so an Ink app leaves no duplicates on resize.
25
+ // The artifacts a real agent pane shows come from a frame that OVERFLOWS the
26
+ // screen, which fixtures/repaint-tui.mjs reproduces with FIXED_LINES set.
27
+ import React from 'react';
28
+ import { render, Box, Text, useStdout } from 'ink';
29
+
30
+ const TRANSCRIPT = Array.from({ length: 60 }, (_, i) =>
31
+ Array.from({ length: 22 }, (_, j) => `${String(i + 1).padStart(3, '0')}.${String(j).padStart(2, '0')}`).join(' '));
32
+
33
+ function App() {
34
+ const { stdout } = useStdout();
35
+ const [size, setSize] = React.useState({ cols: stdout.columns || 80, rows: stdout.rows || 24 });
36
+ React.useEffect(() => {
37
+ const onResize = () => setSize({ cols: stdout.columns || 80, rows: stdout.rows || 24 });
38
+ stdout.on('resize', onResize);
39
+ return () => stdout.off('resize', onResize);
40
+ }, [stdout]);
41
+
42
+ // A fixed number of transcript lines, NOT trimmed to fit — the important part.
43
+ // An agent renders the tail of its conversation, so narrowing the pane wraps
44
+ // those lines and the frame becomes TALLER than the screen. Printing it then
45
+ // scrolls the overflow into scrollback, which is where the duplicates a user
46
+ // sees after zooming in actually come from.
47
+ const KEEP = 30;
48
+ return React.createElement(
49
+ Box,
50
+ { flexDirection: 'column' },
51
+ ...TRANSCRIPT.slice(-KEEP).map((line, i) => React.createElement(Text, { key: i }, line)),
52
+ React.createElement(
53
+ Box,
54
+ { borderStyle: 'round', paddingX: 1 },
55
+ React.createElement(Text, null, `[ink ${size.cols}x${size.rows}]`),
56
+ ),
57
+ );
58
+ }
59
+
60
+ render(React.createElement(App));
61
+ // Stay up like an agent waiting for input, so a resize has something to answer.
62
+ process.stdin.resume();
63
+ setInterval(() => {}, 1 << 30);
server/fixtures/repaint-tui.mjs ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // A stand-in for an agent TUI, for the resize tests. Repaints the way Claude
2
+ // Code does (measured from a live pane): hide the cursor, home, erase every
3
+ // visible row with `\x1b[2K\x1b[1B`, home again, then print the tail of its
4
+ // transcript wrapped to the CURRENT width — no screen clear, no scrollback
5
+ // clear, no alternate screen. That is what makes a resize duplicate content:
6
+ // rows the emulator pushed into scrollback while shrinking are printed again.
7
+ //
8
+ // Every token is unique (`LLL.CC`), so a token appearing twice in the grid is a
9
+ // real duplicate and not two identical filler lines.
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ const COLS = () => process.stdout.columns || 80;
14
+ const ROWS = () => process.stdout.rows || 24;
15
+
16
+ const TRANSCRIPT = Array.from({ length: 60 }, (_, i) =>
17
+ Array.from({ length: 22 }, (_, j) => `${String(i + 1).padStart(3, '0')}.${String(j).padStart(2, '0')}`).join(' '));
18
+
19
+ // With FIXED_LINES set, print that many transcript lines whatever the size —
20
+ // which is what an agent showing the tail of a conversation does. Narrowing the
21
+ // pane then WRAPS those lines, so the printed frame becomes taller than the screen
22
+ // and printing it scrolls the overflow into scrollback. That overflow is a copy of
23
+ // what the frame also shows, and it is the artifact a real agent pane leaves
24
+ // behind on zoom. Without the variable, the frame is trimmed to fit and nothing
25
+ // scrolls.
26
+ const FIXED = Number(process.env.FIXED_LINES || 0);
27
+ const HISTORY = Number(process.env.HISTORY_LINES || 0);
28
+
29
+ function paint(limit = null) {
30
+ let out = '\x1b[?25l\x1b[H';
31
+ for (let r = 0; r < ROWS(); r++) out += '\x1b[2K\x1b[1B';
32
+ out += '\x1b[H';
33
+ // Print enough of the transcript to fill the screen at this width — the tail,
34
+ // like a TUI showing the most recent output above its input box.
35
+ const perLine = Math.max(1, Math.ceil(TRANSCRIPT[0].length / COLS()));
36
+ const fit = Math.max(1, Math.floor((ROWS() - 2) / perLine));
37
+ out += TRANSCRIPT.slice(-(limit ?? (FIXED || fit)))
38
+ .map((line, index) => `\x1b[38;5;${31 + (index % 6)}m${line}\x1b[0m`)
39
+ .join('\r\n');
40
+ out += `\r\n[fixture ${COLS()}x${ROWS()}]\x1b[?25h`;
41
+ process.stdout.write(out);
42
+ }
43
+
44
+ process.stdout.on('resize', paint);
45
+ process.stdin.resume();
46
+ if (HISTORY > 0) {
47
+ process.stdout.write(Array.from({ length: HISTORY }, (_, i) =>
48
+ `history-${String(i + 1).padStart(4, '0')}`).join('\r\n') + '\r\n');
49
+ }
50
+ const resumeDelay = Number(process.env.DELAY_RESUME_MS || 0);
51
+ const launchMarker = path.join(process.cwd(), '.repaint-fixture-launched');
52
+ if (resumeDelay > 0 && fs.existsSync(launchMarker)) {
53
+ // Claude can pause after the first part of its resumed screen. A startup
54
+ // capture must span that gap; otherwise the remainder is mistaken for new
55
+ // terminal history and duplicates the persisted transcript.
56
+ paint(1);
57
+ setTimeout(paint, resumeDelay);
58
+ } else {
59
+ if (resumeDelay > 0) fs.writeFileSync(launchMarker, '1');
60
+ paint();
61
+ }
62
+ setInterval(() => {}, 1 << 30);
server/migration.test.mjs ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // End-to-end check of the libghostty-backed session model.
2
+ // Uses a `shell` session so it costs no agent tokens.
3
+ // node migration.test.mjs
4
+ import { spawn } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { WebSocket } from 'ws';
9
+
10
+ const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'am-migration-'));
11
+
12
+ const PORT = 7893;
13
+ const CTRL = '\x00\x00AM:';
14
+ const base = `http://localhost:${PORT}`;
15
+
16
+ let failures = 0;
17
+ const check = (name, ok, detail = '') => {
18
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`);
19
+ if (!ok) failures++;
20
+ };
21
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
+
23
+ const srv = spawn('node', ['src/index.js'], {
24
+ env: { ...process.env, PORT: String(PORT), DATA_DIR, AM_BASHRC: '/nonexistent' },
25
+ stdio: ['ignore', 'pipe', 'pipe'],
26
+ });
27
+ let bootLog = '';
28
+ srv.stdout.on('data', (d) => { bootLog += d; });
29
+ srv.stderr.on('data', (d) => { bootLog += d; });
30
+
31
+ /** A viewer: collects raw bytes and control frames, can send input/resize/claim. */
32
+ function view(id, cols = 100, rows = 30) {
33
+ return new Promise((resolve) => {
34
+ const ws = new WebSocket(`ws://localhost:${PORT}/ws?session=${id}&cols=${cols}&rows=${rows}`);
35
+ const v = {
36
+ bytes: '', frames: [], closes: [],
37
+ type: (d) => ws.send(JSON.stringify({ t: 'i', d })),
38
+ resize: (c, r) => ws.send(JSON.stringify({ t: 'r', cols: c, rows: r })),
39
+ claim: () => ws.send(JSON.stringify({ t: 'claim' })),
40
+ lastFrame: (t) => [...v.frames].reverse().find((f) => f.t === t) || null,
41
+ open: () => ws.readyState === 1,
42
+ close: () => { try { ws.close(); } catch {} },
43
+ };
44
+ ws.on('message', (data) => {
45
+ const s = data.toString('utf8');
46
+ if (s.startsWith(CTRL)) { try { v.frames.push(JSON.parse(s.slice(CTRL.length))); } catch {} }
47
+ else v.bytes += s;
48
+ });
49
+ ws.on('close', (code) => v.closes.push(code));
50
+ ws.on('open', () => resolve(v));
51
+ ws.on('error', () => resolve(v));
52
+ });
53
+ }
54
+
55
+ const stateOf = async (id) => {
56
+ const tree = await (await fetch(`${base}/api/tree`)).json();
57
+ return (tree.sessions || []).find((s) => s.id === id) || null;
58
+ };
59
+
60
+ try {
61
+ let up = false;
62
+ // Generous: importing the dependency tree off a cold FUSE-mounted workspace can
63
+ // take half a minute (express alone measured 32s), and a boot timeout looks
64
+ // exactly like a broken server.
65
+ for (let i = 0; i < 400; i++) {
66
+ try { const r = await fetch(`${base}/api/health`); if (r.ok) { up = true; break; } } catch {}
67
+ await sleep(250);
68
+ }
69
+ check('server boots without tmux', up);
70
+ if (!up) { console.log(bootLog.slice(-1500)); throw new Error('no boot'); }
71
+
72
+ const health = await (await fetch(`${base}/api/health`)).json();
73
+ check('health reports the libghostty engine', health.engine === 'libghostty' && health.ghostty === true,
74
+ health.ghosttyError || `engine=${health.engine} ghostty=${health.ghostty}`);
75
+
76
+ const created = await (await fetch(`${base}/api/sessions`, {
77
+ method: 'POST', headers: { 'content-type': 'application/json' },
78
+ body: JSON.stringify({ cli: 'shell', name: 'mig test' }),
79
+ })).json();
80
+ check('session created', !!created.id, created.id || JSON.stringify(created).slice(0, 140));
81
+ const id = created.id;
82
+ if (!id) throw new Error('no session');
83
+
84
+ // --- attach, run something, verify output ---------------------------------
85
+ const a = await view(id);
86
+ check('viewer attaches', a.open());
87
+ await sleep(1500);
88
+ check('the first viewer controls the session', a.lastFrame('restore')?.controller === true);
89
+ a.type('echo hello-from-viewer-a\r');
90
+ await sleep(1300);
91
+ check('input reaches the PTY and output comes back', a.bytes.includes('hello-from-viewer-a'),
92
+ `${a.bytes.length}B received`);
93
+
94
+ // --- closing the browser must NOT kill the session ------------------------
95
+ a.close();
96
+ await sleep(700);
97
+ const survived = await stateOf(id);
98
+ check('session survives the viewer closing', !!survived && survived.state !== 'stopped',
99
+ survived ? `state=${survived.state}` : 'missing from tree');
100
+
101
+ // --- work continues with nobody attached ---------------------------------
102
+ await fetch(`${base}/api/sessions/${id}/input`, {
103
+ method: 'POST', headers: { 'content-type': 'application/json' },
104
+ body: JSON.stringify({ text: 'echo ran-while-detached' }),
105
+ });
106
+ await sleep(1600);
107
+
108
+ // --- reattach: restore frame + canonical scrollback -----------------------
109
+ const b = await view(id);
110
+ await sleep(1000);
111
+ const restore = b.lastFrame('restore');
112
+ check('reattach sends a restore frame', !!restore, restore && `${restore.cols}x${restore.rows}`);
113
+ check('restore serializes earlier scrollback', b.bytes.includes('hello-from-viewer-a'),
114
+ `${b.bytes.length}B restored`);
115
+ check('restore includes work done while detached', b.bytes.includes('ran-while-detached'));
116
+ check('no tmux [exited] noise', !b.bytes.includes('[exited]'));
117
+
118
+ // --- state comes from the grid, no subprocess ----------------------------
119
+ b.type('for i in 1 2 3 4 5 6 7 8; do echo busy $i; sleep 0.4; done\r');
120
+ await sleep(1700);
121
+ const busy = await stateOf(id);
122
+ check('a busy session reads as working', busy && busy.state === 'working', busy && busy.state);
123
+ // Poll rather than guess: the loop above takes ~3.2s and the working
124
+ // threshold is 4s of unchanged text, so a fixed sleep races the transition.
125
+ let calm = null;
126
+ for (let i = 0; i < 30; i++) {
127
+ calm = await stateOf(id);
128
+ if (calm && calm.state === 'idle') break;
129
+ await sleep(500);
130
+ }
131
+ check('an idle shell settles back to idle', calm && calm.state === 'idle', calm && calm.state);
132
+
133
+ // --- the agent-watch API still works, now off the grid --------------------
134
+ // capturePane() reads the held grid directly. Same shape as the old endpoint:
135
+ // plain text, with screen and scrollback above it.
136
+ const tailRes = await fetch(`${base}/api/agents/${id}/tail?lines=200`);
137
+ const tail = await tailRes.json();
138
+ const tailText = typeof tail === 'string' ? tail : (tail.text || tail.tail || JSON.stringify(tail));
139
+ check('agent tail endpoint answers from the grid', tailRes.ok && tailText.length > 0,
140
+ `${tailText.length} chars`);
141
+ check('agent tail includes screen AND scrollback',
142
+ tailText.includes('hello-from-viewer-a') && tailText.includes('busy 8'),
143
+ tailText.includes('hello-from-viewer-a') ? 'has scrollback' : 'MISSING scrollback');
144
+ check('agent tail has no trailing blank padding', !/\n\s*\n\s*$/.test(tailText));
145
+
146
+ // --- two viewers share one grid with one explicit controller --------------
147
+ const c = await view(id, 150, 40);
148
+ await sleep(400);
149
+ c.resize(150, 40);
150
+ await sleep(500);
151
+ b.resize(40, 20);
152
+ await sleep(900);
153
+ check('both viewers stay connected (no handover)', b.open() && c.open());
154
+ const bGrid = b.lastFrame('grid');
155
+ const cGrid = c.lastFrame('grid');
156
+ check('both viewers are told the same grid',
157
+ !!bGrid && !!cGrid && bGrid.cols === cGrid.cols && bGrid.rows === cGrid.rows,
158
+ bGrid && cGrid ? `${bGrid.cols}x${bGrid.rows} vs ${cGrid.cols}x${cGrid.rows}` : 'missing grid frame');
159
+ check('grid follows the controller, not the smallest viewer', !!bGrid && bGrid.cols === 40 && bGrid.rows === 20,
160
+ bGrid && `${bGrid.cols}x${bGrid.rows}`);
161
+ check('viewer roles are explicit', !!bGrid && bGrid.controller === true && bGrid.viewers === 2
162
+ && cGrid?.controller === false && cGrid.viewers === 2);
163
+ c.type('echo ignored-watcher-input\r');
164
+ await sleep(500);
165
+ check('watcher input is ignored', !b.bytes.includes('ignored-watcher-input'));
166
+ c.claim();
167
+ await sleep(700);
168
+ const claimed = c.lastFrame('grid');
169
+ check('a watcher can explicitly claim control', !!claimed && claimed.controller === true
170
+ && claimed.cols === 150 && claimed.rows === 40,
171
+ claimed && `${claimed.cols}x${claimed.rows} controller=${claimed.controller}`);
172
+ c.type('echo seen-by-both\r');
173
+ await sleep(1100);
174
+ check('both viewers see the same live output',
175
+ b.bytes.includes('seen-by-both') && c.bytes.includes('seen-by-both'));
176
+
177
+ b.resize(60, 20);
178
+ await sleep(500);
179
+ const stays = c.lastFrame('grid');
180
+ check('a watcher resize cannot disturb the controller', !!stays && stays.cols === 150 && stays.rows === 40,
181
+ stays && `${stays.cols}x${stays.rows}`);
182
+ b.close();
183
+
184
+ // --- stopping is explicit ------------------------------------------------
185
+ await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
186
+ await sleep(1000);
187
+ check('explicit stop closes viewers with the exit code', c.closes.includes(4000),
188
+ `codes=${JSON.stringify(c.closes)}`);
189
+ const after = await stateOf(id);
190
+ check('stopped session reports stopped', after && after.state === 'stopped', after && after.state);
191
+
192
+ await fetch(`${base}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
193
+ } catch (err) {
194
+ check('no exceptions', false, String(err && err.message ? err.message : err));
195
+ console.log('--- server log tail ---\n' + bootLog.slice(-1200));
196
+ } finally {
197
+ srv.kill('SIGTERM');
198
+ await sleep(600);
199
+ srv.kill('SIGKILL');
200
+ try { fs.rmSync(DATA_DIR, { recursive: true, force: true }); } catch {}
201
+ console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
202
+ process.exit(failures ? 1 : 0);
203
+ }
server/mobile.test.mjs ADDED
@@ -0,0 +1,612 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Browser-level mobile terminal invariants.
2
+ //
3
+ // This deliberately starts with a desktop controller so opening the same
4
+ // session on a phone exercises the watcher -> controller handoff. Chromium's
5
+ // visual viewport is replaced with a controllable EventTarget so the keyboard
6
+ // test covers both viewport height and iOS's non-zero offsetTop.
7
+ //
8
+ // npm run test:mobile
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { spawn, spawnSync } from 'node:child_process';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { chromium } from 'playwright';
15
+ import { WebSocket } from 'ws';
16
+
17
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
18
+ const ROOT = path.dirname(HERE);
19
+ const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'am-mobile-'));
20
+ const PUBLIC_DIR = process.env.MOBILE_PUBLIC_DIR || path.join(DATA_DIR, 'public');
21
+ const API = 'http://127.0.0.1:7896';
22
+ const WEB = API;
23
+ const CTRL = '\x00\x00AM:';
24
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
25
+
26
+ let failures = 0;
27
+ const check = (name, ok, detail = '') => {
28
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`);
29
+ if (!ok) failures++;
30
+ };
31
+ const waitFor = async (fn, timeout = 15_000) => {
32
+ const until = Date.now() + timeout;
33
+ while (Date.now() < until) {
34
+ try { if (await fn()) return true; } catch {}
35
+ await sleep(100);
36
+ }
37
+ return false;
38
+ };
39
+
40
+ if (!process.env.MOBILE_PUBLIC_DIR) {
41
+ const build = spawnSync('npm', ['run', 'build', '--', '--outDir', PUBLIC_DIR], {
42
+ cwd: path.join(ROOT, 'web'), encoding: 'utf8',
43
+ });
44
+ if (build.status !== 0) {
45
+ throw new Error(`mobile test web build failed:\n${build.stdout}\n${build.stderr}`);
46
+ }
47
+ }
48
+
49
+ const backend = spawn('node', ['src/index.js'], {
50
+ cwd: HERE,
51
+ // Do not inherit the production Space hostname: this backend is deliberately
52
+ // reached through localhost, and the origin guard should validate it as such.
53
+ env: {
54
+ ...process.env,
55
+ PORT: '7896', DATA_DIR, PUBLIC_DIR, AM_BASHRC: '/nonexistent', SPACE_HOST: '',
56
+ },
57
+ stdio: ['ignore', 'pipe', 'pipe'],
58
+ });
59
+ let logs = '';
60
+ for (const child of [backend]) {
61
+ child.stdout.on('data', (data) => { logs += data; });
62
+ child.stderr.on('data', (data) => { logs += data; });
63
+ }
64
+
65
+ let browser;
66
+ let desktop;
67
+ let id;
68
+ let secondId;
69
+ try {
70
+ const ready = await waitFor(async () => {
71
+ return fetch(`${API}/api/health`).then((r) => r.ok).catch(() => false);
72
+ }, 60_000);
73
+ if (!ready) throw new Error(`test servers did not start:\n${logs.slice(-2000)}`);
74
+ await fetch(`${API}/api/welcome/seen`, { method: 'POST' });
75
+ const created = await (await fetch(`${API}/api/sessions`, {
76
+ method: 'POST', headers: { 'content-type': 'application/json' },
77
+ body: JSON.stringify({ cli: 'shell', name: 'mobile-terminal-e2e', path: '.' }),
78
+ })).json();
79
+ id = created.id;
80
+ if (!id) throw new Error(`session creation failed: ${JSON.stringify(created)}`);
81
+ const second = await (await fetch(`${API}/api/sessions`, {
82
+ method: 'POST', headers: { 'content-type': 'application/json' },
83
+ body: JSON.stringify({ cli: 'shell', name: 'mobile-terminal-second', path: '.' }),
84
+ })).json();
85
+ secondId = second.id;
86
+ if (!secondId) throw new Error(`second session creation failed: ${JSON.stringify(second)}`);
87
+
88
+ const desktopFrames = [];
89
+ desktop = new WebSocket(`ws://127.0.0.1:7896/ws?session=${id}&cols=140&rows=45`);
90
+ desktop.on('message', (raw) => {
91
+ const text = raw.toString();
92
+ if (text.startsWith(CTRL)) desktopFrames.push(JSON.parse(text.slice(CTRL.length)));
93
+ });
94
+ await new Promise((resolve, reject) => {
95
+ desktop.once('open', resolve);
96
+ desktop.once('error', reject);
97
+ });
98
+ await sleep(400);
99
+ desktop.send(JSON.stringify({
100
+ t: 'i',
101
+ d: "for i in $(seq 1 220); do printf 'MOBILE-HISTORY-%04d\\n' \"$i\"; done; printf '\\033[?1000h\\033[?1006h'\r",
102
+ }));
103
+ const historyReady = await waitFor(async () => {
104
+ const body = await (await fetch(`${API}/api/agents/${id}/tail?lines=400`)).json();
105
+ return body.text?.includes('MOBILE-HISTORY-0220');
106
+ });
107
+ if (!historyReady) throw new Error('history did not reach the terminal');
108
+
109
+ browser = await chromium.launch({ headless: true });
110
+ const context = await browser.newContext({
111
+ viewport: { width: 375, height: 667 },
112
+ screen: { width: 375, height: 667 },
113
+ deviceScaleFactor: 2,
114
+ hasTouch: true,
115
+ isMobile: true,
116
+ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148',
117
+ });
118
+ await context.addInitScript(() => {
119
+ const nativeWebSocket = window.WebSocket;
120
+ window.__terminalSockets = [];
121
+ class RecordedWebSocket extends nativeWebSocket {
122
+ constructor(url, protocols) {
123
+ super(url, protocols);
124
+ if (String(url).includes('/ws?session=')) {
125
+ const record = { url: String(url), sent: [], events: [] };
126
+ window.__terminalSockets.push(record);
127
+ this.addEventListener('open', () => record.events.push({ type: 'open' }));
128
+ this.addEventListener('close', (event) => record.events.push({
129
+ type: 'close', code: event.code, reason: event.reason,
130
+ }));
131
+ this.addEventListener('error', () => record.events.push({ type: 'error' }));
132
+ this.addEventListener('message', (event) => {
133
+ record.events.push({ type: 'message', dataType: typeof event.data });
134
+ if (typeof event.data === 'string' && event.data.startsWith('\x00\x00AM:')) {
135
+ try { record.lastFrame = JSON.parse(event.data.slice(5)); } catch {}
136
+ }
137
+ });
138
+ const nativeSend = this.send;
139
+ this.send = (data) => {
140
+ try { record.sent.push(JSON.parse(String(data))); } catch {}
141
+ return nativeSend.call(this, data);
142
+ };
143
+ }
144
+ }
145
+ }
146
+ class OfflineWebSocket extends EventTarget {
147
+ static CONNECTING = 0;
148
+ static OPEN = 1;
149
+ static CLOSING = 2;
150
+ static CLOSED = 3;
151
+ readyState = OfflineWebSocket.CONNECTING;
152
+ binaryType = 'blob';
153
+ send() {}
154
+ close() { this.readyState = OfflineWebSocket.CLOSED; }
155
+ }
156
+ let offline = false;
157
+ try { offline = localStorage.getItem('__am_test_offline_sockets') === '1'; } catch {}
158
+ window.WebSocket = offline ? OfflineWebSocket : RecordedWebSocket;
159
+
160
+ const viewport = new EventTarget();
161
+ Object.assign(viewport, {
162
+ width: 375, height: 667, offsetLeft: 0, offsetTop: 0,
163
+ pageLeft: 0, pageTop: 0, scale: 1,
164
+ });
165
+ Object.defineProperty(window, 'visualViewport', {
166
+ configurable: true, value: viewport,
167
+ });
168
+ const virtualKeyboard = new EventTarget();
169
+ virtualKeyboard.boundingRect = new DOMRect(0, 667, 375, 0);
170
+ Object.defineProperty(navigator, 'virtualKeyboard', {
171
+ configurable: true, value: virtualKeyboard,
172
+ });
173
+ window.__setVirtualKeyboard = (top, height, width = viewport.width) => {
174
+ virtualKeyboard.boundingRect = new DOMRect(0, top, width, height);
175
+ virtualKeyboard.dispatchEvent(new Event('geometrychange'));
176
+ };
177
+ window.__setVisualViewport = (height, offsetTop, width = viewport.width) => {
178
+ viewport.width = width;
179
+ viewport.height = height;
180
+ viewport.offsetTop = offsetTop;
181
+ viewport.pageTop = offsetTop;
182
+ viewport.dispatchEvent(new Event('resize'));
183
+ viewport.dispatchEvent(new Event('scroll'));
184
+ };
185
+ // Safari can fire with the final height but a stale zero offset, then update
186
+ // offsetTop without another event. Focus stabilization must catch that.
187
+ window.__setVisualViewportLate = (height, offsetTop, delay = 80) => {
188
+ viewport.height = height;
189
+ viewport.offsetTop = 0;
190
+ viewport.pageTop = 0;
191
+ viewport.dispatchEvent(new Event('resize'));
192
+ setTimeout(() => {
193
+ viewport.offsetTop = offsetTop;
194
+ viewport.pageTop = offsetTop;
195
+ }, delay);
196
+ };
197
+ });
198
+ const page = await context.newPage();
199
+ await page.goto(WEB, { waitUntil: 'domcontentloaded' });
200
+ await page.locator('.sidebar .row').filter({ hasText: 'mobile-terminal-e2e' }).first().click();
201
+ await page.locator('.tile-terminal:not(.tile-cached) .xterm-screen').waitFor({ state: 'visible' });
202
+
203
+ const latestGrid = () => [...desktopFrames].reverse().find((frame) =>
204
+ frame.t === 'grid' || frame.t === 'restore');
205
+ const mobileClaimed = await waitFor(() => desktopFrames.some((frame) =>
206
+ frame.t === 'grid' && frame.controller === false && frame.cols < 60));
207
+ const initialSocket = await page.evaluate(() => window.__terminalSockets.at(-1));
208
+ check('opening a session on mobile claims geometry before resizing',
209
+ mobileClaimed && initialSocket?.sent?.some((message) => message.t === 'claim'),
210
+ JSON.stringify({
211
+ mobileClaimed,
212
+ id,
213
+ mobileUrl: initialSocket?.url,
214
+ mobileFrame: initialSocket?.lastFrame,
215
+ mobileEvents: initialSocket?.events,
216
+ sent: initialSocket?.sent,
217
+ desktopState: desktop.readyState,
218
+ desktopLast: latestGrid(),
219
+ }));
220
+
221
+ const initialFit = await page.evaluate(() => {
222
+ const host = document.querySelector('.tile-terminal:not(.tile-cached) .term-host').getBoundingClientRect();
223
+ const screen = document.querySelector('.tile-terminal:not(.tile-cached) .xterm-screen').getBoundingClientRect();
224
+ return { host: { width: host.width, height: host.height }, screen: { width: screen.width, height: screen.height } };
225
+ });
226
+ check('the initial mobile terminal fits its panel in both dimensions',
227
+ initialFit.screen.width <= initialFit.host.width + 1
228
+ && initialFit.screen.height <= initialFit.host.height + 1,
229
+ JSON.stringify(initialFit));
230
+
231
+ // Switching to another session and back must retain the original xterm and
232
+ // WebSocket. A mobile Back alone was never sufficient to catch this: it only
233
+ // hides the whole stage without changing activeRef.
234
+ const firstSocketUrl = initialSocket.url;
235
+ await page.getByTitle('Back to list').click();
236
+ await page.locator('.sidebar .row').filter({ hasText: 'mobile-terminal-second' }).first().click();
237
+ await page.locator('.tile-terminal:not(.tile-cached) .xterm-screen').waitFor({ state: 'visible' });
238
+ const secondOpened = await waitFor(() => page.evaluate(() => window.__terminalSockets.length === 2));
239
+ await page.getByTitle('Back to list').click();
240
+ await page.locator('.sidebar .row').filter({ hasText: 'mobile-terminal-e2e' }).first().click();
241
+ await page.locator('.tile-terminal:not(.tile-cached) .xterm-screen').waitFor({ state: 'visible' });
242
+ await sleep(250);
243
+ const retained = await page.evaluate((url) => {
244
+ const matching = window.__terminalSockets.filter((socket) => socket.url === url);
245
+ const cachedTiles = [...document.querySelectorAll('.tile-terminal')];
246
+ return {
247
+ matching: matching.length,
248
+ closed: matching.flatMap((socket) => socket.events).some((event) => event.type === 'close'),
249
+ terminals: document.querySelectorAll('.tile-terminal .xterm').length,
250
+ hidden: cachedTiles.filter((tile) => getComputedStyle(tile).display === 'none').length,
251
+ };
252
+ }, firstSocketUrl);
253
+ check('switching sessions reuses the original terminal and socket',
254
+ secondOpened && retained.matching === 1 && !retained.closed
255
+ && retained.terminals === 2 && retained.hidden === 1,
256
+ JSON.stringify({ secondOpened, retained }));
257
+ const hiddenMessagesBeforeZoom = await page.evaluate((sessionId) => {
258
+ const socket = window.__terminalSockets.find((item) => item.url.includes(`session=${sessionId}`));
259
+ return socket?.sent?.length ?? -1;
260
+ }, secondId);
261
+ await page.getByTitle('Zoom in').click();
262
+ await page.getByTitle('Zoom out').click();
263
+ await sleep(250);
264
+ const hiddenMessagesAfterZoom = await page.evaluate((sessionId) => {
265
+ const socket = window.__terminalSockets.find((item) => item.url.includes(`session=${sessionId}`));
266
+ return socket?.sent?.length ?? -1;
267
+ }, secondId);
268
+ check('zooming the active pane does not claim or resize a hidden cached pane',
269
+ hiddenMessagesBeforeZoom >= 0 && hiddenMessagesAfterZoom === hiddenMessagesBeforeZoom,
270
+ JSON.stringify({ hiddenMessagesBeforeZoom, hiddenMessagesAfterZoom }));
271
+
272
+ // If the initial-claim assertion failed, take control through the existing
273
+ // explicit zoom path so scrolling and keyboard assertions remain diagnostic.
274
+ if (!mobileClaimed) {
275
+ await page.getByTitle('Zoom in').click();
276
+ await page.getByTitle('Zoom out').click();
277
+ await waitFor(() => desktopFrames.some((frame) => frame.controller === false));
278
+ }
279
+ await sleep(500);
280
+
281
+ const portraitGrid = latestGrid();
282
+ await page.setViewportSize({ width: 667, height: 375 });
283
+ await page.evaluate(() => window.__setVisualViewport(375, 0, 667));
284
+ const landscapeResized = await waitFor(() => {
285
+ const frame = latestGrid();
286
+ return frame?.cols > portraitGrid.cols && frame?.rows < portraitGrid.rows;
287
+ });
288
+ const landscapeFit = await page.evaluate(() => {
289
+ const host = document.querySelector('.tile-terminal:not(.tile-cached) .term-host').getBoundingClientRect();
290
+ const screen = document.querySelector('.tile-terminal:not(.tile-cached) .xterm-screen').getBoundingClientRect();
291
+ return { host: { width: host.width, height: host.height }, screen: { width: screen.width, height: screen.height } };
292
+ });
293
+ check('orientation changes refit mobile rows and columns',
294
+ landscapeResized && landscapeFit.screen.width <= landscapeFit.host.width + 1
295
+ && landscapeFit.screen.height <= landscapeFit.host.height + 1,
296
+ JSON.stringify({ landscapeResized, portraitGrid, landscape: latestGrid(), landscapeFit }));
297
+ await page.setViewportSize({ width: 375, height: 667 });
298
+ await page.evaluate(() => window.__setVisualViewport(667, 0, 375));
299
+ await waitFor(() => {
300
+ const frame = latestGrid();
301
+ return frame?.cols === portraitGrid.cols && frame?.rows === portraitGrid.rows;
302
+ });
303
+
304
+ await waitFor(() => page.locator('.tile-terminal:not(.tile-cached) .xterm-viewport').evaluate((node) =>
305
+ node.scrollHeight > node.clientHeight));
306
+ const returnedToBottom = await waitFor(() => page.locator('.tile-terminal:not(.tile-cached) .xterm-viewport').evaluate((node) =>
307
+ Math.abs(node.scrollTop - (node.scrollHeight - node.clientHeight)) <= 1));
308
+ check('returning to portrait keeps a live terminal anchored at the bottom', returnedToBottom);
309
+
310
+ const beforeScroll = await page.locator('.tile-terminal:not(.tile-cached) .xterm-viewport').evaluate((node) => ({
311
+ top: node.scrollTop, max: node.scrollHeight - node.clientHeight,
312
+ area: node.querySelector('.xterm-scroll-area')?.getBoundingClientRect().height || 0,
313
+ }));
314
+ const messagesBeforeScroll = await page.evaluate(() =>
315
+ window.__terminalSockets.reduce((count, socket) => count + socket.sent.length, 0));
316
+ await page.evaluate((sessionId) => {
317
+ const original = Storage.prototype.setItem;
318
+ window.__previewWritesDuringGesture = 0;
319
+ Storage.prototype.setItem = function instrumentPreviewWrite(key, value) {
320
+ if (key === `am-terminal-preview:${sessionId}`) window.__previewWritesDuringGesture++;
321
+ return original.call(this, key, value);
322
+ };
323
+ }, id);
324
+ const hostBox = await page.locator('.tile-terminal:not(.tile-cached) .term-host').boundingBox();
325
+ const x = hostBox.x + hostBox.width / 2;
326
+ const y = hostBox.y + Math.min(120, hostBox.height / 3);
327
+ const dragDistance = 96;
328
+ const dragSteps = 16;
329
+ await page.locator('.tile-terminal:not(.tile-cached) .term-host').dispatchEvent('touchstart', {
330
+ touches: [{ identifier: 1, clientX: x, clientY: y }],
331
+ });
332
+ for (let step = 1; step <= dragSteps; step++) {
333
+ await page.locator('.tile-terminal:not(.tile-cached) .term-host').dispatchEvent('touchmove', {
334
+ touches: [{
335
+ identifier: 1, clientX: x, clientY: y + (dragDistance * step / dragSteps),
336
+ }],
337
+ });
338
+ // Keep the gesture active beyond the preview debounce. A throttle would
339
+ // write synchronously during this loop; a true debounce must stay silent.
340
+ await sleep(60);
341
+ }
342
+ const writesBeforeTouchEnd = await page.evaluate(() => window.__previewWritesDuringGesture);
343
+ await page.locator('.tile-terminal:not(.tile-cached) .term-host').dispatchEvent('touchend', { touches: [] });
344
+ await sleep(800);
345
+ const historyTop = await page.locator('.tile-terminal:not(.tile-cached) .xterm-viewport').evaluate((node) => node.scrollTop);
346
+ const writesAfterTouchEnd = await page.evaluate(() => window.__previewWritesDuringGesture);
347
+ const messagesAfterScroll = await page.evaluate(() =>
348
+ window.__terminalSockets.reduce((count, socket) => count + socket.sent.length, 0));
349
+ const scrolledPixels = beforeScroll.top - historyTop;
350
+ check('small mobile touch moves track the finger through mouse-mode history',
351
+ beforeScroll.max > 0
352
+ && scrolledPixels >= dragDistance * 0.8
353
+ && scrolledPixels <= dragDistance * 1.2,
354
+ JSON.stringify({ beforeScroll, historyTop, scrolledPixels, dragDistance }));
355
+ check('terminal preview persistence waits until the touch gesture is idle',
356
+ writesBeforeTouchEnd === 0 && writesAfterTouchEnd >= 1,
357
+ JSON.stringify({ writesBeforeTouchEnd, writesAfterTouchEnd }));
358
+ check('local history scrolling sends no PTY control or resize messages',
359
+ messagesAfterScroll === messagesBeforeScroll,
360
+ JSON.stringify({ messagesBeforeScroll, messagesAfterScroll }));
361
+
362
+ await page.locator('.tile-terminal:not(.tile-cached) .term-host').dispatchEvent('touchstart', {
363
+ touches: [{ identifier: 2, clientX: x, clientY: y + dragDistance }],
364
+ });
365
+ for (let step = 1; step <= dragSteps; step++) {
366
+ await page.locator('.tile-terminal:not(.tile-cached) .term-host').dispatchEvent('touchmove', {
367
+ touches: [{
368
+ identifier: 2, clientX: x,
369
+ clientY: y + dragDistance - (dragDistance * step / dragSteps),
370
+ }],
371
+ });
372
+ }
373
+ await page.locator('.tile-terminal:not(.tile-cached) .term-host').dispatchEvent('touchend', { touches: [] });
374
+ await sleep(100);
375
+ const returnedScroll = await page.locator('.tile-terminal:not(.tile-cached) .xterm-viewport').evaluate((node) => ({
376
+ top: node.scrollTop, max: node.scrollHeight - node.clientHeight,
377
+ }));
378
+ check('the reverse touch drag returns to the live bottom',
379
+ Math.abs(returnedScroll.top - returnedScroll.max) < 2,
380
+ JSON.stringify(returnedScroll));
381
+
382
+ const fullKeyboardGrid = latestGrid();
383
+ await page.locator('.tile-terminal:not(.tile-cached) .term-host').click();
384
+ const mobileInputAnchor = await page.evaluate(() => {
385
+ const host = document.querySelector('.tile-terminal:not(.tile-cached) .term-host').getBoundingClientRect();
386
+ const node = document.querySelector('.tile-terminal:not(.tile-cached) .xterm-helper-textarea');
387
+ const input = node.getBoundingClientRect();
388
+ return {
389
+ ok: input.width >= 1 && input.height >= 1
390
+ && input.top >= host.bottom - 5 && input.left > host.left && input.right < host.right,
391
+ host: { top: host.top, right: host.right, bottom: host.bottom, left: host.left },
392
+ input: { top: input.top, right: input.right, bottom: input.bottom, left: input.left,
393
+ width: input.width, height: input.height },
394
+ style: node.getAttribute('style'),
395
+ };
396
+ });
397
+ check('the focused xterm input is anchored at the mobile terminal bottom',
398
+ mobileInputAnchor.ok, JSON.stringify(mobileInputAnchor));
399
+
400
+ // A direct .hf.space app receives real viewport geometry on the affected
401
+ // devices. Even if a test browser emits no change, focus alone must not
402
+ // invoke the Hub-iframe fallback outside an iframe.
403
+ await sleep(650);
404
+ const directNoSignalLayout = await page.locator('.app').evaluate((node) => ({
405
+ height: node.getBoundingClientRect().height,
406
+ strategy: document.documentElement.dataset.keyboardLayout ?? null,
407
+ }));
408
+ check('direct app does not guess a keyboard height without a browser signal',
409
+ Math.abs(directNoSignalLayout.height - 667) < 1 && directNoSignalLayout.strategy === null,
410
+ JSON.stringify(directNoSignalLayout));
411
+
412
+ // Embedded Chromium can leave the child visual viewport unchanged but expose
413
+ // the OSK rectangle. Ensure that independent signal clips the app and PTY.
414
+ await page.evaluate(() => window.__setVirtualKeyboard(430, 237));
415
+ const geometryResized = await waitFor(() => latestGrid()?.rows < fullKeyboardGrid.rows);
416
+ const geometryLayout = await page.locator('.app').evaluate((node) => {
417
+ const box = node.getBoundingClientRect();
418
+ const keybar = document.querySelector('.tile-terminal:not(.tile-cached) .term-keybar')?.getBoundingClientRect();
419
+ return { top: box.top, bottom: box.bottom, height: box.height, keybarBottom: keybar?.bottom ?? null };
420
+ });
421
+ check('keyboard geometry resizes an embedded mobile terminal without viewport changes',
422
+ geometryResized && Math.abs(geometryLayout.height - 430) < 1
423
+ && geometryLayout.keybarBottom <= 430,
424
+ JSON.stringify({ geometryResized, geometryLayout, grid: latestGrid() }));
425
+ await page.evaluate(() => window.__setVirtualKeyboard(667, 0));
426
+ const geometryClosed = await waitFor(() => latestGrid()?.rows >= fullKeyboardGrid.rows);
427
+ check('clearing embedded keyboard geometry restores the terminal', geometryClosed,
428
+ JSON.stringify(latestGrid()));
429
+
430
+ const beforeKeyboardGrid = latestGrid();
431
+ await page.evaluate(() => window.__setVisualViewportLate(360, 118));
432
+ const keyboardResized = await waitFor(() => {
433
+ const frame = latestGrid();
434
+ return frame?.controller === false && frame?.rows < beforeKeyboardGrid.rows;
435
+ });
436
+ const keyboardLayout = await page.evaluate(() => {
437
+ const rect = (selector) => {
438
+ const node = document.querySelector(selector);
439
+ if (!node) return null;
440
+ const box = node.getBoundingClientRect();
441
+ return {
442
+ top: box.top, right: box.right, bottom: box.bottom, left: box.left,
443
+ width: box.width, height: box.height,
444
+ };
445
+ };
446
+ const app = rect('.app');
447
+ const host = rect('.tile-terminal:not(.tile-cached) .term-host');
448
+ const keybar = rect('.tile-terminal:not(.tile-cached) .term-keybar');
449
+ return {
450
+ app,
451
+ hostBottom: host?.bottom ?? null,
452
+ keybarBottom: keybar?.bottom ?? null,
453
+ vvh: getComputedStyle(document.documentElement).getPropertyValue('--vvh').trim(),
454
+ vvtop: getComputedStyle(document.documentElement).getPropertyValue('--vv-top').trim(),
455
+ pageScroll: window.scrollY,
456
+ };
457
+ });
458
+ check('the app follows the keyboard-shrunken visual viewport',
459
+ Math.abs(keyboardLayout.app.top - 118) < 1
460
+ && Math.abs(keyboardLayout.app.height - 360) < 1
461
+ && Math.abs(keyboardLayout.app.bottom - 478) < 1
462
+ && Math.abs(keyboardLayout.app.left) < 1
463
+ && Math.abs(keyboardLayout.app.width - 375) < 1,
464
+ JSON.stringify(keyboardLayout));
465
+ check('terminal input controls remain above the keyboard',
466
+ keyboardResized && keyboardLayout.hostBottom <= 478 && keyboardLayout.keybarBottom <= 478,
467
+ JSON.stringify({ keyboardResized, beforeKeyboardGrid, after: latestGrid(), keyboardLayout }));
468
+
469
+ await page.evaluate(() => window.__setVisualViewport(667, 0));
470
+ const keyboardClosed = await waitFor(() => latestGrid()?.rows >= beforeKeyboardGrid.rows);
471
+ const restoredLayout = await page.locator('.app').evaluate((node) => {
472
+ const rect = node.getBoundingClientRect();
473
+ return { top: rect.top, height: rect.height };
474
+ });
475
+ check('closing the keyboard restores viewport and terminal geometry',
476
+ keyboardClosed && Math.abs(restoredLayout.top) < 1 && Math.abs(restoredLayout.height - 667) < 1,
477
+ JSON.stringify({ keyboardClosed, restoredLayout, grid: latestGrid() }));
478
+
479
+ // The huggingface.co Space page is a cross-origin wrapper around the actual
480
+ // app iframe. Mobile Safari does not propagate its keyboard viewport into
481
+ // that child, so verify the focus-derived fallback with every geometry signal
482
+ // deliberately left unchanged.
483
+ const embeddedPage = await context.newPage();
484
+ await embeddedPage.setContent(
485
+ `<style>html,body{margin:0}iframe{display:block;width:375px;height:667px;border:0}</style>`
486
+ + `<iframe src="${WEB}"></iframe>`,
487
+ );
488
+ const embeddedReady = await waitFor(() => embeddedPage.frames().some((frame) =>
489
+ frame !== embeddedPage.mainFrame() && frame.url().startsWith(WEB)));
490
+ const embeddedFrame = embeddedPage.frames().find((frame) =>
491
+ frame !== embeddedPage.mainFrame() && frame.url().startsWith(WEB));
492
+ if (!embeddedReady || !embeddedFrame) throw new Error('embedded app frame did not load');
493
+ await embeddedFrame.locator('.sidebar .row').filter({ hasText: 'mobile-terminal-e2e' }).first().click();
494
+ await embeddedFrame.locator('.tile-terminal:not(.tile-cached) .xterm-screen').waitFor({ state: 'visible' });
495
+ await embeddedFrame.locator('.tile-terminal:not(.tile-cached) .term-host').click();
496
+ const embeddedFallback = await waitFor(() => embeddedFrame.evaluate(() =>
497
+ document.documentElement.dataset.keyboardLayout === 'focus-fallback'));
498
+ const embeddedLayout = await embeddedFrame.locator('.app').evaluate((node) => {
499
+ const app = node.getBoundingClientRect();
500
+ const keybar = document.querySelector('.tile-terminal:not(.tile-cached) .term-keybar')
501
+ ?.getBoundingClientRect();
502
+ return {
503
+ height: app.height,
504
+ bottom: app.bottom,
505
+ keybarBottom: keybar?.bottom ?? null,
506
+ strategy: document.documentElement.dataset.keyboardLayout ?? null,
507
+ visualViewportHeight: window.visualViewport?.height ?? null,
508
+ innerHeight: window.innerHeight,
509
+ };
510
+ });
511
+ check('Hub iframe uses a focus fallback when keyboard geometry is unavailable',
512
+ embeddedFallback
513
+ && Math.abs(embeddedLayout.height - 360) < 1
514
+ && embeddedLayout.keybarBottom <= embeddedLayout.bottom
515
+ && embeddedLayout.visualViewportHeight === 667
516
+ && embeddedLayout.innerHeight === 667,
517
+ JSON.stringify(embeddedLayout));
518
+ await embeddedFrame.locator('.xterm-helper-textarea').evaluate((node) => node.blur());
519
+ const embeddedRestored = await waitFor(() => embeddedFrame.evaluate(() => {
520
+ const app = document.querySelector('.app')?.getBoundingClientRect();
521
+ return !document.documentElement.dataset.keyboardLayout && Math.abs((app?.height ?? 0) - 667) < 1;
522
+ }));
523
+ check('blurring an embedded prompt restores the full iframe height', embeddedRestored);
524
+ await embeddedPage.close();
525
+
526
+ // A compact last-frame preview should survive a full page reload and remain
527
+ // available while the backend/Space is unavailable. This deliberately blocks
528
+ // terminal sockets after reload, so success cannot come from a fast restore.
529
+ const previewSaved = await waitFor(() => page.evaluate((sessionId) => {
530
+ try {
531
+ const saved = JSON.parse(localStorage.getItem(`am-terminal-preview:${sessionId}`) || 'null');
532
+ return saved?.rows?.some((line) => String(line).includes('MOBILE-HISTORY-0220'));
533
+ } catch { return false; }
534
+ }, id), 5_000);
535
+ await page.evaluate(() => localStorage.setItem('__am_test_offline_sockets', '1'));
536
+ await page.reload({ waitUntil: 'domcontentloaded' });
537
+ await page.locator('.sidebar .row').filter({ hasText: 'mobile-terminal-e2e' }).first().click();
538
+ const previewVisible = await page.locator('.term-preview').filter({ hasText: 'MOBILE-HISTORY-0220' })
539
+ .isVisible().catch(() => false);
540
+ check('the last terminal view survives reload while the backend is unavailable',
541
+ previewSaved && previewVisible, JSON.stringify({ previewSaved, previewVisible }));
542
+
543
+ await context.close();
544
+
545
+ // The pane deck is shared by desktop group layouts too. Put the two existing
546
+ // sessions in one group and verify explicit grid placement plus persistence
547
+ // across Overview and Settings (both used to tear terminal panes down).
548
+ const group = await (await fetch(`${API}/api/groups`, {
549
+ method: 'POST', headers: { 'content-type': 'application/json' },
550
+ body: JSON.stringify({ name: 'cache-layout-group' }),
551
+ })).json();
552
+ for (const sessionId of [id, secondId]) {
553
+ await fetch(`${API}/api/move`, {
554
+ method: 'POST', headers: { 'content-type': 'application/json' },
555
+ body: JSON.stringify({ ref: `s:${sessionId}`, to: { kind: 'into', groupId: group.id } }),
556
+ });
557
+ }
558
+ const desktopContext = await browser.newContext({ viewport: { width: 1200, height: 800 } });
559
+ const desktopPage = await desktopContext.newPage();
560
+ await desktopPage.goto(WEB, { waitUntil: 'domcontentloaded' });
561
+ await desktopPage.locator('.row.group-head').filter({ hasText: 'cache-layout-group' }).click();
562
+ const groupTerms = desktopPage.locator('.tile-terminal:not(.tile-cached) .xterm');
563
+ await groupTerms.first().waitFor({ state: 'visible' });
564
+ const groupReady = await waitFor(() => groupTerms.count().then((count) => count === 2));
565
+ const groupLayout = await desktopPage.locator('.tile-terminal:not(.tile-cached)').evaluateAll((tiles) =>
566
+ tiles.map((tile) => {
567
+ const box = tile.getBoundingClientRect();
568
+ tile.setAttribute('data-cache-probe', 'retained');
569
+ return { left: box.left, top: box.top, width: box.width, height: box.height };
570
+ }));
571
+ check('retained terminals occupy the desktop group grid', groupReady
572
+ && groupLayout.length === 2 && Math.abs(groupLayout[0].top - groupLayout[1].top) < 1
573
+ && groupLayout[1].left > groupLayout[0].left + groupLayout[0].width,
574
+ JSON.stringify(groupLayout));
575
+
576
+ await desktopPage.locator('.ov-row').click();
577
+ const overviewRetained = await desktopPage.locator('.tile-terminal[data-cache-probe="retained"]')
578
+ .count() === 2;
579
+ await desktopPage.locator('.row.group-head').filter({ hasText: 'cache-layout-group' }).click();
580
+ const groupRestored = await waitFor(() => desktopPage
581
+ .locator('.tile-terminal:not(.tile-cached)[data-cache-probe="retained"]')
582
+ .count().then((count) => count === 2));
583
+ check('Overview hides group terminals without recreating them', overviewRetained && groupRestored,
584
+ JSON.stringify({ overviewRetained, groupRestored }));
585
+
586
+ await desktopPage.getByTitle('Settings').click();
587
+ await desktopPage.locator('.app.settings').waitFor({ state: 'visible' });
588
+ const settingsRetained = await desktopPage
589
+ .locator('.app.app-suspended .tile-terminal[data-cache-probe="retained"] .xterm').count() === 2;
590
+ await desktopPage.locator('.app.settings').getByTitle('Back').click();
591
+ const settingsRestored = await waitFor(() => desktopPage
592
+ .locator('.tile-terminal:not(.tile-cached)[data-cache-probe="retained"] .xterm')
593
+ .count().then((count) => count === 2));
594
+ check('Settings hides group terminals without recreating them', settingsRetained && settingsRestored,
595
+ JSON.stringify({ settingsRetained, settingsRestored }));
596
+ await desktopContext.close();
597
+ } catch (error) {
598
+ check('mobile browser test completes', false, String(error?.stack || error));
599
+ console.log(logs.slice(-3000));
600
+ } finally {
601
+ try { desktop?.close(); } catch {}
602
+ try { await browser?.close(); } catch {}
603
+ if (id) await fetch(`${API}/api/sessions/${id}/stop`, { method: 'POST' }).catch(() => {});
604
+ if (secondId) await fetch(`${API}/api/sessions/${secondId}/stop`, { method: 'POST' }).catch(() => {});
605
+ backend.kill('SIGTERM');
606
+ await sleep(400);
607
+ backend.kill('SIGKILL');
608
+ try { fs.rmSync(DATA_DIR, { recursive: true, force: true }); } catch {}
609
+ }
610
+
611
+ console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
612
+ process.exit(failures ? 1 : 0);
server/package-lock.json CHANGED
@@ -7,13 +7,55 @@
7
  "": {
8
  "name": "agent-manager-server",
9
  "version": "0.1.0",
 
10
  "dependencies": {
 
11
  "express": "^4.19.2",
12
  "node-pty": "^1.0.0",
13
  "web-push": "^3.6.7",
14
  "ws": "^8.18.0"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  }
16
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  "node_modules/accepts": {
18
  "version": "1.3.8",
19
  "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -357,6 +399,21 @@
357
  "node": ">= 0.6"
358
  }
359
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  "node_modules/function-bind": {
361
  "version": "1.1.2",
362
  "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -657,6 +714,17 @@
657
  "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
658
  "license": "MIT"
659
  },
 
 
 
 
 
 
 
 
 
 
 
660
  "node_modules/node-pty": {
661
  "version": "1.1.0",
662
  "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
@@ -706,6 +774,38 @@
706
  "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
707
  "license": "MIT"
708
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
709
  "node_modules/proxy-addr": {
710
  "version": "2.0.7",
711
  "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
 
7
  "": {
8
  "name": "agent-manager-server",
9
  "version": "0.1.0",
10
+ "license": "Apache-2.0",
11
  "dependencies": {
12
+ "@coder/libghostty-vt-node": "0.1.0-beta.0",
13
  "express": "^4.19.2",
14
  "node-pty": "^1.0.0",
15
  "web-push": "^3.6.7",
16
  "ws": "^8.18.0"
17
+ },
18
+ "devDependencies": {
19
+ "@xterm/headless": "^6.0.0",
20
+ "playwright": "^1.62.1"
21
+ },
22
+ "engines": {
23
+ "node": ">=20.19"
24
+ }
25
+ },
26
+ "node_modules/@coder/libghostty-vt-node": {
27
+ "version": "0.1.0-beta.0",
28
+ "resolved": "https://registry.npmjs.org/@coder/libghostty-vt-node/-/libghostty-vt-node-0.1.0-beta.0.tgz",
29
+ "integrity": "sha512-m0KqgBfEzD7G6DIFWe3OSGoa0muDHneXDKHDVz8sJdSYl2N8HN9W642H3LSw2YDkw9VuxxkbHqXdN98akkqwxQ==",
30
+ "hasInstallScript": true,
31
+ "license": "MIT",
32
+ "dependencies": {
33
+ "node-addon-api": "^8.3.1",
34
+ "node-gyp-build": "^4.8.4"
35
+ },
36
+ "engines": {
37
+ "node": ">=20.19"
38
  }
39
  },
40
+ "node_modules/@coder/libghostty-vt-node/node_modules/node-addon-api": {
41
+ "version": "8.9.0",
42
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
43
+ "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==",
44
+ "license": "MIT",
45
+ "engines": {
46
+ "node": "^18 || ^20 || >= 21"
47
+ }
48
+ },
49
+ "node_modules/@xterm/headless": {
50
+ "version": "6.0.0",
51
+ "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz",
52
+ "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==",
53
+ "dev": true,
54
+ "license": "MIT",
55
+ "workspaces": [
56
+ "addons/*"
57
+ ]
58
+ },
59
  "node_modules/accepts": {
60
  "version": "1.3.8",
61
  "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
 
399
  "node": ">= 0.6"
400
  }
401
  },
402
+ "node_modules/fsevents": {
403
+ "version": "2.3.2",
404
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
405
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
406
+ "dev": true,
407
+ "hasInstallScript": true,
408
+ "license": "MIT",
409
+ "optional": true,
410
+ "os": [
411
+ "darwin"
412
+ ],
413
+ "engines": {
414
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
415
+ }
416
+ },
417
  "node_modules/function-bind": {
418
  "version": "1.1.2",
419
  "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
 
714
  "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
715
  "license": "MIT"
716
  },
717
+ "node_modules/node-gyp-build": {
718
+ "version": "4.8.4",
719
+ "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
720
+ "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
721
+ "license": "MIT",
722
+ "bin": {
723
+ "node-gyp-build": "bin.js",
724
+ "node-gyp-build-optional": "optional.js",
725
+ "node-gyp-build-test": "build-test.js"
726
+ }
727
+ },
728
  "node_modules/node-pty": {
729
  "version": "1.1.0",
730
  "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
 
774
  "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
775
  "license": "MIT"
776
  },
777
+ "node_modules/playwright": {
778
+ "version": "1.62.1",
779
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
780
+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
781
+ "dev": true,
782
+ "license": "Apache-2.0",
783
+ "dependencies": {
784
+ "playwright-core": "1.62.1"
785
+ },
786
+ "bin": {
787
+ "playwright": "cli.js"
788
+ },
789
+ "engines": {
790
+ "node": ">=20"
791
+ },
792
+ "optionalDependencies": {
793
+ "fsevents": "2.3.2"
794
+ }
795
+ },
796
+ "node_modules/playwright-core": {
797
+ "version": "1.62.1",
798
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
799
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
800
+ "dev": true,
801
+ "license": "Apache-2.0",
802
+ "bin": {
803
+ "playwright-core": "cli.js"
804
+ },
805
+ "engines": {
806
+ "node": ">=20"
807
+ }
808
+ },
809
  "node_modules/proxy-addr": {
810
  "version": "2.0.7",
811
  "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
server/package.json CHANGED
@@ -2,20 +2,31 @@
2
  "name": "agent-manager-server",
3
  "version": "0.1.0",
4
  "private": true,
5
- "description": "Agent Manager backend (Express + ws + node-pty + tmux)",
6
  "license": "Apache-2.0",
7
- "repository": { "type": "git", "url": "https://github.com/huggingface/agent-manager" },
 
 
 
8
  "type": "module",
9
  "main": "src/index.js",
10
  "scripts": {
11
  "start": "node src/index.js",
12
  "dev": "node --watch src/index.js",
13
- "test": "node test/repin.test.mjs"
 
 
 
14
  },
15
  "dependencies": {
 
16
  "express": "^4.19.2",
17
  "node-pty": "^1.0.0",
18
  "web-push": "^3.6.7",
19
  "ws": "^8.18.0"
 
 
 
 
20
  }
21
  }
 
2
  "name": "agent-manager-server",
3
  "version": "0.1.0",
4
  "private": true,
5
+ "description": "Agent Manager backend (Express + ws + node-pty + libghostty-vt)",
6
  "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/huggingface/agent-manager"
10
+ },
11
  "type": "module",
12
  "main": "src/index.js",
13
  "scripts": {
14
  "start": "node src/index.js",
15
  "dev": "node --watch src/index.js",
16
+ "test": "node test/repin.test.mjs && node migration.test.mjs && node resize.test.mjs"
17
+ },
18
+ "engines": {
19
+ "node": ">=20.19"
20
  },
21
  "dependencies": {
22
+ "@coder/libghostty-vt-node": "0.1.0-beta.0",
23
  "express": "^4.19.2",
24
  "node-pty": "^1.0.0",
25
  "web-push": "^3.6.7",
26
  "ws": "^8.18.0"
27
+ },
28
+ "devDependencies": {
29
+ "@xterm/headless": "^6.0.0",
30
+ "playwright": "^1.62.1"
31
  }
32
  }
server/resize.test.mjs ADDED
@@ -0,0 +1,623 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Invariants for the server-held terminal model.
2
+ //
3
+ // The contract is explicit rather than heuristic: shell output uses ordinary
4
+ // reflow, while known primary-screen agent TUIs settle their SIGWINCH repaint in
5
+ // a scratch terminal so presentation bytes never become history. Exactly one
6
+ // viewer controls input and PTY geometry; Ghostty remains the durable authority.
7
+ // node resize.test.mjs
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import { spawn } from 'node:child_process';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { WebSocket } from 'ws';
14
+ import {
15
+ TERMINAL_HISTORY_VERSION, createTerminalHistoryCheckpoint, loadTerminalHistory,
16
+ traceHistoryLines,
17
+ } from './src/history-store.js';
18
+ import { mergeRepaintArchive, repaintArchiveView } from './src/runner.js';
19
+ import { snapshotToRestoreAnsi, styledTerminalRows } from './src/snapshot.js';
20
+
21
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
22
+ const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'am-resize-'));
23
+ const FIXTURE = path.join(HERE, 'fixtures', 'repaint-tui.mjs');
24
+ const PORT = 7895;
25
+ const CTRL = '\x00\x00AM:';
26
+ const base = `http://localhost:${PORT}`;
27
+
28
+ let failures = 0;
29
+ const check = (name, ok, detail = '') => {
30
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`);
31
+ if (!ok) failures++;
32
+ };
33
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
34
+ const waitFor = async (fn, timeout = 8000) => {
35
+ const until = Date.now() + timeout;
36
+ while (Date.now() < until) {
37
+ if (await fn()) return true;
38
+ await sleep(100);
39
+ }
40
+ return false;
41
+ };
42
+
43
+ const srv = spawn('node', ['src/index.js'], {
44
+ cwd: HERE,
45
+ env: {
46
+ ...process.env,
47
+ PORT: String(PORT),
48
+ DATA_DIR,
49
+ AM_BASHRC: '/nonexistent',
50
+ AM_HISTORY_SAVE_MS: '50',
51
+ AM_STARTUP_CAPTURE_IDLE_MS: '700',
52
+ AM_STARTUP_CAPTURE_MAX_MS: '3000',
53
+ AM_TEST_REPAINT_CMD: `env FIXED_LINES=30 HISTORY_LINES=2105 DELAY_RESUME_MS=400 ${JSON.stringify(process.execPath)} ${JSON.stringify(FIXTURE)}`,
54
+ },
55
+ stdio: ['ignore', 'pipe', 'pipe'],
56
+ });
57
+ let log = '';
58
+ srv.stdout.on('data', (data) => { log += data; });
59
+ srv.stderr.on('data', (data) => { log += data; });
60
+
61
+ let Headless = null;
62
+ try { Headless = (await import('@xterm/headless')).default.Terminal; } catch {}
63
+
64
+ function view(id, cols, rows, mirror = false) {
65
+ return new Promise((resolve) => {
66
+ const ws = new WebSocket(`ws://localhost:${PORT}/ws?session=${id}&cols=${cols}&rows=${rows}`);
67
+ const term = mirror && Headless
68
+ ? new Headless({ cols, rows, scrollback: 20000, allowProposedApi: true })
69
+ : null;
70
+ const v = {
71
+ bytes: '', frames: [], events: [], term,
72
+ type: (data) => ws.send(JSON.stringify({ t: 'i', d: data })),
73
+ resize: (nextCols, nextRows) => ws.send(JSON.stringify({ t: 'r', cols: nextCols, rows: nextRows })),
74
+ claim: () => ws.send(JSON.stringify({ t: 'claim' })),
75
+ lastFrame: (type) => [...v.frames].reverse().find((frame) => frame.t === type) || null,
76
+ countFrames: (type) => v.frames.filter((frame) => frame.t === type).length,
77
+ open: () => ws.readyState === WebSocket.OPEN,
78
+ close: () => { try { ws.close(); } catch {} },
79
+ screenText: async () => {
80
+ if (!term) return '';
81
+ await new Promise((done) => term.write('', done));
82
+ const buf = term.buffer.active;
83
+ let text = '';
84
+ for (let i = 0; i < buf.length; i++) {
85
+ const line = buf.getLine(i);
86
+ const row = line?.translateToString(true) ?? '';
87
+ // Snapshot restores use absolute cursor painting, which cannot carry
88
+ // xterm's private isWrapped bit. A full row is nevertheless a visual
89
+ // wrap and must be joined before checking transcript completeness.
90
+ if (i > 0 && !line?.isWrapped) {
91
+ const previous = buf.getLine(i - 1)?.translateToString(true) ?? '';
92
+ if (previous.length < term.cols) text += '\n';
93
+ }
94
+ text += row;
95
+ }
96
+ return text;
97
+ },
98
+ scrollsBack: async () => {
99
+ if (!term) return false;
100
+ await new Promise((done) => term.write('', done));
101
+ const buf = term.buffer.active;
102
+ const bottom = buf.baseY;
103
+ term.scrollToTop();
104
+ const top = buf.viewportY;
105
+ term.scrollToBottom();
106
+ return bottom > 0 && top < bottom && buf.viewportY === bottom;
107
+ },
108
+ bufferState: async () => {
109
+ if (!term) return null;
110
+ await new Promise((done) => term.write('', done));
111
+ const buf = term.buffer.active;
112
+ const initialViewport = buf.viewportY;
113
+ let coloredHistoryCells = 0;
114
+ for (let y = 0; y < buf.baseY; y++) {
115
+ const line = buf.getLine(y);
116
+ if (!line) continue;
117
+ for (let x = 0; x < line.length; x++) {
118
+ const cell = line.getCell(x);
119
+ if (cell?.getChars() && (!cell.isFgDefault() || !cell.isBgDefault())) {
120
+ coloredHistoryCells++;
121
+ }
122
+ }
123
+ }
124
+ term.scrollToTop();
125
+ const top = buf.viewportY;
126
+ term.scrollToBottom();
127
+ return {
128
+ historyRows: buf.baseY,
129
+ initialViewport,
130
+ atBottom: initialViewport === buf.baseY,
131
+ canReachBothEnds: buf.baseY > 0 && top === 0 && buf.viewportY === buf.baseY,
132
+ coloredHistoryCells,
133
+ };
134
+ },
135
+ };
136
+ ws.on('message', (data) => {
137
+ const text = data.toString('utf8');
138
+ if (text.startsWith(CTRL)) {
139
+ let frame;
140
+ try { frame = JSON.parse(text.slice(CTRL.length)); } catch { return; }
141
+ v.frames.push(frame);
142
+ v.events.push({ type: 'frame', frame });
143
+ if (term && (frame.t === 'grid' || frame.t === 'restore')) {
144
+ const applyGrid = () => {
145
+ if (frame.reset) { term.reset(); term.clear(); }
146
+ if (frame.cols > 0 && frame.rows > 0
147
+ && (term.cols !== frame.cols || term.rows !== frame.rows)) {
148
+ term.resize(frame.cols, frame.rows);
149
+ }
150
+ };
151
+ const geometryChanged = frame.cols > 0 && frame.rows > 0
152
+ && (term.cols !== frame.cols || term.rows !== frame.rows);
153
+ if (frame.reset || geometryChanged) term.write('', applyGrid);
154
+ else applyGrid();
155
+ }
156
+ } else {
157
+ v.bytes += text;
158
+ v.events.push({ type: 'data', text });
159
+ if (term) term.write(text);
160
+ }
161
+ });
162
+ ws.on('open', () => resolve(v));
163
+ ws.on('error', () => resolve(v));
164
+ });
165
+ }
166
+
167
+ async function session(name, cli = 'shell') {
168
+ const created = await (await fetch(`${base}/api/sessions`, {
169
+ method: 'POST', headers: { 'content-type': 'application/json' },
170
+ body: JSON.stringify({ cli, name }),
171
+ })).json();
172
+ if (!created.id) throw new Error(`no session: ${JSON.stringify(created).slice(0, 200)}`);
173
+ return created.id;
174
+ }
175
+
176
+ const gridText = async (id, lines = 2000) => {
177
+ const response = await fetch(`${base}/api/agents/${id}/tail?lines=${lines}`);
178
+ const body = await response.json();
179
+ return typeof body === 'string' ? body : (body.text || body.tail || '');
180
+ };
181
+
182
+ /** Every fixture token is unique, so repeats prove a frame entered history twice. */
183
+ function duplicateTokens(text) {
184
+ const counts = new Map();
185
+ for (const match of text.matchAll(/\b\d{3}\.\d{2}\b/g)) {
186
+ counts.set(match[0], (counts.get(match[0]) || 0) + 1);
187
+ }
188
+ return [...counts.values()].filter((count) => count > 1).length;
189
+ }
190
+
191
+ const uniqueTokens = (text) => new Set(text.match(/\b\d{3}\.\d{2}\b/g) || []).size;
192
+
193
+ const stop = async (id) => {
194
+ await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' }).catch(() => {});
195
+ await sleep(250);
196
+ };
197
+
198
+ try {
199
+ {
200
+ const directory = fs.mkdtempSync(path.join(DATA_DIR, 'history-schema-'));
201
+ const legacyFile = path.join(directory, 'legacy.json');
202
+ fs.writeFileSync(legacyFile, JSON.stringify({ version: 1, cols: 80, lines: ['old'] }));
203
+ check('legacy checkpoints remain readable for unaffected shell sessions',
204
+ loadTerminalHistory(directory, 'legacy')?.version === 1);
205
+
206
+ const checkpoint = createTerminalHistoryCheckpoint({
207
+ directory, id: 'current', delayMs: 1,
208
+ snapshot: () => ({
209
+ cols: 90, scrollbackLines: [{ text: 'clean', ansi: '\x1b[38;5;2mclean\x1b[0m' }],
210
+ }),
211
+ blocked: () => false,
212
+ });
213
+ checkpoint.flush();
214
+ const wrote = await waitFor(() => loadTerminalHistory(directory, 'current')?.version
215
+ === TERMINAL_HISTORY_VERSION);
216
+ check('new checkpoints use the clean startup generation', wrote);
217
+ check('checkpoints retain validated scrollback styling',
218
+ loadTerminalHistory(directory, 'current')?.lines[0]?.ansi
219
+ === '\x1b[38;5;2mclean\x1b[0m');
220
+
221
+ const styled = styledTerminalRows({
222
+ cols: 20, rows: 1,
223
+ scrollbackLines: [{ row: 0, text: 'red history' }],
224
+ visibleLines: [{ row: 0, text: 'live' }],
225
+ }, '<div style="font-family: monospace; white-space: pre;"><div style="display: inline;color: var(--vt-palette-1);">red history</div>\nlive</div>');
226
+ const restore = snapshotToRestoreAnsi({
227
+ cols: 20, rows: 1, cursorRow: 0, cursorCol: 0, cells: [],
228
+ scrollbackLines: styled.slice(0, 1),
229
+ });
230
+ check('Ghostty HTML scrollback colors convert back to ANSI',
231
+ styled[0].ansi?.includes('\x1b[38;5;1m')
232
+ && restore.includes('\x1b[38;5;1mred history'));
233
+ }
234
+
235
+ {
236
+ const recovered = traceHistoryLines({ turns: [
237
+ { role: 'user', blocks: [{ type: 'text', text: 'older prompt' }] },
238
+ { role: 'assistant', kind: 'final', blocks: [{ type: 'text', text: 'older answer' }] },
239
+ { role: 'user', blocks: [{ type: 'text', text: 'live prompt' }] },
240
+ { role: 'assistant', kind: 'final', blocks: [{ type: 'text', text: 'live answer' }] },
241
+ ] });
242
+ const text = recovered.map((line) => line.text).join('\n');
243
+ check('trace recovery seeds only turns older than the live viewport',
244
+ text.includes('older prompt') && text.includes('older answer')
245
+ && !text.includes('live prompt') && !text.includes('live answer'));
246
+
247
+ const alreadyVisible = traceHistoryLines({ turns: [
248
+ { role: 'user', blocks: [{ type: 'text', text: 'hi there' }] },
249
+ { role: 'assistant', kind: 'final', blocks: [{ type: 'text', text: 'Hey!' }] },
250
+ { role: 'user', blocks: [{ type: 'text', text: 'live prompt' }] },
251
+ ] }, '❯ hi there\n\n● Hey!');
252
+ check('trace recovery does not duplicate short turns replayed by the agent',
253
+ alreadyVisible.length === 0);
254
+
255
+ const archiveSeed = 'older unique turn\n❯ hi there\n\n● Hey!\n';
256
+ const wide = 'Claude welcome frame\nolder unique turn\n❯ hi there\n\n● Hey!\nnewer live turn\n';
257
+ const archive = mergeRepaintArchive(archiveSeed, wide);
258
+ check('a wide repaint merges recovered turns into one full archive',
259
+ archive === wide && archive.split('❯ hi there').length - 1 === 1);
260
+ check('a wide repaint needs no duplicate scrollback',
261
+ repaintArchiveView(archive, wide).history.length === 0);
262
+ const narrow = 'newer live turn\n';
263
+ const narrowView = repaintArchiveView(archive, narrow);
264
+ const narrowHistory = narrowView.history.map((line) => line.text).join('\n');
265
+ check('a narrow repaint restores the archive prefix above the viewport',
266
+ narrowHistory.includes('Claude welcome frame') && narrowHistory.includes('❯ hi there'));
267
+ check('a repaint replaces a volatile old footer after its transcript overlap',
268
+ repaintArchiveView('deep history\nshared transcript\n[old 160x40]\n',
269
+ 'shared transcript\n[new 90x24]\n').archive
270
+ === 'deep history\nshared transcript\n[new 90x24]\n');
271
+ const poem = '❯ write me a poem about silicon\n\n● Silicon\n\n'
272
+ + 'Second most common thing underfoot, plain as the beach you forget while you walk it —\n'
273
+ + 'Someone thought to melt it, draw it out into a single perfect column, one lattice unbroken.\n';
274
+ const volatilePrefix = repaintArchiveView(
275
+ `older unique turn\n${poem}✻ Worked for 8s\n[old footer]\n`,
276
+ `✻ Worked for 2s\n${poem}✻ Worked for 8s\n[new footer]\n`,
277
+ ).archive;
278
+ check('a volatile leading status cannot anchor after and duplicate the repainted turn',
279
+ volatilePrefix.split('❯ write me a poem about silicon').length - 1 === 1
280
+ && volatilePrefix.includes('[new footer]') && !volatilePrefix.includes('[old footer]'));
281
+ check('zooming wide again exposes the archive without growing it',
282
+ repaintArchiveView(narrowView.archive, wide).archive === wide);
283
+ }
284
+
285
+ const up = await waitFor(async () => {
286
+ try { return (await fetch(`${base}/api/health`)).ok; } catch { return false; }
287
+ }, 100_000);
288
+ if (!up) throw new Error(`server never came up:\n${log.slice(-1200)}`);
289
+
290
+ // A long logical line must survive ordinary Ghostty reflow. The former
291
+ // screen-carry path cropped the right side before rebuilding a narrower grid.
292
+ {
293
+ const id = await session('long line reflow');
294
+ const v = await view(id, 110, 24, true);
295
+ await sleep(700);
296
+ v.type(`printf 'LONG-%s-END-SENTINEL\\n' "$(printf 'x%.0s' {1..90})"\r`);
297
+ const printed = await waitFor(async () => (await gridText(id)).includes('END-SENTINEL'));
298
+ check('long line is present before resize', printed);
299
+ v.resize(60, 24);
300
+ const resized = await waitFor(() => v.lastFrame('grid')?.cols === 60);
301
+ check('resize settles at the requested geometry', resized, JSON.stringify(v.lastFrame('grid')));
302
+ const after = await gridText(id);
303
+ check('narrowing does not crop the right side of a logical line', after.includes('END-SENTINEL'));
304
+ check('resize does not retransmit all history', v.lastFrame('grid')?.reset === false);
305
+ if (Headless) check('the ordered viewer reflow also retains the line', (await v.screenText()).includes('END-SENTINEL'));
306
+ v.close();
307
+ await stop(id);
308
+ }
309
+
310
+ // The geometry frame must precede output triggered by SIGWINCH. Otherwise a
311
+ // TUI's repaint is interpreted at the old size and only reflowed afterward,
312
+ // which can leave duplicate or displaced rows in a browser emulator.
313
+ {
314
+ const id = await session('ordered repaint');
315
+ const v = await view(id, 100, 30, true);
316
+ await sleep(500);
317
+ v.type(`trap 'printf "\\033[2J\\033[HRESIZE-AT-%sx%s\\n" "$COLUMNS" "$LINES"' WINCH; printf 'TRAP-READY\\n'\r`);
318
+ await waitFor(() => v.bytes.includes('TRAP-READY'));
319
+ v.events.length = 0;
320
+ v.resize(73, 21);
321
+ const repainted = await waitFor(() => v.events.some((event) => event.type === 'data'
322
+ && event.text.includes('RESIZE-AT-73x21')));
323
+ const gridAt = v.events.findIndex((event) => event.type === 'frame'
324
+ && event.frame.t === 'grid' && event.frame.cols === 73 && event.frame.rows === 21);
325
+ const repaintAt = v.events.findIndex((event) => event.type === 'data'
326
+ && event.text.includes('RESIZE-AT-73x21'));
327
+ check('SIGWINCH repaint arrives after the confirmed grid', repainted && gridAt >= 0 && gridAt < repaintAt,
328
+ `grid event ${gridAt}, repaint event ${repaintAt}`);
329
+ if (Headless) {
330
+ const screen = await v.screenText();
331
+ check('ordered repaint is rendered once', screen.split('RESIZE-AT-73x21').length - 1 === 1);
332
+ }
333
+ v.close();
334
+ await stop(id);
335
+ }
336
+
337
+ // Claude-style primary-screen TUIs print a complete frame on every SIGWINCH.
338
+ // Some frames are taller than the pane; streaming those repaint bytes would
339
+ // archive another welcome/conversation copy on every zoom step. Agent resizes
340
+ // therefore settle in a scratch terminal and commit only the final screen.
341
+ {
342
+ const id = await session('captured agent repaint', 'test-repaint');
343
+ const v = await view(id, 150, 40, true);
344
+ const painted = await waitFor(async () => (await gridText(id)).includes('[fixture 150x40]'));
345
+ check('Claude-style fixture paints its initial frame', painted);
346
+ const beforeGrid = duplicateTokens(await gridText(id));
347
+ const initialGridText = await gridText(id);
348
+ const initialBrowserText = Headless ? await v.screenText() : '';
349
+ const beforeBrowser = Headless ? duplicateTokens(initialBrowserText) : 0;
350
+ const initialGridTokens = uniqueTokens(initialGridText);
351
+ const initialBrowserTokens = Headless ? uniqueTokens(initialBrowserText) : 0;
352
+ check('fixture starts without duplicate history', beforeGrid === 0 && beforeBrowser === 0,
353
+ `grid=${beforeGrid}, browser=${beforeBrowser}`);
354
+ if (Headless) {
355
+ check('fixture starts with deep history', initialBrowserText.includes('history-0001'));
356
+ check('browser viewport can scroll through that history', await v.scrollsBack());
357
+ const state = await v.bufferState();
358
+ check('fixture starts at the live bottom', state?.atBottom, JSON.stringify(state));
359
+ check('fixture starts with styled history', state?.coloredHistoryCells > 0,
360
+ JSON.stringify(state));
361
+ }
362
+
363
+ for (const [cols, rows] of [[120, 32], [90, 24], [120, 32], [150, 40]]) {
364
+ v.resize(cols, rows);
365
+ const settled = await waitFor(() => v.lastFrame('grid')?.cols === cols
366
+ && v.lastFrame('grid')?.rows === rows);
367
+ check(`agent repaint settles at ${cols}x${rows}`, settled, JSON.stringify(v.lastFrame('grid')));
368
+ await sleep(150);
369
+ const stepGridText = await gridText(id);
370
+ check(`canonical history stays unique at ${cols}x${rows}`, duplicateTokens(stepGridText) === beforeGrid,
371
+ `${duplicateTokens(stepGridText)} duplicate tokens`);
372
+ if (Headless) {
373
+ const stepBrowserText = await v.screenText();
374
+ check(`browser history stays unique at ${cols}x${rows}`, duplicateTokens(stepBrowserText) === beforeBrowser,
375
+ `${duplicateTokens(stepBrowserText)} duplicate tokens`);
376
+ check(`browser history stays complete at ${cols}x${rows}`,
377
+ uniqueTokens(stepBrowserText) === initialBrowserTokens,
378
+ `${initialBrowserTokens} before, ${uniqueTokens(stepBrowserText)} after`);
379
+ check(`browser deep history survives at ${cols}x${rows}`,
380
+ stepBrowserText.includes('history-0001'));
381
+ const state = await v.bufferState();
382
+ check(`browser reaches both history ends at ${cols}x${rows}`,
383
+ state?.canReachBothEnds, JSON.stringify(state));
384
+ check(`browser remains at the live bottom at ${cols}x${rows}`,
385
+ state?.atBottom, JSON.stringify(state));
386
+ check(`styled history survives at ${cols}x${rows}`,
387
+ state?.coloredHistoryCells > 0, JSON.stringify(state));
388
+ }
389
+ }
390
+
391
+ const finalGridText = await gridText(id);
392
+ const afterGrid = duplicateTokens(finalGridText);
393
+ check('zoom round-trip adds no duplicate frame to canonical history', afterGrid === beforeGrid,
394
+ `${beforeGrid} before, ${afterGrid} after`);
395
+ check('zoom round-trip retains canonical history', uniqueTokens(finalGridText) === initialGridTokens,
396
+ `${initialGridTokens} before, ${uniqueTokens(finalGridText)} after`);
397
+ if (Headless) {
398
+ const finalBrowserText = await v.screenText();
399
+ const afterBrowser = duplicateTokens(finalBrowserText);
400
+ check('zoom round-trip adds no duplicate frame to browser history', afterBrowser === beforeBrowser,
401
+ `${beforeBrowser} before, ${afterBrowser} after`);
402
+ check('zoom round-trip retains browser history', uniqueTokens(finalBrowserText) === initialBrowserTokens,
403
+ `${initialBrowserTokens} before, ${uniqueTokens(finalBrowserText)} after`);
404
+ }
405
+ v.close();
406
+ await sleep(250);
407
+ const reattached = await view(id, 150, 40, true);
408
+ const restored = await waitFor(() => reattached.lastFrame('restore')?.cols === 150);
409
+ const restoredText = Headless ? await reattached.screenText() : '';
410
+ check('reattach remains free of resize-generated frames', restored
411
+ && (!Headless || duplicateTokens(restoredText) === beforeGrid),
412
+ Headless ? `${duplicateTokens(restoredText)} duplicate tokens` : '');
413
+ check('reattach retains complete and deep history', restored
414
+ && (!Headless || (uniqueTokens(restoredText) === initialBrowserTokens
415
+ && restoredText.includes('history-0001'))));
416
+ if (Headless) {
417
+ const state = await reattached.bufferState();
418
+ check('reattach reaches both history ends and starts at bottom',
419
+ state?.canReachBothEnds && state?.atBottom, JSON.stringify(state));
420
+ check('reattach retains styled history', state?.coloredHistoryCells > 0,
421
+ JSON.stringify(state));
422
+ }
423
+ reattached.close();
424
+ await stop(id);
425
+
426
+ // Stopping the PTY models a backend restart: a new host must load its
427
+ // durable checkpoint before the resumed TUI paints, without duplicating
428
+ // either the old frame or its welcome banner.
429
+ const checkpoint = path.join(DATA_DIR, 'state', 'terminal-history', `${id}.json`);
430
+ check('terminal history is checkpointed durably', await waitFor(() => fs.existsSync(checkpoint)));
431
+ // A mobile pane opens its socket with xterm's provisional 80x24 geometry,
432
+ // then reports the measured phone grid as soon as layout settles. Exercise
433
+ // that change while the resumed TUI is still repainting, not only after the
434
+ // startup transaction has committed.
435
+ const restarted = await view(id, 80, 24, true);
436
+ restarted.resize(40, 28);
437
+ const resumed = await waitFor(async () => (await gridText(id)).includes('[fixture 40x28]'));
438
+ const startupCommitted = await waitFor(() => restarted.frames.some((frame) =>
439
+ frame.t === 'grid' && frame.reset));
440
+ await sleep(350);
441
+ const restartedText = Headless ? await restarted.screenText() : '';
442
+ const restartDuplicates = Headless ? duplicateTokens(restartedText) : 0;
443
+ check('host restart restores deep scrollback', resumed
444
+ && (!Headless || restartedText.includes('history-0001')));
445
+ check('delayed startup repaint commits as one transaction', startupCommitted);
446
+ check('host restart does not duplicate delayed repaint content', !Headless
447
+ || restartDuplicates === beforeBrowser,
448
+ Headless ? `${restartDuplicates} duplicate tokens` : '');
449
+ if (Headless) {
450
+ check('host restart retains exact transcript cardinality',
451
+ uniqueTokens(restartedText) === initialBrowserTokens,
452
+ `${initialBrowserTokens} before, ${uniqueTokens(restartedText)} after`);
453
+ const state = await restarted.bufferState();
454
+ check('restored viewport reaches both ends and starts at bottom',
455
+ state?.canReachBothEnds && state?.atBottom, JSON.stringify(state));
456
+ check('host restart retains styled history', state?.coloredHistoryCells > 0,
457
+ JSON.stringify(state));
458
+ }
459
+
460
+ // Exercise the actual failure combination: after a delayed restart, a
461
+ // second viewer takes the geometry lease, zooms narrow, then zooms wide.
462
+ // Every transition must preserve the same transcript and scroll model.
463
+ const takeover = await view(id, 110, 28, true);
464
+ await sleep(150);
465
+ takeover.claim();
466
+ const claimed = await waitFor(() => takeover.lastFrame('grid')?.controller === true
467
+ && takeover.lastFrame('grid')?.cols === 110 && takeover.lastFrame('grid')?.rows === 28);
468
+ check('post-restart watcher claims the narrow zoom geometry', claimed,
469
+ JSON.stringify(takeover.lastFrame('grid')));
470
+ await sleep(150);
471
+ if (Headless) {
472
+ const narrowText = await takeover.screenText();
473
+ const state = await takeover.bufferState();
474
+ check('post-restart narrow zoom keeps one complete transcript',
475
+ duplicateTokens(narrowText) === beforeBrowser
476
+ && uniqueTokens(narrowText) === initialBrowserTokens,
477
+ `${duplicateTokens(narrowText)} duplicates, ${uniqueTokens(narrowText)} tokens`);
478
+ check('post-restart narrow zoom keeps scroll and styling',
479
+ state?.canReachBothEnds && state?.atBottom && state?.coloredHistoryCells > 0,
480
+ JSON.stringify(state));
481
+ }
482
+ takeover.resize(150, 40);
483
+ const widened = await waitFor(() => takeover.lastFrame('grid')?.cols === 150
484
+ && takeover.lastFrame('grid')?.rows === 40);
485
+ check('post-restart controller zooms wide again', widened,
486
+ JSON.stringify(takeover.lastFrame('grid')));
487
+ await sleep(150);
488
+ if (Headless) {
489
+ const wideText = await takeover.screenText();
490
+ const state = await takeover.bufferState();
491
+ check('post-restart wide zoom keeps one complete transcript',
492
+ duplicateTokens(wideText) === beforeBrowser
493
+ && uniqueTokens(wideText) === initialBrowserTokens,
494
+ `${duplicateTokens(wideText)} duplicates, ${uniqueTokens(wideText)} tokens`);
495
+ check('post-restart wide zoom keeps scroll and styling',
496
+ state?.canReachBothEnds && state?.atBottom && state?.coloredHistoryCells > 0,
497
+ JSON.stringify(state));
498
+ }
499
+ takeover.close();
500
+ restarted.close();
501
+ await stop(id);
502
+ }
503
+
504
+ // A drag should issue one final resize, not one SIGWINCH per animation frame.
505
+ {
506
+ const id = await session('resize storm');
507
+ const v = await view(id, 120, 40);
508
+ await sleep(400);
509
+ for (let i = 0; i < 16; i++) {
510
+ v.resize(120 - i * 2, 40 - i);
511
+ await sleep(25);
512
+ }
513
+ v.resize(88, 24);
514
+ const settled = await waitFor(() => v.lastFrame('grid')?.cols === 88 && v.lastFrame('grid')?.rows === 24);
515
+ check('resize storm settles at its final size', settled, JSON.stringify(v.lastFrame('grid')));
516
+ check('resize storm is coalesced', v.countFrames('grid') <= 3, `${v.countFrames('grid')} grid frames`);
517
+ v.close();
518
+ await stop(id);
519
+ }
520
+
521
+ // Low zoom on a large/high-DPI panel can legitimately exceed the former
522
+ // 400x200 cap. The safety bound must not create dead space in that range.
523
+ {
524
+ const id = await session('large panel');
525
+ const v = await view(id, 120, 40);
526
+ await sleep(300);
527
+ v.resize(640, 300);
528
+ const expanded = await waitFor(() => v.lastFrame('grid')?.cols === 640
529
+ && v.lastFrame('grid')?.rows === 300);
530
+ check('large low-zoom panel is not clipped to the legacy grid cap', expanded,
531
+ JSON.stringify(v.lastFrame('grid')));
532
+ v.close();
533
+ await stop(id);
534
+ }
535
+
536
+ // Reattachment is serialized from Ghostty state, not from a 256 KiB suffix
537
+ // of raw PTY traffic. Both the oldest and newest retained rows must return.
538
+ {
539
+ const id = await session('full canonical restore');
540
+ const a = await view(id, 110, 30);
541
+ await sleep(600);
542
+ a.type(`for i in $(seq 1 4500); do printf 'RESTORE-%04d-%070d\\n' "$i" "$i"; done\r`);
543
+ const complete = await waitFor(async () => (await gridText(id, 5000)).includes('RESTORE-4500-'), 20_000);
544
+ check('large history finishes printing', complete);
545
+ a.close();
546
+ await sleep(300);
547
+ const b = await view(id, 110, 30, true);
548
+ const restored = await waitFor(() => b.bytes.includes('RESTORE-4500-'), 12_000);
549
+ check('reattach receives the canonical restore', restored && !!b.lastFrame('restore'));
550
+ check('restore is not capped at the old raw replay limit', b.bytes.length > 262_144, `${b.bytes.length} bytes`);
551
+ check('restore includes the oldest retained output', b.bytes.includes('RESTORE-0001-'));
552
+ check('restore includes the newest retained output', b.bytes.includes('RESTORE-4500-'));
553
+ if (Headless) {
554
+ const rendered = await b.screenText();
555
+ check('a fresh emulator can scroll to the oldest restored output', rendered.includes('RESTORE-0001-'));
556
+ check('a fresh emulator contains the newest restored output', rendered.includes('RESTORE-4500-'));
557
+ }
558
+ b.close();
559
+ await stop(id);
560
+ }
561
+
562
+ // One viewer owns both input and size. A watcher has no effect until an
563
+ // explicit claim, after which the old controller becomes inert.
564
+ {
565
+ const id = await session('controller lease');
566
+ const a = await view(id, 120, 35);
567
+ await sleep(400);
568
+ const b = await view(id, 60, 20);
569
+ await sleep(400);
570
+ check('first viewer is the controller', a.lastFrame('grid')?.controller === true
571
+ && b.lastFrame('restore')?.controller === false);
572
+ b.resize(50, 15);
573
+ await sleep(400);
574
+ check('watcher size cannot shrink the session', b.lastFrame('restore')?.cols === 120
575
+ && !b.frames.some((frame) => frame.t === 'grid' && frame.cols === 50));
576
+ b.type('echo WATCHER-MUST-NOT-RUN\r');
577
+ await sleep(400);
578
+ check('watcher input is rejected by the server', !a.bytes.includes('WATCHER-MUST-NOT-RUN'));
579
+
580
+ b.claim();
581
+ const claimed = await waitFor(() => b.lastFrame('grid')?.controller === true
582
+ && b.lastFrame('grid')?.cols === 50 && b.lastFrame('grid')?.rows === 15);
583
+ check('watcher can claim input and geometry together', claimed, JSON.stringify(b.lastFrame('grid')));
584
+ b.type('echo CONTROLLER-RAN\r');
585
+ check('new controller input reaches every viewer', await waitFor(() => a.bytes.includes('CONTROLLER-RAN')
586
+ && b.bytes.includes('CONTROLLER-RAN')));
587
+ a.resize(140, 45);
588
+ await sleep(400);
589
+ check('old controller becomes an inert watcher', b.lastFrame('grid')?.cols === 50
590
+ && b.lastFrame('grid')?.rows === 15);
591
+
592
+ b.close();
593
+ const handed = await waitFor(() => a.lastFrame('grid')?.controller === true
594
+ && a.lastFrame('grid')?.cols === 140 && a.lastFrame('grid')?.rows === 45);
595
+ check('controller lease passes to a remaining viewer on disconnect', handed, JSON.stringify(a.lastFrame('grid')));
596
+ a.close();
597
+ await stop(id);
598
+ }
599
+
600
+ // Focus and unrelated layout events may re-report the same preference.
601
+ {
602
+ const id = await session('no-op size');
603
+ const v = await view(id, 80, 24);
604
+ await sleep(300);
605
+ const before = v.countFrames('grid');
606
+ for (let i = 0; i < 5; i++) v.resize(80, 24);
607
+ await sleep(500);
608
+ check('re-reporting the current size performs no reset', v.countFrames('grid') === before,
609
+ `${v.countFrames('grid') - before} extra frames`);
610
+ v.close();
611
+ await stop(id);
612
+ }
613
+ } catch (err) {
614
+ check('no exceptions', false, String(err && err.message ? err.message : err));
615
+ console.log('--- server log tail ---\n' + log.slice(-1600));
616
+ } finally {
617
+ srv.kill('SIGTERM');
618
+ await sleep(500);
619
+ srv.kill('SIGKILL');
620
+ try { fs.rmSync(DATA_DIR, { recursive: true, force: true }); } catch {}
621
+ console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
622
+ process.exit(failures ? 1 : 0);
623
+ }
server/src/config.js CHANGED
@@ -46,16 +46,15 @@ export function refreshVersions() {
46
  }
47
  }
48
 
49
- export const TMUX_AVAILABLE = commandExists('tmux');
50
- export const USE_TMUX = process.env.USE_TMUX
51
- ? process.env.USE_TMUX === '1'
52
- : TMUX_AVAILABLE;
53
 
54
  /**
55
  * CLI catalog.
56
  * - `bin` : binary checked on PATH to report availability
57
  * - `run` : command run inside the session shell on first launch
58
  * - `cont` : command used to resume a prior conversation (null = no resume)
 
59
  */
60
  // One setup story for every agent; only the accepted secret names differ.
61
  const setupHint = (...keys) =>
@@ -81,23 +80,27 @@ export const CLIS = [
81
  // nothing to launch HERE (that is the point), but unlike files/trace it holds
82
  // a conversation, so it gets a light and a digest like any other agent.
83
  { id: 'remote', label: 'Remote agent', bin: null, color: '#5ec2e0', run: null, cont: null },
84
- { id: 'claude', label: 'Claude Code', bin: 'claude', color: '#d97757', run: 'claude', cont: 'claude --continue',
85
  withPrompt: (q) => `claude ${q}`,
86
  setup: setupHint('ANTHROPIC_API_KEY') },
87
- { id: 'codex', label: 'Codex', bin: 'codex', color: '#5eb6a6', run: 'codex', cont: 'codex resume --last',
88
  withPrompt: (q) => `codex ${q}`,
89
  setup: setupHint('OPENAI_API_KEY') },
90
- { id: 'gemini', label: 'Gemini CLI', bin: 'gemini', color: '#4796e3', run: 'gemini', cont: null,
91
  withPrompt: (q) => `gemini -i ${q}`, // -i = interactive session seeded with the prompt
92
  setup: setupHint('GEMINI_API_KEY') },
93
- { id: 'opencode', label: 'opencode', bin: 'opencode', color: '#8a93a0', run: 'opencode', cont: 'opencode --continue',
94
  withPrompt: (q) => `opencode --prompt ${q}`,
95
  setup: setupHint('ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'OPENROUTER_API_KEY') },
96
- { id: 'hermes', label: 'Hermes', bin: 'hermes', color: '#a78bfa', run: 'hermes', cont: 'hermes -c',
97
  setup: setupHint('HF_TOKEN', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY', 'NOUS_API_KEY') },
98
  // `chat` = TUI in --local mode: embedded agent, no gateway/daemon needed.
99
- { id: 'openclaw', label: 'OpenClaw', bin: 'openclaw', color: '#c83636', run: 'openclaw chat', cont: null,
100
  setup: setupHint('ANTHROPIC_API_KEY') },
 
 
 
 
101
  ];
102
 
103
  export function cliById(id) {
 
46
  }
47
  }
48
 
49
+ // tmux is no longer part of the terminal path: sessions are PTYs held by this
50
+ // process with a libghostty-vt grid as the authoritative screen (see runner.js).
 
 
51
 
52
  /**
53
  * CLI catalog.
54
  * - `bin` : binary checked on PATH to report availability
55
  * - `run` : command run inside the session shell on first launch
56
  * - `cont` : command used to resume a prior conversation (null = no resume)
57
+ * - `resizeMode: 'repaint'`: primary-screen TUI redraws its frame on SIGWINCH
58
  */
59
  // One setup story for every agent; only the accepted secret names differ.
60
  const setupHint = (...keys) =>
 
80
  // nothing to launch HERE (that is the point), but unlike files/trace it holds
81
  // a conversation, so it gets a light and a digest like any other agent.
82
  { id: 'remote', label: 'Remote agent', bin: null, color: '#5ec2e0', run: null, cont: null },
83
+ { id: 'claude', label: 'Claude Code', bin: 'claude', color: '#d97757', run: 'claude', cont: 'claude --continue', resizeMode: 'repaint',
84
  withPrompt: (q) => `claude ${q}`,
85
  setup: setupHint('ANTHROPIC_API_KEY') },
86
+ { id: 'codex', label: 'Codex', bin: 'codex', color: '#5eb6a6', run: 'codex', cont: 'codex resume --last', resizeMode: 'repaint',
87
  withPrompt: (q) => `codex ${q}`,
88
  setup: setupHint('OPENAI_API_KEY') },
89
+ { id: 'gemini', label: 'Gemini CLI', bin: 'gemini', color: '#4796e3', run: 'gemini', cont: null, resizeMode: 'repaint',
90
  withPrompt: (q) => `gemini -i ${q}`, // -i = interactive session seeded with the prompt
91
  setup: setupHint('GEMINI_API_KEY') },
92
+ { id: 'opencode', label: 'opencode', bin: 'opencode', color: '#8a93a0', run: 'opencode', cont: 'opencode --continue', resizeMode: 'repaint',
93
  withPrompt: (q) => `opencode --prompt ${q}`,
94
  setup: setupHint('ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'OPENROUTER_API_KEY') },
95
+ { id: 'hermes', label: 'Hermes', bin: 'hermes', color: '#a78bfa', run: 'hermes', cont: 'hermes -c', resizeMode: 'repaint',
96
  setup: setupHint('HF_TOKEN', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY', 'NOUS_API_KEY') },
97
  // `chat` = TUI in --local mode: embedded agent, no gateway/daemon needed.
98
+ { id: 'openclaw', label: 'OpenClaw', bin: 'openclaw', color: '#c83636', run: 'openclaw chat', cont: null, resizeMode: 'repaint',
99
  setup: setupHint('ANTHROPIC_API_KEY') },
100
+ // Test-only primary-screen repaint fixture. Absent from every normal runtime.
101
+ ...(process.env.AM_TEST_REPAINT_CMD
102
+ ? [{ id: 'test-repaint', label: 'Repaint fixture', bin: 'bash', color: '#8aa0ad', run: process.env.AM_TEST_REPAINT_CMD, cont: null, resizeMode: 'repaint' }]
103
+ : []),
104
  ];
105
 
106
  export function cliById(id) {
server/src/demo.js CHANGED
@@ -1,7 +1,7 @@
1
  // Demo mode: a pure view filter. Activating snapshots the session and group IDs
2
  // that exist right now and hides them from the sidebar/overview, so the Space
3
  // reads like a fresh install (empty workspace + welcome). Nothing is deleted —
4
- // tmux sessions keep running, logins and secrets stay valid — and anything
5
  // created *after* activation shows through (it isn't in the snapshot).
6
  // Deactivating clears the snapshot and every real session reappears untouched.
7
  import fs from 'fs';
 
1
  // Demo mode: a pure view filter. Activating snapshots the session and group IDs
2
  // that exist right now and hides them from the sidebar/overview, so the Space
3
  // reads like a fresh install (empty workspace + welcome). Nothing is deleted —
4
+ // live sessions keep running, logins and secrets stay valid — and anything
5
  // created *after* activation shows through (it isn't in the snapshot).
6
  // Deactivating clears the snapshot and every real session reappears untouched.
7
  import fs from 'fs';
server/src/history-store.js ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const fileFor = (directory, id) => path.join(
5
+ directory, `${String(id).replace(/[^a-zA-Z0-9._-]/g, '_')}.json`,
6
+ );
7
+
8
+ export const TERMINAL_HISTORY_VERSION = 8;
9
+
10
+ const validAnsi = (ansi, text) => typeof ansi === 'string'
11
+ && ansi.replace(/\x1b\[[0-9;]*m/g, '') === text;
12
+
13
+ /** Load a plain-text Ghostty scrollback checkpoint, ignoring old/bad schemas. */
14
+ export function loadTerminalHistory(directory, id) {
15
+ try {
16
+ const body = fs.readFileSync(fileFor(directory, id), 'utf8');
17
+ const saved = JSON.parse(body);
18
+ if (![1, 2, 3, 4, 5, 6, 7, TERMINAL_HISTORY_VERSION].includes(saved?.version)
19
+ || !Number.isFinite(saved.cols) || !Array.isArray(saved.lines)) return null;
20
+ const lines = saved.lines.flatMap((line) => {
21
+ if (typeof line === 'string') return [{ text: line }];
22
+ if (!line || typeof line.text !== 'string') return [];
23
+ return [validAnsi(line.ansi, line.text)
24
+ ? { text: line.text, ansi: line.ansi } : { text: line.text }];
25
+ });
26
+ return lines.length ? {
27
+ version: saved.version, cols: Math.max(1, Math.round(saved.cols)), lines, body,
28
+ } : null;
29
+ } catch { return null; }
30
+ }
31
+
32
+ /**
33
+ * Debounced, atomic checkpoint writer for one held terminal.
34
+ *
35
+ * `snapshot` is deliberately injected: storage knows nothing about Ghostty or
36
+ * session hosts. `blocked` keeps an in-flight repaint transaction from saving
37
+ * a pre-commit grid. At most one write is active; a newer snapshot supersedes
38
+ * any queued one.
39
+ */
40
+ export function createTerminalHistoryCheckpoint({
41
+ directory, id, delayMs, snapshot, blocked, persistedBody = null,
42
+ }) {
43
+ let timer = null;
44
+ let pending = null;
45
+ let writing = false;
46
+ let lastBody = persistedBody;
47
+
48
+ const writePending = () => {
49
+ if (writing || !pending) return;
50
+ const next = pending;
51
+ pending = null;
52
+ if (next === lastBody) return;
53
+ writing = true;
54
+ const target = fileFor(directory, id);
55
+ const temporary = `${target}.${process.pid}.tmp`;
56
+ fs.promises.mkdir(directory, { recursive: true })
57
+ .then(() => fs.promises.writeFile(temporary, next))
58
+ .then(() => fs.promises.rename(temporary, target))
59
+ .then(() => { lastBody = next; })
60
+ .catch((error) => {
61
+ console.error('[runner] history checkpoint', error && error.message);
62
+ fs.promises.unlink(temporary).catch(() => {});
63
+ })
64
+ .finally(() => {
65
+ writing = false;
66
+ if (pending) writePending();
67
+ });
68
+ };
69
+
70
+ const schedule = () => {
71
+ if (timer) return;
72
+ timer = setTimeout(flush, delayMs);
73
+ if (timer.unref) timer.unref();
74
+ };
75
+
76
+ const flush = () => {
77
+ if (timer) { clearTimeout(timer); timer = null; }
78
+ if (blocked()) { schedule(); return; }
79
+ let snap;
80
+ try { snap = snapshot(); } catch { return; }
81
+ const lines = (snap.scrollbackLines || []).map((line) => {
82
+ const text = line.text || '';
83
+ return validAnsi(line.ansi, text) ? { text, ansi: line.ansi } : text;
84
+ });
85
+ pending = JSON.stringify({ version: TERMINAL_HISTORY_VERSION, cols: snap.cols, lines });
86
+ writePending();
87
+ };
88
+
89
+ return { schedule, flush };
90
+ }
91
+
92
+ const normalText = (text) => String(text || '').replace(/\s+/g, ' ').trim().toLowerCase();
93
+
94
+ /**
95
+ * Render earlier conversational turns as plain terminal history.
96
+ *
97
+ * The latest user turn is deliberately omitted: the resumed TUI owns that
98
+ * viewport. Messages already visible in the live grid are omitted as well, so
99
+ * trace recovery cannot create a second copy of content Claude did repaint.
100
+ */
101
+ export function traceHistoryLines(page, currentText = '', maxChars = 1024 * 1024) {
102
+ const turns = Array.isArray(page?.turns) ? page.turns : [];
103
+ let latestUser = -1;
104
+ for (let i = turns.length - 1; i >= 0; i--) {
105
+ if (turns[i]?.role === 'user') { latestUser = i; break; }
106
+ }
107
+ const current = normalText(currentText);
108
+ const currentLines = new Set(String(currentText || '').split(/\r?\n/)
109
+ .map(normalText).filter(Boolean));
110
+ const lines = [];
111
+ let chars = 0;
112
+ for (const turn of turns.slice(0, latestUser < 0 ? turns.length : latestUser)) {
113
+ if (turn?.role !== 'user' && turn?.role !== 'assistant') continue;
114
+ if (turn.role === 'assistant' && turn.kind && turn.kind !== 'final') continue;
115
+ const text = (turn.blocks || [])
116
+ .filter((block) => block?.type === 'text')
117
+ .map((block) => String(block.text || ''))
118
+ .join('\n')
119
+ .trim();
120
+ if (!text) continue;
121
+ const rendered = `${turn.role === 'user' ? '❯' : '●'} ${text}`;
122
+ // Prefix matching needs a minimum length to avoid accidental prose hits,
123
+ // but short prompts are safe to match as complete marked terminal rows.
124
+ if (!rendered.includes('\n') && currentLines.has(normalText(rendered))) continue;
125
+ const normalized = normalText(text);
126
+ const probe = normalized.slice(0, 80);
127
+ if (probe.length >= 12 && current.includes(probe)) continue;
128
+ const next = [...rendered.split('\n'), ''];
129
+ for (const line of next) { lines.push(line); chars += line.length + 1; }
130
+ while (chars > maxChars && lines.length) chars -= lines.shift().length + 1;
131
+ }
132
+ while (lines.at(-1) === '') lines.pop();
133
+ return lines.map((text) => ({ text }));
134
+ }
server/src/index.js CHANGED
@@ -8,7 +8,7 @@ import crypto from 'node:crypto';
8
  import express from 'express';
9
  import { WebSocketServer } from 'ws';
10
  import {
11
- PORT, PUBLIC_DIR, DATA_DIR, WORKSPACES_DIR, SKILLS_DIR, USE_TMUX, TMUX_AVAILABLE,
12
  ensureDirs, cliCatalog, cliById, slugify, workspacePath, refreshVersions, PASSIVE_CLIS, isRemote,
13
  } from './config.js';
14
  import * as remote from './remote.js';
@@ -16,7 +16,12 @@ import * as store from './sessions.js';
16
  import * as groups from './groups.js';
17
  import * as order from './order.js';
18
  import * as demo from './demo.js';
19
- import { attach, agentInfo, deriveState, stop, ensureRunning, sendInput, copySelection, paneModes, isRunning, capturePane } from './runner.js';
 
 
 
 
 
20
  import { buildUsage } from './usage.js';
21
  import { buildTraces, traceDigests, digestFor, traceLocation, readTrace, readTraceBundle, readTraceByPath, traceHarnessOf } from './traces.js';
22
  import { initPush, publicKey, deviceCount, addSubscription, removeSubscription, sendToAll } from './push.js';
@@ -130,6 +135,12 @@ await Promise.race([
130
  // terminal. An unhandled rejection from an async route, or an error emitted by
131
  // a dropped socket, would otherwise take the whole thing down. Log and keep
132
  // running instead of exiting.
 
 
 
 
 
 
133
  process.on('unhandledRejection', (e) => console.error('[unhandledRejection]', e));
134
  process.on('uncaughtException', (e) => console.error('[uncaughtException]', e));
135
 
@@ -150,7 +161,7 @@ app.use((req, res, next) => {
150
  app.get('/api/visibility', (_req, res) => res.json(visibility()));
151
 
152
  app.get('/api/health', (_req, res) =>
153
- res.json({ ok: true, tmux: USE_TMUX, tmuxAvailable: TMUX_AVAILABLE }));
154
 
155
  app.get('/api/clis', (_req, res) => res.json(cliCatalog()));
156
 
@@ -248,8 +259,8 @@ async function deliver(session, text, from) {
248
  }
249
 
250
  // Type a prompt into a session's terminal from the Overview — no pane needed.
251
- // If the agent is stopped, wake it first (detached tmux + resume) and give the
252
- // CLI a moment to boot before the keystrokes land.
253
  app.post('/api/sessions/:id/input', async (req, res) => {
254
  const s = store.get(req.params.id);
255
  if (!s) return res.status(404).json({ error: 'not found' });
@@ -267,9 +278,8 @@ app.post('/api/sessions/:id/input', async (req, res) => {
267
  // ---------- agent-to-agent API (/api/agents) ----------
268
  // Agents coordinate through the same primitives the operator drives from the
269
  // Overview: read the roster, watch a pane, send a prompt, wait, launch a peer,
270
- // stop one. This adds no capability every agent in this container can already
271
- // `tmux send-keys` at its neighbours — it makes the capability legible,
272
- // attributed, and stable enough to document in the environment skill.
273
  //
274
  // The etiquette (check state before interrupting, don't ping-pong, don't stop
275
  // someone unasked, don't spawn armies) is TAUGHT in that skill rather than
@@ -707,7 +717,8 @@ app.get('/api/info', (_req, res) => res.json({
707
  home: process.env.HOME || null,
708
  spaceId: process.env.SPACE_ID || null,
709
  spaceHost: process.env.SPACE_HOST || null,
710
- tmux: USE_TMUX,
 
711
  locked: isPublic(),
712
  lockReason: visibility().reason,
713
  lockBucket: visibility().bucket,
@@ -972,7 +983,7 @@ Hermes — alongside plain shells and a file browser.
972
  ## What persists (and what doesn't)
973
  - \`/data\` is **durable storage** (a mounted bucket). Files under \`/data/workspaces/…\` and \`/data/home/…\` survive restarts and sleep.
974
  - **Empty directories are not persisted** — only files. If a folder must exist, keep a file in it.
975
- - Sessions are **tmux-backed**: they keep running when the browser disconnects and can be resumed after the Space sleeps.
976
  - Exception: OpenClaw runs with its own \`$HOME\` on local disk for filesystem compatibility; that state is backed up to the bucket every minute.
977
 
978
  ## You may not be alone
@@ -2136,77 +2147,54 @@ wss.on('connection', (ws, req) => {
2136
  return;
2137
  }
2138
 
2139
- // tmux prints a bare "[exited]" line as its parting output — strip it (the
2140
- // client shows a styled stopped-state instead) but pass everything else.
2141
- const stripExit = (d) => (typeof d === 'string' ? d.replace(/(\r?\n)?\[exited\](\r?\n)?$/, '') : d);
2142
  handle.onData((d) => {
2143
  if (ws.readyState !== ws.OPEN) return;
2144
- const out = stripExit(d);
2145
- if (out.length) ws.send(out);
2146
  });
2147
  handle.onExit(() => {
 
 
 
2148
  if (ws.readyState === ws.OPEN) {
2149
- // Our tmux client can die for two very different reasons, and the browser
2150
- // must react differently to each:
2151
- // 4001 = another device attached and tmux's -D detached us. The session
2152
- // is alive and now belongs to that device, so this client goes
2153
- // quiet instead of reconnecting (that would be a tug-of-war).
2154
- // 4000 = the agent process itself exited. Do NOT auto-reconnect (it
2155
- // would respawn the agent in a loop); offer a Restart instead.
2156
- const handedOff = USE_TMUX && isRunning(session.id);
2157
- try {
2158
- ws.close(handedOff ? 4001 : 4000, handedOff ? 'handedoff' : 'exited');
2159
- } catch { ws.close(); }
2160
  }
2161
  });
 
 
 
 
 
 
 
2162
 
 
 
 
 
2163
  ws.on('message', (raw) => {
2164
  let msg;
2165
  try { msg = JSON.parse(raw.toString()); } catch { return; }
2166
  if (msg.t === 'i') handle.write(msg.d);
2167
  else if (msg.t === 'r') handle.resize(msg.cols, msg.rows);
2168
- else if (msg.t === 'copy') {
2169
- const osc52 = copySelection(session.id);
2170
- if (osc52 && ws.readyState === ws.OPEN) ws.send(osc52);
2171
- }
2172
  });
2173
 
2174
- // Register for copy-mode notifications (the frontend shows an overlay hint).
2175
- registerModeClient(session.id, ws);
2176
- ws.on('close', () => { handle.kill(); unregisterModeClient(session.id, ws); });
2177
- });
2178
-
2179
- // Copy-mode indicator: tmux owns the mode (users scroll into it and find the
2180
- // keyboard "dead"), so poll it — one call for all panes — and push changes to
2181
- // attached clients as a control frame the terminal distinguishes from raw
2182
- // output by its leading NUL sentinel. Only polls while clients are attached.
2183
- const MODE_CTRL = '\x00\x00AM:'; // pty output never leads with this
2184
- const modeClients = new Map(); // sessionId -> Set(ws)
2185
- const lastMode = new Map(); // sessionId -> boolean
2186
- let modeTimer = null;
2187
- function registerModeClient(id, ws) {
2188
- if (!USE_TMUX) return;
2189
- if (!modeClients.has(id)) modeClients.set(id, new Set());
2190
- modeClients.get(id).add(ws);
2191
- // Send current state immediately so a client attaching mid-copy-mode is right.
2192
- try { ws.send(MODE_CTRL + JSON.stringify({ t: 'mode', copy: !!lastMode.get(id) })); } catch {}
2193
- if (!modeTimer) modeTimer = setInterval(pollModes, 350);
2194
- }
2195
- function unregisterModeClient(id, ws) {
2196
- const set = modeClients.get(id);
2197
- if (set) { set.delete(ws); if (!set.size) { modeClients.delete(id); lastMode.delete(id); } }
2198
- if (!modeClients.size && modeTimer) { clearInterval(modeTimer); modeTimer = null; }
2199
- }
2200
- function pollModes() {
2201
- const modes = paneModes();
2202
- for (const [id, set] of modeClients) {
2203
- const now = !!modes[id];
2204
- if (lastMode.get(id) === now) continue;
2205
- lastMode.set(id, now);
2206
- const frame = MODE_CTRL + JSON.stringify({ t: 'mode', copy: now });
2207
- for (const ws of set) if (ws.readyState === ws.OPEN) { try { ws.send(frame); } catch {} }
2208
  }
2209
- }
 
 
 
2210
 
2211
  generateEnvSkill(loadSecretNotes()); // keep the environment skill current on boot
2212
 
@@ -2223,7 +2211,7 @@ setTimeout(() => { traceDigests().catch(() => {}); }, 4000);
2223
  startWatchdog();
2224
 
2225
  server.listen(PORT, () => {
2226
- console.log(`Agent Manager :${PORT} tmux=${USE_TMUX} data=${DATA_DIR}`);
2227
  console.log('⚠ No authentication: this app trusts whoever can reach it.');
2228
  console.log(' Keep this Space PRIVATE — a public instance gives anyone a shell + your logged-in agents.');
2229
  });
 
8
  import express from 'express';
9
  import { WebSocketServer } from 'ws';
10
  import {
11
+ PORT, PUBLIC_DIR, DATA_DIR, WORKSPACES_DIR, SKILLS_DIR,
12
  ensureDirs, cliCatalog, cliById, slugify, workspacePath, refreshVersions, PASSIVE_CLIS, isRemote,
13
  } from './config.js';
14
  import * as remote from './remote.js';
 
16
  import * as groups from './groups.js';
17
  import * as order from './order.js';
18
  import * as demo from './demo.js';
19
+ import { attach, agentInfo, deriveState, stop, stopAll, ensureRunning, sendInput, isRunning, capturePane, ghosttyReady, ghosttyError } from './runner.js';
20
+
21
+ // Control frames ride the terminal socket behind a leading NUL pair, which real
22
+ // PTY output never begins with. Same sentinel the old copy-mode hint used, so the
23
+ // frontend's framing is unchanged.
24
+ const TERM_CTRL = '\x00\x00AM:';
25
  import { buildUsage } from './usage.js';
26
  import { buildTraces, traceDigests, digestFor, traceLocation, readTrace, readTraceBundle, readTraceByPath, traceHarnessOf } from './traces.js';
27
  import { initPush, publicKey, deviceCount, addSubscription, removeSubscription, sendToAll } from './push.js';
 
135
  // terminal. An unhandled rejection from an async route, or an error emitted by
136
  // a dropped socket, would otherwise take the whole thing down. Log and keep
137
  // running instead of exiting.
138
+ // No tmux to outlive us any more: kill the PTYs we hold on the way out so a
139
+ // restart can't leave orphaned agents writing into the workspace.
140
+ for (const sig of ['SIGTERM', 'SIGINT']) {
141
+ process.on(sig, () => { try { stopAll(); } catch {} process.exit(0); });
142
+ }
143
+
144
  process.on('unhandledRejection', (e) => console.error('[unhandledRejection]', e));
145
  process.on('uncaughtException', (e) => console.error('[uncaughtException]', e));
146
 
 
161
  app.get('/api/visibility', (_req, res) => res.json(visibility()));
162
 
163
  app.get('/api/health', (_req, res) =>
164
+ res.json({ ok: true, engine: 'libghostty', ghostty: ghosttyReady(), ghosttyError }));
165
 
166
  app.get('/api/clis', (_req, res) => res.json(cliCatalog()));
167
 
 
259
  }
260
 
261
  // Type a prompt into a session's terminal from the Overview — no pane needed.
262
+ // If the agent is stopped, start its backend PTY and give the resumed CLI a
263
+ // moment to boot before the keystrokes land.
264
  app.post('/api/sessions/:id/input', async (req, res) => {
265
  const s = store.get(req.params.id);
266
  if (!s) return res.status(404).json({ error: 'not found' });
 
278
  // ---------- agent-to-agent API (/api/agents) ----------
279
  // Agents coordinate through the same primitives the operator drives from the
280
  // Overview: read the roster, watch a pane, send a prompt, wait, launch a peer,
281
+ // stop one. This makes container-local coordination legible, attributed, and
282
+ // stable enough to document in the environment skill.
 
283
  //
284
  // The etiquette (check state before interrupting, don't ping-pong, don't stop
285
  // someone unasked, don't spawn armies) is TAUGHT in that skill rather than
 
717
  home: process.env.HOME || null,
718
  spaceId: process.env.SPACE_ID || null,
719
  spaceHost: process.env.SPACE_HOST || null,
720
+ engine: 'libghostty',
721
+ ghostty: ghosttyReady(),
722
  locked: isPublic(),
723
  lockReason: visibility().reason,
724
  lockBucket: visibility().bucket,
 
983
  ## What persists (and what doesn't)
984
  - \`/data\` is **durable storage** (a mounted bucket). Files under \`/data/workspaces/…\` and \`/data/home/…\` survive restarts and sleep.
985
  - **Empty directories are not persisted** — only files. If a folder must exist, keep a file in it.
986
+ - Sessions are held by the Agent Manager backend and keep running when the browser disconnects. A backend restart or Space sleep ends live processes; retained workspace files still persist.
987
  - Exception: OpenClaw runs with its own \`$HOME\` on local disk for filesystem compatibility; that state is backed up to the bucket every minute.
988
 
989
  ## You may not be alone
 
2147
  return;
2148
  }
2149
 
 
 
 
2150
  handle.onData((d) => {
2151
  if (ws.readyState !== ws.OPEN) return;
2152
+ if (d.length) ws.send(d);
 
2153
  });
2154
  handle.onExit(() => {
2155
+ // Only ONE reason to close now: the agent process itself exited. A second
2156
+ // viewer does not detach the first; it starts as a watcher instead. Still no
2157
+ // auto-reconnect on 4000, or we would respawn the agent in a loop.
2158
  if (ws.readyState === ws.OPEN) {
2159
+ try { ws.close(4000, 'exited'); } catch { ws.close(); }
 
 
 
 
 
 
 
 
 
 
2160
  }
2161
  });
2162
+ // A session has one canonical grid and one viewer controls its dimensions.
2163
+ // Watchers still report their preferred size so taking control is immediate.
2164
+ // `reset` means an authoritative Ghostty snapshot follows this frame.
2165
+ handle.onGrid((cols_, rows_, controller, viewers, reset) => {
2166
+ if (ws.readyState !== ws.OPEN) return;
2167
+ try { ws.send(TERM_CTRL + JSON.stringify({ t: 'grid', cols: cols_, rows: rows_, controller, viewers, reset })); } catch {}
2168
+ });
2169
 
2170
+ // Register input before sending the initial restore. Browsers may issue a
2171
+ // claim/resize from `onopen`, while this function is still serializing that
2172
+ // restore. Installing the listener afterwards left a small window where the
2173
+ // first mobile geometry request was silently lost.
2174
  ws.on('message', (raw) => {
2175
  let msg;
2176
  try { msg = JSON.parse(raw.toString()); } catch { return; }
2177
  if (msg.t === 'i') handle.write(msg.d);
2178
  else if (msg.t === 'r') handle.resize(msg.cols, msg.rows);
2179
+ else if (msg.t === 'claim') handle.claim();
 
 
 
2180
  });
2181
 
2182
+ // Ghostty owns the durable terminal model. Reattachment receives one canonical
2183
+ // serialization of its retained scrollback and current styled screen.
2184
+ const restore = handle.restore();
2185
+ if (restore) {
2186
+ try {
2187
+ ws.send(TERM_CTRL + JSON.stringify({
2188
+ t: 'restore', cols: restore.cols, rows: restore.rows,
2189
+ viewers: restore.viewers, controller: restore.controller, reset: true,
2190
+ }));
2191
+ ws.send(restore.ansi);
2192
+ } catch {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2193
  }
2194
+
2195
+ // Detaching a viewer, NOT stopping the session.
2196
+ ws.on('close', () => handle.kill());
2197
+ });
2198
 
2199
  generateEnvSkill(loadSecretNotes()); // keep the environment skill current on boot
2200
 
 
2211
  startWatchdog();
2212
 
2213
  server.listen(PORT, () => {
2214
+ console.log(`Agent Manager :${PORT} engine=libghostty${ghosttyReady() ? '' : ' (UNAVAILABLE)'} data=${DATA_DIR}`);
2215
  console.log('⚠ No authentication: this app trusts whoever can reach it.');
2216
  console.log(' Keep this Space PRIVATE — a public instance gives anyone a shell + your logged-in agents.');
2217
  });
server/src/runner.js CHANGED
@@ -1,12 +1,31 @@
1
  import os from 'node:os';
2
  import path from 'node:path';
3
  import pty from 'node-pty';
4
- import { execFileSync } from 'node:child_process';
5
  import fs from 'node:fs';
6
- import { USE_TMUX, cliById, WORKSPACES_DIR, isRemote } from './config.js';
7
- import { update, list } from './sessions.js';
8
- import { captureOpencodeSession } from './traces.js';
9
  import { remoteState, setPaused } from './remote.js';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  const TERM_ENV = {
12
  ...process.env,
@@ -16,18 +35,74 @@ const TERM_ENV = {
16
  };
17
 
18
  const BASHRC = process.env.AM_BASHRC || '/app/session.bashrc';
19
- const TMUX_CONF = process.env.TMUX_CONF || '/app/tmux.conf';
20
  const AM_USER = process.env.SPACE_AUTHOR_NAME || process.env.AM_USER || os.userInfo().username || 'user';
21
 
22
  // Interactive bash that loads our prompt rcfile (bash ignores a missing rcfile,
23
  // so this is safe in local dev where /app/session.bashrc doesn't exist).
24
  const bashLaunch = `exec bash --rcfile ${BASHRC} -i`;
25
 
26
- const tmuxName = (id) => `am-${id}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
- // If the rendered pane text hasn't changed for this long, it's not working.
29
  const BUSY_SECS = 4;
30
- const paneSig = new Map(); // id -> { sig, changedAt } (changedAt in unix seconds)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  function djb2(s) {
33
  let h = 5381;
@@ -35,108 +110,46 @@ function djb2(s) {
35
  return h;
36
  }
37
 
38
- // In direct-PTY (no-tmux) mode we track live handles to report running status.
39
- const live = new Map(); // id -> Set(handle)
40
- const track = (id, h) => {
41
- if (!live.has(id)) live.set(id, new Set());
42
- live.get(id).add(h);
43
- };
44
- const untrack = (id, h) => {
45
- const s = live.get(id);
46
- if (s) {
47
- s.delete(h);
48
- if (!s.size) live.delete(id);
49
- }
50
- };
51
-
52
  export function isRunning(id) {
53
- if (USE_TMUX) {
54
- try {
55
- execFileSync('tmux', ['has-session', '-t', tmuxName(id)], { stdio: 'ignore' });
56
- return true;
57
- } catch {
58
- return false;
59
- }
60
- }
61
- return live.has(id);
62
  }
63
 
64
- // Which sessions have a pane in copy mode right now — one tmux call covers all
65
- // panes, so the server can push a copy-mode hint to attached clients cheaply.
66
- export function paneModes() {
67
- const modes = {};
68
- if (!USE_TMUX) return modes;
69
- let out = '';
70
- try {
71
- // Mode first (a single 0/1), then the session name — which is `am-<id>`
72
- // and never contains a space, so splitting on the first space is safe.
73
- out = execFileSync('tmux', ['list-panes', '-a', '-F', '#{pane_in_mode} #{session_name}'],
74
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
75
- } catch { return modes; } // no tmux server yet
76
- for (const line of out.split('\n')) {
77
- const sp = line.indexOf(' ');
78
- if (sp < 0) continue;
79
- const name = line.slice(sp + 1);
80
- if (!name.startsWith('am-')) continue;
81
- const id = name.slice(3);
82
- modes[id] = modes[id] || line.slice(0, sp) === '1';
83
  }
84
- return modes;
85
  }
86
 
87
  /**
88
- * Detect activity by DIFFING each pane's rendered text between polls. This
89
- * ignores colour-only animations (e.g. Codex's shimmering banner) that fooled a
90
- * raw output-activity check, while still catching spinners, streaming output and
91
- * elapsed-time counters (their text changes). Returns Map id -> { age } where
92
- * age is seconds since the pane text last changed.
93
  */
94
- let infoMemo = { ts: 0, map: new Map() };
95
-
96
  export function agentInfo() {
97
- // The sweep shells out to tmux once per session, synchronously. Memoize it
98
- // briefly so N browser tabs polling /api/tree don't multiply that cost.
99
- if (Date.now() - infoMemo.ts < 1500) return infoMemo.map;
100
  const map = new Map();
101
- infoMemo = { ts: Date.now(), map };
102
- if (!USE_TMUX) return map;
103
- let list;
104
- try {
105
- // stderr is dropped on every tmux call that already handles its own
106
- // failure: with no server running (fresh container, or after the last
107
- // session ends) tmux prints "error connecting to /tmp/tmux-1000/default"
108
- // and the catch below treats that as "no sessions" — so the message is
109
- // pure noise that made a healthy Space look broken in the log.
110
- list = execFileSync('tmux', ['list-sessions', '-F', '#{session_name}'],
111
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
112
- } catch {
113
- paneSig.clear();
114
- return map; // no server / no sessions
115
- }
116
- const now = Math.floor(Date.now() / 1000);
117
- const live = new Set();
118
- for (const name of list.split('\n')) {
119
- if (!name.startsWith('am-')) continue;
120
- const id = name.slice(3);
121
- live.add(id);
122
- let text = '';
123
- try {
124
- text = execFileSync('tmux', ['capture-pane', '-p', '-t', name],
125
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
126
- } catch {}
127
- const sig = djb2(text);
128
- const prev = paneSig.get(id);
129
- const changedAt = !prev || prev.sig !== sig ? now : prev.changedAt;
130
- paneSig.set(id, { sig, changedAt });
131
- map.set(id, { age: now - changedAt });
132
  }
133
- for (const id of [...paneSig.keys()]) if (!live.has(id)) paneSig.delete(id);
134
  return map;
135
  }
136
 
137
  /**
138
  * Map activity + the session's known CLI into a UI state:
139
- * working — pane text is actively changing (thinking / streaming / a command)
140
  * waiting — agent alive but its screen is static → it's your turn
141
  * idle — a plain shell sitting at its prompt
142
  * stopped — no live session
@@ -151,6 +164,492 @@ export function deriveState(session, info) {
151
  return session.cli === 'shell' ? 'idle' : 'waiting';
152
  }
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  // ---------- Codex conversation pinning ----------
155
  // Codex picks its own conversation id at launch and doesn't accept one up
156
  // front — but it announces the pick immediately: a rollout file named
@@ -598,18 +1097,28 @@ function commandFor(session) {
598
  }
599
 
600
  // Other agents: resume when one likely exists, else a fresh launch. `exec` so
601
- // the agent is the pane's foreground process; when it exits the tmux session
602
- // ends — a clear "done" signal — and the fallback preserves that.
603
  if (session.everStarted && cli.cont) return `${cli.cont} || exec ${cli.run}`;
604
  if (q0 && cli.withPrompt) return `exec ${cli.withPrompt(q0)}`;
605
  return `exec ${cli.run}`;
606
  }
607
 
608
- /** Attach a new PTY client to the session (creating the tmux session if needed). */
609
- export function attach(session, cols, rows) {
610
- // A remote agent has no terminal here by design its harness runs on another
611
- // machine. /ws catches this and says so in the pane.
612
- if (isRemote(session.cli)) throw new Error('this pane has no terminal — it talks to an agent elsewhere');
 
 
 
 
 
 
 
 
 
 
613
  // The recorded workspace-relative path ('' = the workspaces root itself).
614
  // If the folder was deleted or moved, mkdir simply recreates it empty — no
615
  // tracking, no magic.
@@ -617,160 +1126,265 @@ export function attach(session, cols, rows) {
617
  const workdir = path.join(WORKSPACES_DIR, folder);
618
  fs.mkdirSync(workdir, { recursive: true });
619
  const full = commandFor(session);
 
620
 
621
- let term;
622
- if (USE_TMUX) {
623
- const args = [];
624
- if (fs.existsSync(TMUX_CONF)) args.push('-f', TMUX_CONF);
625
- args.push(
626
- // -A: attach if it exists, else create. -D: detach any other client, so
627
- // exactly ONE device drives the session at a time. Sharing it was worse:
628
- // window-size=latest resized the shared window to whoever spoke last, and
629
- // agent TUIs (which paint into the normal buffer, not the alt screen)
630
- // re-emit their frame with erase math computed at the OLD width — so every
631
- // phone/desktop flip left another copy of the same text in the scrollback,
632
- // wrapped at the other device's width. The handover is sequential instead:
633
- // the detached client is told the session lives on (close code 4001) and
634
- // waits for a deliberate return before taking it back (see TerminalPane).
635
- 'new-session', '-A', '-D', '-s', tmuxName(session.id), '-c', workdir,
636
- '-e', `AM_SESSION=${folder}`,
637
- '-e', `AM_NAME=${session.name}`,
638
- '-e', `AM_ID=${session.id}`,
639
- '-e', `AM_USER=${AM_USER}`,
640
- '-e', `AM_ROOT=${WORKSPACES_DIR}`, // prompt shows $PWD relative to this
641
- 'sh', '-lc', full,
642
- );
643
- term = pty.spawn('tmux', args, { name: 'xterm-256color', cols, rows, cwd: workdir, env: TERM_ENV });
644
- } else {
645
- const env = { ...TERM_ENV, AM_SESSION: folder, AM_NAME: session.name, AM_ID: session.id, AM_USER, AM_ROOT: WORKSPACES_DIR };
646
- term = pty.spawn('bash', ['-lc', full], { name: 'xterm-256color', cols, rows, cwd: workdir, env });
 
647
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
648
 
 
 
649
  if (!session.everStarted) update(session.id, { everStarted: true, pendingPrompt: undefined });
650
  if (session.cli === 'codex') scheduleCodexCapture(session, workdir);
651
  if (session.cli === 'opencode') scheduleOpencodeCapture(session, workdir);
652
  if (session.cli === 'claude') scheduleClaudeCapture(session, workdir);
653
-
654
- const handle = {
655
- onData: (cb) => term.onData(cb),
656
- onExit: (cb) => term.onExit(cb),
657
- write: (d) => { try { term.write(d); } catch {} },
658
- resize: (c, r) => { try { term.resize(c, r); } catch {} },
659
- kill: () => { try { term.kill(); } catch {} },
660
- };
661
- track(session.id, handle);
662
- term.onExit(() => untrack(session.id, handle));
663
- return handle;
664
  }
665
 
666
  /**
667
- * Make sure the session's tmux process exists WITHOUT a browser pane attached
668
- * (used by the Overview reply box to wake a stopped agent). Returns true if it
669
- * had to spawn. Direct-PTY mode has no detached equivalent throws if dead.
 
 
 
 
670
  */
671
- export function ensureRunning(session) {
672
- // Nothing to start: a remote agent starts itself, elsewhere. Callers that
673
- // might see one must go through deliver() (index.js) instead of assuming a PTY.
674
- if (isRemote(session.cli)) throw new Error('a remote agent runs on its own machine — nothing to start here');
675
- if (isRunning(session.id)) return false;
676
- if (!USE_TMUX) throw new Error('session is not running');
677
- const folder = session.path ?? session.id;
678
- const workdir = path.join(WORKSPACES_DIR, folder);
679
- fs.mkdirSync(workdir, { recursive: true });
680
- const args = [];
681
- if (fs.existsSync(TMUX_CONF)) args.push('-f', TMUX_CONF);
682
- args.push(
683
- 'new-session', '-d', '-s', tmuxName(session.id), '-c', workdir,
684
- '-x', '200', '-y', '50', // sane size until a client attaches (window-size=latest)
685
- '-e', `AM_SESSION=${folder}`,
686
- '-e', `AM_NAME=${session.name}`,
687
- '-e', `AM_ID=${session.id}`,
688
- '-e', `AM_USER=${AM_USER}`,
689
- '-e', `AM_ROOT=${WORKSPACES_DIR}`,
690
- 'sh', '-lc', commandFor(session),
691
- );
692
- execFileSync('tmux', args, { stdio: 'ignore', env: TERM_ENV });
693
- if (!session.everStarted) update(session.id, { everStarted: true, pendingPrompt: undefined });
694
- if (session.cli === 'codex') scheduleCodexCapture(session, workdir);
695
- if (session.cli === 'opencode') scheduleOpencodeCapture(session, workdir);
696
- if (session.cli === 'claude') scheduleClaudeCapture(session, workdir);
697
- return true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
698
  }
699
 
700
  /** Type a line into the session's terminal (works with no browser attached). */
701
  export async function sendInput(id, text) {
 
 
702
  // Multi-line prompts go in as a bracketed paste so the CLI's composer treats
703
  // the inner newlines as soft line breaks instead of submitting early.
704
  const payload = text.includes('\n') ? `\x1b[200~${text}\x1b[201~` : text;
705
  // The Enter must arrive as its OWN keypress: TUIs (codex) detect rapid input
706
  // bursts as a paste, and a CR inside the burst becomes a newline in the
707
  // composer instead of a submit. A short gap breaks the burst.
708
- const gap = () => new Promise((r) => setTimeout(r, 300));
709
- if (USE_TMUX) {
710
- execFileSync('tmux', ['send-keys', '-t', tmuxName(id), '-l', '--', payload], { stdio: 'ignore' });
711
- await gap();
712
- execFileSync('tmux', ['send-keys', '-t', tmuxName(id), 'Enter'], { stdio: 'ignore' });
713
- return;
714
- }
715
- const set = live.get(id);
716
- if (!set || !set.size) throw new Error('session is not running');
717
- const h = set.values().next().value;
718
- h.write(payload);
719
- await gap();
720
- h.write('\r');
721
  }
722
 
723
  /**
724
  * The session's rendered screen plus `lines` of scrollback above it — what a
725
  * human would see in the pane. Used by the agent API so one agent can watch
726
  * another's progress instead of spending a turn asking.
 
 
 
 
727
  */
728
  export function capturePane(id, lines = 80) {
729
- if (!USE_TMUX) return null;
 
730
  const n = Math.max(0, Math.min(2000, lines));
731
- let out;
732
- try {
733
- out = execFileSync('tmux', ['capture-pane', '-p', '-S', `-${n}`, '-t', tmuxName(id)],
734
- { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
735
- } catch { return null; } // no such session (stopped)
736
- // capture-pane pads the pane to its full height with blank lines; drop them so
737
- // a short screen doesn't arrive as 50 lines of nothing. -S already bounds the
738
- // scrollback, so the caller decides whether to trim the visible screen too.
739
- return out.replace(/\s+$/, '');
740
- }
741
-
742
- /** Return the last mouse selection for this session as a browser-consumable OSC 52. */
743
- export function copySelection(id) {
744
- if (!USE_TMUX) return null;
745
- const bufferName = `am-copy-${tmuxName(id)}`;
746
- let text = '';
747
- try {
748
- text = execFileSync('tmux', ['save-buffer', '-b', bufferName, '-'],
749
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: TERM_ENV });
750
- } catch {
751
- try {
752
- text = execFileSync('tmux', ['save-buffer', '-'],
753
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: TERM_ENV });
754
- } catch {
755
- return null;
756
- }
757
- }
758
- if (!text) return null;
759
- return `\x1b]52;c;${Buffer.from(text, 'utf8').toString('base64')}\x07`;
760
  }
761
 
762
- /** Stop a session entirely (kills the tmux session / the running process). */
763
  export function stop(id) {
764
- // A remote agent has no process to kill — "stopped" means disconnected, so the
765
- // same button pauses it and closes its open polls.
766
- const s = list().find((x) => x.id === id);
767
- if (s && isRemote(s.cli)) { setPaused(s, true, 'stopped from the manager'); return; }
768
- if (USE_TMUX) {
769
- try {
770
- execFileSync('tmux', ['kill-session', '-t', tmuxName(id)], { stdio: 'ignore' });
771
- } catch {}
772
- } else {
773
- const s = live.get(id);
774
- if (s) for (const h of s) h.kill();
 
775
  }
776
  }
 
1
  import os from 'node:os';
2
  import path from 'node:path';
3
  import pty from 'node-pty';
 
4
  import fs from 'node:fs';
 
 
 
5
  import { remoteState, setPaused } from './remote.js';
6
+ import { cliById, isRemote, STATE_DIR, WORKSPACES_DIR } from './config.js';
7
+ import { update, list } from './sessions.js';
8
+ import { captureOpencodeSession, readTrace } from './traces.js';
9
+ import {
10
+ buildPaletteIndex, snapshotToRestoreAnsi, styledSnapshotLines, textColumns,
11
+ } from './snapshot.js';
12
+ import {
13
+ createTerminalHistoryCheckpoint, loadTerminalHistory, TERMINAL_HISTORY_VERSION,
14
+ traceHistoryLines,
15
+ } from './history-store.js';
16
+
17
+ // libghostty-vt ships prebuilts for linux x64/arm64 and macOS arm64. Loading it
18
+ // is guarded so a platform without a prebuilt still boots and says so, rather
19
+ // than taking the whole app down.
20
+ let ghostty = null;
21
+ export let ghosttyError = null;
22
+ try {
23
+ ghostty = await import('@coder/libghostty-vt-node');
24
+ buildPaletteIndex(ghostty.createTerminal);
25
+ } catch (err) {
26
+ ghosttyError = String(err && err.message ? err.message : err);
27
+ console.error('[runner] libghostty-vt unavailable:', ghosttyError);
28
+ }
29
 
30
  const TERM_ENV = {
31
  ...process.env,
 
35
  };
36
 
37
  const BASHRC = process.env.AM_BASHRC || '/app/session.bashrc';
 
38
  const AM_USER = process.env.SPACE_AUTHOR_NAME || process.env.AM_USER || os.userInfo().username || 'user';
39
 
40
  // Interactive bash that loads our prompt rcfile (bash ignores a missing rcfile,
41
  // so this is safe in local dev where /app/session.bashrc doesn't exist).
42
  const bashLaunch = `exec bash --rcfile ${BASHRC} -i`;
43
 
44
+ // ---------- session hosts (what replaced tmux) ----------
45
+ //
46
+ // Every session is one PTY held by THIS process, with a libghostty-vt terminal
47
+ // fed from its output. That terminal is the authoritative screen, so:
48
+ //
49
+ // * a browser attaching gets canonical history plus a snapshot repaint,
50
+ // instead of asking tmux to redraw and hoping the agent's TUI cooperates;
51
+ // * several browsers can watch the same session, with one explicit input/size
52
+ // controller instead of a one-device-at-a-time disconnect handover;
53
+ // * agent state is a property read rather than a `tmux capture-pane`
54
+ // subprocess per session per poll.
55
+ //
56
+ // PTYs still end with this process, but canonical scrollback is checkpointed to
57
+ // durable storage below and rejoined with the CLI's resumed screen on relaunch.
58
 
59
+ // Rendered text unchanged for this long means the agent isn't working.
60
  const BUSY_SECS = 4;
61
+ // Re-rendering the grid to text on every chunk during a burst is wasteful.
62
+ const SAMPLE_THROTTLE_MS = 250;
63
+ // Despite the Node wrapper's `scrollbackLimit` name, Ghostty's native option is
64
+ // a byte budget. Passing a line count such as 20,000 retains only a small native
65
+ // allocation (about 700 ordinary rows). Keep the unit explicit at our boundary.
66
+ const DEFAULT_SCROLLBACK_BYTES = 64 * 1024 * 1024;
67
+ const configuredScrollback = process.env.AM_SCROLLBACK_BYTES === undefined
68
+ ? Number.NaN
69
+ : Number(process.env.AM_SCROLLBACK_BYTES);
70
+ const SCROLLBACK_BYTES = Number.isFinite(configuredScrollback) && configuredScrollback >= 0
71
+ ? configuredScrollback
72
+ : DEFAULT_SCROLLBACK_BYTES;
73
+ // A layout resize is only worth acting on once it stops changing. ResizeObserver
74
+ // fires per animation frame while a window is dragged; coalescing that burst
75
+ // avoids repeatedly reflowing the terminal and repeatedly sending SIGWINCH.
76
+ const RESIZE_SETTLE_MS = Number(process.env.AM_RESIZE_SETTLE_MS || 120);
77
+ // Primary-screen agent TUIs redraw their whole frame after SIGWINCH. Let that
78
+ // burst settle in a scratch emulator, then merge its final frame once; streaming
79
+ // the raw redraw would turn every overflow row into duplicate history.
80
+ const RESIZE_CAPTURE_IDLE_MS = Number(process.env.AM_RESIZE_CAPTURE_IDLE_MS || 80);
81
+ const RESIZE_CAPTURE_MAX_MS = Number(process.env.AM_RESIZE_CAPTURE_MAX_MS || 900);
82
+ // A resumed agent paints through the same primary-screen protocol as a resize,
83
+ // but may pause for seconds between its welcome frame and replayed transcript.
84
+ // Keep that transaction distinct from the deliberately short resize debounce:
85
+ // committing its first chunk makes every later chunk look like new history.
86
+ const STARTUP_CAPTURE_IDLE_MS = Number(process.env.AM_STARTUP_CAPTURE_IDLE_MS || 5000);
87
+ const STARTUP_CAPTURE_MAX_MS = Number(process.env.AM_STARTUP_CAPTURE_MAX_MS || 20000);
88
+ // Unlike the PTY process, /data survives a Space rebuild. Checkpoint canonical
89
+ // scrollback there so deploys and sleeps do not turn a resumed agent into a
90
+ // terminal with only its freshly repainted viewport.
91
+ const HISTORY_SAVE_MS = Number(process.env.AM_HISTORY_SAVE_MS || 5000);
92
+ const HISTORY_DIR = path.join(STATE_DIR, 'terminal-history');
93
+ // Claude's resume stream can pause between its welcome frame and replayed
94
+ // conversation. Treat that as one startup transaction; hydrating during the
95
+ // pause would seed a turn that Claude is about to print itself.
96
+ const TRACE_HYDRATE_IDLE_MS = Number(process.env.AM_TRACE_HYDRATE_IDLE_MS || 5000);
97
+ const TRACE_HYDRATE_MIN_MS = Number(process.env.AM_TRACE_HYDRATE_MIN_MS || 3000);
98
+ // Bound untrusted WebSocket geometry without imposing the old 400x200 ceiling,
99
+ // which left visible dead space on high-DPI displays at low zoom levels.
100
+ const MIN_COLS = 20;
101
+ const MIN_ROWS = 5;
102
+ const MAX_COLS = 1000;
103
+ const MAX_ROWS = 500;
104
+
105
+ const hosts = new Map(); // session id -> host
106
 
107
  function djb2(s) {
108
  let h = 5381;
 
110
  return h;
111
  }
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  export function isRunning(id) {
114
+ return hosts.has(id);
 
 
 
 
 
 
 
 
115
  }
116
 
117
+ export function ghosttyReady() {
118
+ return !!ghostty;
119
+ }
120
+
121
+ /** Sample the grid's rendered text and record when it last changed. */
122
+ function sampleScreen(host) {
123
+ const now = Date.now();
124
+ if (host.lastSampleAt && now - host.lastSampleAt < SAMPLE_THROTTLE_MS) return;
125
+ host.lastSampleAt = now;
126
+ let text = '';
127
+ try { text = host.vt.getVisibleText(); } catch { return; }
128
+ const sig = djb2(text);
129
+ if (host.screenSig !== sig) {
130
+ host.screenSig = sig;
131
+ host.screenChangedAt = now;
 
 
 
 
132
  }
 
133
  }
134
 
135
  /**
136
+ * Activity per session, keyed like the old tmux sweep so callers don't change.
137
+ * No subprocess and no memo: the grid is already current, so this is a map build
138
+ * over held sessions.
 
 
139
  */
 
 
140
  export function agentInfo() {
 
 
 
141
  const map = new Map();
142
+ const now = Date.now();
143
+ for (const [id, host] of hosts) {
144
+ const changedAt = host.screenChangedAt || host.startedAt;
145
+ map.set(id, { age: Math.round((now - changedAt) / 1000), bells: host.bells || 0 });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  }
 
147
  return map;
148
  }
149
 
150
  /**
151
  * Map activity + the session's known CLI into a UI state:
152
+ * working — screen text is actively changing (thinking / streaming / a command)
153
  * waiting — agent alive but its screen is static → it's your turn
154
  * idle — a plain shell sitting at its prompt
155
  * stopped — no live session
 
164
  return session.cli === 'shell' ? 'idle' : 'waiting';
165
  }
166
 
167
+ /** The visible screen as text, with no browser attached. */
168
+ export function peek(id) {
169
+ const host = hosts.get(id);
170
+ if (!host) return null;
171
+ try { return host.vt.getVisibleText(); } catch { return null; }
172
+ }
173
+
174
+ // ---------- the shared grid ----------
175
+ //
176
+ // A PTY has exactly one geometry. One attached viewer therefore holds a size
177
+ // lease (the controller); every other viewer watches the same grid without
178
+ // changing it. A watcher can take the lease through an explicit interaction.
179
+ // This prevents a phone or background tab from resizing a desktop session merely
180
+ // by connecting.
181
+
182
+ function effectiveGrid(host) {
183
+ return host.controller?.want || { cols: host.cols, rows: host.rows };
184
+ }
185
+
186
+ function preferredGrid(cols, rows, fallback) {
187
+ const c = Number.isFinite(cols) ? Math.round(cols) : fallback.cols;
188
+ const r = Number.isFinite(rows) ? Math.round(rows) : fallback.rows;
189
+ return {
190
+ cols: Math.max(MIN_COLS, Math.min(MAX_COLS, c)),
191
+ rows: Math.max(MIN_ROWS, Math.min(MAX_ROWS, r)),
192
+ };
193
+ }
194
+
195
+ function notifyGrid(host, reset = false) {
196
+ for (const sub of host.subs) {
197
+ sub.onGrid(host.cols, host.rows, host.controller === sub, host.subs.size, reset);
198
+ }
199
+ }
200
+
201
+ function clearCaptureTimers(txn) {
202
+ if (txn.idleTimer) clearTimeout(txn.idleTimer);
203
+ if (txn.maxTimer) clearTimeout(txn.maxTimer);
204
+ txn.idleTimer = null;
205
+ txn.maxTimer = null;
206
+ }
207
+
208
+ function logicalText(lines, cols) {
209
+ let text = '';
210
+ for (let line = 0; line < lines.length; line++) {
211
+ const row = lines[line].text || '';
212
+ text += row;
213
+ // A full terminal row is a soft wrap. Do not insert whitespace: Claude's
214
+ // words can be split at an arbitrary column between snapshots. A shorter
215
+ // row ended with a real line break, which must remain part of the overlap.
216
+ if (textColumns(row) < cols) {
217
+ text += '\n';
218
+ }
219
+ }
220
+ return text;
221
+ }
222
+
223
+ function logicalHistory(text) {
224
+ if (!text) return [];
225
+ const lines = text.split('\n');
226
+ if (lines.at(-1) === '') lines.pop();
227
+ return lines.map((line) => ({ text: line }));
228
+ }
229
+
230
+ function visibleRows(vt) {
231
+ try { return vt.getVisibleText().split('\n').map((text) => ({ text })); } catch { return []; }
232
+ }
233
+
234
+ function longestPrefixOccurrence(pattern, text) {
235
+ const prefix = new Array(pattern.length).fill(0);
236
+ for (let i = 1, matched = 0; i < pattern.length; i++) {
237
+ while (matched && pattern[i] !== pattern[matched]) matched = prefix[matched - 1];
238
+ if (pattern[i] === pattern[matched]) matched++;
239
+ prefix[i] = matched;
240
+ }
241
+ let matched = 0, best = 0, bestEnd = -1;
242
+ for (let i = 0; i < text.length; i++) {
243
+ while (matched && text[i] !== pattern[matched]) matched = prefix[matched - 1];
244
+ if (text[i] === pattern[matched]) matched++;
245
+ if (matched > best) { best = matched; bestEnd = i; }
246
+ if (matched === pattern.length) matched = prefix[matched - 1];
247
+ }
248
+ return { length: best, end: bestEnd };
249
+ }
250
+
251
+ /**
252
+ * Merge a primary-screen repaint into the fullest transcript seen so far.
253
+ *
254
+ * A growing pane can reveal an archived suffix after a fresh welcome/header,
255
+ * so the overlap may occur inside the repaint rather than at its first byte.
256
+ * Searching the reversed strings finds the longest archive suffix anywhere in
257
+ * the repaint in linear time. The repaint prefix is inserted before that
258
+ * suffix, preserving a header without retaining two copies of the turns.
259
+ */
260
+ export function mergeRepaintArchive(base, repaint) {
261
+ if (!base) return repaint;
262
+ if (!repaint) return base;
263
+ if (!base.trim()) return repaint;
264
+ if (!repaint.trim()) return base;
265
+ const reverse = (text) => {
266
+ let result = '';
267
+ for (let i = text.length - 1; i >= 0; i--) result += text[i];
268
+ return result;
269
+ };
270
+ // Shape 1: an archive suffix is revealed after a freshly painted header.
271
+ const suffix = longestPrefixOccurrence(
272
+ reverse(base.slice(-repaint.length)), reverse(repaint),
273
+ );
274
+ // Shape 2: the repaint starts in the archive, then replaces its volatile
275
+ // footer (dimensions, status line, prompt chrome) with the new one.
276
+ const prefix = longestPrefixOccurrence(repaint.slice(0, base.length), base);
277
+ const minimum = Math.min(12, base.trim().length, repaint.trim().length);
278
+ if (Math.max(suffix.length, prefix.length) < minimum) return base + repaint;
279
+ if (prefix.length > suffix.length) {
280
+ const baseOffset = prefix.end - prefix.length + 1;
281
+ return base.slice(0, baseOffset) + repaint;
282
+ }
283
+ const repaintOffset = repaint.length - 1 - suffix.end;
284
+ return repaint.slice(0, repaintOffset) + base
285
+ + repaint.slice(repaintOffset + suffix.length);
286
+ }
287
+
288
+ /**
289
+ * Replace the archive's old visible tail with a new repaint.
290
+ *
291
+ * Resize output is presentation, never appended terminal output. Match the
292
+ * longest prefix of the new visible grid inside the archive; everything before
293
+ * that point is the hidden prefix, and the new grid replaces everything after
294
+ * it (including dimension-dependent status/footer text).
295
+ */
296
+ export function repaintArchiveView(archive, visible, fallbackHistory = '') {
297
+ if (!visible) return { archive, history: logicalHistory(archive) };
298
+ const leading = visible.match(/^(?:[ \t]*\n)*/)?.[0].length || 0;
299
+ const candidate = visible.slice(leading);
300
+ let match = candidate ? longestPrefixOccurrence(candidate, archive) : { length: 0, end: -1 };
301
+
302
+ // A few shared characters are not a safe repaint boundary in a substantial
303
+ // screen. Claude's randomized status lines, for example, all begin with
304
+ // "Worked for ". Anchoring a fresh screen there can retain the old turn that
305
+ // precedes a later status line, then append that same turn again. Require a
306
+ // meaningful overlap for long screens, while still accepting complete short
307
+ // prompts in small panes.
308
+ const archiveContentLength = archive.trim().length;
309
+ const confidence = (tail) => Math.min(
310
+ 64,
311
+ archiveContentLength,
312
+ Math.max(12, Math.floor(tail.trim().length / 2)),
313
+ );
314
+ let minimum = confidence(candidate);
315
+
316
+ // A repaint may start with one volatile row before its stable transcript.
317
+ // If the first row offers only a weak match, advance by logical lines until
318
+ // the first substantial overlap is found. The visible grid is bounded to 500
319
+ // rows, and the cap keeps a maliciously fragmented screen from repeatedly
320
+ // scanning a large archive.
321
+ if (match.length < minimum) {
322
+ let offset = 0;
323
+ for (let attempts = 0; attempts < 32;) {
324
+ const newline = candidate.indexOf('\n', offset);
325
+ if (newline < 0) break;
326
+ offset = newline + 1;
327
+ const tail = candidate.slice(offset);
328
+ if (!tail.trim()) break;
329
+ const nextBreak = tail.indexOf('\n');
330
+ const firstLine = tail.slice(0, nextBreak < 0 ? undefined : nextBreak);
331
+ if (firstLine.trim().length < 12) continue;
332
+ attempts++;
333
+ const next = longestPrefixOccurrence(tail, archive);
334
+ const nextMinimum = confidence(tail);
335
+ if (next.length >= nextMinimum) {
336
+ match = next;
337
+ minimum = nextMinimum;
338
+ break;
339
+ }
340
+ }
341
+ }
342
+ let history = fallbackHistory;
343
+ if (match.length >= minimum && minimum > 0) {
344
+ const archiveOffset = match.end - match.length + 1;
345
+ history = archive.slice(0, archiveOffset);
346
+ }
347
+ return { archive: history + visible, history: logicalHistory(history) };
348
+ }
349
+
350
+ function terminalArchive(vt, snap) {
351
+ return logicalText([...(snap.scrollbackLines || []), ...visibleRows(vt)], snap.cols);
352
+ }
353
+
354
+ const MAX_HISTORY_STYLES = 50_000;
355
+
356
+ function learnHistoryStyles(host, vt, snap) {
357
+ if (!host.historyStyles || typeof vt.formatHtml !== 'function') return;
358
+ const visibleHasStyle = (snap.cells || []).some((cell) => cell.bold || cell.italic
359
+ || cell.underline || cell.foreground || cell.background);
360
+ if (!visibleHasStyle && host.historyStyles.size === 0) return;
361
+ let styled;
362
+ try { styled = styledSnapshotLines(snap, vt.formatHtml()); } catch { return; }
363
+ for (const line of [...styled.rows, ...styled.logical]) {
364
+ if (!line.text || !line.ansi) continue;
365
+ // Refresh insertion order so frequently repainted transcript rows survive
366
+ // the bounded cache while old one-off status lines age out.
367
+ host.historyStyles.delete(line.text);
368
+ host.historyStyles.set(line.text, line.ansi);
369
+ }
370
+ while (host.historyStyles.size > MAX_HISTORY_STYLES) {
371
+ host.historyStyles.delete(host.historyStyles.keys().next().value);
372
+ }
373
+ }
374
+
375
+ function withHistoryStyles(host, lines) {
376
+ return (lines || []).map((line) => {
377
+ const ansi = line.ansi || host.historyStyles?.get(line.text || '');
378
+ return ansi ? { ...line, ansi } : line;
379
+ });
380
+ }
381
+
382
+ function styledSnapshot(host, vt, snap) {
383
+ learnHistoryStyles(host, vt, snap);
384
+ return { ...snap, scrollbackLines: withHistoryStyles(host, snap.scrollbackLines) };
385
+ }
386
+
387
+ /**
388
+ * Commit one captured agent repaint without duplicating its overflow rows.
389
+ *
390
+ * The pre-resize scrollback boundary and final repaint are merged once, then
391
+ * both the server terminal and every viewer are restored from that same state.
392
+ * Shell sessions never use this path.
393
+ */
394
+ function finishCapturedGrid(host, txn) {
395
+ if (host.resizeCapture !== txn) return;
396
+ host.resizeCapture = null;
397
+ clearCaptureTimers(txn);
398
+
399
+ let snap = null;
400
+ let replacement = null;
401
+ let committedArchive = null;
402
+ if (txn.sawData) {
403
+ try { snap = txn.vt.snapshot({ includeCells: true, includeScrollback: true }); } catch {}
404
+ if (snap) {
405
+ try {
406
+ learnHistoryStyles(host, txn.vt, snap);
407
+ const visible = visibleRows(txn.vt);
408
+ const visibleText = logicalText(visible, txn.cols);
409
+ const view = repaintArchiveView(txn.archive, visibleText, txn.fallbackHistory);
410
+ committedArchive = view.archive;
411
+ replacement = ghostty.createTerminal({
412
+ cols: txn.cols,
413
+ rows: txn.rows,
414
+ scrollbackLimit: SCROLLBACK_BYTES,
415
+ });
416
+ replacement.feed(snapshotToRestoreAnsi({
417
+ ...snap, scrollbackLines: withHistoryStyles(host, view.history),
418
+ }));
419
+ } catch (error) {
420
+ console.error('[runner] resize capture commit', error && error.message);
421
+ try { replacement?.dispose(); } catch {}
422
+ replacement = null;
423
+ snap = null;
424
+ }
425
+ }
426
+ }
427
+
428
+ try {
429
+ if (!snap) {
430
+ // The foreground process ignored SIGWINCH. Ordinary reflow is the only
431
+ // faithful outcome because no repaint exists to replace the old screen.
432
+ host.vt.resize(txn.cols, txn.rows);
433
+ host.cols = txn.cols;
434
+ host.rows = txn.rows;
435
+ host.repaintArchive = null;
436
+ notifyGrid(host, false);
437
+ } else {
438
+ const previous = host.vt;
439
+ host.vt = replacement;
440
+ host.cols = txn.cols;
441
+ host.rows = txn.rows;
442
+ // The archive retains the full transcript even when a wide grid exposes
443
+ // all of it and therefore needs no scrollback. A later narrow repaint can
444
+ // derive the hidden prefix again instead of losing the oldest rows.
445
+ host.repaintArchive = committedArchive;
446
+ // Reset and restore one canonical snapshot. Keeping a browser's old
447
+ // scrollback while painting only the new viewport makes the two models
448
+ // diverge after a narrow zoom, even when the server history is correct.
449
+ notifyGrid(host, true);
450
+ const committed = host.vt.snapshot({ includeCells: true, includeScrollback: true });
451
+ const ansi = snapshotToRestoreAnsi(styledSnapshot(host, host.vt, committed));
452
+ for (const sub of host.subs) sub.onData(ansi);
453
+ try { previous.dispose(); } catch {}
454
+ sampleScreen(host);
455
+ }
456
+ } finally {
457
+ try { txn.vt.dispose(); } catch {}
458
+ }
459
+ host.historyCheckpoint.schedule();
460
+
461
+ // A newer controller preference may have arrived while the repaint settled.
462
+ const next = effectiveGrid(host);
463
+ if (next.cols !== host.cols || next.rows !== host.rows) scheduleGrid(host);
464
+ }
465
+
466
+ function armCapturedGrid(host, txn) {
467
+ if (txn.idleTimer) clearTimeout(txn.idleTimer);
468
+ txn.idleTimer = setTimeout(() => finishCapturedGrid(host, txn), txn.idleMs);
469
+ if (txn.idleTimer.unref) txn.idleTimer.unref();
470
+ }
471
+
472
+ /** Start or supersede the bounded repaint transaction for an agent TUI. */
473
+ function startCapturedGrid(host, cols, rows, seed = null, resizePty = true) {
474
+ const previous = host.resizeCapture;
475
+ // A viewer can report its measured geometry before the resumed CLI emits
476
+ // its first byte. That resize is still part of startup, so claim the durable
477
+ // seed here rather than leaving it for a later output chunk.
478
+ const pendingStartup = !seed && !previous ? host.startupHistory : null;
479
+ if (pendingStartup) {
480
+ seed = {
481
+ history: pendingStartup.lines,
482
+ historyCols: pendingStartup.cols,
483
+ logical: true,
484
+ };
485
+ }
486
+ // Resizing during a startup capture supersedes its scratch grid, but must not
487
+ // downgrade its long idle window to the ordinary resize debounce. Otherwise
488
+ // a delayed resume repaint is committed later as duplicate terminal output.
489
+ const startupCapture = !!seed || !!previous?.startupCapture;
490
+ let archive = seed?.history
491
+ ? (seed.logical
492
+ ? `${seed.history.map((line) => line.text || '').join('\n')}\n`
493
+ : logicalText(seed.history, seed.historyCols || host.cols))
494
+ : host.repaintArchive;
495
+ let fallbackHistory = seed?.history ? archive : null;
496
+ if (previous) {
497
+ archive = previous.archive;
498
+ fallbackHistory = previous.fallbackHistory;
499
+ host.resizeCapture = null;
500
+ clearCaptureTimers(previous);
501
+ try { previous.vt.dispose(); } catch {}
502
+ }
503
+ try {
504
+ const source = host.vt.snapshot({ includeScrollback: true });
505
+ if (archive == null) {
506
+ archive = terminalArchive(host.vt, source);
507
+ }
508
+ if (fallbackHistory == null) {
509
+ fallbackHistory = logicalText(source.scrollbackLines || [], source.cols);
510
+ }
511
+ } catch { return false; }
512
+
513
+ let scratch;
514
+ try {
515
+ scratch = ghostty.createTerminal({ cols, rows, scrollbackLimit: 4 * 1024 * 1024 });
516
+ } catch (error) {
517
+ console.error('[runner] resize capture setup', error && error.message);
518
+ try { scratch?.dispose(); } catch {}
519
+ return false;
520
+ }
521
+
522
+ const txn = {
523
+ vt: scratch,
524
+ cols,
525
+ rows,
526
+ archive: archive || '',
527
+ fallbackHistory: fallbackHistory || '',
528
+ sawData: false,
529
+ startupCapture,
530
+ idleMs: startupCapture ? STARTUP_CAPTURE_IDLE_MS : RESIZE_CAPTURE_IDLE_MS,
531
+ maxMs: startupCapture ? STARTUP_CAPTURE_MAX_MS : RESIZE_CAPTURE_MAX_MS,
532
+ idleTimer: null,
533
+ maxTimer: null,
534
+ };
535
+ host.resizeCapture = txn;
536
+ if (pendingStartup) host.startupHistory = null;
537
+ if (resizePty) { try { host.pty.resize(cols, rows); } catch {} }
538
+ txn.maxTimer = setTimeout(() => finishCapturedGrid(host, txn), txn.maxMs);
539
+ if (txn.maxTimer.unref) txn.maxTimer.unref();
540
+ return true;
541
+ }
542
+
543
+ /**
544
+ * Move the session to the size its viewers imply.
545
+ *
546
+ * Shells use ordinary reflow: every row is real output. Known agent TUIs on the
547
+ * primary screen instead use a bounded scratch transaction because their full
548
+ * SIGWINCH redraw is presentation, not new history. Alternate-screen programs
549
+ * are already isolated from primary scrollback and use ordinary resize.
550
+ */
551
+ function applyGrid(host) {
552
+ const { cols, rows } = effectiveGrid(host);
553
+ if (!host.resizeCapture && cols === host.cols && rows === host.rows) return false;
554
+ if (host.captureResize) {
555
+ let alt = false;
556
+ try { alt = !!host.vt.snapshot().isAltScreen; } catch {}
557
+ if (!alt && startCapturedGrid(host, cols, rows)) return true;
558
+ }
559
+ host.cols = cols;
560
+ host.rows = rows;
561
+ try { host.vt.resize(cols, rows); } catch {}
562
+ // Put the geometry frame on every viewer's ordered WebSocket stream before
563
+ // SIGWINCH can make the foreground application repaint at the new size.
564
+ // Otherwise those repaint bytes may be interpreted using the old grid and
565
+ // leave duplicated or displaced rows in the browser emulator.
566
+ notifyGrid(host, false);
567
+ try { host.pty.resize(cols, rows); } catch {}
568
+ return true;
569
+ }
570
+
571
+ /**
572
+ * Apply the controller's preferred grid once requests stop arriving. If a
573
+ * pending request clamps back to the current size, report that canonical size.
574
+ */
575
+ function scheduleGrid(host) {
576
+ if (host.gridTimer) clearTimeout(host.gridTimer);
577
+ host.gridTimer = setTimeout(() => {
578
+ host.gridTimer = null;
579
+ if (!applyGrid(host)) notifyGrid(host, false);
580
+ }, RESIZE_SETTLE_MS);
581
+ if (host.gridTimer.unref) host.gridTimer.unref();
582
+ }
583
+
584
+ function armTraceHydration(host) {
585
+ if (!host.traceHistoryPage || host.traceHistoryTimer) return;
586
+ const readyAt = Math.max(
587
+ host.startedAt + TRACE_HYDRATE_MIN_MS,
588
+ (host.lastOutputAt || host.startedAt) + TRACE_HYDRATE_IDLE_MS,
589
+ host.resizeCapture ? Date.now() + TRACE_HYDRATE_IDLE_MS : 0,
590
+ );
591
+ host.traceHistoryTimer = setTimeout(() => {
592
+ host.traceHistoryTimer = null;
593
+ if (hosts.get(host.id) !== host || !host.traceHistoryPage) return;
594
+ if (host.resizeCapture || Date.now() < readyAt) { armTraceHydration(host); return; }
595
+
596
+ let snap;
597
+ try { snap = host.vt.snapshot({ includeCells: true, includeScrollback: true }); } catch { return; }
598
+ const visible = visibleRows(host.vt);
599
+ const visibleText = logicalText(visible, snap.cols);
600
+ const currentText = visible.map((line) => line.text || '').join('\n');
601
+ const recovered = traceHistoryLines(host.traceHistoryPage, currentText);
602
+ host.traceHistoryPage = null;
603
+ learnHistoryStyles(host, host.vt, snap);
604
+
605
+ let replacement = null;
606
+ try {
607
+ // The trace supplies missing older turns while the settled startup grid
608
+ // supplies the welcome and the newest live turn. Merge their overlap
609
+ // into one archive rather than appending either presentation verbatim.
610
+ const startup = terminalArchive(host.vt, snap);
611
+ const archive = mergeRepaintArchive(logicalText(recovered, snap.cols), startup);
612
+ const view = repaintArchiveView(archive, visibleText);
613
+ replacement = ghostty.createTerminal({
614
+ cols: host.cols, rows: host.rows, scrollbackLimit: SCROLLBACK_BYTES,
615
+ });
616
+ replacement.feed(snapshotToRestoreAnsi({
617
+ ...snap,
618
+ scrollbackLines: withHistoryStyles(host, view.history),
619
+ }));
620
+ const previous = host.vt;
621
+ host.vt = replacement;
622
+ host.repaintArchive = view.archive;
623
+ notifyGrid(host, true);
624
+ const committed = host.vt.snapshot({ includeCells: true, includeScrollback: true });
625
+ const ansi = snapshotToRestoreAnsi(styledSnapshot(host, host.vt, committed));
626
+ for (const sub of host.subs) sub.onData(ansi);
627
+ try { previous.dispose(); } catch {}
628
+ sampleScreen(host);
629
+ host.historyCheckpoint.schedule();
630
+ } catch (error) {
631
+ console.error('[runner] trace history restore', error && error.message);
632
+ try { replacement?.dispose(); } catch {}
633
+ }
634
+ }, Math.max(0, readyAt - Date.now()));
635
+ if (host.traceHistoryTimer.unref) host.traceHistoryTimer.unref();
636
+ }
637
+
638
+ async function hydrateTraceHistory(session, host) {
639
+ try {
640
+ let page = await readTrace(session, { offset: 0, limit: 500 });
641
+ if (page.total > page.turns.length) {
642
+ page = await readTrace(session, { offset: Math.max(0, page.total - 500), limit: 500 });
643
+ }
644
+ if (hosts.get(host.id) !== host) return;
645
+ host.traceHistoryPage = page;
646
+ armTraceHydration(host);
647
+ } catch {
648
+ // A new/onboarding session may not have a trace yet; live output remains
649
+ // authoritative and its first checkpoint will become the durable seed.
650
+ }
651
+ }
652
+
653
  // ---------- Codex conversation pinning ----------
654
  // Codex picks its own conversation id at launch and doesn't accept one up
655
  // front — but it announces the pick immediately: a rollout file named
 
1097
  }
1098
 
1099
  // Other agents: resume when one likely exists, else a fresh launch. `exec` so
1100
+ // the agent is the PTY's foreground process; when it exits the session ends —
1101
+ // a clear "done" signal — and the fallback preserves that.
1102
  if (session.everStarted && cli.cont) return `${cli.cont} || exec ${cli.run}`;
1103
  if (q0 && cli.withPrompt) return `exec ${cli.withPrompt(q0)}`;
1104
  return `exec ${cli.run}`;
1105
  }
1106
 
1107
+ /**
1108
+ * Start the session's PTY and its grid, without any browser attached. Returns
1109
+ * true if it had to spawn. This is now the ONLY way a session starts, so the
1110
+ * Overview reply box waking a stopped agent and a browser opening a pane take
1111
+ * exactly the same path.
1112
+ */
1113
+ export function ensureRunning(session, cols = 120, rows = 34) {
1114
+ // Nothing to start: a remote agent starts itself, on its own machine. Both
1115
+ // callers guard this too; keep the refusal here so no future one can spawn
1116
+ // a PTY for a pane that can never use it.
1117
+ if (isRemote(session.cli)) throw new Error('a remote agent runs on its own machine — nothing to start here');
1118
+ const existing = hosts.get(session.id);
1119
+ if (existing) return false;
1120
+ if (!ghostty) throw new Error(`libghostty-vt unavailable: ${ghosttyError}`);
1121
+
1122
  // The recorded workspace-relative path ('' = the workspaces root itself).
1123
  // If the folder was deleted or moved, mkdir simply recreates it empty — no
1124
  // tracking, no magic.
 
1126
  const workdir = path.join(WORKSPACES_DIR, folder);
1127
  fs.mkdirSync(workdir, { recursive: true });
1128
  const full = commandFor(session);
1129
+ const captureResize = cliById(session.cli)?.resizeMode === 'repaint';
1130
 
1131
+ const env = {
1132
+ ...TERM_ENV,
1133
+ AM_SESSION: folder,
1134
+ AM_NAME: session.name,
1135
+ AM_ID: session.id,
1136
+ AM_USER,
1137
+ AM_ROOT: WORKSPACES_DIR, // prompt shows $PWD relative to this
1138
+ };
1139
+ const term = pty.spawn('bash', ['-lc', full], {
1140
+ name: 'xterm-256color', cols, rows, cwd: workdir, env,
1141
+ });
1142
+ const vt = ghostty.createTerminal({ cols, rows, scrollbackLimit: SCROLLBACK_BYTES });
1143
+ const loadedHistory = loadTerminalHistory(HISTORY_DIR, session.id);
1144
+ // Older agent checkpoints may contain startup repaint frames or a turn that
1145
+ // trace hydration raced with Claude's own replay. Rebuild those once from
1146
+ // the trace. Shell history never used that path and remains safe to restore.
1147
+ const persistedHistory = captureResize
1148
+ && loadedHistory?.version < TERMINAL_HISTORY_VERSION ? null : loadedHistory;
1149
+ if (persistedHistory) {
1150
+ try {
1151
+ vt.feed(snapshotToRestoreAnsi({
1152
+ cols, rows, cursorRow: 0, cursorCol: 0, isAltScreen: false, cells: [],
1153
+ scrollbackLines: persistedHistory.lines,
1154
+ }));
1155
+ } catch (error) {
1156
+ console.error('[runner] history restore', error && error.message);
1157
+ }
1158
  }
1159
+ const host = {
1160
+ id: session.id,
1161
+ pty: term,
1162
+ vt,
1163
+ cols,
1164
+ rows,
1165
+ captureResize,
1166
+ resizeCapture: null,
1167
+ repaintArchive: null,
1168
+ historyStyles: new Map((persistedHistory?.lines || [])
1169
+ .filter((line) => line.text && line.ansi).map((line) => [line.text, line.ansi])),
1170
+ startupHistory: captureResize ? persistedHistory : null,
1171
+ historyCheckpoint: null,
1172
+ traceHistoryPage: null,
1173
+ traceHistoryTimer: null,
1174
+ subs: new Set(),
1175
+ controller: null,
1176
+ gridTimer: null,
1177
+ startedAt: Date.now(),
1178
+ lastOutputAt: Date.now(),
1179
+ screenChangedAt: Date.now(),
1180
+ bells: 0,
1181
+ };
1182
+ host.historyCheckpoint = createTerminalHistoryCheckpoint({
1183
+ directory: HISTORY_DIR,
1184
+ id: host.id,
1185
+ delayMs: HISTORY_SAVE_MS,
1186
+ snapshot: () => {
1187
+ const snap = host.vt.snapshot({ includeScrollback: true });
1188
+ learnHistoryStyles(host, host.vt, snap);
1189
+ if (!captureResize) {
1190
+ return { ...snap, scrollbackLines: withHistoryStyles(host, snap.scrollbackLines) };
1191
+ }
1192
+ const archive = host.repaintArchive ?? terminalArchive(host.vt, snap);
1193
+ return {
1194
+ cols: host.cols,
1195
+ scrollbackLines: withHistoryStyles(host, logicalHistory(archive)),
1196
+ };
1197
+ },
1198
+ blocked: () => !!host.resizeCapture,
1199
+ persistedBody: persistedHistory?.body || null,
1200
+ });
1201
+
1202
+ term.onData((chunk) => {
1203
+ host.lastOutputAt = Date.now();
1204
+ if (host.traceHistoryTimer) {
1205
+ clearTimeout(host.traceHistoryTimer);
1206
+ host.traceHistoryTimer = null;
1207
+ }
1208
+ if (host.traceHistoryPage) armTraceHydration(host);
1209
+ let txn = host.resizeCapture;
1210
+ if (!txn && host.startupHistory) {
1211
+ startCapturedGrid(host, host.cols, host.rows, null, false);
1212
+ txn = host.resizeCapture;
1213
+ }
1214
+ if (txn) {
1215
+ try { txn.vt.feed(chunk); } catch (e) { console.error('[runner] resize capture', e && e.message); }
1216
+ txn.sawData = true;
1217
+ for (let i = 0; i < chunk.length; i++) {
1218
+ if (chunk.charCodeAt(i) === 7) { host.bells++; host.lastBellAt = Date.now(); }
1219
+ }
1220
+ armCapturedGrid(host, txn);
1221
+ return;
1222
+ }
1223
+
1224
+ // This is real output outside a resize transaction. It may have advanced
1225
+ // scrollback, so the next transaction takes a fresh canonical boundary.
1226
+ host.repaintArchive = null;
1227
+ try { host.vt.feed(chunk); } catch (e) { console.error('[runner] vt.feed', e && e.message); }
1228
+ host.historyCheckpoint.schedule();
1229
+
1230
+ // State detection rides the feed path: the grid is already current, so there
1231
+ // is nothing to poll and no subprocess to spawn.
1232
+ sampleScreen(host);
1233
+ for (let i = 0; i < chunk.length; i++) {
1234
+ if (chunk.charCodeAt(i) === 7) { host.bells++; host.lastBellAt = Date.now(); }
1235
+ }
1236
+
1237
+ for (const sub of host.subs) sub.onData(chunk);
1238
+ });
1239
+
1240
+ term.onExit(() => {
1241
+ hosts.delete(session.id);
1242
+ if (host.gridTimer) { clearTimeout(host.gridTimer); host.gridTimer = null; }
1243
+ if (host.traceHistoryTimer) { clearTimeout(host.traceHistoryTimer); host.traceHistoryTimer = null; }
1244
+ if (host.resizeCapture) {
1245
+ clearCaptureTimers(host.resizeCapture);
1246
+ try { host.resizeCapture.vt.dispose(); } catch {}
1247
+ host.resizeCapture = null;
1248
+ }
1249
+ host.historyCheckpoint.flush();
1250
+ try { host.vt.dispose(); } catch {}
1251
+ for (const sub of host.subs) sub.onExit();
1252
+ host.subs.clear();
1253
+ });
1254
 
1255
+ hosts.set(session.id, host);
1256
+ if (!persistedHistory && captureResize) hydrateTraceHistory(session, host);
1257
  if (!session.everStarted) update(session.id, { everStarted: true, pendingPrompt: undefined });
1258
  if (session.cli === 'codex') scheduleCodexCapture(session, workdir);
1259
  if (session.cli === 'opencode') scheduleOpencodeCapture(session, workdir);
1260
  if (session.cli === 'claude') scheduleClaudeCapture(session, workdir);
1261
+ return true;
 
 
 
 
 
 
 
 
 
 
1262
  }
1263
 
1264
  /**
1265
+ * Subscribe a viewer to a session, starting it if needed.
1266
+ *
1267
+ * Unlike the tmux version this does NOT spawn anything per viewer, and
1268
+ * `handle.kill()` only unsubscribes — closing a tab must never stop an agent.
1269
+ * `handle.restore()` returns a canonical snapshot: Ghostty's complete plain-text
1270
+ * history followed by a styled visible-screen repaint. It never replays an
1271
+ * arbitrary suffix of old PTY bytes at a new geometry.
1272
  */
1273
+ export function attach(session, cols, rows) {
1274
+ ensureRunning(session, cols, rows);
1275
+ const host = hosts.get(session.id);
1276
+ if (!host) throw new Error('session failed to start');
1277
+
1278
+ const sub = {
1279
+ onData: () => {},
1280
+ onExit: () => {},
1281
+ onGrid: () => {},
1282
+ want: preferredGrid(cols, rows, host),
1283
+ };
1284
+ host.subs.add(sub);
1285
+ if (!host.controller) host.controller = sub;
1286
+ // Existing viewers need to know that the session is now shared. The new
1287
+ // viewer receives the same role/count in its restore frame below.
1288
+ notifyGrid(host, false);
1289
+ if (host.controller === sub && (sub.want.cols !== host.cols || sub.want.rows !== host.rows)) scheduleGrid(host);
1290
+
1291
+ return {
1292
+ onData: (cb) => { sub.onData = (d) => { try { cb(d); } catch {} }; },
1293
+ onExit: (cb) => { sub.onExit = () => { try { cb(); } catch {} }; },
1294
+ onGrid: (cb) => { sub.onGrid = (c, r, controller, viewers, reset) => { try { cb(c, r, controller, viewers, reset); } catch {} }; },
1295
+ // Input and terminal-query responses are accepted from one emulator only.
1296
+ write: (d) => {
1297
+ if (host.controller !== sub) return;
1298
+ try { host.pty.write(d); } catch {}
1299
+ },
1300
+ // Every viewer remembers what it can display, but only the current
1301
+ // controller's request changes the PTY.
1302
+ resize: (c, r) => {
1303
+ if (!Number.isFinite(c) || !Number.isFinite(r)) return;
1304
+ const want = preferredGrid(c, r, host);
1305
+ const had = sub.want;
1306
+ sub.want = want;
1307
+ if (had && had.cols === want.cols && had.rows === want.rows) return;
1308
+ if (host.controller === sub) scheduleGrid(host);
1309
+ },
1310
+ claim: () => {
1311
+ if (!host.subs.has(sub) || host.controller === sub) return;
1312
+ host.controller = sub;
1313
+ notifyGrid(host, false);
1314
+ scheduleGrid(host);
1315
+ },
1316
+ restore: () => {
1317
+ let snap;
1318
+ try { snap = host.vt.snapshot({ includeCells: true, includeScrollback: true }); } catch { return null; }
1319
+ return {
1320
+ ansi: snapshotToRestoreAnsi(styledSnapshot(host, host.vt, snap)),
1321
+ cols: snap.cols,
1322
+ rows: snap.rows,
1323
+ viewers: host.subs.size,
1324
+ controller: host.controller === sub,
1325
+ };
1326
+ },
1327
+ // Detach this viewer only. The session, its grid and its scrollback stay.
1328
+ kill: () => {
1329
+ const controlled = host.controller === sub;
1330
+ host.subs.delete(sub);
1331
+ if (controlled) host.controller = host.subs.values().next().value || null;
1332
+ notifyGrid(host, false);
1333
+ if (controlled && host.controller) scheduleGrid(host);
1334
+ },
1335
+ };
1336
  }
1337
 
1338
  /** Type a line into the session's terminal (works with no browser attached). */
1339
  export async function sendInput(id, text) {
1340
+ const host = hosts.get(id);
1341
+ if (!host) throw new Error('session is not running');
1342
  // Multi-line prompts go in as a bracketed paste so the CLI's composer treats
1343
  // the inner newlines as soft line breaks instead of submitting early.
1344
  const payload = text.includes('\n') ? `\x1b[200~${text}\x1b[201~` : text;
1345
  // The Enter must arrive as its OWN keypress: TUIs (codex) detect rapid input
1346
  // bursts as a paste, and a CR inside the burst becomes a newline in the
1347
  // composer instead of a submit. A short gap breaks the burst.
1348
+ host.pty.write(payload);
1349
+ await new Promise((r) => setTimeout(r, 300));
1350
+ host.pty.write('\r');
 
 
 
 
 
 
 
 
 
 
1351
  }
1352
 
1353
  /**
1354
  * The session's rendered screen plus `lines` of scrollback above it — what a
1355
  * human would see in the pane. Used by the agent API so one agent can watch
1356
  * another's progress instead of spending a turn asking.
1357
+ *
1358
+ * Reads the grid we already hold, so it costs no subprocess. snapshot() returns
1359
+ * history as plain text, which is exactly what the tmux `capture-pane -p` this
1360
+ * replaced produced, so callers see the same shape.
1361
  */
1362
  export function capturePane(id, lines = 80) {
1363
+ const host = hosts.get(id);
1364
+ if (!host) return null; // stopped
1365
  const n = Math.max(0, Math.min(2000, lines));
1366
+ let snap;
1367
+ try { snap = host.vt.snapshot({ includeScrollback: n > 0 }); } catch { return null; }
1368
+ const history = (snap.scrollbackLines || []).slice(-n).map((l) => l.text);
1369
+ const visible = (snap.visibleLines || []).map((l) => l.text);
1370
+ // Trailing blank rows are padding, not content: a short screen should not
1371
+ // arrive as 50 lines of nothing.
1372
+ return [...history, ...visible].join('\n').replace(/\s+$/, '');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1373
  }
1374
 
1375
+ /** Stop a session entirely (kills the process; viewers get an exit close code). */
1376
  export function stop(id) {
1377
+ const host = hosts.get(id);
1378
+ if (!host) return;
1379
+ try { host.pty.kill(); } catch {}
1380
+ }
1381
+
1382
+ /**
1383
+ * Kill every session. Without tmux nothing outlives this process, so a clean
1384
+ * shutdown should not leave orphaned PTYs behind holding the workspace.
1385
+ */
1386
+ export function stopAll() {
1387
+ for (const host of hosts.values()) {
1388
+ try { host.pty.kill(); } catch {}
1389
  }
1390
  }
server/src/snapshot.js ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Turn a libghostty-vt snapshot into an ANSI repaint.
3
+ *
4
+ * Deliberately renderer-agnostic: the output is just bytes, so the same restore
5
+ * works for xterm.js today and anything else that speaks VT later. That
6
+ * portability is the argument for holding the grid server-side: the browser
7
+ * stays a dumb renderer, and a reattaching client is repainted from our own
8
+ * grid instead of asking the agent's TUI to redraw itself.
9
+ *
10
+ * snapshot() omits blank cells entirely, so gaps are painted as spaces.
11
+ */
12
+
13
+ /**
14
+ * snapshot() resolves palette colours to hex against libghostty's OWN default
15
+ * palette, so blindly emitting truecolor would repaint a restored screen in
16
+ * ghostty's greens and yellows instead of the theme the browser renders live
17
+ * with. Probe the library for its palette once, then emit indexed SGR whenever a
18
+ * colour came from it — the client re-maps those through its own theme, and only
19
+ * genuinely truecolor cells stay pinned.
20
+ */
21
+ let paletteIndex = null;
22
+
23
+ export function buildPaletteIndex(createTerminal) {
24
+ if (paletteIndex) return paletteIndex;
25
+ const map = new Map();
26
+ try {
27
+ const probe = createTerminal({ cols: 16, rows: 16, scrollbackLimit: 0 });
28
+ let out = '';
29
+ for (let i = 0; i < 256; i++) {
30
+ if (i % 16 === 0 && i > 0) out += '\r\n';
31
+ out += `\x1b[38;5;${i}mX`;
32
+ }
33
+ probe.feed(`${out}\x1b[0m`);
34
+ for (const cell of probe.snapshot({ includeCells: true }).cells || []) {
35
+ const idx = cell.row * 16 + cell.col;
36
+ // A cell with no explicit foreground rendered as the default, which has no
37
+ // stable index to map back to — leave those to truecolor.
38
+ if (idx < 256 && cell.foreground && !map.has(cell.foreground)) map.set(cell.foreground, idx);
39
+ }
40
+ probe.dispose();
41
+ } catch {
42
+ /* fall through to truecolor-only */
43
+ }
44
+ paletteIndex = map;
45
+ return map;
46
+ }
47
+
48
+ const hexToRgb = (hex) => {
49
+ const h = hex.replace('#', '');
50
+ if (h.length !== 6) return null;
51
+ const n = Number.parseInt(h, 16);
52
+ if (Number.isNaN(n)) return null;
53
+ return `${(n >> 16) & 255};${(n >> 8) & 255};${n & 255}`;
54
+ };
55
+
56
+ function colorSgr(hex, layer) {
57
+ const idx = paletteIndex && paletteIndex.get(hex);
58
+ if (idx !== undefined) return `\x1b[${layer};5;${idx}m`;
59
+ const rgb = hexToRgb(hex);
60
+ return rgb ? `\x1b[${layer};2;${rgb}m` : '';
61
+ }
62
+
63
+ function sgrFor(cell) {
64
+ let out = '';
65
+ if (cell.bold) out += '\x1b[1m';
66
+ if (cell.italic) out += '\x1b[3m';
67
+ if (cell.underline) out += '\x1b[4m';
68
+ if (cell.foreground) out += colorSgr(cell.foreground, 38);
69
+ if (cell.background) out += colorSgr(cell.background, 48);
70
+ return out;
71
+ }
72
+
73
+ /** Cells indexed by row, since snapshot() returns them as a flat sparse list. */
74
+ function cellGrid(snap) {
75
+ const grid = Array.from({ length: snap.rows }, () => new Array(snap.cols).fill(null));
76
+ for (const cell of snap.cells || []) {
77
+ if (cell.row >= 0 && cell.row < snap.rows && cell.col >= 0 && cell.col < snap.cols) {
78
+ grid[cell.row][cell.col] = cell;
79
+ }
80
+ }
81
+ return grid;
82
+ }
83
+
84
+ /**
85
+ * One row as styled text, with no cursor positioning. Everything after the last
86
+ * drawn cell is dropped: trailing spaces are the bulk of a mostly-empty agent
87
+ * screen, and the caller has either just erased the line or is about to end it.
88
+ */
89
+ function renderRow(cells, cols) {
90
+ let last = -1;
91
+ for (let col = cols - 1; col >= 0; col--) {
92
+ if (cells[col] && (cells[col].text !== ' ' || cells[col].background)) { last = col; break; }
93
+ }
94
+ if (last < 0) return '';
95
+
96
+ let out = '';
97
+ let style = '';
98
+ let col = 0;
99
+ while (col <= last) {
100
+ const cell = cells[col];
101
+ if (!cell) {
102
+ if (style) { out += '\x1b[0m'; style = ''; }
103
+ out += ' ';
104
+ col += 1;
105
+ continue;
106
+ }
107
+ const next = sgrFor(cell);
108
+ if (next !== style) {
109
+ out += '\x1b[0m' + next;
110
+ style = next;
111
+ }
112
+ out += cell.text;
113
+ col += Math.max(1, cell.width || 1);
114
+ }
115
+ if (style) out += '\x1b[0m';
116
+ return out;
117
+ }
118
+
119
+ export function snapshotToAnsi(snap) {
120
+ const grid = cellGrid(snap);
121
+
122
+ // Normalise the receiver's active buffer first. Reset attributes so nothing
123
+ // leaks in from what was showing, and hide the cursor so the repaint doesn't
124
+ // strobe across the screen.
125
+ let out = snap.isAltScreen ? '\x1b[?1049h' : '\x1b[?1049l';
126
+ out += '\x1b[?25l\x1b[0m\x1b[H\x1b[2J';
127
+
128
+ for (let row = 0; row < snap.rows; row++) {
129
+ const line = renderRow(grid[row], snap.cols);
130
+ if (line) out += `\x1b[${row + 1};1H` + line;
131
+ }
132
+
133
+ out += `\x1b[0m\x1b[${snap.cursorRow + 1};${snap.cursorCol + 1}H`;
134
+ out += '\x1b[?25h';
135
+ return out;
136
+ }
137
+
138
+ // Snapshot history is plain terminal text. Count its display columns closely
139
+ // enough to know how many visual rows it occupies when restored at a different
140
+ // width; using string.length loses wrapped rows (and then the screen repaint
141
+ // overwrites them). Combining marks are zero-width, common CJK/emoji ranges are
142
+ // two columns, and everything else is one.
143
+ export function textColumns(text) {
144
+ let width = 0;
145
+ for (const char of text) {
146
+ const cp = char.codePointAt(0);
147
+ if (cp === 0x200d || cp === 0xfe0e || cp === 0xfe0f || /\p{Mark}/u.test(char)) continue;
148
+ const wide = cp >= 0x1100 && (
149
+ cp <= 0x115f || cp === 0x2329 || cp === 0x232a
150
+ || (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f)
151
+ || (cp >= 0xac00 && cp <= 0xd7a3)
152
+ || (cp >= 0xf900 && cp <= 0xfaff)
153
+ || (cp >= 0xfe10 && cp <= 0xfe19)
154
+ || (cp >= 0xfe30 && cp <= 0xfe6f)
155
+ || (cp >= 0xff00 && cp <= 0xff60)
156
+ || (cp >= 0xffe0 && cp <= 0xffe6)
157
+ || (cp >= 0x1f300 && cp <= 0x1faff)
158
+ || (cp >= 0x20000 && cp <= 0x3fffd)
159
+ );
160
+ width += wide ? 2 : 1;
161
+ }
162
+ return width;
163
+ }
164
+
165
+ function htmlEntities(text) {
166
+ return text
167
+ .replace(/&#x([0-9a-f]+);/gi, (_, value) => String.fromCodePoint(Number.parseInt(value, 16)))
168
+ .replace(/&#(\d+);/g, (_, value) => String.fromCodePoint(Number.parseInt(value, 10)))
169
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>')
170
+ .replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'")
171
+ .replace(/&amp;/g, '&');
172
+ }
173
+
174
+ function cssColor(style, property, layer) {
175
+ const escaped = property.replace('-', '\\-');
176
+ const palette = style.match(new RegExp(`(?:^|;)${escaped}: var\\(--vt-palette-(\\d+)\\)`));
177
+ if (palette) return `${layer};5;${palette[1]}`;
178
+ const rgb = style.match(new RegExp(`(?:^|;)${escaped}: rgb\\((\\d+), (\\d+), (\\d+)\\)`));
179
+ return rgb ? `${layer};2;${rgb[1]};${rgb[2]};${rgb[3]}` : '';
180
+ }
181
+
182
+ function htmlStyleSgr(style) {
183
+ const codes = [];
184
+ const foreground = cssColor(style, 'color', 38);
185
+ const background = cssColor(style, 'background-color', 48);
186
+ if (foreground) codes.push(foreground);
187
+ if (background) codes.push(background);
188
+ if (style.includes('font-weight: bold')) codes.push('1');
189
+ if (style.includes('opacity: 0.5')) codes.push('2');
190
+ if (style.includes('font-style: italic')) codes.push('3');
191
+ if (/text-decoration-line:[^;]*underline/.test(style)) codes.push('4');
192
+ if (/text-decoration-line:[^;]*blink/.test(style)) codes.push('5');
193
+ if (style.includes('filter: invert(100%)')) codes.push('7');
194
+ if (/text-decoration-line:[^;]*line-through/.test(style)) codes.push('9');
195
+ if (/text-decoration-line:[^;]*overline/.test(style)) codes.push('53');
196
+ return codes.length ? `\x1b[${codes.join(';')}m` : '';
197
+ }
198
+
199
+ const hasAnsiStyle = (text) => text.replace(/\x1b\[0m/g, '').includes('\x1b[');
200
+
201
+ /** Convert Ghostty's deterministic debug HTML into one ANSI string per visual row. */
202
+ function htmlAnsiRows(html) {
203
+ if (typeof html !== 'string') return [];
204
+ const first = html.indexOf('>');
205
+ const last = html.lastIndexOf('</div>');
206
+ if (first < 0 || last <= first) return [];
207
+ const inner = html.slice(first + 1, last);
208
+ const ansi = inner
209
+ .replace(/<div style="([^"]*)">/g, (_, style) => htmlStyleSgr(style))
210
+ .replace(/<\/div>/g, '\x1b[0m');
211
+ return htmlEntities(ansi).split('\n');
212
+ }
213
+
214
+ /** Styled visual rows for the complete primary grid, including scrollback. */
215
+ export function styledTerminalRows(snap, html) {
216
+ const plain = [...(snap.scrollbackLines || []), ...(snap.visibleLines || [])];
217
+ const ansi = htmlAnsiRows(html);
218
+ while (ansi.length < plain.length && !(plain[ansi.length]?.text || '')) ansi.push('');
219
+ if (ansi.length !== plain.length) return plain.map((line) => ({ text: line.text || '' }));
220
+ return plain.map((line, index) => {
221
+ const text = line.text || '';
222
+ const rendered = ansi[index] || '';
223
+ return hasAnsiStyle(rendered) ? { text, ansi: `${rendered}\x1b[0m` } : { text };
224
+ });
225
+ }
226
+
227
+ function joinStyledRows(rows, cols) {
228
+ const lines = [];
229
+ let text = '';
230
+ let ansi = '';
231
+ for (const row of rows) {
232
+ text += row.text || '';
233
+ ansi += row.ansi || row.text || '';
234
+ if (textColumns(row.text || '') < cols) {
235
+ lines.push(hasAnsiStyle(ansi) ? { text, ansi: `${ansi}\x1b[0m` } : { text });
236
+ text = '';
237
+ ansi = '';
238
+ }
239
+ }
240
+ if (text || ansi) lines.push(hasAnsiStyle(ansi) ? { text, ansi: `${ansi}\x1b[0m` } : { text });
241
+ return lines;
242
+ }
243
+
244
+ /** Styled logical lines, joining visual rows that were soft-wrapped. */
245
+ export function styledLogicalLines(snap, html) {
246
+ return joinStyledRows(styledTerminalRows(snap, html), snap.cols);
247
+ }
248
+
249
+ /** Parse Ghostty's formatted state once when both visual and logical keys are needed. */
250
+ export function styledSnapshotLines(snap, html) {
251
+ const rows = styledTerminalRows(snap, html);
252
+ return { rows, logical: joinStyledRows(rows, snap.cols) };
253
+ }
254
+
255
+ /**
256
+ * Rebuild a fresh viewer from Ghostty's canonical state.
257
+ *
258
+ * The binding currently exposes styled cells only for the visible screen, while
259
+ * scrollback is plain text. That is still a much stronger restore boundary than
260
+ * replaying a truncated raw PTY byte stream: it includes every retained history
261
+ * row, is already reflowed to the current geometry, and cannot begin halfway
262
+ * through an escape sequence.
263
+ *
264
+ * A line becomes scrollback only after it leaves the visible grid. Print the
265
+ * history on the primary screen, then scroll the remaining visible history rows
266
+ * off before painting the authoritative current screen.
267
+ */
268
+ export function snapshotToRestoreAnsi(snap) {
269
+ const history = (snap.scrollbackLines || []).map((line) => ({
270
+ text: line.text || '', ansi: typeof line.ansi === 'string' ? line.ansi : '',
271
+ }));
272
+ let out = '\x1b[?1049l\x1b[?25l\x1b[0m\x1b[H\x1b[2J';
273
+ if (history.length) {
274
+ out += history.map((line) => line.ansi || line.text).join('\r\n');
275
+ const visualRows = history.reduce((total, line) =>
276
+ total + Math.max(1, Math.ceil(textColumns(line.text) / Math.max(1, snap.cols))), 0);
277
+ out += `\x1b[${snap.rows};1H` + '\r\n'.repeat(Math.min(visualRows, snap.rows));
278
+ }
279
+ out += snapshotToAnsi(snap);
280
+ return out;
281
+ }
tmux.conf DELETED
@@ -1,163 +0,0 @@
1
- # Agent Manager tmux config (passed via `tmux -f`).
2
- set -g status off # hide the status bar at the bottom
3
- # Mouse ON: tmux only enables mouse reporting on the client when this is on, so
4
- # it's required to forward the wheel to apps that request the mouse (Claude Code
5
- # scrolls its own transcript) and to scroll tmux history in plain shells. The
6
- # cost is that a plain drag goes to tmux copy-mode; Cmd+C in the frontend asks
7
- # tmux to copy that selection explicitly.
8
- set -g mouse on
9
- set -g history-limit 100000 # deep scrollback
10
- set -g window-size latest # size the window to the active client
11
- set -g destroy-unattached off # keep sessions alive when no client attached
12
- set -g escape-time 10
13
- set -g mode-style "fg=#0e1217,bg=#5fd0d8"
14
- # tmux still uses OSC 52 as the transport for explicit browser Cmd+C copies.
15
- set -g set-clipboard on
16
- set -as terminal-features ',*:clipboard'
17
- # Mouse selection should not copy to the system clipboard by itself. Keep the
18
- # highlight active, but store the selected text for the browser's later Cmd+C.
19
- bind -T copy-mode MouseDown1Pane send-keys -X cancel
20
- bind -T copy-mode-vi MouseDown1Pane send-keys -X cancel
21
- bind -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-no-clear "tmux load-buffer -b am-copy-#{session_name} -"
22
- bind -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-no-clear "tmux load-buffer -b am-copy-#{session_name} -"
23
- bind -T copy-mode DoubleClick1Pane select-pane \; send-keys -X select-word \; run-shell -d 0.3 \; send-keys -X copy-pipe-no-clear "tmux load-buffer -b am-copy-#{session_name} -"
24
- bind -T copy-mode-vi DoubleClick1Pane select-pane \; send-keys -X select-word \; run-shell -d 0.3 \; send-keys -X copy-pipe-no-clear "tmux load-buffer -b am-copy-#{session_name} -"
25
- bind -T copy-mode TripleClick1Pane select-pane \; send-keys -X select-line \; run-shell -d 0.3 \; send-keys -X copy-pipe-no-clear "tmux load-buffer -b am-copy-#{session_name} -"
26
- bind -T copy-mode-vi TripleClick1Pane select-pane \; send-keys -X select-line \; run-shell -d 0.3 \; send-keys -X copy-pipe-no-clear "tmux load-buffer -b am-copy-#{session_name} -"
27
- bind -T copy-mode C-c send-keys -X copy-pipe-no-clear
28
- bind -T copy-mode-vi C-c send-keys -X copy-pipe-no-clear
29
-
30
- # Typing beats modes: users scroll into copy mode (mouse wheel) and then
31
- # wonder why the keyboard is dead. Any printable key a command or prompt
32
- # would start with cancels copy mode and lands in the pane, so typing just
33
- # works. Scrolling keys (wheel/PgUp/arrows), Space (begin selection), q and
34
- # Escape keep their copy-mode roles; Cmd+C copies via the C-c binding above.
35
- # The frontend shows a "copy mode" overlay while a pane is in this mode.
36
- bind -T copy-mode a { send-keys -X cancel ; send-keys -- 'a' }
37
- bind -T copy-mode-vi a { send-keys -X cancel ; send-keys -- 'a' }
38
- bind -T copy-mode b { send-keys -X cancel ; send-keys -- 'b' }
39
- bind -T copy-mode-vi b { send-keys -X cancel ; send-keys -- 'b' }
40
- bind -T copy-mode c { send-keys -X cancel ; send-keys -- 'c' }
41
- bind -T copy-mode-vi c { send-keys -X cancel ; send-keys -- 'c' }
42
- bind -T copy-mode d { send-keys -X cancel ; send-keys -- 'd' }
43
- bind -T copy-mode-vi d { send-keys -X cancel ; send-keys -- 'd' }
44
- bind -T copy-mode e { send-keys -X cancel ; send-keys -- 'e' }
45
- bind -T copy-mode-vi e { send-keys -X cancel ; send-keys -- 'e' }
46
- bind -T copy-mode f { send-keys -X cancel ; send-keys -- 'f' }
47
- bind -T copy-mode-vi f { send-keys -X cancel ; send-keys -- 'f' }
48
- bind -T copy-mode g { send-keys -X cancel ; send-keys -- 'g' }
49
- bind -T copy-mode-vi g { send-keys -X cancel ; send-keys -- 'g' }
50
- bind -T copy-mode h { send-keys -X cancel ; send-keys -- 'h' }
51
- bind -T copy-mode-vi h { send-keys -X cancel ; send-keys -- 'h' }
52
- bind -T copy-mode i { send-keys -X cancel ; send-keys -- 'i' }
53
- bind -T copy-mode-vi i { send-keys -X cancel ; send-keys -- 'i' }
54
- bind -T copy-mode j { send-keys -X cancel ; send-keys -- 'j' }
55
- bind -T copy-mode-vi j { send-keys -X cancel ; send-keys -- 'j' }
56
- bind -T copy-mode k { send-keys -X cancel ; send-keys -- 'k' }
57
- bind -T copy-mode-vi k { send-keys -X cancel ; send-keys -- 'k' }
58
- bind -T copy-mode l { send-keys -X cancel ; send-keys -- 'l' }
59
- bind -T copy-mode-vi l { send-keys -X cancel ; send-keys -- 'l' }
60
- bind -T copy-mode m { send-keys -X cancel ; send-keys -- 'm' }
61
- bind -T copy-mode-vi m { send-keys -X cancel ; send-keys -- 'm' }
62
- bind -T copy-mode n { send-keys -X cancel ; send-keys -- 'n' }
63
- bind -T copy-mode-vi n { send-keys -X cancel ; send-keys -- 'n' }
64
- bind -T copy-mode o { send-keys -X cancel ; send-keys -- 'o' }
65
- bind -T copy-mode-vi o { send-keys -X cancel ; send-keys -- 'o' }
66
- bind -T copy-mode p { send-keys -X cancel ; send-keys -- 'p' }
67
- bind -T copy-mode-vi p { send-keys -X cancel ; send-keys -- 'p' }
68
- bind -T copy-mode q { send-keys -X cancel ; send-keys -- 'q' }
69
- bind -T copy-mode-vi q { send-keys -X cancel ; send-keys -- 'q' }
70
- bind -T copy-mode r { send-keys -X cancel ; send-keys -- 'r' }
71
- bind -T copy-mode-vi r { send-keys -X cancel ; send-keys -- 'r' }
72
- bind -T copy-mode s { send-keys -X cancel ; send-keys -- 's' }
73
- bind -T copy-mode-vi s { send-keys -X cancel ; send-keys -- 's' }
74
- bind -T copy-mode t { send-keys -X cancel ; send-keys -- 't' }
75
- bind -T copy-mode-vi t { send-keys -X cancel ; send-keys -- 't' }
76
- bind -T copy-mode u { send-keys -X cancel ; send-keys -- 'u' }
77
- bind -T copy-mode-vi u { send-keys -X cancel ; send-keys -- 'u' }
78
- bind -T copy-mode v { send-keys -X cancel ; send-keys -- 'v' }
79
- bind -T copy-mode-vi v { send-keys -X cancel ; send-keys -- 'v' }
80
- bind -T copy-mode w { send-keys -X cancel ; send-keys -- 'w' }
81
- bind -T copy-mode-vi w { send-keys -X cancel ; send-keys -- 'w' }
82
- bind -T copy-mode x { send-keys -X cancel ; send-keys -- 'x' }
83
- bind -T copy-mode-vi x { send-keys -X cancel ; send-keys -- 'x' }
84
- bind -T copy-mode y { send-keys -X cancel ; send-keys -- 'y' }
85
- bind -T copy-mode-vi y { send-keys -X cancel ; send-keys -- 'y' }
86
- bind -T copy-mode z { send-keys -X cancel ; send-keys -- 'z' }
87
- bind -T copy-mode-vi z { send-keys -X cancel ; send-keys -- 'z' }
88
- bind -T copy-mode A { send-keys -X cancel ; send-keys -- 'A' }
89
- bind -T copy-mode-vi A { send-keys -X cancel ; send-keys -- 'A' }
90
- bind -T copy-mode B { send-keys -X cancel ; send-keys -- 'B' }
91
- bind -T copy-mode-vi B { send-keys -X cancel ; send-keys -- 'B' }
92
- bind -T copy-mode C { send-keys -X cancel ; send-keys -- 'C' }
93
- bind -T copy-mode-vi C { send-keys -X cancel ; send-keys -- 'C' }
94
- bind -T copy-mode D { send-keys -X cancel ; send-keys -- 'D' }
95
- bind -T copy-mode-vi D { send-keys -X cancel ; send-keys -- 'D' }
96
- bind -T copy-mode E { send-keys -X cancel ; send-keys -- 'E' }
97
- bind -T copy-mode-vi E { send-keys -X cancel ; send-keys -- 'E' }
98
- bind -T copy-mode F { send-keys -X cancel ; send-keys -- 'F' }
99
- bind -T copy-mode-vi F { send-keys -X cancel ; send-keys -- 'F' }
100
- bind -T copy-mode G { send-keys -X cancel ; send-keys -- 'G' }
101
- bind -T copy-mode-vi G { send-keys -X cancel ; send-keys -- 'G' }
102
- bind -T copy-mode H { send-keys -X cancel ; send-keys -- 'H' }
103
- bind -T copy-mode-vi H { send-keys -X cancel ; send-keys -- 'H' }
104
- bind -T copy-mode I { send-keys -X cancel ; send-keys -- 'I' }
105
- bind -T copy-mode-vi I { send-keys -X cancel ; send-keys -- 'I' }
106
- bind -T copy-mode J { send-keys -X cancel ; send-keys -- 'J' }
107
- bind -T copy-mode-vi J { send-keys -X cancel ; send-keys -- 'J' }
108
- bind -T copy-mode K { send-keys -X cancel ; send-keys -- 'K' }
109
- bind -T copy-mode-vi K { send-keys -X cancel ; send-keys -- 'K' }
110
- bind -T copy-mode L { send-keys -X cancel ; send-keys -- 'L' }
111
- bind -T copy-mode-vi L { send-keys -X cancel ; send-keys -- 'L' }
112
- bind -T copy-mode M { send-keys -X cancel ; send-keys -- 'M' }
113
- bind -T copy-mode-vi M { send-keys -X cancel ; send-keys -- 'M' }
114
- bind -T copy-mode N { send-keys -X cancel ; send-keys -- 'N' }
115
- bind -T copy-mode-vi N { send-keys -X cancel ; send-keys -- 'N' }
116
- bind -T copy-mode O { send-keys -X cancel ; send-keys -- 'O' }
117
- bind -T copy-mode-vi O { send-keys -X cancel ; send-keys -- 'O' }
118
- bind -T copy-mode P { send-keys -X cancel ; send-keys -- 'P' }
119
- bind -T copy-mode-vi P { send-keys -X cancel ; send-keys -- 'P' }
120
- bind -T copy-mode Q { send-keys -X cancel ; send-keys -- 'Q' }
121
- bind -T copy-mode-vi Q { send-keys -X cancel ; send-keys -- 'Q' }
122
- bind -T copy-mode R { send-keys -X cancel ; send-keys -- 'R' }
123
- bind -T copy-mode-vi R { send-keys -X cancel ; send-keys -- 'R' }
124
- bind -T copy-mode S { send-keys -X cancel ; send-keys -- 'S' }
125
- bind -T copy-mode-vi S { send-keys -X cancel ; send-keys -- 'S' }
126
- bind -T copy-mode T { send-keys -X cancel ; send-keys -- 'T' }
127
- bind -T copy-mode-vi T { send-keys -X cancel ; send-keys -- 'T' }
128
- bind -T copy-mode U { send-keys -X cancel ; send-keys -- 'U' }
129
- bind -T copy-mode-vi U { send-keys -X cancel ; send-keys -- 'U' }
130
- bind -T copy-mode V { send-keys -X cancel ; send-keys -- 'V' }
131
- bind -T copy-mode-vi V { send-keys -X cancel ; send-keys -- 'V' }
132
- bind -T copy-mode W { send-keys -X cancel ; send-keys -- 'W' }
133
- bind -T copy-mode-vi W { send-keys -X cancel ; send-keys -- 'W' }
134
- bind -T copy-mode X { send-keys -X cancel ; send-keys -- 'X' }
135
- bind -T copy-mode-vi X { send-keys -X cancel ; send-keys -- 'X' }
136
- bind -T copy-mode Y { send-keys -X cancel ; send-keys -- 'Y' }
137
- bind -T copy-mode-vi Y { send-keys -X cancel ; send-keys -- 'Y' }
138
- bind -T copy-mode Z { send-keys -X cancel ; send-keys -- 'Z' }
139
- bind -T copy-mode-vi Z { send-keys -X cancel ; send-keys -- 'Z' }
140
- bind -T copy-mode 0 { send-keys -X cancel ; send-keys -- '0' }
141
- bind -T copy-mode-vi 0 { send-keys -X cancel ; send-keys -- '0' }
142
- bind -T copy-mode 1 { send-keys -X cancel ; send-keys -- '1' }
143
- bind -T copy-mode-vi 1 { send-keys -X cancel ; send-keys -- '1' }
144
- bind -T copy-mode 2 { send-keys -X cancel ; send-keys -- '2' }
145
- bind -T copy-mode-vi 2 { send-keys -X cancel ; send-keys -- '2' }
146
- bind -T copy-mode 3 { send-keys -X cancel ; send-keys -- '3' }
147
- bind -T copy-mode-vi 3 { send-keys -X cancel ; send-keys -- '3' }
148
- bind -T copy-mode 4 { send-keys -X cancel ; send-keys -- '4' }
149
- bind -T copy-mode-vi 4 { send-keys -X cancel ; send-keys -- '4' }
150
- bind -T copy-mode 5 { send-keys -X cancel ; send-keys -- '5' }
151
- bind -T copy-mode-vi 5 { send-keys -X cancel ; send-keys -- '5' }
152
- bind -T copy-mode 6 { send-keys -X cancel ; send-keys -- '6' }
153
- bind -T copy-mode-vi 6 { send-keys -X cancel ; send-keys -- '6' }
154
- bind -T copy-mode 7 { send-keys -X cancel ; send-keys -- '7' }
155
- bind -T copy-mode-vi 7 { send-keys -X cancel ; send-keys -- '7' }
156
- bind -T copy-mode 8 { send-keys -X cancel ; send-keys -- '8' }
157
- bind -T copy-mode-vi 8 { send-keys -X cancel ; send-keys -- '8' }
158
- bind -T copy-mode 9 { send-keys -X cancel ; send-keys -- '9' }
159
- bind -T copy-mode-vi 9 { send-keys -X cancel ; send-keys -- '9' }
160
- bind -T copy-mode Enter { send-keys -X cancel ; send-keys Enter }
161
- bind -T copy-mode-vi Enter { send-keys -X cancel ; send-keys Enter }
162
- bind -T copy-mode BSpace { send-keys -X cancel ; send-keys BSpace }
163
- bind -T copy-mode-vi BSpace { send-keys -X cancel ; send-keys BSpace }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/App.tsx CHANGED
@@ -14,7 +14,7 @@ import Locked from './components/Locked';
14
  import Welcome from './components/Welcome';
15
  import * as api from './api';
16
  import type { Cli, GridSpec, MoveTarget, OverviewFilter, Session, Tree } from './types';
17
- import { isPassive } from './types';
18
  import { GridGlyph, ListGlyph } from './components/icons';
19
 
20
  // Phone-sized viewport: the app becomes two full-screen views (list ⇄ pane).
@@ -40,6 +40,7 @@ function autoGrid(n: number): GridSpec {
40
 
41
  type SettingsPage = 'general' | 'usage' | 'skills';
42
  const ROOT_PATH = '.';
 
43
  const normalizePath = (p?: string | null) => (p && p.trim() ? p : ROOT_PATH);
44
 
45
  function initialTheme(): 'light' | 'dark' {
@@ -103,15 +104,188 @@ export default function App() {
103
 
104
  // Track the visual viewport so the mobile layout can sit above the on-screen
105
  // keyboard (which shrinks visualViewport but not the layout viewport on iOS).
106
- // --vvh drives the mobile app height (see styles.css).
 
 
 
107
  useEffect(() => {
108
  const vv = window.visualViewport;
109
- if (!vv) return;
110
- const apply = () => document.documentElement.style.setProperty('--vvh', `${Math.round(vv.height)}px`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  apply();
112
- vv.addEventListener('resize', apply);
113
- vv.addEventListener('scroll', apply);
114
- return () => { vv.removeEventListener('resize', apply); vv.removeEventListener('scroll', apply); };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  }, []);
116
 
117
  // Refresh info while locked so flipping the Space to Private unlocks the UI
@@ -256,6 +430,38 @@ export default function App() {
256
  const visibleIds = visibleSessions.map((s) => s.id).join(',');
257
  const showZoom = visibleSessions.length > 0;
258
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  // Pane rearrangement (drag a pane header onto another tile).
260
  const [paneDrag, setPaneDrag] = useState(false);
261
  const [overTile, setOverTile] = useState<number | null>(null);
@@ -474,21 +680,60 @@ export default function App() {
474
  const slotCount = activeGroup ? g.cols * g.rows : sessions.length;
475
  const slots = Array.from({ length: slotCount }, (_, i) => sessions[i] ?? null);
476
  const canDrag = !!activeGroup && groupSessions.length > 1;
 
 
 
 
 
 
 
 
 
477
  // A sidebar session hovering a full grid still needs somewhere to land:
478
  // offer a ghost strip appended below the tiles.
479
  const ghost = sessionDragActive && !!activeGroup;
480
  return (
481
  <div
482
- className={`tiles${paneDrag ? ' pane-dragging' : ''}${sessionDragActive && activeGroup ? ' session-dragging' : ''}`}
483
  style={{ gridTemplateColumns: `repeat(${g.cols}, 1fr)`, gridTemplateRows: `repeat(${g.rows}, minmax(0, 1fr))` }}
484
  onDragOver={activeGroup ? allowDrop : undefined}
485
  onDragLeave={() => setDropMain(false)}
486
  onDrop={activeGroup ? onDropMain : undefined}
487
  >
488
- {slots.map((s, i) => (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
489
  <div
490
- key={s ? s.id : `empty-${i}`}
491
  className={`tile${s ? '' : ' tile-empty'}${(paneDrag || sessionDragActive) && overTile === i ? ' tile-over' : ''}`}
 
492
  {...(activeGroup ? tileDnd(i, !!s) : {})}
493
  >
494
  {s && (s.cli === 'files' ? (
@@ -512,34 +757,19 @@ export default function App() {
512
  onRename={(name) => renameSession(s.id, name)}
513
  onClose={() => closePane(s.id)}
514
  />
515
- ) : s.cli === 'trace' ? (
516
- <TracePane
517
- session={s}
518
- zoom={zoom}
519
- focused={visibleSessions.length > 1 && s.id === focusedId}
520
- dragId={canDrag ? `p:${s.id}` : undefined}
521
- onDragActive={setPaneDrag}
522
- onFocus={() => setFocusedId(s.id)}
523
- onClose={() => closePane(s.id)}
524
- />
525
  ) : (
526
- <TerminalPane
527
  session={s}
528
- cli={cliMap[s.cli]}
529
- theme={theme}
530
  zoom={zoom}
531
  focused={visibleSessions.length > 1 && s.id === focusedId}
532
- active={s.id === focusedId}
533
  dragId={canDrag ? `p:${s.id}` : undefined}
534
- isMobile={isMobile}
535
  onDragActive={setPaneDrag}
536
  onFocus={() => setFocusedId(s.id)}
537
- onRename={(name) => renameSession(s.id, name)}
538
  onClose={() => closePane(s.id)}
539
  />
540
  ))}
541
  </div>
542
- ))}
543
  {ghost && (
544
  <div
545
  className={`tile tile-ghost${overTile === slots.length ? ' tile-over' : ''}`}
@@ -554,8 +784,9 @@ export default function App() {
554
 
555
  if (info?.locked) return <Locked spaceId={info.spaceId} reason={info.lockReason} bucket={info.lockBucket} />;
556
 
557
- if (settingsOpen) {
558
- return (
 
559
  <SettingsView
560
  page={settingsPage}
561
  onPage={setSettingsPage}
@@ -568,11 +799,8 @@ export default function App() {
568
  demoMode={!!info?.demoMode}
569
  onToggleDemo={toggleDemo}
570
  />
571
- );
572
- }
573
-
574
- return (
575
- <div className={`app${isMobile ? (mobileStage ? ' m-stage' : ' m-home') : ''}`}>
576
  {showWelcome && <Welcome onClose={dismissWelcome} />}
577
  {toast && <div className="toast mono" role="alert">{toast}</div>}
578
  {shareId && sessById[shareId] && (
@@ -658,6 +886,14 @@ export default function App() {
658
  </div>
659
  )}
660
  <div className="stage">
 
 
 
 
 
 
 
 
661
  {activeRef === 'overview' ? (
662
  <Overview
663
  clis={clis}
@@ -674,8 +910,7 @@ export default function App() {
674
  openSession(sid, g?.id);
675
  }}
676
  />
677
- ) : activeGroup ? (
678
- groupSessions.length === 0 ? (
679
  <div
680
  className={`empty-group${dropMain ? ' drop-over' : ''}`}
681
  onDragOver={allowDrop}
@@ -688,17 +923,14 @@ export default function App() {
688
  <div className="dropline">drop an agent here to add it to this group</div>
689
  </div>
690
  </div>
691
- ) : renderTiles(pageSessions, grid)
692
- ) : activeSingle ? (
693
- renderTiles([activeSingle], { cols: 1, rows: 1 })
694
- ) : (
695
  <div className="empty-group">
696
  <EmptyPrompt
697
  path="workspaces"
698
  hints={['create an agent or a group from the sidebar', 'drag an agent onto another to group them']}
699
  />
700
  </div>
701
- )}
702
  </div>
703
  {activeRef === 'overview' && (
704
  <div className="zoombar ov-bar">
@@ -735,5 +967,6 @@ export default function App() {
735
  )}
736
  </div>
737
  </div>
 
738
  );
739
  }
 
14
  import Welcome from './components/Welcome';
15
  import * as api from './api';
16
  import type { Cli, GridSpec, MoveTarget, OverviewFilter, Session, Tree } from './types';
17
+ import { isPassive, isRemote } from './types';
18
  import { GridGlyph, ListGlyph } from './components/icons';
19
 
20
  // Phone-sized viewport: the app becomes two full-screen views (list ⇄ pane).
 
40
 
41
  type SettingsPage = 'general' | 'usage' | 'skills';
42
  const ROOT_PATH = '.';
43
+ const WARM_TERMINAL_LIMIT = 12;
44
  const normalizePath = (p?: string | null) => (p && p.trim() ? p : ROOT_PATH);
45
 
46
  function initialTheme(): 'light' | 'dark' {
 
104
 
105
  // Track the visual viewport so the mobile layout can sit above the on-screen
106
  // keyboard (which shrinks visualViewport but not the layout viewport on iOS).
107
+ // The CSS variables pin the app to that viewport's exact rectangle. The Hub
108
+ // page embeds the app in a cross-origin iframe; mobile Safari leaves that
109
+ // child viewport unchanged when its keyboard opens. In that one no-signal
110
+ // case, fall back to a conservative focus-derived visible height.
111
  useEffect(() => {
112
  const vv = window.visualViewport;
113
+ type VirtualKeyboardLike = EventTarget & { boundingRect?: DOMRectReadOnly };
114
+ const keyboard = (navigator as Navigator & { virtualKeyboard?: VirtualKeyboardLike }).virtualKeyboard;
115
+ type ViewportBaseline = {
116
+ width: number;
117
+ height: number;
118
+ top: number;
119
+ innerHeight: number;
120
+ };
121
+ const root = document.documentElement;
122
+ const keyboardSignalThreshold = 80;
123
+ const embedded = window.self !== window.top;
124
+ let focusedInput: Element | null = null;
125
+ let focusBaseline: ViewportBaseline | null = null;
126
+ let focusFallback = false;
127
+ let focusFallbackTimer: ReturnType<typeof setTimeout> | null = null;
128
+
129
+ const acceptsKeyboardInput = (target: Element | null): target is HTMLElement => {
130
+ if (!(target instanceof HTMLElement)) return false;
131
+ if (target.isContentEditable) return true;
132
+ if (target instanceof HTMLTextAreaElement) return !target.readOnly && !target.disabled;
133
+ if (!(target instanceof HTMLInputElement) || target.readOnly || target.disabled) return false;
134
+ return !['button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit']
135
+ .includes(target.type);
136
+ };
137
+ const embeddedTouchLayout = () => embedded
138
+ && window.matchMedia('(max-width: 720px)').matches
139
+ && (navigator.maxTouchPoints > 0 || window.matchMedia('(pointer: coarse)').matches);
140
+ const captureViewport = (): ViewportBaseline => ({
141
+ width: vv?.width ?? document.documentElement.clientWidth,
142
+ height: vv?.height ?? window.innerHeight,
143
+ top: vv?.offsetTop ?? 0,
144
+ innerHeight: window.innerHeight,
145
+ });
146
+ const hasKeyboardGeometry = () => {
147
+ if (!focusBaseline) return false;
148
+ const keyboardHeight = keyboard?.boundingRect?.height ?? 0;
149
+ const visualHeight = vv?.height ?? window.innerHeight;
150
+ const visualShrink = focusBaseline.height - visualHeight;
151
+ const layoutShrink = focusBaseline.innerHeight - window.innerHeight;
152
+ return keyboardHeight >= keyboardSignalThreshold
153
+ || visualShrink >= keyboardSignalThreshold
154
+ || layoutShrink >= keyboardSignalThreshold;
155
+ };
156
+ const apply = () => {
157
+ const keyboardRect = keyboard?.boundingRect;
158
+ const left = vv?.offsetLeft ?? 0;
159
+ const top = vv?.offsetTop ?? 0;
160
+ const width = vv?.width ?? document.documentElement.clientWidth;
161
+ let height = vv?.height ?? window.innerHeight;
162
+ // Chromium can expose keyboard geometry even when an embedded document's
163
+ // visual viewport is unchanged. Never grow the visual viewport from it;
164
+ // only clip an actually overlapping keyboard.
165
+ if (keyboardRect && keyboardRect.height > 0 && keyboardRect.top > top) {
166
+ height = Math.min(height, keyboardRect.top - top);
167
+ }
168
+ if (hasKeyboardGeometry()) focusFallback = false;
169
+ if (focusFallback && focusBaseline && acceptsKeyboardInput(document.activeElement)) {
170
+ // The parent page owns the real visual viewport, but cross-origin frame
171
+ // isolation prevents us from reading it. A phone keyboard typically
172
+ // consumes roughly the lower half; 54% visible keeps the xterm prompt
173
+ // above it without disturbing direct-app browsers with real geometry.
174
+ const visibleRatio = focusBaseline.width > focusBaseline.height ? 0.48 : 0.54;
175
+ height = Math.min(height, Math.round(focusBaseline.height * visibleRatio));
176
+ root.dataset.keyboardLayout = 'focus-fallback';
177
+ } else if (hasKeyboardGeometry()) {
178
+ root.dataset.keyboardLayout = 'browser-geometry';
179
+ } else {
180
+ delete root.dataset.keyboardLayout;
181
+ }
182
+ root.style.setProperty('--vvw', `${Math.round(width)}px`);
183
+ root.style.setProperty('--vvh', `${Math.round(height)}px`);
184
+ root.style.setProperty('--vv-top', `${Math.round(top)}px`);
185
+ root.style.setProperty('--vv-left', `${Math.round(left)}px`);
186
+ };
187
+
188
+ // WebKit may dispatch the keyboard viewport event before offsetTop has its
189
+ // final value, then correct the property without another event. Re-read at
190
+ // the end of the event turn and while the focus animation settles.
191
+ const settleTimers = new Set<ReturnType<typeof setTimeout>>();
192
+ const focusTimers = new Set<ReturnType<typeof setTimeout>>();
193
+ const onViewportChange = () => {
194
+ apply();
195
+ for (const timer of settleTimers) clearTimeout(timer);
196
+ settleTimers.clear();
197
+ // One delayed read is still racy: WebKit can update offsetTop immediately
198
+ // after it fires. Sample the short animation tail without depending on a
199
+ // second browser event.
200
+ for (const delay of [50, 150, 300, 500]) {
201
+ const timer = setTimeout(() => { settleTimers.delete(timer); apply(); }, delay);
202
+ settleTimers.add(timer);
203
+ }
204
+ };
205
+ const stabilizeFocus = () => {
206
+ for (const timer of focusTimers) clearTimeout(timer);
207
+ focusTimers.clear();
208
+ for (const delay of [0, 50, 150, 300, 500, 800]) {
209
+ const timer = setTimeout(() => { focusTimers.delete(timer); apply(); }, delay);
210
+ focusTimers.add(timer);
211
+ }
212
+ };
213
+ const scheduleEmbeddedFallback = () => {
214
+ if (focusFallbackTimer) clearTimeout(focusFallbackTimer);
215
+ focusFallbackTimer = null;
216
+ if (!embeddedTouchLayout() || !acceptsKeyboardInput(document.activeElement)) return;
217
+ focusFallbackTimer = setTimeout(() => {
218
+ focusFallbackTimer = null;
219
+ if (document.activeElement === focusedInput && !hasKeyboardGeometry()) {
220
+ focusFallback = true;
221
+ apply();
222
+ }
223
+ }, 500);
224
+ };
225
+ const onFocusIn = (event: FocusEvent) => {
226
+ const target = event.target instanceof Element ? event.target : null;
227
+ if (acceptsKeyboardInput(target)) {
228
+ focusedInput = target;
229
+ focusBaseline = captureViewport();
230
+ focusFallback = false;
231
+ stabilizeFocus();
232
+ scheduleEmbeddedFallback();
233
+ return;
234
+ }
235
+ stabilizeFocus();
236
+ };
237
+ const onFocusOut = () => {
238
+ if (focusFallbackTimer) clearTimeout(focusFallbackTimer);
239
+ focusFallbackTimer = null;
240
+ const timer = setTimeout(() => {
241
+ focusTimers.delete(timer);
242
+ if (!acceptsKeyboardInput(document.activeElement)) {
243
+ focusedInput = null;
244
+ focusBaseline = null;
245
+ focusFallback = false;
246
+ apply();
247
+ }
248
+ }, 0);
249
+ focusTimers.add(timer);
250
+ };
251
+ const onOrientationChange = () => {
252
+ if (focusFallbackTimer) clearTimeout(focusFallbackTimer);
253
+ focusFallbackTimer = null;
254
+ focusFallback = false;
255
+ if (acceptsKeyboardInput(document.activeElement)) {
256
+ focusedInput = document.activeElement;
257
+ focusBaseline = captureViewport();
258
+ }
259
+ stabilizeFocus();
260
+ scheduleEmbeddedFallback();
261
+ };
262
  apply();
263
+ vv?.addEventListener('resize', onViewportChange);
264
+ vv?.addEventListener('scroll', onViewportChange);
265
+ vv?.addEventListener('scrollend', onViewportChange);
266
+ keyboard?.addEventListener('geometrychange', onViewportChange);
267
+ window.addEventListener('resize', onViewportChange);
268
+ window.addEventListener('orientationchange', onOrientationChange);
269
+ document.addEventListener('focusin', onFocusIn);
270
+ document.addEventListener('focusout', onFocusOut);
271
+ return () => {
272
+ for (const timer of settleTimers) clearTimeout(timer);
273
+ for (const timer of focusTimers) clearTimeout(timer);
274
+ if (focusFallbackTimer) clearTimeout(focusFallbackTimer);
275
+ vv?.removeEventListener('resize', onViewportChange);
276
+ vv?.removeEventListener('scroll', onViewportChange);
277
+ vv?.removeEventListener('scrollend', onViewportChange);
278
+ keyboard?.removeEventListener('geometrychange', onViewportChange);
279
+ window.removeEventListener('resize', onViewportChange);
280
+ window.removeEventListener('orientationchange', onOrientationChange);
281
+ document.removeEventListener('focusin', onFocusIn);
282
+ document.removeEventListener('focusout', onFocusOut);
283
+ root.style.removeProperty('--vvw');
284
+ root.style.removeProperty('--vvh');
285
+ root.style.removeProperty('--vv-top');
286
+ root.style.removeProperty('--vv-left');
287
+ delete root.dataset.keyboardLayout;
288
+ };
289
  }, []);
290
 
291
  // Refresh info while locked so flipping the Space to Private unlocks the UI
 
430
  const visibleIds = visibleSessions.map((s) => s.id).join(',');
431
  const showZoom = visibleSessions.length > 0;
432
 
433
+ // Keep a small working set of terminal panes alive across navigation. The
434
+ // backend session already survives a viewer disconnect; retaining xterm and
435
+ // its socket as well makes switching back genuinely instant and preserves the
436
+ // exact local scroll/selection state. This is deliberately bounded: mounting
437
+ // every session would start stopped agents and turn a large sidebar into many
438
+ // live WebSockets and 20k-line browser buffers.
439
+ const visibleTerminalIds = (settingsOpen || (isMobile && !mobileStage) ? [] : visibleSessions)
440
+ .filter((s) => !isPassive(s.cli) && !isRemote(s.cli))
441
+ .map((s) => s.id);
442
+ const visibleTerminalKey = visibleTerminalIds.join(',');
443
+ const sessionIdsKey = tree.sessions.map((s) => s.id).join(',');
444
+ const [warmTerminalIds, setWarmTerminalIds] = useState<string[]>([]);
445
+ useEffect(() => {
446
+ const valid = new Set(tree.sessions
447
+ .filter((s) => !isPassive(s.cli) && !isRemote(s.cli))
448
+ .map((s) => s.id));
449
+ setWarmTerminalIds((previous) => {
450
+ const next = [...visibleTerminalIds, ...previous]
451
+ .filter((id, i, all) => valid.has(id) && all.indexOf(id) === i)
452
+ .slice(0, Math.max(WARM_TERMINAL_LIMIT, visibleTerminalIds.length));
453
+ return next.length === previous.length && next.every((id, i) => id === previous[i])
454
+ ? previous : next;
455
+ });
456
+ // Stable string keys avoid running this state update on every tree poll.
457
+ // eslint-disable-next-line react-hooks/exhaustive-deps
458
+ }, [visibleTerminalKey, sessionIdsKey]);
459
+ // Include newly selected panes on the navigation render itself; the effect
460
+ // above retains them for later renders and applies the LRU bound.
461
+ const retainedTerminalIds = [...visibleTerminalIds, ...warmTerminalIds]
462
+ .filter((id, i, all) => !!sessById[id] && all.indexOf(id) === i)
463
+ .slice(0, Math.max(WARM_TERMINAL_LIMIT, visibleTerminalIds.length));
464
+
465
  // Pane rearrangement (drag a pane header onto another tile).
466
  const [paneDrag, setPaneDrag] = useState(false);
467
  const [overTile, setOverTile] = useState<number | null>(null);
 
680
  const slotCount = activeGroup ? g.cols * g.rows : sessions.length;
681
  const slots = Array.from({ length: slotCount }, (_, i) => sessions[i] ?? null);
682
  const canDrag = !!activeGroup && groupSessions.length > 1;
683
+ const slotStyle = (i: number) => ({
684
+ gridColumn: (i % g.cols) + 1,
685
+ gridRow: Math.floor(i / g.cols) + 1,
686
+ });
687
+ const slotByTerminal = new Map(slots
688
+ .map((s, i) => [s, i] as const)
689
+ .filter(([s]) => s && !isPassive(s.cli) && !isRemote(s.cli))
690
+ .map(([s, i]) => [s!.id, i]));
691
+ const deckVisible = !settingsOpen && sessions.length > 0 && (!isMobile || mobileStage);
692
  // A sidebar session hovering a full grid still needs somewhere to land:
693
  // offer a ghost strip appended below the tiles.
694
  const ghost = sessionDragActive && !!activeGroup;
695
  return (
696
  <div
697
+ className={`tiles pane-deck${sessions.length ? '' : ' deck-hidden'}${paneDrag ? ' pane-dragging' : ''}${sessionDragActive && activeGroup ? ' session-dragging' : ''}`}
698
  style={{ gridTemplateColumns: `repeat(${g.cols}, 1fr)`, gridTemplateRows: `repeat(${g.rows}, minmax(0, 1fr))` }}
699
  onDragOver={activeGroup ? allowDrop : undefined}
700
  onDragLeave={() => setDropMain(false)}
701
  onDrop={activeGroup ? onDropMain : undefined}
702
  >
703
+ {retainedTerminalIds.map((id) => {
704
+ const s = sessById[id];
705
+ if (!s) return null;
706
+ const slot = slotByTerminal.get(id);
707
+ const shown = slot !== undefined;
708
+ return (
709
+ <div
710
+ key={`terminal-${id}`}
711
+ className={`tile tile-terminal${shown ? '' : ' tile-cached'}`}
712
+ style={shown ? slotStyle(slot) : undefined}
713
+ {...(shown && activeGroup ? tileDnd(slot, true) : {})}
714
+ >
715
+ <TerminalPane
716
+ session={s}
717
+ cli={cliMap[s.cli]}
718
+ theme={theme}
719
+ zoom={zoom}
720
+ focused={shown && sessions.length > 1 && s.id === focusedId}
721
+ active={shown && deckVisible && s.id === focusedId}
722
+ dragId={shown && canDrag ? `p:${s.id}` : undefined}
723
+ isMobile={isMobile}
724
+ onDragActive={setPaneDrag}
725
+ onFocus={() => setFocusedId(s.id)}
726
+ onRename={(name) => renameSession(s.id, name)}
727
+ onClose={() => closePane(s.id)}
728
+ />
729
+ </div>
730
+ );
731
+ })}
732
+ {slots.map((s, i) => (s && !isPassive(s.cli) && !isRemote(s.cli) ? null : (
733
  <div
734
+ key={s ? `passive-${s.id}` : `empty-${i}`}
735
  className={`tile${s ? '' : ' tile-empty'}${(paneDrag || sessionDragActive) && overTile === i ? ' tile-over' : ''}`}
736
+ style={slotStyle(i)}
737
  {...(activeGroup ? tileDnd(i, !!s) : {})}
738
  >
739
  {s && (s.cli === 'files' ? (
 
757
  onRename={(name) => renameSession(s.id, name)}
758
  onClose={() => closePane(s.id)}
759
  />
 
 
 
 
 
 
 
 
 
 
760
  ) : (
761
+ <TracePane
762
  session={s}
 
 
763
  zoom={zoom}
764
  focused={visibleSessions.length > 1 && s.id === focusedId}
 
765
  dragId={canDrag ? `p:${s.id}` : undefined}
 
766
  onDragActive={setPaneDrag}
767
  onFocus={() => setFocusedId(s.id)}
 
768
  onClose={() => closePane(s.id)}
769
  />
770
  ))}
771
  </div>
772
+ )))}
773
  {ghost && (
774
  <div
775
  className={`tile tile-ghost${overTile === slots.length ? ' tile-over' : ''}`}
 
784
 
785
  if (info?.locked) return <Locked spaceId={info.spaceId} reason={info.lockReason} bucket={info.lockBucket} />;
786
 
787
+ return (
788
+ <>
789
+ {settingsOpen && (
790
  <SettingsView
791
  page={settingsPage}
792
  onPage={setSettingsPage}
 
799
  demoMode={!!info?.demoMode}
800
  onToggleDemo={toggleDemo}
801
  />
802
+ )}
803
+ <div className={`app${settingsOpen ? ' app-suspended' : ''}${isMobile ? (mobileStage ? ' m-stage' : ' m-home') : ''}`}>
 
 
 
804
  {showWelcome && <Welcome onClose={dismissWelcome} />}
805
  {toast && <div className="toast mono" role="alert">{toast}</div>}
806
  {shareId && sessById[shareId] && (
 
886
  </div>
887
  )}
888
  <div className="stage">
889
+ {renderTiles(
890
+ isMobile && !mobileStage
891
+ ? []
892
+ : activeGroup && groupSessions.length > 0
893
+ ? pageSessions
894
+ : activeSingle ? [activeSingle] : [],
895
+ activeGroup && groupSessions.length > 0 ? grid : { cols: 1, rows: 1 },
896
+ )}
897
  {activeRef === 'overview' ? (
898
  <Overview
899
  clis={clis}
 
910
  openSession(sid, g?.id);
911
  }}
912
  />
913
+ ) : activeGroup && groupSessions.length === 0 ? (
 
914
  <div
915
  className={`empty-group${dropMain ? ' drop-over' : ''}`}
916
  onDragOver={allowDrop}
 
923
  <div className="dropline">drop an agent here to add it to this group</div>
924
  </div>
925
  </div>
926
+ ) : !activeGroup && !activeSingle ? (
 
 
 
927
  <div className="empty-group">
928
  <EmptyPrompt
929
  path="workspaces"
930
  hints={['create an agent or a group from the sidebar', 'drag an agent onto another to group them']}
931
  />
932
  </div>
933
+ ) : null}
934
  </div>
935
  {activeRef === 'overview' && (
936
  <div className="zoombar ov-bar">
 
967
  )}
968
  </div>
969
  </div>
970
+ </>
971
  );
972
  }
web/src/components/SettingsView.tsx CHANGED
@@ -13,7 +13,7 @@ const PAGES: { id: Page; label: string }[] = [
13
  { id: 'skills', label: 'Skills' },
14
  ];
15
 
16
- interface Info { dataDir?: string; home?: string; spaceId?: string | null; spaceHost?: string | null; tmux?: boolean; canRelaunch?: boolean; secrets?: string[]; bucketUnverified?: boolean; }
17
 
18
  function urlBase64ToUint8Array(base64: string) {
19
  const padding = '='.repeat((4 - (base64.length % 4)) % 4);
@@ -446,7 +446,7 @@ export default function SettingsView({
446
  <div><span>Space</span><b>{info?.spaceId || '—'}</b></div>
447
  <div><span>Durable storage</span><b className="mono">{info?.dataDir || '—'}</b></div>
448
  <div><span>Home</span><b className="mono">{info?.home || '—'}</b></div>
449
- <div><span>tmux</span><b>{info?.tmux ? 'on' : 'off'}</b></div>
450
  </div>
451
 
452
  </div>
 
13
  { id: 'skills', label: 'Skills' },
14
  ];
15
 
16
+ interface Info { dataDir?: string; home?: string; spaceId?: string | null; spaceHost?: string | null; engine?: string; ghostty?: boolean; canRelaunch?: boolean; secrets?: string[]; bucketUnverified?: boolean; }
17
 
18
  function urlBase64ToUint8Array(base64: string) {
19
  const padding = '='.repeat((4 - (base64.length % 4)) % 4);
 
446
  <div><span>Space</span><b>{info?.spaceId || '—'}</b></div>
447
  <div><span>Durable storage</span><b className="mono">{info?.dataDir || '—'}</b></div>
448
  <div><span>Home</span><b className="mono">{info?.home || '—'}</b></div>
449
+ <div><span>terminal</span><b>{info?.ghostty ? 'libghostty' : 'unavailable'}</b></div>
450
  </div>
451
 
452
  </div>
web/src/components/TerminalPane.tsx CHANGED
@@ -31,29 +31,21 @@ const THEMES: Record<'light' | 'dark', ITheme> = {
31
  },
32
  };
33
 
34
- type ConnState = 'connecting' | 'connected' | 'closed' | 'exited' | 'handedoff';
35
 
36
  // Close code the server uses when the session's process exited for real (vs a
37
  // transient drop). The client must NOT auto-reconnect on this, or it would
38
  // respawn the agent in a loop and trample an in-progress login flow.
39
  const EXIT_CODE = 4000;
40
 
41
- // Close code for "another device took this session over" (tmux attaches with
42
- // -D, so only one client drives a session at a time). The session is still
43
- // running: we must not reconnect on a timer, or this pane and the phone would
44
- // pull the session back and forth. We wait for the user to come back here.
45
- const HANDOFF_CODE = 4001;
46
-
47
  function workspaceLabel(p: string | null) {
48
  const rel = (p || '').replace(/^\.\/?/, '').replace(/^\/+|\/+$/g, '');
49
  return rel ? `workspace/${rel}/` : 'workspace/';
50
  }
51
 
52
  // Serialize the current selection, joining soft-wrapped rows instead of
53
- // emitting a newline per visual row. tmux repaints with explicit cursor moves,
54
- // so a wrapped logical line arrives as separate full-width rows that xterm's
55
- // default getSelection() would break with '\n'. We rejoin a row to the next
56
- // when xterm flags it wrapped OR it's filled to the last column (the tmux case).
57
  function selectionText(term: Terminal): string {
58
  try {
59
  const pos = term.getSelectionPosition?.();
@@ -92,20 +84,56 @@ function selectionText(term: Terminal): string {
92
  // and flush on the user's next click, so the copy lands when they return.
93
  // It must run synchronously inside the triggering event — deferring to a
94
  // setTimeout loses the activation that execCommand needs.
95
- const OSC52_COPY_WINDOW_MS = 1500;
96
- // Server client control frames (copy-mode state) ride the terminal socket
97
- // with this leading NUL sentinel, which real pty output never begins with.
98
  const MODE_CTRL = '\x00\x00AM:';
99
- let osc52CopyAllowedUntil = 0;
100
 
101
- function allowNextOsc52Copy() {
102
- osc52CopyAllowedUntil = Date.now() + OSC52_COPY_WINDOW_MS;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  }
104
 
105
- function consumeOsc52CopyPermission(): boolean {
106
- if (Date.now() > osc52CopyAllowedUntil) return false;
107
- osc52CopyAllowedUntil = 0;
108
- return true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  }
110
 
111
  function legacyCopy(text: string): boolean {
@@ -164,20 +192,24 @@ export default function TerminalPane({
164
  onClose: () => void;
165
  }) {
166
  const hostRef = useRef<HTMLDivElement>(null);
 
167
  const termRef = useRef<Terminal | null>(null);
168
  const resyncRef = useRef<() => void>(() => {});
 
169
  const reconnectRef = useRef<() => void>(() => {});
170
- // Take a handed-off session back (bound inside the connection effect).
171
- const resumeRef = useRef<() => void>(() => {});
 
172
  // Send a raw byte string to the PTY (for the mobile key-bar: arrows, Esc…).
173
  const sendKeyRef = useRef<(d: string) => void>(() => {});
174
  const [conn, setConn] = useState<ConnState>('connecting');
175
- // "starting…" cover while the CLI boots into an empty pane (attach on the
176
  // Space can take seconds). Hidden only when the screen actually SHOWS
177
- // something — byte counts lie, because tmux's attach repaint of a blank
178
- // 200×50 screen is already kilobytes of escapes.
179
  const [booting, setBooting] = useState(true);
180
- const [copyMode, setCopyMode] = useState(false); // tmux copy/scrollback mode
 
181
  const [editing, setEditing] = useState(false);
182
  const [draft, setDraft] = useState(session.name);
183
  // Fallback paste sheet: shown only when we can't read the clipboard directly.
@@ -219,34 +251,33 @@ export default function TerminalPane({
219
  useEffect(() => {
220
  const term = new Terminal({
221
  fontFamily: "'Geist Mono', ui-monospace, 'SF Mono', Menlo, 'Cascadia Code', monospace",
222
- fontSize: 13,
 
 
223
  cursorBlink: true,
224
  scrollback: 20000,
225
  theme: THEMES[theme],
226
- // Let users make a *local* selection even when the app (tmux / an agent
227
- // TUI) has grabbed the mouse: ⌥-drag on macOS, Shift-drag elsewhere.
228
  macOptionClickForcesSelection: true,
229
  });
230
  const fit = new FitAddon();
231
  term.loadAddon(fit);
232
 
233
  let lastSelection = '';
234
- let tmuxSelectionPending = false;
235
- let tmuxSelectionText = '';
236
- let tmuxSelectionAt = 0;
237
 
238
- // tmux emits OSC 52 when copy-mode commits a mouse selection. Do not write
239
- // the system clipboard there; stash the decoded text and wait for Cmd/C.
 
240
  const clipboardProvider = {
241
  readText: (sel: string) => (sel !== 'p' && navigator.clipboard?.readText ? navigator.clipboard.readText().catch(() => '') : ''),
242
  writeText: (sel: string, data: string) => {
243
- const allowed = sel !== 'p' && consumeOsc52CopyPermission();
244
  if (sel !== 'p') {
245
- tmuxSelectionText = data;
246
- tmuxSelectionAt = Date.now();
247
- tmuxSelectionPending = true;
248
  }
249
- if (allowed) copyText(data);
250
  },
251
  };
252
  // addon-clipboard@0.1.0 has a (base64, provider) runtime constructor but
@@ -254,107 +285,122 @@ export default function TerminalPane({
254
  term.loadAddon(new (ClipboardAddon as unknown as new (b: Base64, p: typeof clipboardProvider) => ClipboardAddon)(new Base64(), clipboardProvider));
255
  term.open(hostRef.current!);
256
  termRef.current = term;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
 
258
  // Track the committed local xterm selection so Cmd/Ctrl+C can copy it
259
  // synchronously — deferring (setTimeout) would break execCommand's gesture.
260
  const selSub = term.onSelectionChange(() => {
261
  lastSelection = term.hasSelection() ? selectionText(term) : '';
262
- if (lastSelection) tmuxSelectionPending = false;
263
  });
264
  const copySelection = () => {
265
  const text = term.hasSelection() ? selectionText(term) : lastSelection;
266
  if (text) copyText(text);
267
  };
268
- const copyTmuxSelection = (e?: ClipboardEvent): boolean => {
269
- const fresh = tmuxSelectionText && Date.now() - tmuxSelectionAt < 120_000;
270
- if (!tmuxSelectionPending || !fresh) return false;
271
- if (e?.clipboardData) {
272
- e.clipboardData.setData('text/plain', tmuxSelectionText);
273
- e.preventDefault();
274
- e.stopPropagation();
275
- } else {
276
- copyText(tmuxSelectionText);
277
- }
278
- return true;
279
- };
280
- const host = hostRef.current!;
281
- let mouseDragStart: { x: number; y: number } | null = null;
282
- let mouseDragged = false;
283
- const onPointerDown = (e: PointerEvent) => {
284
- takeBack(); // clicking into a handed-off pane means "I'm working here now"
285
- if (e.pointerType !== 'mouse' || e.button !== 0) return;
286
- mouseDragStart = { x: e.clientX, y: e.clientY };
287
- mouseDragged = false;
288
- };
289
- const onPointerMove = (e: PointerEvent) => {
290
- if (!mouseDragStart || (e.buttons & 1) === 0) return;
291
- if (Math.abs(e.clientX - mouseDragStart.x) > 3 || Math.abs(e.clientY - mouseDragStart.y) > 3) mouseDragged = true;
292
- };
293
- const onPointerUp = (e: PointerEvent) => {
294
- if (e.pointerType !== 'mouse' || e.button !== 0) return;
295
- if (mouseDragged && !term.hasSelection()) {
296
- tmuxSelectionPending = true;
297
- } else if (!term.hasSelection()) {
298
- tmuxSelectionPending = false;
299
- }
300
- mouseDragStart = null;
301
- mouseDragged = false;
302
- };
303
- const onClick = (e: MouseEvent) => {
304
- if (e.detail >= 2 && !term.hasSelection()) {
305
- tmuxSelectionPending = true;
306
- }
307
- };
308
- host.addEventListener('pointerdown', onPointerDown, true);
309
- host.addEventListener('pointermove', onPointerMove, true);
310
- host.addEventListener('pointerup', onPointerUp, true);
311
- host.addEventListener('click', onClick, true);
312
-
313
- const onDocumentPointerDown = (e: PointerEvent) => {
314
- if (!host.contains(e.target as Node)) tmuxSelectionPending = false;
315
- };
316
  const onCopy = (e: ClipboardEvent) => {
317
- copyTmuxSelection(e);
 
 
 
 
318
  };
319
- document.addEventListener('pointerdown', onDocumentPointerDown, true);
320
  document.addEventListener('copy', onCopy, true);
321
 
322
  let ws: WebSocket | null = null;
323
  const send = (o: unknown) => {
324
  if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o));
325
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  // The mobile key-bar sends control sequences the on-screen keyboard can't.
327
- sendKeyRef.current = (d: string) => { send({ t: 'i', d }); endBoot(); };
 
 
 
 
328
 
329
- // ⌘/Ctrl+C copies selected text. With a tmux mouse selection, ask the server
330
- // to run tmux's copy command; the resulting OSC 52 is accepted only because
331
- // it follows this key gesture. Paste is left to xterm's native handler so
332
- // bracketed-paste framing is preserved.
333
  term.attachCustomKeyEventHandler((e) => {
334
  if (e.type !== 'keydown') return true;
335
  if ((e.metaKey || e.ctrlKey) && (e.key === 'c' || e.key === 'C') && term.hasSelection()) {
336
  copySelection();
337
  term.clearSelection();
338
- tmuxSelectionPending = false;
339
  return false;
340
  }
341
- if ((e.metaKey || e.ctrlKey) && (e.key === 'c' || e.key === 'C') && tmuxSelectionPending) {
342
- if (!copyTmuxSelection()) {
343
- allowNextOsc52Copy();
344
- send({ t: 'copy' });
345
- }
346
  return false;
347
  }
348
- if (e.metaKey && (e.key === 'c' || e.key === 'C')) return false;
349
- if (e.key === 'Escape') tmuxSelectionPending = false;
350
  return true;
351
  });
 
 
 
352
  try { fit.fit(); } catch { /* layout not ready yet */ }
353
  // Re-measure once the webfont is ready (glyph width changes vs the fallback).
354
- document.fonts?.ready.then(() => { try { fit.fit(); } catch { /* ignore */ } });
355
 
356
  let closedByUs = false;
357
- let handedOff = false;
358
  let retry: ReturnType<typeof setTimeout> | null = null;
359
  // Reconnect with backoff: a sleeping/unreachable Space shouldn't be hammered
360
  // every second by every open pane. Reset once a connection succeeds.
@@ -385,11 +431,17 @@ export default function TerminalPane({
385
  setBooting(false);
386
  };
387
  const connect = () => {
 
 
388
  setConn('connecting');
389
- setBooting(true);
390
- bootLive = true;
 
 
 
 
391
  if (bootTimer) clearTimeout(bootTimer);
392
- bootTimer = setTimeout(endBoot, 20_000);
393
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
394
  const url = `${proto}://${location.host}/ws?session=${encodeURIComponent(session.id)}&cols=${term.cols}&rows=${term.rows}`;
395
  ws = new WebSocket(url);
@@ -397,18 +449,56 @@ export default function TerminalPane({
397
  ws.onopen = () => {
398
  setConn('connected');
399
  retryDelay = 1200;
400
- try { fit.fit(); } catch { /* ignore */ }
401
- send({ t: 'r', cols: term.cols, rows: term.rows });
 
 
 
402
  };
403
  ws.onmessage = (e) => {
404
  const d = typeof e.data === 'string' ? e.data : new Uint8Array(e.data as ArrayBuffer);
405
- // Control frame (copy-mode changes): a leading NUL sentinel that raw
406
- // pty output never produces — handle it instead of writing to the term.
407
  if (typeof d === 'string' && d.startsWith(MODE_CTRL)) {
408
- try { const m = JSON.parse(d.slice(MODE_CTRL.length)); if (m.t === 'mode') setCopyMode(!!m.copy); } catch { /* ignore */ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  return;
410
  }
411
- term.write(d);
412
  // Probe shortly after each burst (throttled; write() is async).
413
  if (bootLive && !bootCheck) {
414
  bootCheck = setTimeout(() => {
@@ -420,12 +510,9 @@ export default function TerminalPane({
420
  ws.onclose = (e) => {
421
  // A real process exit: stop here and let the user relaunch. Anything
422
  // else is a transient drop (sleep/wake, network) → auto-reconnect and
423
- // reattach to the still-running tmux session.
424
  endBoot();
425
  if (e.code === EXIT_CODE) { setConn('exited'); return; }
426
- // Another device took over: stay detached (no retry timer) until the
427
- // user deliberately comes back to this pane — see takeBack().
428
- if (e.code === HANDOFF_CODE) { handedOff = true; setConn('handedoff'); return; }
429
  setConn('closed');
430
  if (!closedByUs) {
431
  retry = setTimeout(connect, retryDelay);
@@ -435,77 +522,149 @@ export default function TerminalPane({
435
  ws.onerror = () => { try { ws?.close(); } catch { /* ignore */ } };
436
  };
437
  // Manual restart: clear the dead run's screen so the content probe watches
438
- // the NEW process paint, not leftovers (tmux repaints the live screen).
439
  reconnectRef.current = () => { if (retry) clearTimeout(retry); retryDelay = 1200; try { term.reset(); } catch { /* ignore */ } connect(); };
440
 
441
- // Take the session back from whichever device holds it now. Called ONLY for
442
- // a deliberate return to this pane the tab regaining focus/visibility, or
443
- // a tap/click in the terminal — never on a timer, so the phone we just put
444
- // down keeps the session while it sits in the background. Reattaching sizes
445
- // tmux to this device, which is the point: each device gets a window that
446
- // fits it, at the cost of one reflow per handover instead of per glance.
447
- const takeBack = () => {
448
- if (!handedOff || document.hidden) return false;
449
- handedOff = false;
450
- connect();
451
- return true;
 
 
452
  };
453
- resumeRef.current = () => { takeBack(); };
454
-
455
- // Re-fit and tell the server our size. The attached client owns the tmux
456
- // window, so this keeps it fitting this device exactly instead of leaving
457
- // tmux's "dots" filler. Never reconnects: a layout change (another pane
458
- // opening, the sidebar toggling) is not a reason to pull a session off the
459
- // device the user is actually holding.
460
  const resync = () => {
461
- if (handedOff) return;
462
- try { fit.fit(); send({ t: 'r', cols: term.cols, rows: term.rows }); } catch { /* ignore */ }
 
463
  };
464
- // Returning to this tab IS deliberate — take the session back, or just
465
- // re-sync the size if we still hold it.
466
- const onReturn = () => { if (!takeBack()) resync(); };
467
  const onVisible = () => { if (!document.hidden) onReturn(); };
468
- resyncRef.current = resync; // so the zoom control can refit
469
-
470
  // Typing means the user sees enough to interact — drop the boot cover.
471
  // Real keystrokes only (onKey): onData ALSO fires for xterm's automatic
472
  // replies to the TUI's terminal queries (DA/CPR), which arrive instantly
473
  // on attach and must not count as "the user typed".
474
- const keySub = term.onKey(() => endBoot());
475
- const dataSub = term.onData((d) => send({ t: 'i', d }));
 
 
 
 
476
  const ro = new ResizeObserver(resync);
477
  ro.observe(hostRef.current!);
478
  window.addEventListener('focus', onReturn);
479
  document.addEventListener('visibilitychange', onVisible);
480
 
481
- // Touch scrolling: xterm doesn't translate touch gestures for applications
482
- // that grabbed the mouse (tmux always does here), so swipes did nothing on
483
- // phones. Convert drags into wheel steps: SGR mouse-wheel sequences when
484
- // the app tracks the mouse (tmux scrolls its history), local scrollLines
485
- // otherwise.
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  let touchY: number | null = null;
487
- const onTouchStart = (e: TouchEvent) => { takeBack(); touchY = e.touches[0].clientY; };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
  const onTouchMove = (e: TouchEvent) => {
489
- if (touchY == null) return;
490
  const y = e.touches[0].clientY;
491
- const dy = y - touchY;
492
- const steps = Math.trunc(dy / 24); // ~one wheel notch per 24px of drag
493
- if (steps !== 0) {
494
- touchY = y;
495
- let tracking = false;
496
- try { tracking = ((term as unknown as { modes?: { mouseTrackingMode?: string } }).modes?.mouseTrackingMode ?? 'none') !== 'none'; } catch { /* older xterm */ }
497
- const btn = steps > 0 ? 64 : 65; // drag down reveals earlier output = wheel up
498
- for (let i = 0; i < Math.abs(steps); i++) {
499
- if (tracking) send({ t: 'i', d: `\x1b[<${btn};${Math.max(1, Math.floor(term.cols / 2))};${Math.max(1, Math.floor(term.rows / 2))}M` });
500
- else term.scrollLines(steps > 0 ? -1 : 1);
501
- }
 
 
 
502
  }
503
- if (Math.abs(dy) > 4) e.preventDefault(); // keep the page from rubber-banding
 
 
 
 
504
  };
505
- const onTouchEnd = () => { touchY = null; };
506
- host.addEventListener('touchstart', onTouchStart, { passive: true });
507
- host.addEventListener('touchmove', onTouchMove, { passive: false });
508
- host.addEventListener('touchend', onTouchEnd);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
509
 
510
  connect();
511
 
@@ -514,24 +673,28 @@ export default function TerminalPane({
514
  if (retry) clearTimeout(retry);
515
  if (bootTimer) clearTimeout(bootTimer);
516
  if (bootCheck) clearTimeout(bootCheck);
 
 
517
  ro.disconnect();
518
  host.removeEventListener('pointerdown', onPointerDown, true);
519
- host.removeEventListener('pointermove', onPointerMove, true);
520
- host.removeEventListener('pointerup', onPointerUp, true);
521
- host.removeEventListener('click', onClick, true);
522
- document.removeEventListener('pointerdown', onDocumentPointerDown, true);
523
  document.removeEventListener('copy', onCopy, true);
524
- host.removeEventListener('touchstart', onTouchStart);
525
- host.removeEventListener('touchmove', onTouchMove);
526
- host.removeEventListener('touchend', onTouchEnd);
 
 
527
  window.removeEventListener('focus', onReturn);
528
  document.removeEventListener('visibilitychange', onVisible);
529
  dataSub.dispose();
530
  keySub.dispose();
 
531
  selSub.dispose();
532
  try { ws?.close(); } catch { /* ignore */ }
533
  term.dispose();
534
  termRef.current = null;
 
 
535
  };
536
  }, [session.id]);
537
 
@@ -540,19 +703,35 @@ export default function TerminalPane({
540
  if (termRef.current) termRef.current.options.theme = THEMES[theme];
541
  }, [theme]);
542
 
543
- // Zoom: adjust font size (100% = 13px) and refit.
 
 
544
  useEffect(() => {
545
  const t = termRef.current;
546
  if (!t) return;
 
 
 
 
 
 
 
547
  t.options.fontSize = Math.round((13 * zoom) / 100);
548
- resyncRef.current();
549
- }, [zoom]);
550
 
551
  // Move keyboard focus into the terminal whenever this pane becomes the active
552
  // one (e.g. selected from the sidebar, or newly created).
553
  useEffect(() => {
554
  if (!active) return;
555
- const t = setTimeout(() => termRef.current?.focus(), 0);
 
 
 
 
 
 
 
556
  return () => clearTimeout(t);
557
  }, [active]);
558
 
@@ -592,16 +771,18 @@ export default function TerminalPane({
592
  <span className="ph-title" title={`${pathLabel} · double-click to rename`} onDoubleClick={() => { setDraft(session.name); setEditing(true); }}>{session.name}</span>
593
  )}
594
  <div className="ph-right">
 
 
 
 
 
595
  <span className="ph-path" title={pathLabel}>{pathLabel}</span>
596
  <button className="mini-btn ph-close" title="Close" onClick={(e) => { e.stopPropagation(); onClose(); }}><CloseGlyph /></button>
597
  </div>
598
  </div>
599
- <div className="term-host">
600
  <div className="term-fill" ref={hostRef} />
601
  </div>
602
- {copyMode && (
603
- <div className="term-copy-hint mono">copy mode · scroll to read, press Esc or type to return</div>
604
- )}
605
  {isMobile && conn === 'connected' && (
606
  // Control keys the phone keyboard lacks — needed for TUI menus (model
607
  // pickers, etc.). preventDefault keeps terminal focus so the keyboard
@@ -660,21 +841,13 @@ export default function TerminalPane({
660
  <button className="tp-x" onClick={() => { setPasteOpen(false); termRef.current?.focus(); }}>cancel</button>
661
  </div>
662
  )}
663
- {conn === 'handedoff' && (
664
- // The session is running elsewhere, not stopped — say so, and make the
665
- // way back obvious (clicking the terminal works too).
666
- <div className="term-exit mono">
667
- <div className="tx-row">
668
- <span>open on another device · still running</span>
669
- <button
670
- className="tx-btn"
671
- onMouseDown={(e) => e.stopPropagation()}
672
- onClick={(e) => { e.stopPropagation(); resumeRef.current(); }}
673
- ><RefreshGlyph /> resume here</button>
674
- </div>
675
  </div>
676
  )}
677
- {booting && conn !== 'exited' && conn !== 'handedoff' && (
678
  <div className="term-boot mono">
679
  {conn === 'connecting' ? 'connecting' : `starting ${cli?.label || session.cli}`}<span className="et-cursor" />
680
  </div>
 
31
  },
32
  };
33
 
34
+ type ConnState = 'connecting' | 'connected' | 'closed' | 'exited';
35
 
36
  // Close code the server uses when the session's process exited for real (vs a
37
  // transient drop). The client must NOT auto-reconnect on this, or it would
38
  // respawn the agent in a loop and trample an in-progress login flow.
39
  const EXIT_CODE = 4000;
40
 
 
 
 
 
 
 
41
  function workspaceLabel(p: string | null) {
42
  const rel = (p || '').replace(/^\.\/?/, '').replace(/^\/+|\/+$/g, '');
43
  return rel ? `workspace/${rel}/` : 'workspace/';
44
  }
45
 
46
  // Serialize the current selection, joining soft-wrapped rows instead of
47
+ // emitting a newline per visual row. Some full-width rows painted with cursor
48
+ // movement lack xterm's wrap flag, so a filled last cell is a second signal.
 
 
49
  function selectionText(term: Terminal): string {
50
  try {
51
  const pos = term.getSelectionPosition?.();
 
84
  // and flush on the user's next click, so the copy lands when they return.
85
  // It must run synchronously inside the triggering event — deferring to a
86
  // setTimeout loses the activation that execCommand needs.
87
+ // Server client control frames (restore, shared-grid size) ride the terminal
88
+ // socket with this leading NUL sentinel, which real pty output never begins with.
 
89
  const MODE_CTRL = '\x00\x00AM:';
 
90
 
91
+ type TerminalPreview = { version: 1; at: number; cols: number; rows: string[] };
92
+ const PREVIEW_PREFIX = 'am-terminal-preview:';
93
+ const PREVIEW_INDEX = 'am-terminal-preview-index';
94
+ const MAX_PREVIEWS = 12;
95
+
96
+ function loadTerminalPreview(id: string): TerminalPreview | null {
97
+ try {
98
+ const parsed = JSON.parse(localStorage.getItem(`${PREVIEW_PREFIX}${id}`) || 'null');
99
+ if (parsed?.version !== 1 || !Array.isArray(parsed.rows)
100
+ || !parsed.rows.some((line: unknown) => typeof line === 'string' && line.trim())) return null;
101
+ return {
102
+ version: 1,
103
+ at: Number(parsed.at) || 0,
104
+ cols: Math.max(1, Number(parsed.cols) || 80),
105
+ rows: parsed.rows.filter((line: unknown) => typeof line === 'string').slice(0, 80),
106
+ };
107
+ } catch { return null; }
108
  }
109
 
110
+ // Keep only a compact rendering of the viewport, never the full 20k-line
111
+ // scrollback. It is a loading preview, not a second terminal state model.
112
+ function saveTerminalPreview(id: string, term: Terminal) {
113
+ try {
114
+ const buffer = term.buffer.active;
115
+ const rowCount = Math.min(term.rows, 80);
116
+ const colCount = Math.min(term.cols, 240);
117
+ const rows: string[] = [];
118
+ for (let y = 0; y < rowCount; y++) {
119
+ const line = buffer.getLine(buffer.viewportY + y);
120
+ rows.push((line?.translateToString(false, 0, colCount) || '').replace(/[ \t]+$/, ''));
121
+ }
122
+ if (!rows.some((line) => line.trim())) return;
123
+ const saved: TerminalPreview = { version: 1, at: Date.now(), cols: term.cols, rows };
124
+ localStorage.setItem(`${PREVIEW_PREFIX}${id}`, JSON.stringify(saved));
125
+
126
+ let previous: string[] = [];
127
+ try { previous = JSON.parse(localStorage.getItem(PREVIEW_INDEX) || '[]'); } catch {}
128
+ const next = [id, ...previous].filter((value, i, all) =>
129
+ typeof value === 'string' && all.indexOf(value) === i).slice(0, MAX_PREVIEWS);
130
+ localStorage.setItem(PREVIEW_INDEX, JSON.stringify(next));
131
+ for (const stale of previous) {
132
+ if (typeof stale === 'string' && !next.includes(stale)) {
133
+ localStorage.removeItem(`${PREVIEW_PREFIX}${stale}`);
134
+ }
135
+ }
136
+ } catch { /* storage may be disabled or full; previews are best-effort */ }
137
  }
138
 
139
  function legacyCopy(text: string): boolean {
 
192
  onClose: () => void;
193
  }) {
194
  const hostRef = useRef<HTMLDivElement>(null);
195
+ const frameRef = useRef<HTMLDivElement>(null);
196
  const termRef = useRef<Terminal | null>(null);
197
  const resyncRef = useRef<() => void>(() => {});
198
+ const claimRef = useRef<() => void>(() => {});
199
  const reconnectRef = useRef<() => void>(() => {});
200
+ const controllerRef = useRef(false);
201
+ const previousZoomRef = useRef(zoom);
202
+ const [preview] = useState<TerminalPreview | null>(() => loadTerminalPreview(session.id));
203
  // Send a raw byte string to the PTY (for the mobile key-bar: arrows, Esc…).
204
  const sendKeyRef = useRef<(d: string) => void>(() => {});
205
  const [conn, setConn] = useState<ConnState>('connecting');
206
+ // "starting…" cover while the CLI boots into an empty pane (starting on the
207
  // Space can take seconds). Hidden only when the screen actually SHOWS
208
+ // something — byte counts lie because a blank-screen repaint can already be
209
+ // kilobytes of escape sequences.
210
  const [booting, setBooting] = useState(true);
211
+ const [controller, setController] = useState(false);
212
+ const [viewers, setViewers] = useState(1);
213
  const [editing, setEditing] = useState(false);
214
  const [draft, setDraft] = useState(session.name);
215
  // Fallback paste sheet: shown only when we can't read the clipboard directly.
 
251
  useEffect(() => {
252
  const term = new Terminal({
253
  fontFamily: "'Geist Mono', ui-monospace, 'SF Mono', Menlo, 'Cascadia Code', monospace",
254
+ // Start at the requested zoom so attachment does not briefly create a
255
+ // 100% grid and then force an avoidable reflow as the session boots.
256
+ fontSize: Math.round((13 * zoom) / 100),
257
  cursorBlink: true,
258
  scrollback: 20000,
259
  theme: THEMES[theme],
260
+ // Let users make a local selection even when an agent TUI has grabbed
261
+ // the mouse: ⌥-drag on macOS, Shift-drag elsewhere.
262
  macOptionClickForcesSelection: true,
263
  });
264
  const fit = new FitAddon();
265
  term.loadAddon(fit);
266
 
267
  let lastSelection = '';
268
+ let osc52Text = '';
269
+ let osc52At = 0;
 
270
 
271
+ // An agent may deliberately emit OSC 52. Browsers generally reject an
272
+ // unsolicited clipboard write, so retain it briefly and let Cmd/C perform
273
+ // the write inside a real user gesture.
274
  const clipboardProvider = {
275
  readText: (sel: string) => (sel !== 'p' && navigator.clipboard?.readText ? navigator.clipboard.readText().catch(() => '') : ''),
276
  writeText: (sel: string, data: string) => {
 
277
  if (sel !== 'p') {
278
+ osc52Text = data;
279
+ osc52At = Date.now();
 
280
  }
 
281
  },
282
  };
283
  // addon-clipboard@0.1.0 has a (base64, provider) runtime constructor but
 
285
  term.loadAddon(new (ClipboardAddon as unknown as new (b: Base64, p: typeof clipboardProvider) => ClipboardAddon)(new Base64(), clipboardProvider));
286
  term.open(hostRef.current!);
287
  termRef.current = term;
288
+ const host = hostRef.current!;
289
+
290
+ let previewTimer: ReturnType<typeof setTimeout> | null = null;
291
+ const persistPreview = () => {
292
+ if (previewTimer) { clearTimeout(previewTimer); previewTimer = null; }
293
+ saveTerminalPreview(session.id, term);
294
+ };
295
+ const schedulePreview = () => {
296
+ // Preview serialization reads every visible buffer row and writes
297
+ // localStorage synchronously. Debounce until output/scrolling is idle so
298
+ // it never lands in the middle of a mobile drag.
299
+ if (previewTimer) clearTimeout(previewTimer);
300
+ previewTimer = setTimeout(persistPreview, 700);
301
+ };
302
+
303
+ // xterm deliberately parks its real textarea far off-screen. That is fine
304
+ // with a hardware keyboard, but a mobile browser (especially a Space inside
305
+ // a cross-origin iframe) has no visible focus target to pan above the OSK.
306
+ // Keep the still-transparent 1px input at the bottom of the terminal so the
307
+ // browser's native focused-element avoidance can cross the iframe boundary.
308
+ const mobileInput = isMobile
309
+ ? host.querySelector<HTMLTextAreaElement>('.xterm-helper-textarea')
310
+ : null;
311
+ const anchorMobileInput = () => {
312
+ if (!mobileInput) return;
313
+ // xterm rewrites left/top whenever its cursor moves. Custom properties
314
+ // survive those writes and feed the mobile !important rules in CSS.
315
+ mobileInput.style.setProperty('--am-input-left', `${Math.max(1, Math.round(host.clientWidth / 2))}px`);
316
+ mobileInput.style.setProperty('--am-input-top', `${Math.max(1, host.clientHeight - 12)}px`);
317
+ };
318
+ anchorMobileInput();
319
+
320
+ // Track the user's semantic scroll state independently of the viewport's
321
+ // pixel scrollTop. During a row-count change xterm can transiently report
322
+ // the old pixel position against the new scroll height.
323
+ let followingBottom = true;
324
+ const scrollSub = term.onScroll(() => {
325
+ followingBottom = term.buffer.active.viewportY >= term.buffer.active.baseY;
326
+ schedulePreview();
327
+ });
328
 
329
  // Track the committed local xterm selection so Cmd/Ctrl+C can copy it
330
  // synchronously — deferring (setTimeout) would break execCommand's gesture.
331
  const selSub = term.onSelectionChange(() => {
332
  lastSelection = term.hasSelection() ? selectionText(term) : '';
 
333
  });
334
  const copySelection = () => {
335
  const text = term.hasSelection() ? selectionText(term) : lastSelection;
336
  if (text) copyText(text);
337
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  const onCopy = (e: ClipboardEvent) => {
339
+ if (!term.hasSelection()) return;
340
+ const text = selectionText(term);
341
+ if (!text || !e.clipboardData) return;
342
+ e.clipboardData.setData('text/plain', text);
343
+ e.preventDefault();
344
  };
 
345
  document.addEventListener('copy', onCopy, true);
346
 
347
  let ws: WebSocket | null = null;
348
  const send = (o: unknown) => {
349
  if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o));
350
  };
351
+ const claimControl = () => {
352
+ if (controllerRef.current) return;
353
+ // Activation can precede WebSocket.open when a pane is first mounted.
354
+ // Do not consume that claim locally: onopen will retry it once it can
355
+ // actually reach the server.
356
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
357
+ // Optimistic locally so the input event following this gesture is not
358
+ // dropped. WebSocket ordering guarantees claim reaches the server first.
359
+ controllerRef.current = true;
360
+ setController(true);
361
+ send({ t: 'claim' });
362
+ };
363
+ claimRef.current = claimControl;
364
+ const onPointerDown = (e: PointerEvent) => {
365
+ // A touch may only be inspecting local scrollback. Claim lazily below if
366
+ // the gesture actually needs to drive an application's mouse mode.
367
+ if (e.pointerType !== 'touch') claimControl();
368
+ };
369
+ const onPaste = () => claimControl();
370
+ host.addEventListener('pointerdown', onPointerDown, true);
371
+ host.addEventListener('paste', onPaste, true);
372
+
373
  // The mobile key-bar sends control sequences the on-screen keyboard can't.
374
+ sendKeyRef.current = (d: string) => {
375
+ claimControl();
376
+ send({ t: 'i', d });
377
+ endBoot();
378
+ };
379
 
380
+ // ⌘/Ctrl+C copies a local selection. Without one, Ctrl+C remains SIGINT;
381
+ // Cmd+C may accept a recent OSC 52 payload deliberately emitted by the app.
 
 
382
  term.attachCustomKeyEventHandler((e) => {
383
  if (e.type !== 'keydown') return true;
384
  if ((e.metaKey || e.ctrlKey) && (e.key === 'c' || e.key === 'C') && term.hasSelection()) {
385
  copySelection();
386
  term.clearSelection();
 
387
  return false;
388
  }
389
+ if (e.metaKey && (e.key === 'c' || e.key === 'C')) {
390
+ if (osc52Text && Date.now() - osc52At < 120_000) copyText(osc52Text);
 
 
 
391
  return false;
392
  }
393
+ claimControl();
 
394
  return true;
395
  });
396
+ // The only local fit: this terminal is still empty, so there is no buffer to
397
+ // reflow, and it gives the initial size we open the socket with. Every later
398
+ // size change goes through resync() as a REQUEST — see there.
399
  try { fit.fit(); } catch { /* layout not ready yet */ }
400
  // Re-measure once the webfont is ready (glyph width changes vs the fallback).
401
+ document.fonts?.ready.then(() => resync());
402
 
403
  let closedByUs = false;
 
404
  let retry: ReturnType<typeof setTimeout> | null = null;
405
  // Reconnect with backoff: a sleeping/unreachable Space shouldn't be hammered
406
  // every second by every open pane. Reset once a connection succeeds.
 
431
  setBooting(false);
432
  };
433
  const connect = () => {
434
+ controllerRef.current = false;
435
+ setController(false);
436
  setConn('connecting');
437
+ // A reconnect keeps the already-rendered xterm visible. On a fresh page,
438
+ // `booting` instead exposes the saved preview until canonical restore has
439
+ // painted real content.
440
+ const needsCover = !screenHasContent();
441
+ setBooting(needsCover);
442
+ bootLive = needsCover;
443
  if (bootTimer) clearTimeout(bootTimer);
444
+ bootTimer = needsCover ? setTimeout(endBoot, 20_000) : null;
445
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
446
  const url = `${proto}://${location.host}/ws?session=${encodeURIComponent(session.id)}&cols=${term.cols}&rows=${term.rows}`;
447
  ws = new WebSocket(url);
 
449
  ws.onopen = () => {
450
  setConn('connected');
451
  retryDelay = 1200;
452
+ // Selecting a terminal is an explicit foreground action on mobile.
453
+ // Claim before reporting its fit so an already-open desktop does not
454
+ // leave the phone rendering a clipped desktop-sized canonical grid.
455
+ if (isMobile && !document.hidden) claimControl();
456
+ requestSize();
457
  };
458
  ws.onmessage = (e) => {
459
  const d = typeof e.data === 'string' ? e.data : new Uint8Array(e.data as ArrayBuffer);
460
+ // Control frame: a leading NUL sentinel that raw pty output never
461
+ // produces — handle it instead of writing to the term.
462
  if (typeof d === 'string' && d.startsWith(MODE_CTRL)) {
463
+ try {
464
+ const m = JSON.parse(d.slice(MODE_CTRL.length));
465
+ if (m.t === 'grid' || m.t === 'restore') {
466
+ controllerRef.current = !!m.controller;
467
+ setController(!!m.controller);
468
+ setViewers(Math.max(1, Number(m.viewers) || 1));
469
+ const applyGrid = () => {
470
+ try {
471
+ // xterm can retain the old pixel scrollTop when the viewport
472
+ // gains rows (notably landscape -> portrait), which leaves a
473
+ // formerly-live view stranded in history. Preserve the
474
+ // semantic bottom anchor without disturbing someone who is
475
+ // deliberately reading scrollback.
476
+ const wasAtBottom = m.reset || followingBottom;
477
+ if (m.reset) {
478
+ term.reset();
479
+ term.clear();
480
+ }
481
+ if (m.cols > 0 && m.rows > 0 && (term.cols !== m.cols || term.rows !== m.rows)) {
482
+ term.resize(m.cols, m.rows);
483
+ }
484
+ if (wasAtBottom) {
485
+ term.scrollToBottom();
486
+ followingBottom = true;
487
+ }
488
+ schedulePreview();
489
+ } catch { /* ignore */ }
490
+ };
491
+ // Writes are asynchronous. A queued empty write is a barrier so
492
+ // resize/reset cannot reinterpret bytes from the preceding grid.
493
+ const geometryChanged = m.cols > 0 && m.rows > 0
494
+ && (term.cols !== m.cols || term.rows !== m.rows);
495
+ if (m.reset || geometryChanged) term.write('', applyGrid);
496
+ else applyGrid();
497
+ }
498
+ } catch { /* ignore */ }
499
  return;
500
  }
501
+ term.write(d, schedulePreview);
502
  // Probe shortly after each burst (throttled; write() is async).
503
  if (bootLive && !bootCheck) {
504
  bootCheck = setTimeout(() => {
 
510
  ws.onclose = (e) => {
511
  // A real process exit: stop here and let the user relaunch. Anything
512
  // else is a transient drop (sleep/wake, network) → auto-reconnect and
513
+ // reattach to the still-running backend session.
514
  endBoot();
515
  if (e.code === EXIT_CODE) { setConn('exited'); return; }
 
 
 
516
  setConn('closed');
517
  if (!closedByUs) {
518
  retry = setTimeout(connect, retryDelay);
 
522
  ws.onerror = () => { try { ws?.close(); } catch { /* ignore */ } };
523
  };
524
  // Manual restart: clear the dead run's screen so the content probe watches
525
+ // the new process paint, not leftovers.
526
  reconnectRef.current = () => { if (retry) clearTimeout(retry); retryDelay = 1200; try { term.reset(); } catch { /* ignore */ } connect(); };
527
 
528
+ // Report the pane's preferred size without locally fitting its terminal.
529
+ // Only the current controller's preference changes the canonical grid;
530
+ // watchers retain theirs for a future claim.
531
+ let resyncTimer: ReturnType<typeof setTimeout> | null = null;
532
+ const requestSize = () => {
533
+ const box = hostRef.current;
534
+ if (!box || box.clientWidth < 40 || box.clientHeight < 40) return;
535
+ try {
536
+ const d = fit.proposeDimensions();
537
+ const cols = d ? Math.max(1, d.cols) : 0;
538
+ const rows = d ? Math.max(1, d.rows) : 0;
539
+ if (cols > 0 && rows > 0) send({ t: 'r', cols, rows });
540
+ } catch { /* layout not ready */ }
541
  };
542
+ // ResizeObserver fires every frame while a window is dragged or the sidebar
543
+ // animates. Ask once, when it stops.
 
 
 
 
 
544
  const resync = () => {
545
+ anchorMobileInput();
546
+ if (resyncTimer) clearTimeout(resyncTimer);
547
+ resyncTimer = setTimeout(() => { resyncTimer = null; requestSize(); }, 80);
548
  };
549
+ resyncRef.current = resync;
550
+ const onReturn = () => resync();
 
551
  const onVisible = () => { if (!document.hidden) onReturn(); };
 
 
552
  // Typing means the user sees enough to interact — drop the boot cover.
553
  // Real keystrokes only (onKey): onData ALSO fires for xterm's automatic
554
  // replies to the TUI's terminal queries (DA/CPR), which arrive instantly
555
  // on attach and must not count as "the user typed".
556
+ const keySub = term.onKey(() => { claimControl(); endBoot(); });
557
+ // Watchers render output but do not answer terminal queries. This prevents
558
+ // N browser emulators from injecting N DA/CPR responses into one PTY.
559
+ const dataSub = term.onData((d) => {
560
+ if (controllerRef.current) send({ t: 'i', d });
561
+ });
562
  const ro = new ResizeObserver(resync);
563
  ro.observe(hostRef.current!);
564
  window.addEventListener('focus', onReturn);
565
  document.addEventListener('visibilitychange', onVisible);
566
 
567
+ // A phone drag always navigates browser-retained terminal history. Passing
568
+ // it to an agent's mouse mode at the live bottom makes Claude consume the
569
+ // gesture without moving xterm's viewport, leaving no reliable way back
570
+ // through history on touch-only devices. xterm 5.5 registers no touch
571
+ // listeners of its own (verified against the bundled lib) and .term-host
572
+ // sets touch-action:none on mobile, so neither xterm nor the browser will
573
+ // pan: this is the only touch scrolling a phone has, in every pane.
574
+ //
575
+ // Convert the drag to whole rows and carry the remainder in `residual`.
576
+ // Quantising each event to a fixed notch instead silently drops whatever
577
+ // does not fill one — a 96px drag moved the view 68px — and that shortfall
578
+ // is what reads as lag, because the text trails the finger by design.
579
+ // scrollLines moves ydisp, the authority the viewport follows.
580
+ // The frame, not the inner measurement box: .term-host is what carries
581
+ // touch-action:none and what the user actually drags, and it stays the
582
+ // gesture target however the box inside it is nested.
583
+ const frame = frameRef.current ?? host;
584
+ const viewport = host.querySelector<HTMLElement>('.xterm-viewport');
585
  let touchY: number | null = null;
586
+ let residual = 0; // px of gesture not yet worth a whole row
587
+ let glideFrame = 0;
588
+ let velocity = 0; // px/ms, signed like deltaY (positive scrolls down)
589
+ let lastMoveAt = 0;
590
+ // The scroll area spans every line in the buffer, so its height over that
591
+ // line count is the row height under whichever renderer is attached.
592
+ const scrollByPixels = (px: number) => {
593
+ const lines = term.buffer.active.length;
594
+ const cell = viewport && lines ? viewport.scrollHeight / lines : 0;
595
+ if (!cell) return 0;
596
+ residual += px;
597
+ const rows = Math.trunc(residual / cell);
598
+ if (!rows) return 0;
599
+ residual -= rows * cell;
600
+ term.scrollLines(rows);
601
+ return rows;
602
+ };
603
+ const stopGlide = () => { if (glideFrame) { cancelAnimationFrame(glideFrame); glideFrame = 0; } };
604
+ const onTouchStart = (e: TouchEvent) => {
605
+ stopGlide(); // a new touch takes over from any coasting
606
+ velocity = 0;
607
+ residual = 0;
608
+ touchY = e.touches[0].clientY;
609
+ lastMoveAt = e.timeStamp;
610
+ };
611
  const onTouchMove = (e: TouchEvent) => {
612
+ if (touchY == null || !e.touches.length) return;
613
  const y = e.touches[0].clientY;
614
+ const deltaY = touchY - y;
615
+ touchY = y;
616
+ if (deltaY === 0) return;
617
+ const dt = e.timeStamp - lastMoveAt;
618
+ lastMoveAt = e.timeStamp;
619
+ // Weight the newest sample heavily: what matters is the speed at release,
620
+ // and a touch stream is noisy. Ignore a stale gap (a paused finger) and
621
+ // anything faster than a touch stream can legitimately be — a single
622
+ // coalesced jump over a 2ms gap is not a 48px/ms flick, and dividing by
623
+ // it would launch the glide clean through the buffer. A hand tops out
624
+ // around 4px/ms; real events arrive 8-16ms apart.
625
+ if (dt >= 4 && dt < 100) {
626
+ const sample = Math.max(-4, Math.min(4, deltaY / dt));
627
+ velocity = velocity * 0.3 + sample * 0.7;
628
  }
629
+ scrollByPixels(deltaY);
630
+ if (e.cancelable) e.preventDefault(); // keep the page from rubber-banding
631
+ // This listener runs in capture before xterm's listener on the same host.
632
+ // stopPropagation alone would still allow that second handler.
633
+ e.stopImmediatePropagation();
634
  };
635
+ const onTouchEnd = () => {
636
+ touchY = null;
637
+ const v0 = velocity;
638
+ velocity = 0;
639
+ // Only a flick coasts. A slow, deliberate drag through history must land
640
+ // exactly where the finger left it — drifting past the line someone was
641
+ // reading is worse than having no momentum at all. 0.4px/ms is about
642
+ // 400px/s: far above a careful drag, far below a real flick.
643
+ if (!viewport || Math.abs(v0) < 0.4) return;
644
+ let v = v0;
645
+ let previous = 0;
646
+ const glide = (now: number) => {
647
+ // Clamp the step so a dropped frame does not teleport the viewport.
648
+ const frame = previous ? Math.min(now - previous, 50) : 16;
649
+ previous = now;
650
+ const before = term.buffer.active.viewportY;
651
+ const rows = scrollByPixels(v * frame);
652
+ v *= 0.95 ** (frame / 16); // ~5% per 60Hz frame, frame-rate neutral
653
+ // Stop at a standstill, or the moment a requested row does not move the
654
+ // view: that is either end of the buffer, and coasting into it drags.
655
+ if (Math.abs(v) < 0.02 || (rows !== 0 && term.buffer.active.viewportY === before)) {
656
+ glideFrame = 0;
657
+ return;
658
+ }
659
+ glideFrame = requestAnimationFrame(glide);
660
+ };
661
+ glideFrame = requestAnimationFrame(glide);
662
+ };
663
+ const onTouchCancel = () => { touchY = null; velocity = 0; stopGlide(); };
664
+ frame.addEventListener('touchstart', onTouchStart, { passive: true });
665
+ frame.addEventListener('touchmove', onTouchMove, { passive: false, capture: true });
666
+ frame.addEventListener('touchend', onTouchEnd);
667
+ frame.addEventListener('touchcancel', onTouchCancel);
668
 
669
  connect();
670
 
 
673
  if (retry) clearTimeout(retry);
674
  if (bootTimer) clearTimeout(bootTimer);
675
  if (bootCheck) clearTimeout(bootCheck);
676
+ if (resyncTimer) clearTimeout(resyncTimer);
677
+ persistPreview();
678
  ro.disconnect();
679
  host.removeEventListener('pointerdown', onPointerDown, true);
680
+ host.removeEventListener('paste', onPaste, true);
 
 
 
681
  document.removeEventListener('copy', onCopy, true);
682
+ frame.removeEventListener('touchstart', onTouchStart);
683
+ frame.removeEventListener('touchmove', onTouchMove, true);
684
+ frame.removeEventListener('touchend', onTouchEnd);
685
+ frame.removeEventListener('touchcancel', onTouchCancel);
686
+ stopGlide();
687
  window.removeEventListener('focus', onReturn);
688
  document.removeEventListener('visibilitychange', onVisible);
689
  dataSub.dispose();
690
  keySub.dispose();
691
+ scrollSub.dispose();
692
  selSub.dispose();
693
  try { ws?.close(); } catch { /* ignore */ }
694
  term.dispose();
695
  termRef.current = null;
696
+ resyncRef.current = () => {};
697
+ claimRef.current = () => {};
698
  };
699
  }, [session.id]);
700
 
 
703
  if (termRef.current) termRef.current.options.theme = THEMES[theme];
704
  }, [theme]);
705
 
706
+ // Font size and PTY geometry move together. The controller asks the server
707
+ // for the grid that actually fits this pane; the confirmed grid then resizes
708
+ // every viewer in order with the PTY output stream.
709
  useEffect(() => {
710
  const t = termRef.current;
711
  if (!t) return;
712
+ const changed = previousZoomRef.current !== zoom;
713
+ previousZoomRef.current = zoom;
714
+ // Zoom is an explicit interaction outside the terminal element. If this
715
+ // pane is a watcher, take the geometry lease before reporting its newly
716
+ // fitted size; otherwise only the local glyphs grow and the canonical grid
717
+ // remains too large, clipping the bottom/right of the pane.
718
+ if (changed && active) claimRef.current();
719
  t.options.fontSize = Math.round((13 * zoom) / 100);
720
+ if (active) resyncRef.current();
721
+ }, [zoom, active]);
722
 
723
  // Move keyboard focus into the terminal whenever this pane becomes the active
724
  // one (e.g. selected from the sidebar, or newly created).
725
  useEffect(() => {
726
  if (!active) return;
727
+ // A retained pane may have spent time under display:none. Reclaim and
728
+ // remeasure only after its grid cell has layout again; hidden panes never
729
+ // get to resize the canonical PTY.
730
+ const t = setTimeout(() => {
731
+ claimRef.current();
732
+ resyncRef.current();
733
+ termRef.current?.focus();
734
+ }, 0);
735
  return () => clearTimeout(t);
736
  }, [active]);
737
 
 
771
  <span className="ph-title" title={`${pathLabel} · double-click to rename`} onDoubleClick={() => { setDraft(session.name); setEditing(true); }}>{session.name}</span>
772
  )}
773
  <div className="ph-right">
774
+ {viewers > 1 && (
775
+ <span className={`ph-role${controller ? ' controller' : ''}`} title={controller ? 'This pane controls terminal input and size' : 'Interact with the terminal to take control'}>
776
+ {controller ? `${viewers} viewers` : 'watching'}
777
+ </span>
778
+ )}
779
  <span className="ph-path" title={pathLabel}>{pathLabel}</span>
780
  <button className="mini-btn ph-close" title="Close" onClick={(e) => { e.stopPropagation(); onClose(); }}><CloseGlyph /></button>
781
  </div>
782
  </div>
783
+ <div className="term-host" ref={frameRef}>
784
  <div className="term-fill" ref={hostRef} />
785
  </div>
 
 
 
786
  {isMobile && conn === 'connected' && (
787
  // Control keys the phone keyboard lacks — needed for TUI menus (model
788
  // pickers, etc.). preventDefault keeps terminal focus so the keyboard
 
841
  <button className="tp-x" onClick={() => { setPasteOpen(false); termRef.current?.focus(); }}>cancel</button>
842
  </div>
843
  )}
844
+ {booting && preview && conn !== 'exited' && (
845
+ <div className="term-preview mono" aria-label="Restoring terminal">
846
+ <pre style={{ fontSize: `${Math.round((13 * zoom) / 100)}px` }}>{preview.rows.join('\n')}</pre>
847
+ <span>restoring last view…</span>
 
 
 
 
 
 
 
 
848
  </div>
849
  )}
850
+ {booting && !preview && conn !== 'exited' && (
851
  <div className="term-boot mono">
852
  {conn === 'connecting' ? 'connecting' : `starting ${cli?.label || session.cli}`}<span className="et-cursor" />
853
  </div>
web/src/styles.css CHANGED
@@ -67,6 +67,7 @@ body {
67
  .mono { font-family: var(--font-mono); }
68
 
69
  .app { display: flex; height: 100%; }
 
70
  .sidebar { width: 282px; flex: none; background: var(--panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
71
  .main { flex: 1; min-width: 0; padding: 12px; overflow: hidden; display: flex; flex-direction: column; }
72
  .stage { flex: 1; min-height: 0; }
@@ -440,6 +441,7 @@ body {
440
 
441
  /* main: tiles */
442
  .tiles { height: 100%; display: grid; gap: 12px; }
 
443
  .tiles.drop-over { outline: 2px dashed var(--accent); outline-offset: -6px; border-radius: var(--r-xl); }
444
  .tile { display: flex; min-width: 0; min-height: 0; border-radius: var(--r-xl); }
445
  .tile > .slot { flex: 1; min-width: 0; min-height: 0; }
@@ -489,13 +491,9 @@ body {
489
  /* terminal boot cover ("connecting… / starting claude…") */
490
  .term-boot { position: absolute; inset: 0; top: 34px; display: flex; align-items: center; justify-content: center; background: var(--term-bg); color: var(--muted); font-size: 12.5px; z-index: 4; pointer-events: none; }
491
  .term-boot .et-cursor { height: 13px; width: 7px; margin-left: 7px; }
492
- /* copy-mode hint: a translucent banner just under the pane header while tmux is
493
- in copy/scrollback mode, so a "dead" keyboard makes sense */
494
- .term-copy-hint { position: absolute; top: 40px; left: 50%; transform: translateX(-50%); z-index: 5; pointer-events: none;
495
- padding: 3px 11px; border-radius: 999px; font-size: 11px; letter-spacing: 0.01em;
496
- color: var(--accent-fg); background: color-mix(in srgb, var(--accent) 82%, transparent);
497
- box-shadow: 0 2px 10px rgb(0 0 0 / 0.25); animation: rise-in 0.14s ease-out; white-space: nowrap; }
498
-
499
  /* terminal "process exited" overlay */
500
  /* Non-blocking banner so the agent's last output (e.g. a login error) stays readable. */
501
  /* stopped state: quiet line centered in the pane, in the boot-cover's voice */
@@ -552,6 +550,8 @@ body {
552
  .pane-head .ph-left { grid-column: 1; justify-self: start; display: inline-flex; align-items: center; gap: 7px; }
553
  .pane-head .ph-title { grid-column: 2; min-width: 0; max-width: 100%; text-align: center; font-family: var(--font-mono); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: text; }
554
  .pane-head .ph-right { grid-column: 3; min-width: 0; display: inline-flex; align-items: center; justify-self: end; gap: 6px; }
 
 
555
  .pane-head .ph-path { min-width: 0; max-width: min(24vw, 220px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; }
556
  .pane-head .ph-title-input { width: 100%; text-align: center; font: inherit; font-family: var(--font-mono); font-weight: 600; padding: 1px 6px; border: 1px solid var(--accent); border-radius: var(--r-sm); background: var(--panel-2); color: var(--text); min-width: 0; }
557
  .pane-head .ph-close { justify-self: end; }
@@ -563,14 +563,12 @@ body {
563
  .slot.focused { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); box-shadow: 0 4px 18px -10px rgba(0, 0, 0, 0.35); }
564
  .slot.focused .pane-head { background: color-mix(in srgb, var(--accent) 8%, var(--panel)); }
565
 
566
- .term-host { flex: 1; min-height: 0; padding: 10px 8px 8px 12px; background: var(--term-bg); }
567
- /* The terminal's own parent must carry NO padding. FitAddon sizes the grid from
568
- getComputedStyle(parent).height, which under border-box INCLUDES padding so
569
- padding on the direct parent made it count one row (and one column) more than
570
- fits, and the grid overflowed its clipped viewport: the bottom line rendered
571
- sliced in half. Most visible with Codex, which paints its status line on the
572
- very last row, where Claude leaves it blank. The frame keeps the padding; this
573
- filler is the exact box the grid is measured against. */
574
  .term-fill { height: 100%; min-height: 0; }
575
  /* mobile control-key bar: the keys a phone keyboard lacks, pinned below the
576
  terminal so it rides just above the on-screen keyboard */
@@ -595,7 +593,7 @@ body {
595
  .term-paste .tp-input { width: 100%; box-sizing: border-box; resize: none; padding: 6px 8px; border: 1px solid var(--border-strong); border-radius: var(--r-md);
596
  background: var(--panel-2); color: var(--text); font-size: 13px; line-height: 1.35; }
597
  .term-paste .tp-input:focus { outline: none; border-color: var(--accent); }
598
- .term-host .xterm { height: 100%; }
599
  .xterm .xterm-viewport { scrollbar-width: none; }
600
  .xterm .xterm-viewport::-webkit-scrollbar { width: 0; height: 0; display: none; }
601
 
@@ -1039,10 +1037,28 @@ a.btn-ghost { text-decoration: none; }
1039
 
1040
  /* phones: two full-screen views — the sidebar list, or the selected pane */
1041
  @media (max-width: 720px) {
1042
- /* Height follows the VISUAL viewport (set by App from window.visualViewport):
1043
- when the on-screen keyboard opens it shrinks the app so the terminal +
1044
- control bar stay above the keyboard instead of hiding behind it. */
1045
- .app { height: var(--vvh, 100dvh); overflow: hidden; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1046
  .app.m-home .main { display: none; }
1047
  .app.m-home .sidebar { width: 100%; border-right: none; }
1048
  .app.m-stage .sidebar { display: none; }
 
67
  .mono { font-family: var(--font-mono); }
68
 
69
  .app { display: flex; height: 100%; }
70
+ .app.app-suspended { display: none; }
71
  .sidebar { width: 282px; flex: none; background: var(--panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
72
  .main { flex: 1; min-width: 0; padding: 12px; overflow: hidden; display: flex; flex-direction: column; }
73
  .stage { flex: 1; min-height: 0; }
 
441
 
442
  /* main: tiles */
443
  .tiles { height: 100%; display: grid; gap: 12px; }
444
+ .pane-deck.deck-hidden, .tile.tile-cached { display: none; }
445
  .tiles.drop-over { outline: 2px dashed var(--accent); outline-offset: -6px; border-radius: var(--r-xl); }
446
  .tile { display: flex; min-width: 0; min-height: 0; border-radius: var(--r-xl); }
447
  .tile > .slot { flex: 1; min-width: 0; min-height: 0; }
 
491
  /* terminal boot cover ("connecting… / starting claude…") */
492
  .term-boot { position: absolute; inset: 0; top: 34px; display: flex; align-items: center; justify-content: center; background: var(--term-bg); color: var(--muted); font-size: 12.5px; z-index: 4; pointer-events: none; }
493
  .term-boot .et-cursor { height: 13px; width: 7px; margin-left: 7px; }
494
+ .term-preview { position: absolute; inset: 34px 0 0; z-index: 4; overflow: hidden; background: var(--term-bg); color: var(--text); pointer-events: none; }
495
+ .term-preview pre { height: 100%; margin: 0; padding: 10px 8px 8px 12px; overflow: hidden; font: inherit; line-height: 1.15; white-space: pre; }
496
+ .term-preview > span { position: absolute; right: 9px; bottom: 7px; padding: 3px 6px; border: 1px solid var(--border); border-radius: var(--r-sm); background: color-mix(in srgb, var(--term-bg) 88%, transparent); color: var(--muted); font-size: 10px; }
 
 
 
 
497
  /* terminal "process exited" overlay */
498
  /* Non-blocking banner so the agent's last output (e.g. a login error) stays readable. */
499
  /* stopped state: quiet line centered in the pane, in the boot-cover's voice */
 
550
  .pane-head .ph-left { grid-column: 1; justify-self: start; display: inline-flex; align-items: center; gap: 7px; }
551
  .pane-head .ph-title { grid-column: 2; min-width: 0; max-width: 100%; text-align: center; font-family: var(--font-mono); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: text; }
552
  .pane-head .ph-right { grid-column: 3; min-width: 0; display: inline-flex; align-items: center; justify-self: end; gap: 6px; }
553
+ .pane-head .ph-role { flex: none; padding: 2px 6px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font-family: var(--font-mono); font-size: 9.5px; line-height: 1.2; }
554
+ .pane-head .ph-role.controller { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); }
555
  .pane-head .ph-path { min-width: 0; max-width: min(24vw, 220px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; }
556
  .pane-head .ph-title-input { width: 100%; text-align: center; font: inherit; font-family: var(--font-mono); font-weight: 600; padding: 1px 6px; border: 1px solid var(--accent); border-radius: var(--r-sm); background: var(--panel-2); color: var(--text); min-width: 0; }
557
  .pane-head .ph-close { justify-self: end; }
 
563
  .slot.focused { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); box-shadow: 0 4px 18px -10px rgba(0, 0, 0, 0.35); }
564
  .slot.focused .pane-head { background: color-mix(in srgb, var(--accent) 8%, var(--panel)); }
565
 
566
+ .term-host { flex: 1; min-height: 0; background: var(--term-bg); overflow: hidden; }
567
+ /* The grid is measured against this filler, so it must stay unpadded: FitAddon
568
+ reads the PARENT's computed height and does NOT subtract its padding, so a
569
+ padded parent made the grid count a row that doesn't fit and the bottom line
570
+ rendered sliced (most visible with Codex, which paints its status line on the
571
+ very last row). The padding lives on .xterm, which FitAddon does subtract. */
 
 
572
  .term-fill { height: 100%; min-height: 0; }
573
  /* mobile control-key bar: the keys a phone keyboard lacks, pinned below the
574
  terminal so it rides just above the on-screen keyboard */
 
593
  .term-paste .tp-input { width: 100%; box-sizing: border-box; resize: none; padding: 6px 8px; border: 1px solid var(--border-strong); border-radius: var(--r-md);
594
  background: var(--panel-2); color: var(--text); font-size: 13px; line-height: 1.35; }
595
  .term-paste .tp-input:focus { outline: none; border-color: var(--accent); }
596
+ .term-host .xterm { width: 100%; height: 100%; padding: 10px 8px 8px 12px; }
597
  .xterm .xterm-viewport { scrollbar-width: none; }
598
  .xterm .xterm-viewport::-webkit-scrollbar { width: 0; height: 0; display: none; }
599
 
 
1037
 
1038
  /* phones: two full-screen views — the sidebar list, or the selected pane */
1039
  @media (max-width: 720px) {
1040
+ /* Follow the VISUAL viewport rectangle (set from window.visualViewport):
1041
+ when the on-screen keyboard opens it shrinks/moves the app so the terminal
1042
+ and control bar stay above the keyboard instead of hiding behind it. */
1043
+ html, body, #root { overflow: hidden; overscroll-behavior: none; }
1044
+ .app {
1045
+ position: fixed;
1046
+ top: var(--vv-top, 0px);
1047
+ left: var(--vv-left, 0px);
1048
+ width: var(--vvw, 100%);
1049
+ height: var(--vvh, 100dvh);
1050
+ overflow: hidden;
1051
+ }
1052
+ .term-host { touch-action: none; overscroll-behavior: contain; }
1053
+ /* Keep xterm's transparent real input at the visible prompt edge. xterm
1054
+ normally parks/moves it for desktop IME behavior; on phones this element
1055
+ is also the browser's only clue about what must remain above the OSK. */
1056
+ .term-host .xterm-helper-textarea {
1057
+ left: var(--am-input-left, 50%) !important;
1058
+ top: var(--am-input-top, calc(100% - 2px)) !important;
1059
+ width: 1px !important;
1060
+ height: 1px !important;
1061
+ }
1062
  .app.m-home .main { display: none; }
1063
  .app.m-home .sidebar { width: 100%; border-right: none; }
1064
  .app.m-stage .sidebar { display: none; }