Agent Manager commited on
Commit
290f6a9
·
1 Parent(s): 8dd62b7

Refine libghostty session ownership and restore

Browse files
README.md CHANGED
@@ -124,15 +124,22 @@ browser (xterm.js panes)
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
- snapshot repaint plus replayed scrollback rather than a redraw, several browsers
128
- can watch and drive one session at once (they share one grid, sized to the
129
- smallest), and agent state is read from the grid instead of shelling out per
130
- session. A resize is a request too: the browser measures itself and asks, the
131
- backend applies the size once the asking stops and then repaints every viewer
132
- from the grid so a window drag costs one PTY resize instead of one per frame,
133
- each of which would push another copy of a TUI's screen into the scrollback. Sessions survive browser disconnects but NOT a backend restart, and not
134
- a Space sleep/rebuild; with storage the working dir and CLI state persist, so a
135
- re-opened session resumes its own conversation. Claude
 
 
 
 
 
 
 
136
  sessions are pinned to a per-session conversation id at creation; Codex sessions
137
  are pinned right after first launch (the id is captured from the rollout file
138
  Codex creates) — so agents sharing a folder never resume each other's
@@ -144,11 +151,8 @@ conversations.
144
  |---|---|---|
145
  | `PORT` | `7860` | HTTP + WS port (HF `app_port`) |
146
  | `DATA_DIR` | `/data` | Durable root (mounted private Storage Bucket) |
147
- | `AM_SCROLLBACK` | `20000` | Scrollback lines kept per session grid |
148
- | `AM_REPLAY_BYTES` | `262144` | PTY bytes replayed to a reattaching browser |
149
  | `AM_RESIZE_SETTLE_MS` | `120` | Quiet period before a resize is applied to the PTY |
150
- | `AM_RESIZE_CARRY` | `1` | `0` reflows on resize like a plain terminal (duplicates scrollback) |
151
- | `AM_RESIZE_ARCHIVE_MS` | `700` | Grace period for an app to repaint before rows a shrink pushed off are archived |
152
  | `ANTHROPIC_API_KEY` | — | Claude Code / opencode / Hermes (Space **secret**) |
153
  | `OPENAI_API_KEY` / `CODEX_API_KEY` | — | Codex (Space secret) |
154
  | `GEMINI_API_KEY` | — | Gemini CLI (Space secret) |
 
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) |
server/migration.test.mjs CHANGED
@@ -1,13 +1,13 @@
1
- // End-to-end check of the tmux -> libghostty session migration.
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 path from 'node:path';
6
  import { WebSocket } from 'ws';
7
 
8
- // DATA_DIR must be ABSOLUTE: cleanRelPath() resolves against WORKSPACES_DIR and
9
- // compares strings, so a relative root makes every path look like an escape.
10
- const DATA_DIR = path.resolve('./.mig');
11
 
12
  const PORT = 7893;
13
  const CTRL = '\x00\x00AM:';
@@ -28,7 +28,7 @@ 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. */
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}`);
@@ -36,6 +36,7 @@ function view(id, cols = 100, rows = 30) {
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
  lastFrame: (t) => [...v.frames].reverse().find((f) => f.t === t) || null,
40
  open: () => ws.readyState === 1,
41
  close: () => { try { ws.close(); } catch {} },
@@ -84,6 +85,7 @@ try {
84
  const a = await view(id);
85
  check('viewer attaches', a.open());
86
  await sleep(1500);
 
87
  a.type('echo hello-from-viewer-a\r');
88
  await sleep(1300);
89
  check('input reaches the PTY and output comes back', a.bytes.includes('hello-from-viewer-a'),
@@ -103,12 +105,12 @@ try {
103
  });
104
  await sleep(1600);
105
 
106
- // --- reattach: restore frame + replayed scrollback ------------------------
107
  const b = await view(id);
108
  await sleep(1000);
109
  const restore = b.lastFrame('restore');
110
  check('reattach sends a restore frame', !!restore, restore && `${restore.cols}x${restore.rows}`);
111
- check('restore replays earlier scrollback', b.bytes.includes('hello-from-viewer-a'),
112
  `${b.bytes.length}B restored`);
113
  check('restore includes work done while detached', b.bytes.includes('ran-while-detached'));
114
  check('no tmux [exited] noise', !b.bytes.includes('[exited]'));
@@ -129,8 +131,8 @@ try {
129
  check('an idle shell settles back to idle', calm && calm.state === 'idle', calm && calm.state);
130
 
131
  // --- the agent-watch API still works, now off the grid --------------------
132
- // capturePane() used to shell out to `tmux capture-pane`; it reads the held
133
- // grid instead. Same shape (plain text, screen + scrollback above it).
134
  const tailRes = await fetch(`${base}/api/agents/${id}/tail?lines=200`);
135
  const tail = await tailRes.json();
136
  const tailText = typeof tail === 'string' ? tail : (tail.text || tail.tail || JSON.stringify(tail));
@@ -141,7 +143,7 @@ try {
141
  tailText.includes('hello-from-viewer-a') ? 'has scrollback' : 'MISSING scrollback');
142
  check('agent tail has no trailing blank padding', !/\n\s*\n\s*$/.test(tailText));
143
 
144
- // --- two viewers share one grid, nobody is kicked -------------------------
145
  const c = await view(id, 150, 40);
146
  await sleep(400);
147
  c.resize(150, 40);
@@ -154,19 +156,30 @@ try {
154
  check('both viewers are told the same grid',
155
  !!bGrid && !!cGrid && bGrid.cols === cGrid.cols && bGrid.rows === cGrid.rows,
156
  bGrid && cGrid ? `${bGrid.cols}x${bGrid.rows} vs ${cGrid.cols}x${cGrid.rows}` : 'missing grid frame');
157
- check('grid follows the smallest viewer', !!bGrid && bGrid.cols === 40 && bGrid.rows === 20,
158
  bGrid && `${bGrid.cols}x${bGrid.rows}`);
159
- check('grid is flagged shared', !!bGrid && bGrid.shared === true && bGrid.viewers === 2);
 
 
 
 
 
 
 
 
 
 
160
  c.type('echo seen-by-both\r');
161
  await sleep(1100);
162
  check('both viewers see the same live output',
163
  b.bytes.includes('seen-by-both') && c.bytes.includes('seen-by-both'));
164
 
 
 
 
 
 
165
  b.close();
166
- await sleep(1000);
167
- const grown = c.lastFrame('grid');
168
- check('grid grows back when a viewer leaves', !!grown && grown.cols === 150 && grown.rows === 40,
169
- grown && `${grown.cols}x${grown.rows}`);
170
 
171
  // --- stopping is explicit ------------------------------------------------
172
  await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
@@ -184,6 +197,7 @@ try {
184
  srv.kill('SIGTERM');
185
  await sleep(600);
186
  srv.kill('SIGKILL');
 
187
  console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
188
  process.exit(failures ? 1 : 0);
189
  }
 
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:';
 
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}`);
 
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 {} },
 
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'),
 
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]'));
 
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));
 
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);
 
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' });
 
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/resize.test.mjs CHANGED
@@ -1,35 +1,19 @@
1
- // Resize behaviour of a server-held grid.
2
  //
3
- // The bug this pins down: resizing a pane duplicated content in the scrollback
4
- // (the same lines re-wrapped at every width the pane passed through) and left
5
- // the browser's buffer disagreeing with the grid. Three causes, all here:
6
- //
7
- // * REFLOW. Narrowing rewraps the outgoing screen — a 119-column row becomes
8
- // two rows at 110 — and the excess scrolls up into scrollback, where the
9
- // TUI's repaint of that same screen leaves it stranded as a copy. One copy
10
- // per resize, so one per zoom click. Fixed by clearing the screen before the
11
- // reflow and carrying it across by hand.
12
- // * a resize STORM — ResizeObserver fires per animation frame while a window
13
- // is dragged, and every tick used to resize the PTY, so one drag paid the
14
- // above dozens of times. Fixed by coalescing.
15
- // * the browser reflowing ITSELF (fit.fit()) before the server confirmed, so
16
- // its scrollback was a byte log wrapped at mixed widths while the grid held
17
- // a properly reflowed one. Fixed by making the browser ask, not act.
18
- //
19
- // Every duplication check has teeth: AM_RESIZE_CARRY=0 restores plain reflow and
20
- // the zoom checks fail (63 duplicated tokens in the grid, 101 in the browser).
21
- //
22
- // fixtures/repaint-tui.mjs stands in for an agent TUI and repaints exactly the
23
- // way Claude Code does, so this costs no agent tokens.
24
  // node resize.test.mjs
 
 
25
  import { spawn } from 'node:child_process';
26
  import path from 'node:path';
27
  import { fileURLToPath } from 'node:url';
28
  import { WebSocket } from 'ws';
29
 
30
  const HERE = path.dirname(fileURLToPath(import.meta.url));
31
- const DATA_DIR = path.join(HERE, '.resize');
32
- const FIXTURE = path.join(HERE, 'fixtures', 'repaint-tui.mjs');
33
  const PORT = 7895;
34
  const CTRL = '\x00\x00AM:';
35
  const base = `http://localhost:${PORT}`;
@@ -39,7 +23,15 @@ const check = (name, ok, detail = '') => {
39
  console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`);
40
  if (!ok) failures++;
41
  };
42
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
 
 
 
 
 
 
 
 
43
 
44
  const srv = spawn('node', ['src/index.js'], {
45
  cwd: HERE,
@@ -47,65 +39,58 @@ const srv = spawn('node', ['src/index.js'], {
47
  stdio: ['ignore', 'pipe', 'pipe'],
48
  });
49
  let log = '';
50
- srv.stdout.on('data', (d) => { log += d; });
51
- srv.stderr.on('data', (d) => { log += d; });
52
 
53
- // The browser's own emulator, so a viewer can be checked the way a user sees it
54
- // rather than only through the grid. Optional: it is a devDependency, and the
55
- // suite still runs (minus those checks) in a production install.
56
  let Headless = null;
57
  try { Headless = (await import('@xterm/headless')).default.Terminal; } catch {}
58
 
59
  function view(id, cols, rows, mirror = false) {
60
  return new Promise((resolve) => {
61
  const ws = new WebSocket(`ws://localhost:${PORT}/ws?session=${id}&cols=${cols}&rows=${rows}`);
62
- // A stand-in for TerminalPane, mirroring its message handling EXACTLY — the
63
- // point is to catch the ordering hazard in it: xterm's write is asynchronous,
64
- // so a resize applied outside the write callback can overtake the bytes that
65
- // were sent before it.
66
  const term = mirror && Headless
67
  ? new Headless({ cols, rows, scrollback: 20000, allowProposedApi: true })
68
  : null;
69
  const v = {
70
  bytes: '', frames: [], term,
71
- type: (d) => ws.send(JSON.stringify({ t: 'i', d })),
72
- resize: (c, r) => ws.send(JSON.stringify({ t: 'r', cols: c, rows: r })),
73
- lastFrame: (t) => [...v.frames].reverse().find((f) => f.t === t) || null,
74
- countFrames: (t) => v.frames.filter((f) => f.t === t).length,
 
 
75
  close: () => { try { ws.close(); } catch {} },
76
- // Everything the viewer has: scrollback above the screen, then the screen.
77
  screenText: async () => {
78
  if (!term) return '';
79
- await new Promise((r) => term.write('', r));
80
- const buf = term.buffer.active;
81
- const out = [];
82
- for (let i = 0; i < buf.length; i++) out.push(buf.getLine(i)?.translateToString(true) ?? '');
83
- return out.join('\n');
84
- },
85
- // Just the visible screen — what the app is displaying right now.
86
- viewportText: async () => {
87
- if (!term) return '';
88
- await new Promise((r) => term.write('', r));
89
  const buf = term.buffer.active;
90
- const out = [];
91
- for (let i = buf.baseY; i < buf.baseY + term.rows; i++) out.push(buf.getLine(i)?.translateToString(true) ?? '');
92
- return out.join('\n');
93
  },
94
  };
95
  ws.on('message', (data) => {
96
- const s = data.toString('utf8');
97
- if (s.startsWith(CTRL)) {
98
- let m = null;
99
- try { m = JSON.parse(s.slice(CTRL.length)); } catch { return; }
100
- v.frames.push(m);
101
- if (term && (m.t === 'grid' || m.t === 'restore') && m.cols > 0 && m.rows > 0
102
- && (term.cols !== m.cols || term.rows !== m.rows)) {
103
- if (m.clear) term.write('', () => term.resize(m.cols, m.rows));
104
- else term.resize(m.cols, m.rows);
 
 
 
 
 
 
 
 
105
  }
106
  } else {
107
- v.bytes += s;
108
- if (term) term.write(s);
109
  }
110
  });
111
  ws.on('open', () => resolve(v));
@@ -113,18 +98,6 @@ function view(id, cols, rows, mirror = false) {
113
  });
114
  }
115
 
