File size: 19,147 Bytes
5b5da0e
 
 
 
 
 
fd0a8b6
5b5da0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fd0a8b6
 
5b5da0e
fd0a8b6
 
 
5b5da0e
fd0a8b6
5b5da0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fd0a8b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7c805c5
 
 
 
fd0a8b6
 
 
7c805c5
 
 
 
 
 
 
 
 
 
 
fd0a8b6
 
 
 
 
 
 
 
 
 
68fe810
 
c3210f3
 
 
 
 
68fe810
 
 
 
fd0a8b6
 
 
 
 
 
68fe810
 
 
 
 
 
 
 
 
 
 
 
 
fd0a8b6
68fe810
 
 
fd0a8b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b38b5c1
 
 
5b5da0e
 
 
b38b5c1
 
 
 
5b5da0e
 
 
 
fd0a8b6
 
 
 
 
5b5da0e
b38b5c1
fd0a8b6
 
 
 
 
 
545eab2
 
 
 
3c13663
 
 
 
 
 
545eab2
 
fd0a8b6
 
 
 
 
 
 
5b5da0e
 
8c11ea0
 
 
 
 
 
00f2d73
8c11ea0
 
 
b38b5c1
 
 
8c11ea0
00f2d73
 
c3210f3
 
 
 
 
 
 
 
 
 
5b5da0e
 
 
 
b38b5c1
5b5da0e
 
 
 
 
d5d1801
 
 
 
5b5da0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fd0a8b6
9c5792a
5b5da0e
2d4a54b
5b5da0e
 
 
 
9c5792a
5b5da0e
 
 
b38b5c1
fd0a8b6
5b5da0e
 
 
 
 
 
 
 
 
 
 
 
 
7d9aec4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9c5792a
7d9aec4
 
 
 
 
b38b5c1
7d9aec4
 
 
 
 
5972612
5017021
 
 
5972612
 
 
 
7d9aec4
5017021
5972612
7d9aec4
 
 
 
 
5972612
 
 
 
7d9aec4
 
a883217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5b5da0e
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
import os from 'node:os';
import path from 'node:path';
import pty from 'node-pty';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import { USE_TMUX, cliById, WORKSPACES_DIR } from './config.js';
import { update, list } from './sessions.js';

const TERM_ENV = {
  ...process.env,
  TERM: 'xterm-256color',
  COLORTERM: 'truecolor',
  LANG: process.env.LANG || 'C.UTF-8',
};

const BASHRC = process.env.AM_BASHRC || '/app/session.bashrc';
const TMUX_CONF = process.env.TMUX_CONF || '/app/tmux.conf';
const AM_USER = process.env.SPACE_AUTHOR_NAME || process.env.AM_USER || os.userInfo().username || 'user';

// Interactive bash that loads our prompt rcfile (bash ignores a missing rcfile,
// so this is safe in local dev where /app/session.bashrc doesn't exist).
const bashLaunch = `exec bash --rcfile ${BASHRC} -i`;

const tmuxName = (id) => `am-${id}`;

// If the rendered pane text hasn't changed for this long, it's not working.
const BUSY_SECS = 4;
const paneSig = new Map(); // id -> { sig, changedAt } (changedAt in unix seconds)

function djb2(s) {
  let h = 5381;
  for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
  return h;
}

// In direct-PTY (no-tmux) mode we track live handles to report running status.
const live = new Map(); // id -> Set(handle)
const track = (id, h) => {
  if (!live.has(id)) live.set(id, new Set());
  live.get(id).add(h);
};
const untrack = (id, h) => {
  const s = live.get(id);
  if (s) {
    s.delete(h);
    if (!s.size) live.delete(id);
  }
};

export function isRunning(id) {
  if (USE_TMUX) {
    try {
      execFileSync('tmux', ['has-session', '-t', tmuxName(id)], { stdio: 'ignore' });
      return true;
    } catch {
      return false;
    }
  }
  return live.has(id);
}

/**
 * Detect activity by DIFFING each pane's rendered text between polls. This
 * ignores colour-only animations (e.g. Codex's shimmering banner) that fooled a
 * raw output-activity check, while still catching spinners, streaming output and
 * elapsed-time counters (their text changes). Returns Map id -> { age } where
 * age is seconds since the pane text last changed.
 */
let infoMemo = { ts: 0, map: new Map() };