116
- /** Tokens the fixture prints are unique, so a repeat is a duplicated line. */
117
- function duplicateTokens(text) {
118
- const counts = new Map();
119
- for (const m of text.matchAll(/\b\d{3}\.\d{2}\b/g)) counts.set(m[0], (counts.get(m[0]) || 0) + 1);
120
- return [...counts.values()].filter((n) => n > 1).length;
121
- }
122
-
123
- const gridText = async (id) => {
124
- const r = await (await fetch(`${base}/api/agents/${id}/tail?lines=500`)).json();
125
- return typeof r === 'string' ? r : (r.text || '');
126
- };
127
-
128
  async function session(name) {
129
  const created = await (await fetch(`${base}/api/sessions`, {
130
  method: 'POST', headers: { 'content-type': 'application/json' },
@@ -134,350 +107,145 @@ async function session(name) {
134
  return created.id;
135
  }
136
 
137
- /** A pane showing the fixture, painted and settled. */
138
- async function paintedFixture(name, cols = 120, rows = 40, mirror = false) {
139
- const id = await session(name);
140
- const v = await view(id, cols, rows, mirror);
141
- v.resize(cols, rows);
142
- await sleep(900);
143
- v.type(`node ${FIXTURE}\r`);
144
- await sleep(1500);
145
- return { id, v };
146
- }
147
 
148
  try {
149
- let up = false;
150
- // Generous: importing the dependency tree off a cold FUSE-mounted workspace can
151
- // take half a minute (express alone measured 32s), and a boot timeout looks
152
- // exactly like a broken server.
153
- for (let i = 0; i < 400; i++) {
154
- try { if ((await fetch(`${base}/api/health`)).ok) { up = true; break; } } catch {}
155
- await sleep(250);
156
- }
157
- if (!up) throw new Error(`server never came up:\n${log.slice(-800)}`);
158
 
159
- // --- one deliberate resize -----------------------------------------------
 
160
  {
161
- const { id, v } = await paintedFixture('resize once');
162
- const before = duplicateTokens(await gridText(id));
163
- check('a painted fixture starts with no duplicated lines', before === 0, `${before} duplicated tokens`);
164
-
165
- v.resize(80, 30);
166
- await sleep(1200);
167
- const after = duplicateTokens(await gridText(id));
168
- // One repaint at a new width cannot avoid leaving the rows the shrink pushed
169
- // into scrollback, so a handful is expected; a copy of the whole screen is
170
- // not.
171
- check('one resize duplicates at most a few lines', after <= 12, `${after} duplicated tokens`);
172
- check('the fixture followed the resize', (await gridText(id)).includes('[fixture 80x30]'));
 
173
  v.close();
174
- await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
175
  }
176
 
177
- // --- a drag: ResizeObserver fires every frame ----------------------------
178
  {
179
- const { id, v } = await paintedFixture('resize storm');
180
- // 16 sizes in ~500ms is a slow drag; a real one is faster.
 
181
  for (let i = 0; i < 16; i++) {
182
  v.resize(120 - i * 2, 40 - i);
183
- await sleep(30);
184
  }
185
  v.resize(88, 24);
186
- await sleep(1500);
187
-
188
- const dupes = duplicateTokens(await gridText(id));
189
- check('a drag does not multiply scrollback', dupes <= 12, `${dupes} duplicated tokens`);
190
- check('the grid settles at the final size', (await gridText(id)).includes('[fixture 88x24]'));
191
- // Every viewer must be told the size that was actually applied, and the
192
- // coalescing must not swallow the last one.
193
- const last = v.lastFrame('grid');
194
- check('the last grid frame matches the final size', !!last && last.cols === 88 && last.rows === 24,
195
- last && `${last.cols}x${last.rows}`);
196
- check('the storm is coalesced into few PTY resizes', v.countFrames('grid') <= 4,
197
- `${v.countFrames('grid')} grid frames`);
198
- v.close();
199
- await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
200
- }
201
-
202
- // --- the browser is repainted from the grid after a resize ---------------
203
- // An app that ignores SIGWINCH (a bash prompt, an agent sitting idle) would
204
- // otherwise leave the viewer showing its own reflow of a byte log, which is
205
- // exactly where the two disagree.
206
- {
207
- const { id, v } = await paintedFixture('resize repaint');
208
- const beforeBytes = v.bytes.length;
209
- v.resize(70, 26);
210
- await sleep(1200);
211
- const sent = v.bytes.slice(beforeBytes);
212
- check('a settled resize repaints the viewer from the grid', sent.includes('\x1b[2J'),
213
- `${sent.length}B after resize`);
214
- // The size must arrive BEFORE the bytes that assume it.
215
- check('the grid frame precedes the repaint',
216
- v.frames.some((f) => f.t === 'grid' && f.cols === 70 && f.rows === 26));
217
  v.close();
218
- await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
219
  }
220
 
221
- // --- zooming: a sequence of deliberate, settled resizes ------------------
222
- // Each zoom click is a real resize, so coalescing cannot help here. What keeps
223
- // scrollback clean is clearing the screen before the reflow (nothing left to
224
- // rewrap into history) and carrying it across by hand instead.
225
  {
226
- const { id, v } = await paintedFixture('resize zoom');
227
- // The first resize is the one that teaches the host this app repaints (a
228
- // shell session is assumed not to), so measure from after it.
229
- v.resize(110, 36);
230
- await sleep(900);
231
- const learned = duplicateTokens(await gridText(id));
232
-
233
- for (const [c, r] of [[100, 32], [90, 28], [80, 24], [90, 28], [100, 32], [110, 36]]) {
234
- v.resize(c, r);
235
- await sleep(400);
236
- }
237
- await sleep(1200);
238
- const after = duplicateTokens(await gridText(id));
239
- check('zooming in and out adds no duplicated lines', after <= learned,
240
- `${learned} before, ${after} after six zoom steps`);
241
- check('the zoomed grid still shows the fixture', (await gridText(id)).includes('[fixture 110x36]'));
242
- v.close();
243
- await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
244
- }
245
-
246
- // --- what the BROWSER ends up holding ------------------------------------
247
- // The grid being clean is only half the fix: the duplication a user sees is in
248
- // xterm's buffer, which reflows harder than libghostty does. This drives the
249
- // real emulator through the real protocol.
250
- if (!Headless) {
251
- console.log('SKIP browser-side checks (@xterm/headless not installed)');
252
- } else {
253
- const { id, v } = await paintedFixture('resize browser', 120, 40, true);
254
- v.resize(110, 36);
255
- await sleep(900);
256
- const learned = duplicateTokens(await v.screenText());
257
- for (const [c, r] of [[100, 32], [90, 28], [80, 24], [90, 28], [100, 32], [110, 36]]) {
258
- v.resize(c, r);
259
- await sleep(400);
260
- }
261
- await sleep(1200);
262
- const seen = await v.screenText();
263
- check("zooming leaves no duplicates in the browser's own buffer", duplicateTokens(seen) <= learned,
264
- `${learned} before, ${duplicateTokens(seen)} after six zoom steps`);
265
- check('the browser ends at the grid size', v.term.cols === 110 && v.term.rows === 36,
266
- `${v.term.cols}x${v.term.rows}`);
267
- // Ordering: if resize overtook the repaint, the screen would be torn or stale.
268
- check('the browser shows the fixture at the final size', seen.includes('[fixture 110x36]'));
269
- v.close();
270
- await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
271
- }
272
-
273
- // --- scrolling back still reaches everything -----------------------------
274
- // The first version of the carry cleared the screen and dropped the rows that
275
- // no longer fitted, so every zoom-in quietly ate history: a 40->24 shrink took
276
- // 16 lines with it and the pane could no longer scroll all the way up. Rows
277
- // that fall off the top have to be ARCHIVED, not discarded.
278
- if (!Headless) {
279
- console.log('SKIP scrollback checks (@xterm/headless not installed)');
280
- } else {
281
- const id = await session('resize history');
282
- const v = await view(id, 120, 40, true);
283
- v.resize(120, 40);
284
- await sleep(900);
285
- // Teach the host that this session repaints, so the carry path is the one
286
- // under test, then leave a real log behind — that is what must survive.
287
- v.type(`node ${FIXTURE}\r`);
288
- await sleep(1500);
289
- v.resize(118, 38);
290
- await sleep(800);
291
- v.type('\x03');
292
- await sleep(800);
293
- // The trailing blank lines are load-bearing. Bash redraws its prompt on every
294
- // SIGWINCH and, when that prompt is wrapped, draws over the rows above it —
295
- // verified identical with AM_RESIZE_CARRY=0, so it is bash's doing and not the
296
- // resize path's. Without the padding this test would be asserting something no
297
- // terminal delivers.
298
- v.type('for i in $(seq 1 200); do echo "hist-$i"; done; echo; echo; echo\r');
299
- await sleep(2000);
300
-
301
- const missing = (text) => {
302
- const gone = [];
303
- for (let i = 1; i <= 200; i++) if (!new RegExp(`hist-${i}(?!\\d)`).test(text)) gone.push(i);
304
- return gone;
305
- };
306
- const before = missing(await v.screenText());
307
- check('the log is complete before zooming', before.length === 0, `${before.length} lines missing`);
308
-
309
- for (const [c, r] of [[100, 30], [90, 24], [100, 30], [110, 34], [120, 40], [90, 24]]) {
310
- v.resize(c, r);
311
- await sleep(400);
312
- }
313
- await sleep(1200);
314
- // The grid is the authority and has to be exact.
315
- const gridGone = missing(await gridText(id));
316
- check('zooming loses no scrollback in the grid', gridGone.length === 0,
317
- gridGone.length ? `${gridGone.length} lines gone, e.g. hist-${gridGone.slice(0, 5).join(', hist-')}` : '');
318
- // xterm's own reflow drops a couple of lines over a cycle this violent no
319
- // matter what we do — AM_RESIZE_CARRY=0 loses three in the same run, from the
320
- // same region — so this is a bound, not a promise. A reattach repaints the
321
- // pane from the grid, which is why the grid check above is the strict one.
322
- const after = missing(await v.screenText());
323
- check('zooming loses no more browser scrollback than plain reflow does', after.length <= 4,
324
- after.length ? `${after.length} lines gone, e.g. hist-${after.slice(0, 5).join(', hist-')}` : 'none');
325
- v.close();
326
- await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
327
- }
328
-
329
- // --- zooming OUT gives back the top, it does not copy it -----------------
330
- // Growing the screen pulls history back down out of scrollback to fill the new
331
- // rows. The carry used to paint the outgoing screen over exactly those rows, so
332
- // zooming out destroyed the history it had just recovered and left the old
333
- // screen stranded above the app's repaint: "I can't scroll all the way to the
334
- // top, and zooming out further duplicates at the top."
335
- if (!Headless) {
336
- console.log('SKIP zoom-out checks (@xterm/headless not installed)');
337
- } else {
338
- const id = await session('resize zoom out');
339
- const v = await view(id, 120, 40, true);
340
- v.resize(120, 40);
341
- await sleep(900);
342
- v.type(`node ${FIXTURE}\r`);
343
- await sleep(1500);
344
- v.type('\x03');
345
- await sleep(800);
346
- v.type('for i in $(seq 1 120); do echo "out-$i"; done; echo; echo; echo\r');
347
- await sleep(2000);
348
-
349
- const count = (text, n) => (text.match(new RegExp(`out-${n}(?!\\d)`, 'g')) || []).length;
350
- // Zoom in, then back out past where it started.
351
- for (const [c, r] of [[100, 26], [90, 20], [100, 26], [110, 34], [120, 44]]) {
352
- v.resize(c, r);
353
- await sleep(500);
354
- }
355
- await sleep(1200);
356
- const seen = await v.screenText();
357
- const grid = await gridText(id);
358
- const lost = [];
359
- const doubled = [];
360
- for (let i = 1; i <= 120; i++) {
361
- if (count(grid, i) === 0) lost.push(i);
362
- if (count(grid, i) > 1) doubled.push(i);
363
  }
364
- check('zooming out keeps every line it recovered', lost.length === 0,
365
- lost.length ? `${lost.length} gone, e.g. out-${lost.slice(0, 5).join(', out-')}` : '');
366
- check('zooming out copies nothing to the top', doubled.length === 0,
367
- doubled.length ? `${doubled.length} doubled, e.g. out-${doubled.slice(0, 5).join(', out-')}` : '');
368
- const browserDoubled = [];
369
- for (let i = 1; i <= 120; i++) if (count(seen, i) > 1) browserDoubled.push(i);
370
- check('and copies nothing in the browser either', browserDoubled.length === 0,
371
- browserDoubled.length ? `${browserDoubled.length} doubled, e.g. out-${browserDoubled.slice(0, 5).join(', out-')}` : '');
372
- v.close();
373
- await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
374
  }
375
 
376
- // --- an app whose frame overflows the pane -------------------------------
377
- // The case a real agent pane hits on zoom, and the one thing here that is NOT
378
- // ours to fix: an agent renders the tail of its conversation, narrowing wraps
379
- // those lines, and the frame becomes taller than the screen. Printing it scrolls
380
- // the overflow into scrollback, where it is a copy of what the frame also shows.
381
- // Any terminal does this; measured here at 251 duplicated tokens with the carry
382
- // on and 371 with AM_RESIZE_CARRY=0, so what we can prevent, we do.
383
- //
384
- // The copies are one per print: the app painted an overflowing frame three times
385
- // here (once on launch, once per resize) and rows appear up to three times, with
386
- // the carry on and off alike. What the carry removes is OUR share — the rewrapped
387
- // copy reflow would archive on top — which is the whole 371-vs-251 difference.
388
- // The bound below is what catches that share coming back.
389
- if (!Headless) {
390
- console.log('SKIP overflow checks (@xterm/headless not installed)');
391
- } else {
392
- const id = await session('resize overflow');
393
- const v = await view(id, 150, 40, true);
394
- v.resize(150, 40);
395
- await sleep(900);
396
- v.type(`FIXED_LINES=30 node ${FIXTURE}\r`);
397
- await sleep(2000);
398
- v.resize(148, 38); // teaches host.repaints = true
399
- await sleep(1000);
400
- v.resize(100, 26); // 100% -> 150%: wraps the frame past the screen
401
- await sleep(2000);
402
-
403
- const buffer = await v.screenText();
404
- const counts = new Map();
405
- for (const m of buffer.matchAll(/\b\d{3}\.\d{2}\b/g)) counts.set(m[0], (counts.get(m[0]) || 0) + 1);
406
- const values = [...counts.values()];
407
- const dupes = values.filter((n) => n > 1).length;
408
- const most = Math.max(0, ...values);
409
- // 251 with the carry, 371 without it. The gap is the rewrap copy, and 300 sits
410
- // in it: this fails if the resize path starts archiving a screen again.
411
- check('an overflowing frame duplicates only what the app itself printed', dupes <= 300,
412
- `${dupes} duplicated tokens, up to ${most} copies each (app printed 3 frames)`);
413
- v.close();
414
- await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
415
- }
416
-
417
- // --- the screen survives a resize the app ignores ------------------------
418
- // Clearing before the reflow is only safe because the screen is re-painted
419
- // afterwards. An app that never answers SIGWINCH is the case that proves it:
420
- // here the fixture teaches the host that this session repaints, then exits, so
421
- // the shell prompt below it gets the carry treatment while redrawing nothing.
422
  {
423
- const { id, v } = await paintedFixture('resize carry');
424
- v.resize(110, 36); // teaches host.repaints = true
425
- await sleep(900);
426
- v.type('\x03'); // Ctrl-C: the fixture dies, prompt comes back
427
- await sleep(800);
428
- v.type('printf "carry-%s\\n" 1 2 3 4 5\r');
429
- await sleep(800);
430
- check('the lines are on screen before the resize', (await gridText(id)).includes('carry-5'));
431
- v.resize(84, 30);
432
- await sleep(1200);
433
- const text = await gridText(id);
434
- check('a screen the app never repaints survives the resize', text.includes('carry-5'),
435
- text.includes('carry-1') ? '' : 'lost the earlier lines too');
436
- check('and is not duplicated by the carry', (text.match(/carry-5/g) || []).length === 1,
437
- `${(text.match(/carry-5/g) || []).length} copies`);
438
- v.close();
439
- await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  }
441
 
442
- // --- a shell's rewrapped scrollback is the log, not a duplicate ----------
443
- // Nothing repaints it, so reflow is the only thing preserving it: the clear
444
- // must stay off until an app proves it repaints.
445
  {
446
- const id = await session('resize shell log');
447
- const v = await view(id, 120, 40);
448
- v.resize(120, 40);
449
- await sleep(900);
450
- v.type('for i in $(seq 1 60); do echo "log-line-$i"; done\r');
451
- await sleep(1200);
452
- v.resize(90, 30);
453
- await sleep(1200);
454
- const text = await gridText(id);
455
- check('a shell keeps its scrolled-off output across a resize', text.includes('log-line-5'),
456
- text.includes('log-line-55') ? '' : 'lost recent lines too');
457
- v.close();
458
- await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
459
- }
460
-
461
- // --- a viewer that only re-asks for the same size costs nothing ----------
462
- // Tab focus and unrelated layout changes call resync() constantly.
463
- {
464
- const { id, v } = await paintedFixture('resize noop');
465
- const framesBefore = v.countFrames('grid');
466
- for (let i = 0; i < 5; i++) { v.resize(120, 40); await sleep(120); }
467
- await sleep(600);
468
- check('re-requesting the current size changes nothing', v.countFrames('grid') === framesBefore,
469
- `${v.countFrames('grid') - framesBefore} extra grid frames`);
470
- check('no duplication from no-op resyncs', duplicateTokens(await gridText(id)) === 0);
471
  v.close();
472
- await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
473
  }
474
  } catch (err) {
475
  check('no exceptions', false, String(err && err.message ? err.message : err));
476
- console.log('--- server log tail ---\n' + log.slice(-1200));
477
  } finally {
478
  srv.kill('SIGTERM');
479
- await sleep(600);
480
  srv.kill('SIGKILL');
 
481
  console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
482
  process.exit(failures ? 1 : 0);
483
  }
 
1
+ // Invariants for the server-held terminal model.
2
  //
3
+ // These tests intentionally avoid classifying foreground applications or
4
+ // counting "acceptable" duplicate rows. The contract is smaller and firmer:
5
+ // Ghostty owns retained state, a resize is ordered ordinary reflow, and exactly
6
+ // one viewer controls input and PTY geometry. Full serialization is attach-only.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
15
  const HERE = path.dirname(fileURLToPath(import.meta.url));
16
+ const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'am-resize-'));
 
17
  const PORT = 7895;
18
  const CTRL = '\x00\x00AM:';
19
  const base = `http://localhost:${PORT}`;
 
23
  console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`);
24
  if (!ok) failures++;
25
  };
26
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
27
+ const waitFor = async (fn, timeout = 8000) => {
28
+ const until = Date.now() + timeout;
29
+ while (Date.now() < until) {
30
+ if (await fn()) return true;
31
+ await sleep(100);
32
+ }
33
+ return false;
34
+ };
35
 
36
  const srv = spawn('node', ['src/index.js'], {
37
  cwd: HERE,
 
39
  stdio: ['ignore', 'pipe', 'pipe'],
40
  });
41
  let log = '';
42
+ srv.stdout.on('data', (data) => { log += data; });
43
+ srv.stderr.on('data', (data) => { log += data; });
44
 
 
 
 
45
  let Headless = null;
46
  try { Headless = (await import('@xterm/headless')).default.Terminal; } catch {}
47
 
48
  function view(id, cols, rows, mirror = false) {
49
  return new Promise((resolve) => {
50
  const ws = new WebSocket(`ws://localhost:${PORT}/ws?session=${id}&cols=${cols}&rows=${rows}`);
 
 
 
 
51
  const term = mirror && Headless
52
  ? new Headless({ cols, rows, scrollback: 20000, allowProposedApi: true })
53
  : null;
54
  const v = {
55
  bytes: '', frames: [], term,
56
+ type: (data) => ws.send(JSON.stringify({ t: 'i', d: data })),
57
+ resize: (nextCols, nextRows) => ws.send(JSON.stringify({ t: 'r', cols: nextCols, rows: nextRows })),
58
+ claim: () => ws.send(JSON.stringify({ t: 'claim' })),
59
+ lastFrame: (type) => [...v.frames].reverse().find((frame) => frame.t === type) || null,
60
+ countFrames: (type) => v.frames.filter((frame) => frame.t === type).length,
61
+ open: () => ws.readyState === WebSocket.OPEN,
62
  close: () => { try { ws.close(); } catch {} },
 
63
  screenText: async () => {
64
  if (!term) return '';
65
+ await new Promise((done) => term.write('', done));
 
 
 
 
 
 
 
 
 
66
  const buf = term.buffer.active;
67
+ const lines = [];
68
+ for (let i = 0; i < buf.length; i++) lines.push(buf.getLine(i)?.translateToString(true) ?? '');
69
+ return lines.join('\n');
70
  },
71
  };
72
  ws.on('message', (data) => {
73
+ const text = data.toString('utf8');
74
+ if (text.startsWith(CTRL)) {
75
+ let frame;
76
+ try { frame = JSON.parse(text.slice(CTRL.length)); } catch { return; }
77
+ v.frames.push(frame);
78
+ if (term && (frame.t === 'grid' || frame.t === 'restore')) {
79
+ const applyGrid = () => {
80
+ if (frame.reset) { term.reset(); term.clear(); }
81
+ if (frame.cols > 0 && frame.rows > 0
82
+ && (term.cols !== frame.cols || term.rows !== frame.rows)) {
83
+ term.resize(frame.cols, frame.rows);
84
+ }
85
+ };
86
+ const geometryChanged = frame.cols > 0 && frame.rows > 0
87
+ && (term.cols !== frame.cols || term.rows !== frame.rows);
88
+ if (frame.reset || geometryChanged) term.write('', applyGrid);
89
+ else applyGrid();
90
  }
91
  } else {
92
+ v.bytes += text;
93
+ if (term) term.write(text);
94
  }
95
  });
96
  ws.on('open', () => resolve(v));
 
98
  });