export function agentInfo() {
  // The sweep shells out to tmux once per session, synchronously. Memoize it
  // briefly so N browser tabs polling /api/tree don't multiply that cost.
  if (Date.now() - infoMemo.ts < 1500) return infoMemo.map;
  const map = new Map();
  infoMemo = { ts: Date.now(), map };
  if (!USE_TMUX) return map;
  let list;
  try {
    list = execFileSync('tmux', ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' });
  } catch {
    paneSig.clear();
    return map; // no server / no sessions
  }
  const now = Math.floor(Date.now() / 1000);
  const live = new Set();
  for (const name of list.split('\n')) {
    if (!name.startsWith('am-')) continue;
    const id = name.slice(3);
    live.add(id);
    let text = '';
    try { text = execFileSync('tmux', ['capture-pane', '-p', '-t', name], { encoding: 'utf8' }); } catch {}
    const sig = djb2(text);
    const prev = paneSig.get(id);
    const changedAt = !prev || prev.sig !== sig ? now : prev.changedAt;
    paneSig.set(id, { sig, changedAt });
    map.set(id, { age: now - changedAt });
  }
  for (const id of [...paneSig.keys()]) if (!live.has(id)) paneSig.delete(id);
  return map;
}

/**
 * Map activity + the session's known CLI into a UI state:
 *   working  β€” pane text is actively changing (thinking / streaming / a command)
 *   waiting  β€” agent alive but its screen is static β†’ it's your turn
 *   idle     β€” a plain shell sitting at its prompt
 *   stopped  β€” no live session
 */
export function deriveState(session, info) {
  if (session.cli === 'files') return 'idle'; // passive panel, not a process
  if (!info) return isRunning(session.id) ? 'idle' : 'stopped';
  if (info.age <= BUSY_SECS) return 'working';
  return session.cli === 'shell' ? 'idle' : 'waiting';
}

// ---------- Codex conversation pinning ----------
// Codex picks its own conversation id at launch and doesn't accept one up
// front β€” but it announces the pick immediately: a rollout file named
// rollout-<ts>-<id>.jsonl appears under $CODEX_HOME/sessions with the cwd in
// its first line. Capture that id shortly after launch and pin it on the
// session, so restarts resume THIS agent's conversation β€” `resume --last`
// would grab whichever Codex agent in the same folder ran last.
const codexCapturing = new Set(); // session ids with a capture in flight

function codexSessionsRoot() {
  const home = process.env.CODEX_HOME || path.join(process.env.HOME || os.homedir(), '.codex');
  return path.join(home, 'sessions');
}

// Rollout files touched since `sinceMs`, newest first.
function codexRolloutsSince(sinceMs) {
  const out = [];
  const walk = (dir, depth) => {
    if (depth > 5) return;
    let ents = [];
    try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
    for (const e of ents) {
      const p = path.join(dir, e.name);
      if (e.isDirectory()) walk(p, depth + 1);
      else if (e.name.startsWith('rollout-') && e.name.endsWith('.jsonl')) {
        try { const m = fs.statSync(p).mtimeMs; if (m >= sinceMs) out.push({ p, m }); } catch {}
      }
    }
  };
  walk(codexSessionsRoot(), 0);
  return out.sort((a, b) => b.m - a.m);
}

// First line of a (potentially large) file without reading all of it. Codex's
// session_meta line carries the full embedded instruction text (~22KB as of
// 0.142), so read in chunks until the newline β€” a fixed small buffer would
// truncate the JSON and make every capture silently fail.
function firstLine(p) {
  const fd = fs.openSync(p, 'r');
  try {
    const CHUNK = 65536, MAX = 1024 * 1024;
    let buf = Buffer.alloc(0);
    for (let pos = 0; pos < MAX; pos += CHUNK) {
      const b = Buffer.alloc(CHUNK);
      const n = fs.readSync(fd, b, 0, CHUNK, pos);
      buf = Buffer.concat([buf, b.subarray(0, n)]);
      const nl = buf.indexOf(0x0a);
      if (nl >= 0) return buf.toString('utf8', 0, nl);
      if (n < CHUNK) break; // EOF
    }
    return buf.toString('utf8');
  } finally { fs.closeSync(fd); }
}

function tryCaptureCodexId(sessionId, workdir, sinceMs) {
  const claimed = new Set(list().filter((s) => s.id !== sessionId && s.codexSessionId).map((s) => s.codexSessionId));
  for (const c of codexRolloutsSince(sinceMs)) {
    const m = c.p.match(/rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/);
    if (!m || claimed.has(m[1])) continue;
    let meta;
    try { meta = JSON.parse(firstLine(c.p)); } catch { continue; }
    const mp = (meta && meta.payload) || {};
    if (mp.cwd !== workdir) continue;
    // A sibling's ongoing conversation in the same folder gets fresh writes
    // (mtime) during our capture window β€” require the rollout to have been
    // CREATED after this launch so we never claim someone else's thread.
    const created = Date.parse(mp.timestamp || meta.timestamp || '') || 0;
    if (created && created < sinceMs - 15_000) continue;
    // Skip Codex's internal guardian/subagent rollouts β€” they share the cwd but
    // aren't this agent's conversation, so pinning one would break resume and
    // the Overview digest.
    if (mp.thread_source === 'subagent' || (mp.source && mp.source.subagent)) continue;
    update(sessionId, { codexSessionId: m[1], codexRollout: c.p });
    return true;
  }
  return false;
}

// A pin captured before subagents were filtered out (or one whose rollout was
// rotated away) may point at a guardian/missing rollout β€” clear it so we
// re-capture the real conversation on this launch.
function pinIsStale(session) {
  if (!session.codexSessionId) return false;
  const p = session.codexRollout;
  if (!p || !fs.existsSync(p)) return true;
  try {
    const mp = (JSON.parse(firstLine(p)) || {}).payload || {};
    return mp.thread_source === 'subagent' || !!(mp.source && mp.source.subagent);
  } catch { return false; }
}

function scheduleCodexCapture(session, workdir) {
  if (session.codexSessionId && pinIsStale(session)) {
    session = update(session.id, { codexSessionId: undefined, codexRollout: undefined }) || session;
  }
  if (session.codexSessionId || codexCapturing.has(session.id)) return;
  codexCapturing.add(session.id);
  const since = Date.now() - 2000;
  const delays = [5000, 15000, 45000]; // rollout appears ~instantly; retries cover slow starts
  const attempt = (i) => {
    if (tryCaptureCodexId(session.id, workdir, since) || i + 1 >= delays.length) {
      codexCapturing.delete(session.id);
      return;
    }
    const t = setTimeout(() => attempt(i + 1), delays[i + 1] - delays[i]);
    if (t.unref) t.unref();
  };
  const t0 = setTimeout(() => attempt(0), delays[0]);
  if (t0.unref) t0.unref();
}

// Single-quote a string for embedding in an `sh -lc` command line.
const shq = (t) => `'${String(t).replace(/'/g, `'\\''`)}'`;

function commandFor(session) {
  const cli = cliById(session.cli) || cliById('shell');
  if (cli.id === 'shell') return bashLaunch;
  // Quickstart: a prompt queued at creation rides the FIRST launch command
  // (claude 'p', codex 'p', gemini -i 'p', opencode --prompt 'p') β€” the CLI
  // starts already working on it, no typing race against a booting TUI.
  const q0 = !session.everStarted && session.pendingPrompt ? shq(session.pendingPrompt) : '';

  // Claude keys conversations by working directory, so grouped sessions sharing
  // a folder would all `--continue` onto the SAME most-recent conversation. Pin
  // each session to its own conversation id instead: create it with
  // --session-id, resume it with --resume. Decide resume-vs-fresh by whether the
  // transcript exists on disk (NOT via `resume || fresh`: that chain also fired
  // when claude itself exited non-zero, silently respawning a crashed session
  // as a fresh conversation and eating the pane's "done" signal). The fresh
  // branch keeps `|| exec claude` as a last resort for an unsupported flag.
  if (cli.id === 'claude' && session.sessionUuid) {
    const fresh = `claude --session-id ${session.sessionUuid}${q0 ? ` ${q0}` : ''} || exec claude`;
    if (!session.everStarted) return fresh;
    const projects = '"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects"';
    const hasTranscript = `[ -n "$(find ${projects} -name '${session.sessionUuid}*' -print -quit 2>/dev/null)" ]`;
    return `if ${hasTranscript}; then exec claude --resume ${session.sessionUuid}; else ${fresh}; fi`;
  }

  // OpenClaw: first run needs its onboarding wizard (keys, workspace); once the
  // config exists, go straight to the TUI. Decided by config-file existence β€”
  // same honest pattern as the Claude transcript check.
  if (cli.id === 'openclaw') {
    // OpenClaw runs with its own HOME on local disk (see entrypoint.sh): its
    // state can't live on the FUSE bucket and it rejects symlinked paths.
    // Locally (no OPENCLAW_HOME) the real HOME is used unchanged.
    return 'HOME="${OPENCLAW_HOME:-$HOME}"; export HOME; '
      + 'if [ -s "$HOME/.openclaw/openclaw.json" ]; then exec openclaw chat; '
      + 'else openclaw onboard && exec openclaw chat; fi';
  }

  // Codex: resume this agent's pinned conversation (captured from its rollout
  // file after launch β€” see scheduleCodexCapture). Existence-checked like
  // Claude, so a purged rollout starts fresh honestly and a crash ends the
  // pane instead of respawning. Unpinned sessions fall through to the generic
  // `resume --last` below (correct while the agent has its folder to itself).
  if (cli.id === 'codex' && session.codexSessionId && session.codexRollout) {
    return `if [ -f '${session.codexRollout}' ]; then exec codex resume ${session.codexSessionId}; else exec codex; fi`;
  }

  // opencode (seen on 1.17.13): at startup it creates a DIRECTORY named
  // opencode.json at its own config path, then every message dies re-reading
  // it (EISDIR β†’ the model silently never responds; the next launch exits
  // instantly). Clear a directory-shaped entry AND occupy the path with a real
  // config file β€” copied from opencode.jsonc when one exists β€” so the bug
  // can't re-trigger mid-session. An existing config FILE is left untouched.
  if (cli.id === 'opencode') {
    const guard = 'G="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/opencode.json"; '
      + 'mkdir -p "$(dirname "$G")"; [ -d "$G" ] && rm -rf "$G"; '
      + '[ -e "$G" ] || { [ -f "${G}c" ] && cp "${G}c" "$G" || echo "{}" > "$G"; }; ';
    const base = session.everStarted && cli.cont
      ? `${cli.cont} || exec ${cli.run}`
      : `exec ${q0 && cli.withPrompt ? cli.withPrompt(q0) : cli.run}`;
    return `${guard}${base}`;
  }

  // codex without a pinned conversation: `resume --last` scopes to the cwd,
  // so in a SHARED folder it can resume a SIBLING's conversation (cross-talk).
  // Only resume when this session has the folder to itself; otherwise start
  // fresh β€” capture pins the new conversation right away.
  if (cli.id === 'codex' && session.everStarted) {
    const folder = session.path ?? session.id;
    const shared = list().some((o) => o.id !== session.id && o.cli === 'codex' && (o.path ?? o.id) === folder);
    if (shared) return `exec ${cli.run}`;
  }

  // Other agents: resume when one likely exists, else a fresh launch. `exec` so
  // the agent is the pane's foreground process; when it exits the tmux session
  // ends β€” a clear "done" signal β€” and the fallback preserves that.
  if (session.everStarted && cli.cont) return `${cli.cont} || exec ${cli.run}`;
  if (q0 && cli.withPrompt) return `exec ${cli.withPrompt(q0)}`;
  return `exec ${cli.run}`;
}

/** Attach a new PTY client to the session (creating the tmux session if needed). */
export function attach(session, cols, rows) {
  // The recorded workspace-relative path ('' = the workspaces root itself).
  // If the folder was deleted or moved, mkdir simply recreates it empty β€” no
  // tracking, no magic.
  const folder = session.path ?? session.id;
  const workdir = path.join(WORKSPACES_DIR, folder);
  fs.mkdirSync(workdir, { recursive: true });
  const full = commandFor(session);

  let term;
  if (USE_TMUX) {
    const args = [];
    if (fs.existsSync(TMUX_CONF)) args.push('-f', TMUX_CONF);
    args.push(
      // -A: attach if it exists, else create. We deliberately do NOT pass -D
      // (detach others): the same session may be open in two browser windows,
      // and -D + auto-reconnect would make them fight. Instead each client
      // re-syncs its size on focus (see TerminalPane), and window-size=latest
      // makes the focused window fit exactly.
      'new-session', '-A', '-s', tmuxName(session.id), '-c', workdir,
      '-e', `AM_SESSION=${folder}`,
      '-e', `AM_NAME=${session.name}`,
      '-e', `AM_ID=${session.id}`,
      '-e', `AM_USER=${AM_USER}`,
      '-e', `AM_ROOT=${WORKSPACES_DIR}`, // prompt shows $PWD relative to this
      'sh', '-lc', full,
    );
    term = pty.spawn('tmux', args, { name: 'xterm-256color', cols, rows, cwd: workdir, env: TERM_ENV });
  } else {
    const env = { ...TERM_ENV, AM_SESSION: folder, AM_NAME: session.name, AM_ID: session.id, AM_USER, AM_ROOT: WORKSPACES_DIR };
    term = pty.spawn('bash', ['-lc', full], { name: 'xterm-256color', cols, rows, cwd: workdir, env });
  }

  if (!session.everStarted) update(session.id, { everStarted: true, pendingPrompt: undefined });
  if (session.cli === 'codex') scheduleCodexCapture(session, workdir);

  const handle = {
    onData: (cb) => term.onData(cb),
    onExit: (cb) => term.onExit(cb),
    write: (d) => { try { term.write(d); } catch {} },
    resize: (c, r) => { try { term.resize(c, r); } catch {} },
    kill: () => { try { term.kill(); } catch {} },
  };
  track(session.id, handle);
  term.onExit(() => untrack(session.id, handle));
  return handle;
}

/**
 * Make sure the session's tmux process exists WITHOUT a browser pane attached
 * (used by the Overview reply box to wake a stopped agent). Returns true if it
 * had to spawn. Direct-PTY mode has no detached equivalent β€” throws if dead.
 */
export function ensureRunning(session) {
  if (isRunning(session.id)) return false;
  if (!USE_TMUX) throw new Error('session is not running');
  const folder = session.path ?? session.id;
  const workdir = path.join(WORKSPACES_DIR, folder);
  fs.mkdirSync(workdir, { recursive: true });
  const args = [];
  if (fs.existsSync(TMUX_CONF)) args.push('-f', TMUX_CONF);
  args.push(
    'new-session', '-d', '-s', tmuxName(session.id), '-c', workdir,
    '-x', '200', '-y', '50', // sane size until a client attaches (window-size=latest)
    '-e', `AM_SESSION=${folder}`,
    '-e', `AM_NAME=${session.name}`,
    '-e', `AM_ID=${session.id}`,
    '-e', `AM_USER=${AM_USER}`,
    '-e', `AM_ROOT=${WORKSPACES_DIR}`,
    'sh', '-lc', commandFor(session),
  );
  execFileSync('tmux', args, { stdio: 'ignore', env: TERM_ENV });
  if (!session.everStarted) update(session.id, { everStarted: true, pendingPrompt: undefined });
  if (session.cli === 'codex') scheduleCodexCapture(session, workdir);
  return true;
}

/** Type a line into the session's terminal (works with no browser attached). */
export async function sendInput(id, text) {
  // Multi-line prompts go in as a bracketed paste so the CLI's composer treats
  // the inner newlines as soft line breaks instead of submitting early.
  const payload = text.includes('\n') ? `\x1b[200~${text}\x1b[201~` : text;
  // The Enter must arrive as its OWN keypress: TUIs (codex) detect rapid input
  // bursts as a paste, and a CR inside the burst becomes a newline in the
  // composer instead of a submit. A short gap breaks the burst.
  const gap = () => new Promise((r) => setTimeout(r, 300));
  if (USE_TMUX) {
    execFileSync('tmux', ['send-keys', '-t', tmuxName(id), '-l', '--', payload], { stdio: 'ignore' });
    await gap();
    execFileSync('tmux', ['send-keys', '-t', tmuxName(id), 'Enter'], { stdio: 'ignore' });
    return;
  }
  const set = live.get(id);
  if (!set || !set.size) throw new Error('session is not running');
  const h = set.values().next().value;
  h.write(payload);
  await gap();
  h.write('\r');
}

/** Return the last mouse selection for this session as a browser-consumable OSC 52. */
export function copySelection(id) {
  if (!USE_TMUX) return null;
  const bufferName = `am-copy-${tmuxName(id)}`;
  let text = '';
  try {
    text = execFileSync('tmux', ['save-buffer', '-b', bufferName, '-'], { encoding: 'utf8', env: TERM_ENV });
  } catch {
    try {
      text = execFileSync('tmux', ['save-buffer', '-'], { encoding: 'utf8', env: TERM_ENV });
    } catch {
      return null;
    }
  }
  if (!text) return null;
  return `\x1b]52;c;${Buffer.from(text, 'utf8').toString('base64')}\x07`;
}

/** Stop a session entirely (kills the tmux session / the running process). */
export function stop(id) {
  if (USE_TMUX) {
    try {
      execFileSync('tmux', ['kill-session', '-t', tmuxName(id)], { stdio: 'ignore' });
    } catch {}
  } else {
    const s = live.get(id);
    if (s) for (const h of s) h.kill();
  }
}