99
  }
100
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  async function session(name) {
102
  const created = await (await fetch(`${base}/api/sessions`, {
103
  method: 'POST', headers: { 'content-type': 'application/json' },
 
107
  return created.id;
108
  }
109
 
110
+ const gridText = async (id, lines = 2000) => {
111
+ const response = await fetch(`${base}/api/agents/${id}/tail?lines=${lines}`);
112
+ const body = await response.json();
113
+ return typeof body === 'string' ? body : (body.text || body.tail || '');
114
+ };
115
+
116
+ const stop = async (id) => {
117
+ await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' }).catch(() => {});
118
+ await sleep(250);
119
+ };
120
 
121
  try {
122
+ const up = await waitFor(async () => {
123
+ try { return (await fetch(`${base}/api/health`)).ok; } catch { return false; }
124
+ }, 100_000);
125
+ if (!up) throw new Error(`server never came up:\n${log.slice(-1200)}`);
 
 
 
 
 
126
 
127
+ // A long logical line must survive ordinary Ghostty reflow. The former
128
+ // screen-carry path cropped the right side before rebuilding a narrower grid.
129
  {
130
+ const id = await session('long line reflow');
131
+ const v = await view(id, 110, 24, true);
132
+ await sleep(700);
133
+ v.type(`printf 'LONG-%s-END-SENTINEL\\n' "$(printf 'x%.0s' {1..90})"\r`);
134
+ const printed = await waitFor(async () => (await gridText(id)).includes('END-SENTINEL'));
135
+ check('long line is present before resize', printed);
136
+ v.resize(60, 24);
137
+ const resized = await waitFor(() => v.lastFrame('grid')?.cols === 60);
138
+ check('resize settles at the requested geometry', resized, JSON.stringify(v.lastFrame('grid')));
139
+ const after = await gridText(id);
140
+ check('narrowing does not crop the right side of a logical line', after.includes('END-SENTINEL'));
141
+ check('resize does not retransmit all history', v.lastFrame('grid')?.reset === false);
142
+ if (Headless) check('the ordered viewer reflow also retains the line', (await v.screenText()).includes('END-SENTINEL'));
143
  v.close();
144
+ await stop(id);
145
  }
146
 
147
+ // A drag should issue one final resize, not one SIGWINCH per animation frame.
148
  {
149
+ const id = await session('resize storm');
150
+ const v = await view(id, 120, 40);
151
+ await sleep(400);
152
  for (let i = 0; i < 16; i++) {
153
  v.resize(120 - i * 2, 40 - i);
154
+ await sleep(25);
155
  }
156
  v.resize(88, 24);
157
+ const settled = await waitFor(() => v.lastFrame('grid')?.cols === 88 && v.lastFrame('grid')?.rows === 24);
158
+ check('resize storm settles at its final size', settled, JSON.stringify(v.lastFrame('grid')));
159
+ check('resize storm is coalesced', v.countFrames('grid') <= 3, `${v.countFrames('grid')} grid frames`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  v.close();
161
+ await stop(id);
162
  }
163
 
164
+ // Reattachment is serialized from Ghostty state, not from a 256 KiB suffix
165
+ // of raw PTY traffic. Both the oldest and newest retained rows must return.
 
 
166
  {
167
+ const id = await session('full canonical restore');
168
+ const a = await view(id, 110, 30);
169
+ await sleep(600);
170
+ a.type(`for i in $(seq 1 4500); do printf 'RESTORE-%04d-%070d\\n' "$i" "$i"; done\r`);
171
+ const complete = await waitFor(async () => (await gridText(id, 5000)).includes('RESTORE-4500-'), 20_000);
172
+ check('large history finishes printing', complete);
173
+ a.close();
174
+ await sleep(300);
175
+ const b = await view(id, 110, 30, true);
176
+ const restored = await waitFor(() => b.bytes.includes('RESTORE-4500-'), 12_000);
177
+ check('reattach receives the canonical restore', restored && !!b.lastFrame('restore'));
178
+ check('restore is not capped at the old raw replay limit', b.bytes.length > 262_144, `${b.bytes.length} bytes`);
179
+ check('restore includes the oldest retained output', b.bytes.includes('RESTORE-0001-'));
180
+ check('restore includes the newest retained output', b.bytes.includes('RESTORE-4500-'));
181
+ if (Headless) {
182
+ const rendered = await b.screenText();
183
+ check('a fresh emulator can scroll to the oldest restored output', rendered.includes('RESTORE-0001-'));
184
+ check('a fresh emulator contains the newest restored output', rendered.includes('RESTORE-4500-'));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  }
186
+ b.close();
187
+ await stop(id);
 
 
 
 
 
 
 
 
188
  }
189
 
190
+ // One viewer owns both input and size. A watcher has no effect until an
191
+ // explicit claim, after which the old controller becomes inert.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  {
193
+ const id = await session('controller lease');
194
+ const a = await view(id, 120, 35);
195
+ await sleep(400);
196
+ const b = await view(id, 60, 20);
197
+ await sleep(400);
198
+ check('first viewer is the controller', a.lastFrame('grid')?.controller === true
199
+ && b.lastFrame('restore')?.controller === false);
200
+ b.resize(50, 15);
201
+ await sleep(400);
202
+ check('watcher size cannot shrink the session', b.lastFrame('restore')?.cols === 120
203
+ && !b.frames.some((frame) => frame.t === 'grid' && frame.cols === 50));
204
+ b.type('echo WATCHER-MUST-NOT-RUN\r');
205
+ await sleep(400);
206
+ check('watcher input is rejected by the server', !a.bytes.includes('WATCHER-MUST-NOT-RUN'));
207
+
208
+ b.claim();
209
+ const claimed = await waitFor(() => b.lastFrame('grid')?.controller === true
210
+ && b.lastFrame('grid')?.cols === 50 && b.lastFrame('grid')?.rows === 15);
211
+ check('watcher can claim input and geometry together', claimed, JSON.stringify(b.lastFrame('grid')));
212
+ b.type('echo CONTROLLER-RAN\r');
213
+ check('new controller input reaches every viewer', await waitFor(() => a.bytes.includes('CONTROLLER-RAN')
214
+ && b.bytes.includes('CONTROLLER-RAN')));
215
+ a.resize(140, 45);
216
+ await sleep(400);
217
+ check('old controller becomes an inert watcher', b.lastFrame('grid')?.cols === 50
218
+ && b.lastFrame('grid')?.rows === 15);
219
+
220
+ b.close();
221
+ const handed = await waitFor(() => a.lastFrame('grid')?.controller === true
222
+ && a.lastFrame('grid')?.cols === 140 && a.lastFrame('grid')?.rows === 45);
223
+ check('controller lease passes to a remaining viewer on disconnect', handed, JSON.stringify(a.lastFrame('grid')));
224
+ a.close();
225
+ await stop(id);
226
  }
227
 
228
+ // Focus and unrelated layout events may re-report the same preference.
 
 
229
  {
230
+ const id = await session('no-op size');
231
+ const v = await view(id, 80, 24);
232
+ await sleep(300);
233
+ const before = v.countFrames('grid');
234
+ for (let i = 0; i < 5; i++) v.resize(80, 24);
235
+ await sleep(500);
236
+ check('re-reporting the current size performs no reset', v.countFrames('grid') === before,
237
+ `${v.countFrames('grid') - before} extra frames`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  v.close();
239
+ await stop(id);
240
  }
241
  } catch (err) {
242
  check('no exceptions', false, String(err && err.message ? err.message : err));
243
+ console.log('--- server log tail ---\n' + log.slice(-1600));
244
  } finally {
245
  srv.kill('SIGTERM');
246
+ await sleep(500);
247
  srv.kill('SIGKILL');
248
+ try { fs.rmSync(DATA_DIR, { recursive: true, force: true }); } catch {}
249
  console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
250
  process.exit(failures ? 1 : 0);
251
  }
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/index.js CHANGED
@@ -259,8 +259,8 @@ async function deliver(session, text, from) {
259
  }
260
 
261
  // Type a prompt into a session's terminal from the Overview — no pane needed.
262
- // If the agent is stopped, wake it first (detached tmux + resume) and give the
263
- // CLI a 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,9 +278,8 @@ app.post('/api/sessions/:id/input', async (req, res) => {
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 adds no capability every agent in this container can already
282
- // `tmux send-keys` at its neighbours — it makes the capability legible,
283
- // attributed, and stable enough to document in the environment skill.
284
  //
285
  // The etiquette (check state before interrupting, don't ping-pong, don't stop
286
  // someone unasked, don't spawn armies) is TAUGHT in that skill rather than
@@ -984,7 +983,7 @@ Hermes — alongside plain shells and a file browser.
984
  ## What persists (and what doesn't)
985
  - \`/data\` is **durable storage** (a mounted bucket). Files under \`/data/workspaces/…\` and \`/data/home/…\` survive restarts and sleep.
986
  - **Empty directories are not persisted** — only files. If a folder must exist, keep a file in it.
987
- - Sessions are **tmux-backed**: they keep running when the browser disconnects and can be resumed after the Space sleeps.
988
  - Exception: OpenClaw runs with its own \`$HOME\` on local disk for filesystem compatibility; that state is backed up to the bucket every minute.
989
 
990
  ## You may not be alone
@@ -2153,35 +2152,30 @@ wss.on('connection', (ws, req) => {
2153
  if (d.length) ws.send(d);
2154
  });
2155
  handle.onExit(() => {
2156
- // Only ONE reason to close now: the agent process itself exited. There is no
2157
- // handover any more the grid is shared, so a second device attaching does
2158
- // not take the session away from the first. Still no auto-reconnect on 4000,
2159
- // or we would respawn the agent in a loop.
2160
  if (ws.readyState === ws.OPEN) {
2161
  try { ws.close(4000, 'exited'); } catch { ws.close(); }
2162
  }
2163
  });
2164
- // The shared grid moved (a viewer joined, left, or asked for a different size).
2165
- // Every viewer is told, so nobody keeps drawing into a stale geometry.
2166
- // `clear` says the backend cleared its screen before reflowing and is sending a
2167
- // repaint: the browser has to do the same, or its emulator archives a rewrapped
2168
- // copy of the screen that ours deliberately did not.
2169
- handle.onGrid((cols_, rows_, shared, viewers, clear) => {
2170
  if (ws.readyState !== ws.OPEN) return;
2171
- try { ws.send(TERM_CTRL + JSON.stringify({ t: 'grid', cols: cols_, rows: rows_, shared, viewers, clear })); } catch {}
2172
  });
2173
 
2174
- // Hand the screen back immediately: replayed scrollback, then a snapshot
2175
- // repaint on top as the authority. No tmux redraw, and nothing is asked of the
2176
- // agent's TUI — which is why this works even while it sits idle.
2177
  const restore = handle.restore();
2178
  if (restore) {
2179
  try {
2180
  ws.send(TERM_CTRL + JSON.stringify({
2181
  t: 'restore', cols: restore.cols, rows: restore.rows,
2182
- viewers: restore.viewers, shared: restore.shared,
2183
  }));
2184
- if (restore.replay) ws.send(restore.replay);
2185
  ws.send(restore.ansi);
2186
  } catch {}
2187
  }
@@ -2191,8 +2185,7 @@ wss.on('connection', (ws, req) => {
2191
  try { msg = JSON.parse(raw.toString()); } catch { return; }
2192
  if (msg.t === 'i') handle.write(msg.d);
2193
  else if (msg.t === 'r') handle.resize(msg.cols, msg.rows);
2194
- // 'copy' is gone: scrollback now lives in the browser (it is replayed on
2195
- // attach), so a selection is local and needs no round trip to tmux.
2196
  });
2197
 
2198
  // Detaching a viewer, NOT stopping the session.
 
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
 
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
 
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
+ // Ghostty owns the durable terminal model. Reattachment receives one canonical
2171
+ // serialization of its retained scrollback and current styled screen.
 
2172
  const restore = handle.restore();
2173
  if (restore) {
2174
  try {
2175
  ws.send(TERM_CTRL + JSON.stringify({
2176
  t: 'restore', cols: restore.cols, rows: restore.rows,
2177
+ viewers: restore.viewers, controller: restore.controller, reset: true,
2178
  }));
 
2179
  ws.send(restore.ansi);
2180
  } catch {}
2181
  }
 
2185
  try { msg = JSON.parse(raw.toString()); } catch { return; }
2186
  if (msg.t === 'i') handle.write(msg.d);
2187
  else if (msg.t === 'r') handle.resize(msg.cols, msg.rows);
2188
+ else if (msg.t === 'claim') handle.claim();
 
2189
  });
2190
 
2191
  // Detaching a viewer, NOT stopping the session.
server/src/runner.js CHANGED
@@ -6,7 +6,7 @@ import { remoteState, setPaused } from './remote.js';
6
  import { cliById, WORKSPACES_DIR, isRemote } from './config.js';
7
  import { update, list } from './sessions.js';
8
  import { captureOpencodeSession } from './traces.js';
9
- import { buildPaletteIndex, rowsToAnsi, snapshotToAnsi, snapshotToRows } from './snapshot.js';
10
 
11
  // libghostty-vt ships prebuilts for linux x64/arm64 and macOS arm64. Loading it
12
  // is guarded so a platform without a prebuilt still boots and says so, rather
@@ -40,10 +40,10 @@ const bashLaunch = `exec bash --rcfile ${BASHRC} -i`;
40
  // Every session is one PTY held by THIS process, with a libghostty-vt terminal
41
  // fed from its output. That terminal is the authoritative screen, so:
42
  //
43
- // * a browser attaching gets a snapshot repaint plus replayed scrollback,
44
  // instead of asking tmux to redraw and hoping the agent's TUI cooperates;
45
- // * several browsers can watch and drive the same session at once, so the old
46
- // one-device-at-a-time handover is gone;
47
  // * agent state is a property read rather than a `tmux capture-pane`
48
  // subprocess per session per poll.
49
  //
@@ -55,28 +55,20 @@ const bashLaunch = `exec bash --rcfile ${BASHRC} -i`;
55
  const BUSY_SECS = 4;
56
  // Re-rendering the grid to text on every chunk during a burst is wasteful.
57
  const SAMPLE_THROTTLE_MS = 250;
58
- // Scrollback replayed to a reattaching browser, as raw PTY bytes. snapshot()
59
- // returns history as plain LINES, so replaying that would hand back colourless
60
- // scrollback under a fully styled screen.
61
- const REPLAY_BYTES = Number(process.env.AM_REPLAY_BYTES || 256 * 1024);
62
- const SCROLLBACK_LINES = Number(process.env.AM_SCROLLBACK || 20000);
63
- // A resize is only worth acting on once it stops changing. ResizeObserver fires
64
- // per animation frame while a window is dragged, and every applied size costs a
65
- // rewrap of the outgoing screen and a full TUI repaint (see carryScreen for what
66
- // that leaves behind). Coalescing a drag into one resize is what keeps the
67
- // scrollback from filling with the same screen re-wrapped a dozen ways.
 
 
 
68
  const RESIZE_SETTLE_MS = Number(process.env.AM_RESIZE_SETTLE_MS || 120);
69
- // Escape hatch for carryScreen: with AM_RESIZE_CARRY=0 a resize reflows the way a
70
- // plain terminal does, duplication and all. Kept because carrying the screen by
71
- // hand is the one part of a resize that makes an assumption about the app.
72
- const RESIZE_CARRY = process.env.AM_RESIZE_CARRY !== '0';
73
- // How long an app gets to answer SIGWINCH before the rows a shrink pushed off the
74
- // screen are treated as history rather than as a copy (see settleArchive).
75
- // Generous on purpose: an agent TUI that debounces SIGWINCH must not be mistaken
76
- // for one that ignored it, and waiting costs nothing a user can see because the
77
- // rows in question are off-screen either way. A repaint that arrives sooner ends
78
- // the wait immediately.
79
- const ARCHIVE_SETTLE_MS = Number(process.env.AM_RESIZE_ARCHIVE_MS || 700);
80
 
81
  const hosts = new Map(); // session id -> host
82
 
@@ -149,220 +141,51 @@ export function peek(id) {
149
 
150
  // ---------- the shared grid ----------
151
  //
152
- // One session, one grid, however many viewers. A client may REQUEST a size but
153
- // never imposes one: letting each client size itself is what garbles a second
154
- // device, because the phone resizes the PTY while the laptop keeps drawing into
155
- // its old geometry. The grid follows the smallest attached viewer so the content
156
- // fits everywhere, and grows back as viewers leave.
157
 
158
  function effectiveGrid(host) {
159
- if (!host.sizes.size) return { cols: host.cols, rows: host.rows };
160
- let cols = Infinity;
161
- let rows = Infinity;
162
- for (const s of host.sizes.values()) {
163
- cols = Math.min(cols, s.cols);
164
- rows = Math.min(rows, s.rows);
165
- }
166
- return { cols, rows };
167
- }
168
-
169
- /**
170
- * Re-frame a snapshot for a different geometry, so it can be painted into one.
171
- *
172
- * BOTTOM-anchored, in both directions, because that is what the emulator does on
173
- * its own. Growing the screen pulls history back DOWN out of scrollback to fill
174
- * the new rows from the top (measured: 12 rows -> 30 brought 18 lines back), so a
175
- * top-anchored paint lands right on top of them — which destroyed the history and
176
- * left the old screen stranded above the app's fresh repaint. Anchoring at the
177
- * bottom puts the carried screen exactly where the emulator already put it, with
178
- * the recovered history above it untouched.
179
- *
180
- * On a shrink the rows that fall off the top are not in here at all —
181
- * settleArchive decides what becomes of those.
182
- */
183
- function fitSnapshot(snap, cols, rows) {
184
- const shift = snap.rows - rows; // > 0 shrinking, < 0 growing
185
- return {
186
- cols,
187
- rows,
188
- isAltScreen: snap.isAltScreen,
189
- cursorRow: Math.max(0, Math.min(rows - 1, snap.cursorRow - shift)),
190
- cursorCol: Math.max(0, Math.min(cols - 1, snap.cursorCol)),
191
- cells: (snap.cells || [])
192
- .filter((c) => c.row - shift >= 0 && c.row - shift < rows && c.col < cols)
193
- .map((c) => (shift ? { ...c, row: c.row - shift } : c)),
194
- };
195
- }
196
-
197
- /**
198
- * Carry the current screen across a resize by hand, instead of letting reflow do
199
- * it — the whole reason a resize duplicated scrollback.
200
- *
201
- * Reflow is not wrong, it is just redundant here: narrowing rewraps every row of
202
- * the outgoing screen (a 119-column row becomes two at 110), which scrolls the
203
- * excess up into scrollback — and then the app repaints that same screen, so the
204
- * rewrapped copy is left above it forever. Six zoom clicks, six copies.
205
- *
206
- * So the screen is cleared BEFORE the reflow, which leaves it nothing to archive,
207
- * and re-painted after: snapshotToAnsi positions every row absolutely and emits
208
- * no newline, so the paint itself cannot wrap or scroll either.
209
- *
210
- * The rows that no longer fit cannot be settled here, which took two tries to
211
- * see. Dropping them ate real history — a 40->24 shrink lost 16 lines a user
212
- * could previously scroll back to. Archiving them brought the duplication
213
- * straight back, because for a repainting TUI those rows ARE the screen it is
214
- * about to reprint. Whether they are redundant depends on what the app does
215
- * NEXT, so they are handed to settleArchive to decide once that is known.
216
- *
217
- * Returns { pre, ansi, dropped }: bytes for before the resize and after it (both
218
- * of which every viewer needs too) plus the rows in limbo. Null when reflow
219
- * should be left alone (see host.repaints).
220
- */
221
- function carryScreen(host, cols, rows) {
222
- if (!RESIZE_CARRY || !host.repaints) return null;
223
- let snap;
224
- let ansi;
225
- try {
226
- snap = host.vt.snapshot({ includeCells: true });
227
- // Rows only, no erase: on a grow the emulator has already refilled the top of
228
- // the screen from scrollback, and that history has to survive this paint.
229
- ansi = snapshotToRows(fitSnapshot(snap, cols, rows));
230
- } catch { return null; }
231
-
232
- const drop = Math.max(0, snap.rows - rows);
233
- const pre = '\x1b[0m\x1b[H\x1b[2J';
234
- try { host.vt.feed(pre); } catch { return null; }
235
- return { pre, ansi, dropped: drop ? rowsToAnsi(snap, 0, drop) : [] };
236
- }
237
-
238
- /**
239
- * Put lines into scrollback without disturbing the screen.
240
- *
241
- * Printing is the only way in — there is no API for "append to history" — so the
242
- * lines are printed at the top and then pushed off it, and the screen is painted
243
- * back from the grid afterwards. Costs one repaint, which is why it only happens
244
- * when nothing else repainted.
245
- */
246
- function archiveLines(host, lines) {
247
- let restore;
248
- try { restore = snapshotToAnsi(host.vt.snapshot({ includeCells: true })); } catch { return; }
249
- let bytes = '\x1b[0m\x1b[H\x1b[2J\x1b[1;1H' + lines.join('\r\n');
250
- // A newline on the LAST row is what moves a row into scrollback, and it moves
251
- // the top row — not the one just printed. Enough of them to clear the screen
252
- // archives every line, whether they all fitted on it or not.
253
- bytes += `\x1b[0m\x1b[${host.rows};1H` + '\r\n'.repeat(Math.min(lines.length, host.rows));
254
- bytes += restore;
255
- try { host.vt.feed(bytes); } catch { return; }
256
- for (const sub of host.subs) sub.onData(bytes);
257
  }
258
 
259
- /**
260
- * Decide what the rows a shrink pushed off the screen were: a copy, or history.
261
- *
262
- * A screenful of output within the window means the app answered SIGWINCH by
263
- * reprinting its screen, so those rows are about to appear again and archiving
264
- * them is what filled the scrollback with duplicates. Silence means they were the
265
- * only copy — a shell's log, a TUI that exited, an agent sitting idle — and
266
- * dropping them is what stopped a pane from scrolling all the way up.
267
- *
268
- * Deciding afterwards is the point: at resize time this is not yet knowable, and
269
- * both guesses are wrong for somebody. Waiting costs nothing visible — the rows in
270
- * question are off-screen either way — so the window is generous rather than tight,
271
- * because an agent TUI that debounces SIGWINCH must not be mistaken for one that
272
- * ignored it. A repaint that does arrive cancels this immediately (see onData).
273
- *
274
- * Only a session that stayed silent through TWO resizes stops being treated as
275
- * repainting: one slow frame would otherwise drop a Claude pane onto the plain
276
- * reflow path for good, which is the duplication this all started with.
277
- */
278
- function settleArchive(host) {
279
- host.archiveTimer = null;
280
- const pending = host.pendingArchive;
281
- host.pendingArchive = null;
282
- if (!pending) return;
283
- host.silentResizes += 1;
284
- if (host.silentResizes >= 2) host.repaints = false;
285
- if (pending.length) archiveLines(host, pending);
286
  }
287
 
288
  /**
289
  * Move the session to the size its viewers imply.
290
  *
291
- * The grid is resized BEFORE the PTY: the app's SIGWINCH repaint then lands in a
292
- * grid that already has the new geometry, instead of one line of its frame being
293
- * measured against the old one.
294
- *
295
- * Every viewer is then repainted from the grid. A resize is the one moment the
296
- * browser's own reflow of its byte log is guaranteed to disagree with us, and an
297
- * app that doesn't repaint on SIGWINCH at all (a bash prompt, an agent sitting
298
- * idle) would otherwise leave that disagreement on screen until it next drew
299
- * something. Repainting from the grid costs one screen of bytes and makes the
300
- * pane authoritative again — the same argument as the repaint on attach.
301
  */
302
  function applyGrid(host) {
303
  const { cols, rows } = effectiveGrid(host);
304
  if (cols === host.cols && rows === host.rows) return false;
305
- // Only a screen that gets SMALLER in some dimension archives anything: rows are
306
- // pushed off the top, or a narrower width rewraps them until they overflow. A
307
- // screen that only grows takes rows back out of scrollback instead, and needs no
308
- // help doing it — interfering there is what painted over the history a zoom-out
309
- // had just recovered. So growing is left entirely alone, right down to not
310
- // repainting: each emulator recovers its own rows, and the app's own repaint (or
311
- // the next attach) settles any difference.
312
- const shrinking = cols < host.cols || rows < host.rows;
313
  host.cols = cols;
314
  host.rows = rows;
315
- const carried = shrinking ? carryScreen(host, cols, rows) : null;
316
- // Viewers get the erase BEFORE they resize, in the same order the grid saw it,
317
- // so their emulators skip the same reflow ours did.
318
- if (carried) for (const sub of host.subs) sub.onData(carried.pre);
319
  try { host.vt.resize(cols, rows); } catch {}
320
  try { host.pty.resize(cols, rows); } catch {}
321
- host.resizedAt = Date.now();
322
- host.resizeBytes = 0;
323
- if (carried) {
324
- // Painted straight away, not deferred. Holding it back until the app had its
325
- // say was tried, on the theory that an app reprinting its own frame makes our
326
- // copy redundant — measured, it changes nothing, because such an app erases
327
- // the screen before it prints. All deferring bought was a blank pane.
328
- try { host.vt.feed(carried.ansi); } catch {}
329
- for (const sub of host.subs) sub.onData(carried.ansi);
330
- // Oldest first: a second resize before the verdict lands adds to the same
331
- // batch rather than replacing it.
332
- host.pendingArchive = [...(host.pendingArchive || []), ...carried.dropped];
333
- if (host.archiveTimer) clearTimeout(host.archiveTimer);
334
- host.archiveTimer = setTimeout(() => settleArchive(host), ARCHIVE_SETTLE_MS);
335
- if (host.archiveTimer.unref) host.archiveTimer.unref();
336
- }
337
- let ansi = null;
338
- if (!carried && shrinking) { try { ansi = snapshotToAnsi(host.vt.snapshot({ includeCells: true })); } catch {} }
339
- for (const sub of host.subs) {
340
- // `carried` doubles as the flag: the viewer's own emulator has to skip its
341
- // reflow exactly when we skipped ours, or it archives what we did not — and it
342
- // must not resize until the bytes above have been parsed at the OLD size.
343
- sub.onGrid(cols, rows, host.subs.size > 1, host.subs.size, !!carried);
344
- if (ansi) sub.onData(ansi);
345
- }
346
  return true;
347
  }
348
 
349
  /**
350
- * Apply the grid once the requests stop arriving. The viewers that asked are
351
- * remembered so a request the grid can't honour (a big laptop attached beside a
352
- * phone) is still answered with the size that actually applies — a client that
353
- * hears nothing back would sit there drawing into a geometry it doesn't have.
354
  */
355
- function scheduleGrid(host, sub) {
356
- if (sub) host.pendingSizes.add(sub);
357
  if (host.gridTimer) clearTimeout(host.gridTimer);
358
  host.gridTimer = setTimeout(() => {
359
  host.gridTimer = null;
360
- const pending = [...host.pendingSizes];
361
- host.pendingSizes.clear();
362
- if (applyGrid(host)) return; // applyGrid told everyone
363
- for (const s of pending) {
364
- if (host.subs.has(s)) s.onGrid(host.cols, host.rows, host.subs.size > 1, host.subs.size);
365
- }
366
  }, RESIZE_SETTLE_MS);
367
  if (host.gridTimer.unref) host.gridTimer.unref();
368
  }
@@ -855,33 +678,16 @@ export function ensureRunning(session, cols = 120, rows = 34) {
855
  const term = pty.spawn('bash', ['-lc', full], {
856
  name: 'xterm-256color', cols, rows, cwd: workdir, env,
857
  });
858
- const vt = ghostty.createTerminal({ cols, rows, scrollbackLimit: SCROLLBACK_LINES });
859
- const isShell = (cliById(session.cli) || cliById('shell')).id === 'shell';
860
-
861
  const host = {
862
  id: session.id,
863
  pty: term,
864
  vt,
865
  cols,
866
  rows,
867
- // Does this app reprint its screen after SIGWINCH? That decides whether the
868
- // reflow on resize archives a duplicate of the screen or archives the log
869
- // (see carryScreen). Every agent TUI repaints; a shell prompt does not, and
870
- // its rewrapped scrollback is the real thing, not a copy. The guess is
871
- // corrected below, because `vim` or a hand-typed `claude` breaks it.
872
- repaints: !isShell,
873
- resizedAt: 0,
874
- resizeBytes: 0,
875
- pendingArchive: null,
876
- archiveTimer: null,
877
- silentResizes: 0,
878
  subs: new Set(),
879
- sizes: new Map(), // sub -> the grid that viewer can display
880
- pendingSizes: new Set(), // subs whose request hasn't been answered yet
881
  gridTimer: null,
882
- history: [],
883
- historyBytes: 0,
884
- historyDropped: false,
885
  startedAt: Date.now(),
886
  screenChangedAt: Date.now(),
887
  bells: 0,
@@ -890,27 +696,6 @@ export function ensureRunning(session, cols = 120, rows = 34) {
890
  term.onData((chunk) => {
891
  try { vt.feed(chunk); } catch (e) { console.error('[runner] vt.feed', e && e.message); }
892
 
893
- // A screenful arriving after a resize means the app answered SIGWINCH by
894
- // reprinting its screen. That settles the question settleArchive was waiting on
895
- // — the rows it holds are about to be printed again, so archiving them would
896
- // duplicate them — and it settles it NOW, whenever the frame happens to arrive,
897
- // instead of when a timer says so.
898
- if (host.resizedAt) {
899
- if (Date.now() - host.resizedAt > ARCHIVE_SETTLE_MS + 150) host.resizedAt = 0;
900
- else {
901
- host.resizeBytes += chunk.length;
902
- // A quarter screen of bytes is far more than a shell prompt redrawing
903
- // itself and far less than a TUI frame.
904
- if (host.resizeBytes > (host.cols * host.rows) / 4) {
905
- host.resizedAt = 0;
906
- host.repaints = true; // also catches `vim` or a hand-typed `claude` in a shell pane
907
- host.silentResizes = 0;
908
- host.pendingArchive = null; // a copy after all
909
- if (host.archiveTimer) { clearTimeout(host.archiveTimer); host.archiveTimer = null; }
910
- }
911
- }
912
- }
913
-
914
  // State detection rides the feed path: the grid is already current, so there
915
  // is nothing to poll and no subprocess to spawn.
916
  sampleScreen(host);
@@ -918,20 +703,12 @@ export function ensureRunning(session, cols = 120, rows = 34) {
918
  if (chunk.charCodeAt(i) === 7) { host.bells++; host.lastBellAt = Date.now(); }
919
  }
920
 
921
- host.history.push(chunk);
922
- host.historyBytes += chunk.length;
923
- while (host.historyBytes > REPLAY_BYTES && host.history.length > 1) {
924
- host.historyBytes -= host.history.shift().length;
925
- host.historyDropped = true;
926
- }
927
-
928
  for (const sub of host.subs) sub.onData(chunk);
929
  });
930
 
931
  term.onExit(() => {
932
  hosts.delete(session.id);
933
  if (host.gridTimer) { clearTimeout(host.gridTimer); host.gridTimer = null; }
934
- if (host.archiveTimer) { clearTimeout(host.archiveTimer); host.archiveTimer = null; }
935
  try { vt.dispose(); } catch {}
936
  for (const sub of host.subs) sub.onExit();
937
  host.subs.clear();
@@ -945,25 +722,14 @@ export function ensureRunning(session, cols = 120, rows = 34) {
945
  return true;
946
  }
947
 
948
- /**
949
- * The bytes to replay for scrollback. A ring that has wrapped almost certainly
950
- * starts mid-escape-sequence, so drop everything before the first newline — the
951
- * repaint that follows is the authority for the visible screen either way.
952
- */
953
- function replayBytes(host) {
954
- const joined = host.history.join('');
955
- if (!host.historyDropped) return joined;
956
- const nl = joined.indexOf('\n');
957
- return nl >= 0 ? joined.slice(nl + 1) : joined;
958
- }
959
-
960
  /**
961
  * Subscribe a viewer to a session, starting it if needed.
962
  *
963
  * Unlike the tmux version this does NOT spawn anything per viewer, and
964
  * `handle.kill()` only unsubscribes — closing a tab must never stop an agent.
965
- * `handle.restore()` returns the bytes that rebuild the screen: replayed
966
- * scrollback first, then a snapshot repaint on top as the authority.
 
967
  */
968
  export function attach(session, cols, rows) {
969
  ensureRunning(session, cols, rows);
@@ -974,48 +740,64 @@ export function attach(session, cols, rows) {
974
  onData: () => {},
975
  onExit: () => {},
976
  onGrid: () => {},
 
 
 
 
977
  };
978
  host.subs.add(sub);
 
 
 
 
 
979
 
980
  return {
981
  onData: (cb) => { sub.onData = (d) => { try { cb(d); } catch {} }; },
982
  onExit: (cb) => { sub.onExit = () => { try { cb(); } catch {} }; },
983
- onGrid: (cb) => { sub.onGrid = (c, r, shared, viewers, clear) => { try { cb(c, r, shared, viewers, clear); } catch {} }; },
984
- write: (d) => { try { host.pty.write(d); } catch {} },
985
- // A request, not a command: the shared grid is recomputed from all viewers,
986
- // and only once the requests settle.
 
 
 
 
987
  resize: (c, r) => {
988
  if (!Number.isFinite(c) || !Number.isFinite(r)) return;
989
  const want = {
990
  cols: Math.max(20, Math.min(400, Math.round(c))),
991
  rows: Math.max(5, Math.min(200, Math.round(r))),
992
  };
993
- const had = host.sizes.get(sub);
994
- host.sizes.set(sub, want);
995
- // A resync that changes nothing (tab focus, an unrelated pane opening) must
996
- // not schedule anything — but the FIRST request from a viewer is always
997
- // worth answering, so it learns the grid it joined.
998
  if (had && had.cols === want.cols && had.rows === want.rows) return;
999
- scheduleGrid(host, sub);
 
 
 
 
 
 
1000
  },
1001
  restore: () => {
1002
  let snap;
1003
- try { snap = host.vt.snapshot({ includeCells: true }); } catch { return null; }
1004
  return {
1005
- replay: replayBytes(host),
1006
- ansi: snapshotToAnsi(snap),
1007
  cols: snap.cols,
1008
  rows: snap.rows,
1009
  viewers: host.subs.size,
1010
- shared: host.subs.size > 1,
1011
  };
1012
  },
1013
  // Detach this viewer only. The session, its grid and its scrollback stay.
1014
  kill: () => {
 
1015
  host.subs.delete(sub);
1016
- host.sizes.delete(sub);
1017
- host.pendingSizes.delete(sub);
1018
- scheduleGrid(host); // the grid grows back for whoever is left
1019
  },
1020
  };
1021
  }
 
6
  import { cliById, WORKSPACES_DIR, isRemote } from './config.js';
7
  import { update, list } from './sessions.js';
8
  import { captureOpencodeSession } from './traces.js';
9
+ import { buildPaletteIndex, snapshotToRestoreAnsi } from './snapshot.js';
10
 
11
  // libghostty-vt ships prebuilts for linux x64/arm64 and macOS arm64. Loading it
12
  // is guarded so a platform without a prebuilt still boots and says so, rather
 
40
  // Every session is one PTY held by THIS process, with a libghostty-vt terminal
41
  // fed from its output. That terminal is the authoritative screen, so:
42
  //
43
+ // * a browser attaching gets canonical history plus a snapshot repaint,
44
  // instead of asking tmux to redraw and hoping the agent's TUI cooperates;
45
+ // * several browsers can watch the same session, with one explicit input/size
46
+ // controller instead of a one-device-at-a-time disconnect handover;
47
  // * agent state is a property read rather than a `tmux capture-pane`
48
  // subprocess per session per poll.
49
  //
 
55
  const BUSY_SECS = 4;
56
  // Re-rendering the grid to text on every chunk during a burst is wasteful.
57
  const SAMPLE_THROTTLE_MS = 250;
58
+ // Despite the Node wrapper's `scrollbackLimit` name, Ghostty's native option is
59
+ // a byte budget. Passing a line count such as 20,000 retains only a small native
60
+ // allocation (about 700 ordinary rows). Keep the unit explicit at our boundary.
61
+ const DEFAULT_SCROLLBACK_BYTES = 64 * 1024 * 1024;
62
+ const configuredScrollback = process.env.AM_SCROLLBACK_BYTES === undefined
63
+ ? Number.NaN
64
+ : Number(process.env.AM_SCROLLBACK_BYTES);
65
+ const SCROLLBACK_BYTES = Number.isFinite(configuredScrollback) && configuredScrollback >= 0
66
+ ? configuredScrollback
67
+ : DEFAULT_SCROLLBACK_BYTES;
68
+ // A layout resize is only worth acting on once it stops changing. ResizeObserver
69
+ // fires per animation frame while a window is dragged; coalescing that burst
70
+ // avoids repeatedly reflowing the terminal and repeatedly sending SIGWINCH.
71
  const RESIZE_SETTLE_MS = Number(process.env.AM_RESIZE_SETTLE_MS || 120);
 
 
 
 
 
 
 
 
 
 
 
72
 
73
  const hosts = new Map(); // session id -> host
74
 
 
141
 
142
  // ---------- the shared grid ----------
143
  //
144
+ // A PTY has exactly one geometry. One attached viewer therefore holds a size
145
+ // lease (the controller); every other viewer watches the same grid without
146
+ // changing it. A watcher can take the lease through an explicit interaction.
147
+ // This prevents a phone or background tab from resizing a desktop session merely
148
+ // by connecting.
149
 
150
  function effectiveGrid(host) {
151
+ return host.controller?.want || { cols: host.cols, rows: host.rows };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  }
153
 
154
+ function notifyGrid(host, reset = false) {
155
+ for (const sub of host.subs) {
156
+ sub.onGrid(host.cols, host.rows, host.controller === sub, host.subs.size, reset);
157
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  }
159
 
160
  /**
161
  * Move the session to the size its viewers imply.
162
  *
163
+ * Ghostty performs ordinary terminal reflow. We deliberately do not clear,
164
+ * classify, carry, or synthesize history around it: those policies cannot know
165
+ * whether the foreground app will repaint and can destroy real output. Viewers
166
+ * receive the confirmed geometry before subsequent PTY output and perform the
167
+ * same ordinary reflow; full history serialization is reserved for attachment.
 
 
 
 
 
168
  */
169
  function applyGrid(host) {
170
  const { cols, rows } = effectiveGrid(host);
171
  if (cols === host.cols && rows === host.rows) return false;
 
 
 
 
 
 
 
 
172
  host.cols = cols;
173
  host.rows = rows;
 
 
 
 
174
  try { host.vt.resize(cols, rows); } catch {}
175
  try { host.pty.resize(cols, rows); } catch {}
176
+ notifyGrid(host, false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  return true;
178
  }
179
 
180
  /**
181
+ * Apply the controller's preferred grid once requests stop arriving. If a
182
+ * pending request clamps back to the current size, report that canonical size.
 
 
183
  */
184
+ function scheduleGrid(host) {
 
185
  if (host.gridTimer) clearTimeout(host.gridTimer);
186
  host.gridTimer = setTimeout(() => {
187
  host.gridTimer = null;
188
+ if (!applyGrid(host)) notifyGrid(host, false);
 
 
 
 
 
189
  }, RESIZE_SETTLE_MS);
190
  if (host.gridTimer.unref) host.gridTimer.unref();
191
  }
 
678
  const term = pty.spawn('bash', ['-lc', full], {
679
  name: 'xterm-256color', cols, rows, cwd: workdir, env,
680
  });
681
+ const vt = ghostty.createTerminal({ cols, rows, scrollbackLimit: SCROLLBACK_BYTES });
 
 
682
  const host = {
683
  id: session.id,
684
  pty: term,
685
  vt,
686
  cols,
687
  rows,
 
 
 
 
 
 
 
 
 
 
 
688
  subs: new Set(),
689
+ controller: null,
 
690
  gridTimer: null,
 
 
 
691
  startedAt: Date.now(),
692
  screenChangedAt: Date.now(),
693
  bells: 0,
 
696
  term.onData((chunk) => {
697
  try { vt.feed(chunk); } catch (e) { console.error('[runner] vt.feed', e && e.message); }
698
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
699
  // State detection rides the feed path: the grid is already current, so there
700
  // is nothing to poll and no subprocess to spawn.
701
  sampleScreen(host);
 
703
  if (chunk.charCodeAt(i) === 7) { host.bells++; host.lastBellAt = Date.now(); }
704
  }
705
 
 
 
 
 
 
 
 
706
  for (const sub of host.subs) sub.onData(chunk);
707
  });
708
 
709
  term.onExit(() => {
710
  hosts.delete(session.id);
711
  if (host.gridTimer) { clearTimeout(host.gridTimer); host.gridTimer = null; }
 
712
  try { vt.dispose(); } catch {}
713
  for (const sub of host.subs) sub.onExit();
714
  host.subs.clear();
 
722
  return true;
723
  }
724
 
 
 
 
 
 
 
 
 
 
 
 
 
725
  /**
726
  * Subscribe a viewer to a session, starting it if needed.
727
  *
728
  * Unlike the tmux version this does NOT spawn anything per viewer, and
729
  * `handle.kill()` only unsubscribes — closing a tab must never stop an agent.
730
+ * `handle.restore()` returns a canonical snapshot: Ghostty's complete plain-text
731
+ * history followed by a styled visible-screen repaint. It never replays an
732
+ * arbitrary suffix of old PTY bytes at a new geometry.
733
  */
734
  export function attach(session, cols, rows) {
735
  ensureRunning(session, cols, rows);
 
740
  onData: () => {},
741
  onExit: () => {},
742
  onGrid: () => {},
743
+ want: {
744
+ cols: Math.max(20, Math.min(400, Math.round(Number.isFinite(cols) ? cols : host.cols))),
745
+ rows: Math.max(5, Math.min(200, Math.round(Number.isFinite(rows) ? rows : host.rows))),
746
+ },
747
  };
748
  host.subs.add(sub);
749
+ if (!host.controller) host.controller = sub;
750
+ // Existing viewers need to know that the session is now shared. The new
751
+ // viewer receives the same role/count in its restore frame below.
752
+ notifyGrid(host, false);
753
+ if (host.controller === sub && (sub.want.cols !== host.cols || sub.want.rows !== host.rows)) scheduleGrid(host);
754
 
755
  return {
756
  onData: (cb) => { sub.onData = (d) => { try { cb(d); } catch {} }; },
757
  onExit: (cb) => { sub.onExit = () => { try { cb(); } catch {} }; },
758
+ onGrid: (cb) => { sub.onGrid = (c, r, controller, viewers, reset) => { try { cb(c, r, controller, viewers, reset); } catch {} }; },
759
+ // Input and terminal-query responses are accepted from one emulator only.
760
+ write: (d) => {
761
+ if (host.controller !== sub) return;
762
+ try { host.pty.write(d); } catch {}
763
+ },
764
+ // Every viewer remembers what it can display, but only the current
765
+ // controller's request changes the PTY.
766
  resize: (c, r) => {
767
  if (!Number.isFinite(c) || !Number.isFinite(r)) return;
768
  const want = {
769
  cols: Math.max(20, Math.min(400, Math.round(c))),
770
  rows: Math.max(5, Math.min(200, Math.round(r))),
771
  };
772
+ const had = sub.want;
773
+ sub.want = want;
 
 
 
774
  if (had && had.cols === want.cols && had.rows === want.rows) return;
775
+ if (host.controller === sub) scheduleGrid(host);
776
+ },
777
+ claim: () => {
778
+ if (!host.subs.has(sub) || host.controller === sub) return;
779
+ host.controller = sub;
780
+ notifyGrid(host, false);
781
+ scheduleGrid(host);
782
  },
783
  restore: () => {
784
  let snap;
785
+ try { snap = host.vt.snapshot({ includeCells: true, includeScrollback: true }); } catch { return null; }
786
  return {
787
+ ansi: snapshotToRestoreAnsi(snap),
 
788
  cols: snap.cols,
789
  rows: snap.rows,
790
  viewers: host.subs.size,
791
+ controller: host.controller === sub,
792
  };
793
  },
794
  // Detach this viewer only. The session, its grid and its scrollback stay.
795
  kill: () => {
796
+ const controlled = host.controller === sub;
797
  host.subs.delete(sub);
798
+ if (controlled) host.controller = host.subs.values().next().value || null;
799
+ notifyGrid(host, false);
800
+ if (controlled && host.controller) scheduleGrid(host);
801
  },
802
  };
803
  }
server/src/snapshot.js CHANGED
@@ -116,48 +116,12 @@ function renderRow(cells, cols) {
116
  return out;
117
  }
118
 
119
- /**
120
- * Rows `from`..`to` as styled lines, ready to be PRINTED rather than placed —
121
- * for putting rows into scrollback, where a line has to be written and scrolled
122
- * off rather than positioned. Blank rows are kept as empty strings so the
123
- * archived block keeps its shape.
124
- */
125
- export function rowsToAnsi(snap, from, to) {
126
- const grid = cellGrid(snap);
127
- const out = [];
128
- for (let row = Math.max(0, from); row < Math.min(snap.rows, to); row++) {
129
- out.push(renderRow(grid[row], snap.cols));
130
- }
131
- return out;
132
- }
133
-
134
- /**
135
- * The rows placed absolutely, with NO erase and no buffer switch — for painting
136
- * into a screen whose other rows must survive.
137
- *
138
- * That is the case after a resize: growing pulls history back down out of
139
- * scrollback into the top rows, and erasing before painting would throw exactly
140
- * that away. The caller erased before the resize instead, so everything this does
141
- * not cover is already blank.
142
- */
143
- export function snapshotToRows(snap) {
144
- const grid = cellGrid(snap);
145
- let out = '\x1b[?25l\x1b[0m';
146
- for (let row = 0; row < snap.rows; row++) {
147
- const line = renderRow(grid[row], snap.cols);
148
- if (line) out += `\x1b[${row + 1};1H` + line;
149
- }
150
- out += `\x1b[0m\x1b[${snap.cursorRow + 1};${snap.cursorCol + 1}H\x1b[?25h`;
151
- return out;
152
- }
153
-
154
  export function snapshotToAnsi(snap) {
155
  const grid = cellGrid(snap);
156
 
157
- // Normalise the buffer first: a byte replay can leave the receiver on the
158
- // alternate screen (or off it), and the repaint alone would then land on the
159
- // wrong buffer. Reset attributes so nothing leaks in from what was showing,
160
- // and hide the cursor so the repaint doesn't strobe across the screen.
161
  let out = snap.isAltScreen ? '\x1b[?1049h' : '\x1b[?1049l';
162
  out += '\x1b[?25l\x1b[0m\x1b[H\x1b[2J';
163
 
@@ -170,3 +134,27 @@ export function snapshotToAnsi(snap) {
170
  out += '\x1b[?25h';
171
  return out;
172
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
 
134
  out += '\x1b[?25h';
135
  return out;
136
  }
137
+
138
+ /**
139
+ * Rebuild a fresh viewer from Ghostty's canonical state.
140
+ *
141
+ * The binding currently exposes styled cells only for the visible screen, while
142
+ * scrollback is plain text. That is still a much stronger restore boundary than
143
+ * replaying a truncated raw PTY byte stream: it includes every retained history
144
+ * row, is already reflowed to the current geometry, and cannot begin halfway
145
+ * through an escape sequence.
146
+ *
147
+ * A line becomes scrollback only after it leaves the visible grid. Print the
148
+ * history on the primary screen, then scroll the remaining visible history rows
149
+ * off before painting the authoritative current screen.
150
+ */
151
+ export function snapshotToRestoreAnsi(snap) {
152
+ const history = (snap.scrollbackLines || []).map((line) => line.text || '');
153
+ let out = '\x1b[?1049l\x1b[?25l\x1b[0m\x1b[H\x1b[2J';
154
+ if (history.length) {
155
+ out += history.join('\r\n');
156
+ out += `\x1b[${snap.rows};1H` + '\r\n'.repeat(Math.min(history.length, snap.rows));
157
+ }
158
+ out += snapshotToAnsi(snap);
159
+ return out;
160
+ }
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?.();
@@ -96,15 +88,6 @@ function selectionText(term: Terminal): string {
96
  // socket with this leading NUL sentinel, which real pty output never begins with.
97
  const MODE_CTRL = '\x00\x00AM:';
98
 
99
- // OSC 52 used to arrive constantly, because tmux emitted it for every mouse
100
- // selection in copy mode — hence a gate that only let a write through right
101
- // after a real Cmd/Ctrl+C. With tmux gone the only source is the AGENT itself
102
- // deliberately writing the clipboard, and nothing grants that permission
103
- // automatically: the text is stashed and the user's next Cmd+C copies it.
104
- function consumeOsc52CopyPermission(): boolean {
105
- return false;
106
- }
107
-
108
  function legacyCopy(text: string): boolean {
109
  const prev = document.activeElement as HTMLElement | null;
110
  try {
@@ -162,22 +145,19 @@ export default function TerminalPane({
162
  }) {
163
  const hostRef = useRef<HTMLDivElement>(null);
164
  const termRef = useRef<Terminal | null>(null);
165
- const resyncRef = useRef<() => void>(() => {});
166
  const reconnectRef = useRef<() => void>(() => {});
167
- // Take a handed-off session back (bound inside the connection effect).
168
- const resumeRef = useRef<() => void>(() => {});
169
  // Send a raw byte string to the PTY (for the mobile key-bar: arrows, Esc…).
170
  const sendKeyRef = useRef<(d: string) => void>(() => {});
171
  const [conn, setConn] = useState<ConnState>('connecting');
172
- // "starting…" cover while the CLI boots into an empty pane (attach on the
173
  // Space can take seconds). Hidden only when the screen actually SHOWS
174
- // something — byte counts lie, because tmux's attach repaint of a blank
175
- // 200×50 screen is already kilobytes of escapes.
176
  const [booting, setBooting] = useState(true);
177
- const [copyMode, setCopyMode] = useState(false); // legacy tmux copy-mode hint
178
- // How many browsers share this session's grid. >1 means the size is a
179
- // compromise, which is worth showing rather than leaving as a mystery.
180
- const [viewers, setViewers] = useState(0);
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.
@@ -223,30 +203,27 @@ export default function TerminalPane({
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
@@ -259,104 +236,90 @@ export default function TerminalPane({
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
- // There is no tmux copy-mode selection to fetch any more: scrollback is
342
- // replayed into this terminal on attach, so every selection is local.
343
- if ((e.metaKey || e.ctrlKey) && (e.key === 'c' || e.key === 'C') && tmuxSelectionPending) {
344
- copyTmuxSelection();
345
  return false;
346
  }
347
- if (e.metaKey && (e.key === 'c' || e.key === 'C')) return false;
348
- if (e.key === 'Escape') tmuxSelectionPending = false;
349
  return true;
350
  });
351
  // The only local fit: this terminal is still empty, so there is no buffer to
352
  // reflow, and it gives the initial size we open the socket with. Every later
353
  // size change goes through resync() as a REQUEST — see there.
354
  try { fit.fit(); } catch { /* layout not ready yet */ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
  // Re-measure once the webfont is ready (glyph width changes vs the fallback).
356
- document.fonts?.ready.then(() => resync());
357
 
358
  let closedByUs = false;
359
- let handedOff = false;
360
  let retry: ReturnType<typeof setTimeout> | null = null;
361
  // Reconnect with backoff: a sleeping/unreachable Space shouldn't be hammered
362
  // every second by every open pane. Reset once a connection succeeds.
@@ -387,6 +350,8 @@ export default function TerminalPane({
387
  setBooting(false);
388
  };
389
  const connect = () => {
 
 
390
  setConn('connecting');
391
  setBooting(true);
392
  bootLive = true;
@@ -408,24 +373,28 @@ export default function TerminalPane({
408
  if (typeof d === 'string' && d.startsWith(MODE_CTRL)) {
409
  try {
410
  const m = JSON.parse(d.slice(MODE_CTRL.length));
411
- if (m.t === 'mode') setCopyMode(!!m.copy);
412
- // The server owns the grid and tells every viewer its size. Conform
413
- // instead of assuming our own fit won: if a second device is
414
- // attached the grid follows the smaller of us, and drawing into our
415
- // own geometry instead would garble the screen.
416
- else if (m.t === 'grid' || m.t === 'restore') {
417
- if (m.cols > 0 && m.rows > 0 && (term.cols !== m.cols || term.rows !== m.rows)) {
418
- // m.clear: the backend already sent us the bytes that erase this
419
- // screen and archive the rows about to fall off it, so skip our own
420
- // reflow — xterm rewraps the outgoing screen into scrollback harder
421
- // than the grid does (119 lines against 80 over one drag), and that
422
- // copy is the duplication. The empty write is a barrier: writes are
423
- // asynchronous, so resizing outside its callback would apply the new
424
- // size to bytes that were written for the old one.
425
- if (m.clear) term.write('', () => { try { term.resize(m.cols, m.rows); } catch { /* ignore */ } });
426
- else try { term.resize(m.cols, m.rows); } catch { /* ignore */ }
427
- }
428
- setViewers(m.viewers > 1 ? m.viewers : 0);
 
 
 
 
429
  }
430
  } catch { /* ignore */ }
431
  return;
@@ -442,12 +411,9 @@ export default function TerminalPane({
442
  ws.onclose = (e) => {
443
  // A real process exit: stop here and let the user relaunch. Anything
444
  // else is a transient drop (sleep/wake, network) → auto-reconnect and
445
- // reattach to the still-running tmux session.
446
  endBoot();
447
  if (e.code === EXIT_CODE) { setConn('exited'); return; }
448
- // Another device took over: stay detached (no retry timer) until the
449
- // user deliberately comes back to this pane — see takeBack().
450
- if (e.code === HANDOFF_CODE) { handedOff = true; setConn('handedoff'); return; }
451
  setConn('closed');
452
  if (!closedByUs) {
453
  retry = setTimeout(connect, retryDelay);
@@ -457,40 +423,24 @@ export default function TerminalPane({
457
  ws.onerror = () => { try { ws?.close(); } catch { /* ignore */ } };
458
  };
459
  // Manual restart: clear the dead run's screen so the content probe watches
460
- // the NEW process paint, not leftovers (tmux repaints the live screen).
461
  reconnectRef.current = () => { if (retry) clearTimeout(retry); retryDelay = 1200; try { term.reset(); } catch { /* ignore */ } connect(); };
462
 
463
- // Take the session back from whichever device holds it now. Called ONLY for
464
- // a deliberate return to this pane the tab regaining focus/visibility, or
465
- // a tap/click in the terminal — never on a timer, so the phone we just put
466
- // down keeps the session while it sits in the background. Reattaching sizes
467
- // tmux to this device, which is the point: each device gets a window that
468
- // fits it, at the cost of one reflow per handover instead of per glance.
469
- const takeBack = () => {
470
- if (!handedOff || document.hidden) return false;
471
- handedOff = false;
472
- connect();
473
- return true;
474
- };
475
- resumeRef.current = () => { takeBack(); };
476
-
477
- // Measure this pane and REQUEST that size. Deliberately does not call
478
- // fit.fit(): resizing ourselves reflows our buffer against a geometry the
479
- // session may never adopt (the grid follows the smallest viewer), and the
480
- // in-flight bytes were written for the size we just left — so the screen is
481
- // drawn twice, at two widths, and the difference lands in the scrollback.
482
- // The server owns the grid; we conform when it tells us what it applied.
483
  let resyncTimer: ReturnType<typeof setTimeout> | null = null;
484
  const requestSize = () => {
485
- if (handedOff) return;
486
- // A collapsed or hidden pane measures as a couple of cells, and the grid
487
- // follows the SMALLEST viewer — so asking from one would shrink the session
488
- // for everybody actually looking at it.
489
  const box = hostRef.current;
490
  if (!box || box.clientWidth < 40 || box.clientHeight < 40) return;
491
  try {
492
  const d = fit.proposeDimensions();
493
- if (d && d.cols > 0 && d.rows > 0) send({ t: 'r', cols: d.cols, rows: d.rows });
 
 
 
 
 
494
  } catch { /* layout not ready */ }
495
  };
496
  // ResizeObserver fires every frame while a window is dragged or the sidebar
@@ -499,30 +449,27 @@ export default function TerminalPane({
499
  if (resyncTimer) clearTimeout(resyncTimer);
500
  resyncTimer = setTimeout(() => { resyncTimer = null; requestSize(); }, 80);
501
  };
502
- // Returning to this tab IS deliberate — take the session back, or just
503
- // re-sync the size if we still hold it.
504
- const onReturn = () => { if (!takeBack()) resync(); };
505
  const onVisible = () => { if (!document.hidden) onReturn(); };
506
- resyncRef.current = resync; // so the zoom control can refit
507
-
508
  // Typing means the user sees enough to interact — drop the boot cover.
509
  // Real keystrokes only (onKey): onData ALSO fires for xterm's automatic
510
  // replies to the TUI's terminal queries (DA/CPR), which arrive instantly
511
  // on attach and must not count as "the user typed".
512
- const keySub = term.onKey(() => endBoot());
513
- const dataSub = term.onData((d) => send({ t: 'i', d }));
 
 
 
 
514
  const ro = new ResizeObserver(resync);
515
  ro.observe(hostRef.current!);
516
  window.addEventListener('focus', onReturn);
517
  document.addEventListener('visibilitychange', onVisible);
518
 
519
- // Touch scrolling: xterm doesn't translate touch gestures for applications
520
- // that grabbed the mouse (tmux always does here), so swipes did nothing on
521
- // phones. Convert drags into wheel steps: SGR mouse-wheel sequences when
522
- // the app tracks the mouse (tmux scrolls its history), local scrollLines
523
- // otherwise.
524
  let touchY: number | null = null;
525
- const onTouchStart = (e: TouchEvent) => { takeBack(); touchY = e.touches[0].clientY; };
526
  const onTouchMove = (e: TouchEvent) => {
527
  if (touchY == null) return;
528
  const y = e.touches[0].clientY;
@@ -534,7 +481,11 @@ export default function TerminalPane({
534
  try { tracking = ((term as unknown as { modes?: { mouseTrackingMode?: string } }).modes?.mouseTrackingMode ?? 'none') !== 'none'; } catch { /* older xterm */ }
535
  const btn = steps > 0 ? 64 : 65; // drag down reveals earlier output = wheel up
536
  for (let i = 0; i < Math.abs(steps); i++) {
537
- 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` });
 
 
 
 
538
  else term.scrollLines(steps > 0 ? -1 : 1);
539
  }
540
  }
@@ -553,12 +504,10 @@ export default function TerminalPane({
553
  if (bootTimer) clearTimeout(bootTimer);
554
  if (bootCheck) clearTimeout(bootCheck);
555
  if (resyncTimer) clearTimeout(resyncTimer);
 
556
  ro.disconnect();
557
  host.removeEventListener('pointerdown', onPointerDown, true);
558
- host.removeEventListener('pointermove', onPointerMove, true);
559
- host.removeEventListener('pointerup', onPointerUp, true);
560
- host.removeEventListener('click', onClick, true);
561
- document.removeEventListener('pointerdown', onDocumentPointerDown, true);
562
  document.removeEventListener('copy', onCopy, true);
563
  host.removeEventListener('touchstart', onTouchStart);
564
  host.removeEventListener('touchmove', onTouchMove);
@@ -571,6 +520,7 @@ export default function TerminalPane({
571
  try { ws?.close(); } catch { /* ignore */ }
572
  term.dispose();
573
  termRef.current = null;
 
574
  };
575
  }, [session.id]);
576
 
@@ -579,12 +529,13 @@ export default function TerminalPane({
579
  if (termRef.current) termRef.current.options.theme = THEMES[theme];
580
  }, [theme]);
581
 
582
- // Zoom: adjust font size (100% = 13px) and refit.
 
583
  useEffect(() => {
584
  const t = termRef.current;
585
  if (!t) return;
586
  t.options.fontSize = Math.round((13 * zoom) / 100);
587
- resyncRef.current();
588
  }, [zoom]);
589
 
590
  // Move keyboard focus into the terminal whenever this pane becomes the active
@@ -631,6 +582,11 @@ export default function TerminalPane({
631
  <span className="ph-title" title={`${pathLabel} · double-click to rename`} onDoubleClick={() => { setDraft(session.name); setEditing(true); }}>{session.name}</span>
632
  )}
633
  <div className="ph-right">
 
 
 
 
 
634
  <span className="ph-path" title={pathLabel}>{pathLabel}</span>
635
  <button className="mini-btn ph-close" title="Close" onClick={(e) => { e.stopPropagation(); onClose(); }}><CloseGlyph /></button>
636
  </div>
@@ -638,9 +594,6 @@ export default function TerminalPane({
638
  <div className="term-host">
639
  <div className="term-fill" ref={hostRef} />
640
  </div>
641
- {copyMode && (
642
- <div className="term-copy-hint mono">copy mode · scroll to read, press Esc or type to return</div>
643
- )}
644
  {isMobile && conn === 'connected' && (
645
  // Control keys the phone keyboard lacks — needed for TUI menus (model
646
  // pickers, etc.). preventDefault keeps terminal focus so the keyboard
@@ -699,21 +652,7 @@ export default function TerminalPane({
699
  <button className="tp-x" onClick={() => { setPasteOpen(false); termRef.current?.focus(); }}>cancel</button>
700
  </div>
701
  )}
702
- {conn === 'handedoff' && (
703
- // The session is running elsewhere, not stopped — say so, and make the
704
- // way back obvious (clicking the terminal works too).
705
- <div className="term-exit mono">
706
- <div className="tx-row">
707
- <span>open on another device · still running</span>
708
- <button
709
- className="tx-btn"
710
- onMouseDown={(e) => e.stopPropagation()}
711
- onClick={(e) => { e.stopPropagation(); resumeRef.current(); }}
712
- ><RefreshGlyph /> resume here</button>
713
- </div>
714
- </div>
715
- )}
716
- {booting && conn !== 'exited' && conn !== 'handedoff' && (
717
  <div className="term-boot mono">
718
  {conn === 'connecting' ? 'connecting' : `starting ${cli?.label || session.cli}`}<span className="et-cursor" />
719
  </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?.();
 
88
  // socket with this leading NUL sentinel, which real pty output never begins with.
89
  const MODE_CTRL = '\x00\x00AM:';
90
 
 
 
 
 
 
 
 
 
 
91
  function legacyCopy(text: string): boolean {
92
  const prev = document.activeElement as HTMLElement | null;
93
  try {
 
145
  }) {
146
  const hostRef = useRef<HTMLDivElement>(null);
147
  const termRef = useRef<Terminal | null>(null);
148
+ const localLayoutRef = useRef<() => void>(() => {});
149
  const reconnectRef = useRef<() => void>(() => {});
150
+ const controllerRef = useRef(false);
 
151
  // Send a raw byte string to the PTY (for the mobile key-bar: arrows, Esc…).
152
  const sendKeyRef = useRef<(d: string) => void>(() => {});
153
  const [conn, setConn] = useState<ConnState>('connecting');
154
+ // "starting…" cover while the CLI boots into an empty pane (starting on the
155
  // Space can take seconds). Hidden only when the screen actually SHOWS
156
+ // something — byte counts lie because a blank-screen repaint can already be
157
+ // kilobytes of escape sequences.
158
  const [booting, setBooting] = useState(true);
159
+ const [controller, setController] = useState(false);
160
+ const [viewers, setViewers] = useState(1);
 
 
161
  const [editing, setEditing] = useState(false);
162
  const [draft, setDraft] = useState(session.name);
163
  // Fallback paste sheet: shown only when we can't read the clipboard directly.
 
203
  cursorBlink: true,
204
  scrollback: 20000,
205
  theme: THEMES[theme],
206
+ // Let users make a local selection even when an agent TUI has grabbed
207
+ // the mouse: ⌥-drag on macOS, Shift-drag elsewhere.
208
  macOptionClickForcesSelection: true,
209
  });
210
  const fit = new FitAddon();
211
  term.loadAddon(fit);
212
 
213
  let lastSelection = '';
214
+ let osc52Text = '';
215
+ let osc52At = 0;
 
216
 
217
+ // An agent may deliberately emit OSC 52. Browsers generally reject an
218
+ // unsolicited clipboard write, so retain it briefly and let Cmd/C perform
219
+ // the write inside a real user gesture.
220
  const clipboardProvider = {
221
  readText: (sel: string) => (sel !== 'p' && navigator.clipboard?.readText ? navigator.clipboard.readText().catch(() => '') : ''),
222
  writeText: (sel: string, data: string) => {
 
223
  if (sel !== 'p') {
224
+ osc52Text = data;
225
+ osc52At = Date.now();
 
226
  }
 
227
  },
228
  };
229
  // addon-clipboard@0.1.0 has a (base64, provider) runtime constructor but
 
236
  // synchronously — deferring (setTimeout) would break execCommand's gesture.
237
  const selSub = term.onSelectionChange(() => {
238
  lastSelection = term.hasSelection() ? selectionText(term) : '';
 
239
  });
240
  const copySelection = () => {
241
  const text = term.hasSelection() ? selectionText(term) : lastSelection;
242
  if (text) copyText(text);
243
  };
 
 
 
 
 
 
 
 
 
 
 
 
244
  const host = hostRef.current!;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  const onCopy = (e: ClipboardEvent) => {
246
+ if (!term.hasSelection()) return;
247
+ const text = selectionText(term);
248
+ if (!text || !e.clipboardData) return;
249
+ e.clipboardData.setData('text/plain', text);
250
+ e.preventDefault();
251
  };
 
252
  document.addEventListener('copy', onCopy, true);
253
 
254
  let ws: WebSocket | null = null;
255
  const send = (o: unknown) => {
256
  if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o));
257
  };
258
+ const claimControl = () => {
259
+ if (controllerRef.current) return;
260
+ // Optimistic locally so the input event following this gesture is not
261
+ // dropped. WebSocket ordering guarantees claim reaches the server first.
262
+ controllerRef.current = true;
263
+ setController(true);
264
+ send({ t: 'claim' });
265
+ };
266
+ const onPointerDown = (e: PointerEvent) => {
267
+ // A touch may only be inspecting local scrollback. Claim lazily below if
268
+ // the gesture actually needs to drive an application's mouse mode.
269
+ if (e.pointerType !== 'touch') claimControl();
270
+ };
271
+ const onPaste = () => claimControl();
272
+ host.addEventListener('pointerdown', onPointerDown, true);
273
+ host.addEventListener('paste', onPaste, true);
274
+
275
  // The mobile key-bar sends control sequences the on-screen keyboard can't.
276
+ sendKeyRef.current = (d: string) => {
277
+ claimControl();
278
+ send({ t: 'i', d });
279
+ endBoot();
280
+ };
281
 
282
+ // ⌘/Ctrl+C copies a local selection. Without one, Ctrl+C remains SIGINT;
283
+ // Cmd+C may accept a recent OSC 52 payload deliberately emitted by the app.
 
 
284
  term.attachCustomKeyEventHandler((e) => {
285
  if (e.type !== 'keydown') return true;
286
  if ((e.metaKey || e.ctrlKey) && (e.key === 'c' || e.key === 'C') && term.hasSelection()) {
287
  copySelection();
288
  term.clearSelection();
 
289
  return false;
290
  }
291
+ if (e.metaKey && (e.key === 'c' || e.key === 'C')) {
292
+ if (osc52Text && Date.now() - osc52At < 120_000) copyText(osc52Text);
 
 
293
  return false;
294
  }
295
+ claimControl();
 
296
  return true;
297
  });
298
  // The only local fit: this terminal is still empty, so there is no buffer to
299
  // reflow, and it gives the initial size we open the socket with. Every later
300
  // size change goes through resync() as a REQUEST — see there.
301
  try { fit.fit(); } catch { /* layout not ready yet */ }
302
+ let layoutFrame = 0;
303
+ const syncLocalLayout = () => {
304
+ cancelAnimationFrame(layoutFrame);
305
+ layoutFrame = requestAnimationFrame(() => {
306
+ const box = hostRef.current;
307
+ const root = box?.querySelector<HTMLElement>('.xterm');
308
+ const screen = box?.querySelector<HTMLElement>('.xterm-screen');
309
+ if (!box || !root || !screen) return;
310
+ const style = getComputedStyle(box);
311
+ const innerWidth = box.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
312
+ const innerHeight = box.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
313
+ root.style.width = `${Math.max(innerWidth, Math.ceil(screen.getBoundingClientRect().width))}px`;
314
+ root.style.height = `${Math.max(innerHeight, Math.ceil(screen.getBoundingClientRect().height))}px`;
315
+ });
316
+ };
317
+ localLayoutRef.current = syncLocalLayout;
318
+ syncLocalLayout();
319
  // Re-measure once the webfont is ready (glyph width changes vs the fallback).
320
+ document.fonts?.ready.then(() => { resync(); syncLocalLayout(); });
321
 
322
  let closedByUs = false;
 
323
  let retry: ReturnType<typeof setTimeout> | null = null;
324
  // Reconnect with backoff: a sleeping/unreachable Space shouldn't be hammered
325
  // every second by every open pane. Reset once a connection succeeds.
 
350
  setBooting(false);
351
  };
352
  const connect = () => {
353
+ controllerRef.current = false;
354
+ setController(false);
355
  setConn('connecting');
356
  setBooting(true);
357
  bootLive = true;
 
373
  if (typeof d === 'string' && d.startsWith(MODE_CTRL)) {
374
  try {
375
  const m = JSON.parse(d.slice(MODE_CTRL.length));
376
+ if (m.t === 'grid' || m.t === 'restore') {
377
+ controllerRef.current = !!m.controller;
378
+ setController(!!m.controller);
379
+ setViewers(Math.max(1, Number(m.viewers) || 1));
380
+ const applyGrid = () => {
381
+ try {
382
+ if (m.reset) {
383
+ term.reset();
384
+ term.clear();
385
+ }
386
+ if (m.cols > 0 && m.rows > 0 && (term.cols !== m.cols || term.rows !== m.rows)) {
387
+ term.resize(m.cols, m.rows);
388
+ }
389
+ syncLocalLayout();
390
+ } catch { /* ignore */ }
391
+ };
392
+ // Writes are asynchronous. A queued empty write is a barrier so
393
+ // resize/reset cannot reinterpret bytes from the preceding grid.
394
+ const geometryChanged = m.cols > 0 && m.rows > 0
395
+ && (term.cols !== m.cols || term.rows !== m.rows);
396
+ if (m.reset || geometryChanged) term.write('', applyGrid);
397
+ else applyGrid();
398
  }
399
  } catch { /* ignore */ }
400
  return;
 
411
  ws.onclose = (e) => {
412
  // A real process exit: stop here and let the user relaunch. Anything
413
  // else is a transient drop (sleep/wake, network) → auto-reconnect and
414
+ // reattach to the still-running backend session.
415
  endBoot();
416
  if (e.code === EXIT_CODE) { setConn('exited'); return; }
 
 
 
417
  setConn('closed');
418
  if (!closedByUs) {
419
  retry = setTimeout(connect, retryDelay);
 
423
  ws.onerror = () => { try { ws?.close(); } catch { /* ignore */ } };
424
  };
425
  // Manual restart: clear the dead run's screen so the content probe watches
426
+ // the new process paint, not leftovers.
427
  reconnectRef.current = () => { if (retry) clearTimeout(retry); retryDelay = 1200; try { term.reset(); } catch { /* ignore */ } connect(); };
428
 
429
+ // Report the pane's preferred size without locally fitting its terminal.
430
+ // Only the current controller's preference changes the canonical grid;
431
+ // watchers retain theirs for a future claim.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  let resyncTimer: ReturnType<typeof setTimeout> | null = null;
433
  const requestSize = () => {
 
 
 
 
434
  const box = hostRef.current;
435
  if (!box || box.clientWidth < 40 || box.clientHeight < 40) return;
436
  try {
437
  const d = fit.proposeDimensions();
438
+ // Zoom is local presentation, not PTY geometry. Normalize the measured
439
+ // cells back to the 13px canonical font before sending a preference.
440
+ const scale = (Number(term.options.fontSize) || 13) / 13;
441
+ const cols = d ? Math.max(1, Math.floor(d.cols * scale)) : 0;
442
+ const rows = d ? Math.max(1, Math.floor(d.rows * scale)) : 0;
443
+ if (cols > 0 && rows > 0) send({ t: 'r', cols, rows });
444
  } catch { /* layout not ready */ }
445
  };
446
  // ResizeObserver fires every frame while a window is dragged or the sidebar
 
449
  if (resyncTimer) clearTimeout(resyncTimer);
450
  resyncTimer = setTimeout(() => { resyncTimer = null; requestSize(); }, 80);
451
  };
452
+ const onReturn = () => resync();
 
 
453
  const onVisible = () => { if (!document.hidden) onReturn(); };
 
 
454
  // Typing means the user sees enough to interact — drop the boot cover.
455
  // Real keystrokes only (onKey): onData ALSO fires for xterm's automatic
456
  // replies to the TUI's terminal queries (DA/CPR), which arrive instantly
457
  // on attach and must not count as "the user typed".
458
+ const keySub = term.onKey(() => { claimControl(); endBoot(); });
459
+ // Watchers render output but do not answer terminal queries. This prevents
460
+ // N browser emulators from injecting N DA/CPR responses into one PTY.
461
+ const dataSub = term.onData((d) => {
462
+ if (controllerRef.current) send({ t: 'i', d });
463
+ });
464
  const ro = new ResizeObserver(resync);
465
  ro.observe(hostRef.current!);
466
  window.addEventListener('focus', onReturn);
467
  document.addEventListener('visibilitychange', onVisible);
468
 
469
+ // Touch scrolling prefers browser-retained history. At the live bottom an
470
+ // app that tracks the mouse receives SGR wheel events from the controller.
 
 
 
471
  let touchY: number | null = null;
472
+ const onTouchStart = (e: TouchEvent) => { touchY = e.touches[0].clientY; };
473
  const onTouchMove = (e: TouchEvent) => {
474
  if (touchY == null) return;
475
  const y = e.touches[0].clientY;
 
481
  try { tracking = ((term as unknown as { modes?: { mouseTrackingMode?: string } }).modes?.mouseTrackingMode ?? 'none') !== 'none'; } catch { /* older xterm */ }
482
  const btn = steps > 0 ? 64 : 65; // drag down reveals earlier output = wheel up
483
  for (let i = 0; i < Math.abs(steps); i++) {
484
+ const inHistory = term.buffer.active.viewportY < term.buffer.active.baseY;
485
+ if (tracking && !inHistory) {
486
+ claimControl();
487
+ send({ t: 'i', d: `\x1b[<${btn};${Math.max(1, Math.floor(term.cols / 2))};${Math.max(1, Math.floor(term.rows / 2))}M` });
488
+ }
489
  else term.scrollLines(steps > 0 ? -1 : 1);
490
  }
491
  }
 
504
  if (bootTimer) clearTimeout(bootTimer);
505
  if (bootCheck) clearTimeout(bootCheck);
506
  if (resyncTimer) clearTimeout(resyncTimer);
507
+ cancelAnimationFrame(layoutFrame);
508
  ro.disconnect();
509
  host.removeEventListener('pointerdown', onPointerDown, true);
510
+ host.removeEventListener('paste', onPaste, true);
 
 
 
511
  document.removeEventListener('copy', onCopy, true);
512
  host.removeEventListener('touchstart', onTouchStart);
513
  host.removeEventListener('touchmove', onTouchMove);
 
520
  try { ws?.close(); } catch { /* ignore */ }
521
  term.dispose();
522
  termRef.current = null;
523
+ localLayoutRef.current = () => {};
524
  };
525
  }, [session.id]);
526
 
 
529
  if (termRef.current) termRef.current.options.theme = THEMES[theme];
530
  }, [theme]);
531
 
532
+ // Zoom changes only this viewer. The canonical terminal rows/columns stay
533
+ // fixed, and the host becomes a two-dimensional viewport when cells grow.
534
  useEffect(() => {
535
  const t = termRef.current;
536
  if (!t) return;
537
  t.options.fontSize = Math.round((13 * zoom) / 100);
538
+ localLayoutRef.current();
539
  }, [zoom]);
540
 
541
  // Move keyboard focus into the terminal whenever this pane becomes the active
 
582
  <span className="ph-title" title={`${pathLabel} · double-click to rename`} onDoubleClick={() => { setDraft(session.name); setEditing(true); }}>{session.name}</span>
583
  )}
584
  <div className="ph-right">
585
+ {viewers > 1 && (
586
+ <span className={`ph-role${controller ? ' controller' : ''}`} title={controller ? 'This pane controls terminal input and size' : 'Interact with the terminal to take control'}>
587
+ {controller ? `${viewers} viewers` : 'watching'}
588
+ </span>
589
+ )}
590
  <span className="ph-path" title={pathLabel}>{pathLabel}</span>
591
  <button className="mini-btn ph-close" title="Close" onClick={(e) => { e.stopPropagation(); onClose(); }}><CloseGlyph /></button>
592
  </div>
 
594
  <div className="term-host">
595
  <div className="term-fill" ref={hostRef} />
596
  </div>
 
 
 
597
  {isMobile && conn === 'connected' && (
598
  // Control keys the phone keyboard lacks — needed for TUI menus (model
599
  // pickers, etc.). preventDefault keeps terminal focus so the keyboard
 
652
  <button className="tp-x" onClick={() => { setPasteOpen(false); termRef.current?.focus(); }}>cancel</button>
653
  </div>
654
  )}
655
+ {booting && conn !== 'exited' && (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656
  <div className="term-boot mono">
657
  {conn === 'connecting' ? 'connecting' : `starting ${cli?.label || session.cli}`}<span className="et-cursor" />
658
  </div>
web/src/styles.css CHANGED
@@ -489,13 +489,6 @@ 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 +545,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 +558,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 +588,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
 
 
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
  /* terminal "process exited" overlay */
493
  /* Non-blocking banner so the agent's last output (e.g. a login error) stays readable. */
494
  /* stopped state: quiet line centered in the pane, in the boot-cover's voice */
 
545
  .pane-head .ph-left { grid-column: 1; justify-self: start; display: inline-flex; align-items: center; gap: 7px; }
546
  .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; }
547
  .pane-head .ph-right { grid-column: 3; min-width: 0; display: inline-flex; align-items: center; justify-self: end; gap: 6px; }
548
+ .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; }
549
+ .pane-head .ph-role.controller { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); }
550
  .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; }
551
  .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; }
552
  .pane-head .ph-close { justify-self: end; }
 
558
  .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); }
559
  .slot.focused .pane-head { background: color-mix(in srgb, var(--accent) 8%, var(--panel)); }
560
 
561
+ .term-host { flex: 1; min-height: 0; padding: 10px 8px 8px 12px; background: var(--term-bg); overflow: auto; overscroll-behavior: contain; }
562
+ /* The grid is measured against this filler, so it must stay unpadded: FitAddon
563
+ reads the PARENT's computed height and does NOT subtract its padding, so a
564
+ padded parent made the grid count a row that doesn't fit and the bottom line
565
+ rendered sliced (most visible with Codex, which paints its status line on the
566
+ very last row). The padding lives on .xterm, which FitAddon does subtract. */
 
 
567
  .term-fill { height: 100%; min-height: 0; }
568
  /* mobile control-key bar: the keys a phone keyboard lacks, pinned below the
569
  terminal so it rides just above the on-screen keyboard */
 
588
  .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);
589
  background: var(--panel-2); color: var(--text); font-size: 13px; line-height: 1.35; }
590
  .term-paste .tp-input:focus { outline: none; border-color: var(--accent); }
591
+ .term-host .xterm { min-width: 100%; min-height: 100%; }
592
  .xterm .xterm-viewport { scrollbar-width: none; }
593
  .xterm .xterm-viewport::-webkit-scrollbar { width: 0; height: 0; display: none; }
594