@volter/supercode-terminal 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.mjs CHANGED
@@ -24,6 +24,14 @@ const SESSION_NAME = /^[A-Za-z0-9_-]{1,64}$/;
24
24
  // relay clients.) Session-scoped, so other sessions on the same tmux server
25
25
  // keep their own settings.
26
26
  const TERMINAL_LIKE_SESSION = Object.freeze({ status: 'off', mouse: 'off', prefix: 'None', prefix2: 'None', 'remain-on-exit': 'failed' });
27
+ // A pane session (p_*) keeps its pane after its program ends however it ended, a clean end included, until the
28
+ // pane's end is in the pane-exit log: the record is written from the dead pane's own status, by the substrate's
29
+ // pane-died hook or by this host (#sessionNames), whichever reads the pane first, and the pane is closed after it.
30
+ // Only p_* sessions: they are the ones the hook and the host close.
31
+ const PANE_SESSION = Object.freeze({ ...TERMINAL_LIKE_SESSION, 'remain-on-exit': 'on' });
32
+ // What tmux draws in a dead pane (tmux 3.3 and later; before it the line is tmux's own and cannot be set): nothing
33
+ // for a clean end, whose pane is about to close, and tmux's own default line for a failed one.
34
+ const PANE_DEAD_LINE = '#{?#{&&:#{==:#{pane_dead_status},0},#{==:#{pane_dead_signal},}},,Pane is dead (#{?#{!=:#{pane_dead_status},},status #{pane_dead_status},}#{?#{!=:#{pane_dead_signal},},signal #{pane_dead_signal},}#, #{t:pane_dead_time})}';
27
35
 
28
36
  function runTmuxInput(socket, args, input = null) {
29
37
  return new Promise((resolve, reject) => {
@@ -167,6 +175,8 @@ export class TerminalWebSocketBridge {
167
175
 
168
176
  export class TmuxTerminalHost {
169
177
  #allowUnownedControl;
178
+ #answered = false;
179
+ #dead = new Map();
170
180
  #generation = 0;
171
181
  #listing = null;
172
182
  #attachments = new Map();
@@ -213,6 +223,12 @@ export class TmuxTerminalHost {
213
223
  /** Complete context observation for callers deciding whether a pane has ended. */
214
224
  hasLiveContext(contextKey) {
215
225
  assertContextKey(contextKey);
226
+ // A pane session whose program has ended is closed first, so tmux's answer is of the sessions something runs in.
227
+ // When the server answered and none is left, that is the complete reading: a server closing its last pane has
228
+ // no session to list and is not yet gone. A server that could not be read here is tmux's own reading below.
229
+ let names = null;
230
+ try { names = this.#sessionNames(); } catch { /* read below */ }
231
+ if (names && !names.length && this.#answered) return Promise.resolve(false);
216
232
  return this.#tmux.hasSessionContextAsync(this.#contextOption, contextKey, this.#socket);
217
233
  }
218
234
 
@@ -226,6 +242,9 @@ export class TmuxTerminalHost {
226
242
  this.#catalog.delete(id);
227
243
  }
228
244
  }
245
+ // No session, nothing more to ask: a server left with none is on its way out, and answers the readings below
246
+ // "no current target" until it is gone.
247
+ if (!names.length) return [];
229
248
  const panes = this.#panes();
230
249
  const [contextKeys, sizes] = await Promise.all([
231
250
  this.#tmux.sessionOptionValuesAsync(this.#contextOption, this.#socket),
@@ -236,7 +255,10 @@ export class TmuxTerminalHost {
236
255
  const id = this.#catalogId(name);
237
256
  const pane = panes.find((candidate) => candidate.session === name && candidate.window === 0 && candidate.pane === 0);
238
257
  const size = sizes.get(name);
258
+ const dead = this.#dead.get(name);
239
259
  return Object.freeze({
260
+ // a session of another name whose program failed: tmux keeps its pane for its last screen, and nothing runs in it
261
+ ...(dead ? { dead: true, exitCode: dead.exitCode } : {}),
240
262
  activeCommand: pane?.currentCommand ?? null,
241
263
  rootPid: pane?.rootPid ?? null,
242
264
  contextKey: contextKeys.get(name) ?? null,
@@ -275,7 +297,8 @@ export class TmuxTerminalHost {
275
297
  ownerOption: this.#ownerOption,
276
298
  panes: 1,
277
299
  sessionOptions: input.contextKey ? { [this.#contextOption]: input.contextKey } : {},
278
- settings: TERMINAL_LIKE_SESSION,
300
+ settings: name.startsWith('p_') ? PANE_SESSION : TERMINAL_LIKE_SESSION,
301
+ optionalSettings: name.startsWith('p_') ? { 'remain-on-exit-format': PANE_DEAD_LINE } : {},
279
302
  socket: this.#socket,
280
303
  });
281
304
  // Raised once tmux has the session: a listing started before then cannot be the one that answers below.
@@ -415,6 +438,9 @@ export class TmuxTerminalHost {
415
438
  throw new TerminalHostError('close_not_allowed', 'This host does not own that tmux session.');
416
439
  }
417
440
  this.#tmux.killSessionProcessTree({ session: entry.name, socket: this.#socket });
441
+ // The close's own record: tmux's session-closed hook writes it too, but not when this session was the server's
442
+ // last (the server exits before the hook's run-shell has started).
443
+ if (entry.name.startsWith('p_') && !paneExitRecorded(entry.name)) recordPaneExit(entry.name, 0, null, null, hookStamp());
418
444
  this.#generation += 1;
419
445
  this.#catalog.delete(sessionId);
420
446
  this.#idByName.delete(entry.name);
@@ -491,13 +517,43 @@ export class TmuxTerminalHost {
491
517
  }
492
518
  }
493
519
 
520
+ /**
521
+ * The sessions something runs in. A session whose pane's program has ended is still a session to tmux, kept by
522
+ * `remain-on-exit`; it is never answered as one here, to any caller (a listing, a liveness reading, an attachment,
523
+ * input, a close, a name's reuse). A pane session (p_*) in that state is finished off here as the pane-died hook
524
+ * would: its end written to the pane-exit log when no line names the pane yet (the screen with it when it failed),
525
+ * then the pane closed. So neither the record nor the pane's removal waits on a run-shell inside the server, which
526
+ * a server without `tmux` on its PATH cannot run. A session of another name is not this host's to close (tmux keeps
527
+ * its failed pane for its last screen): it stays listed, marked `dead` with its `exitCode`, as the ConPTY host
528
+ * marks one.
529
+ */
494
530
  #sessionNames() {
531
+ let states;
532
+ this.#answered = false;
495
533
  try {
496
- return this.#tmux.listTmuxSessionNames(this.#socket);
534
+ states = this.#tmux.listSessionStates(this.#socket);
535
+ this.#answered = true;
497
536
  } catch (error) {
498
- if (isEmptyServerError(error)) return [];
499
- throw error;
537
+ if (!isEmptyServerError(error)) throw error;
538
+ states = [];
539
+ }
540
+ const names = [];
541
+ this.#dead.clear();
542
+ for (const state of states) {
543
+ if (!state.dead) names.push(state.name);
544
+ else if (!state.name.startsWith('p_')) { names.push(state.name); this.#dead.set(state.name, { exitCode: state.exitCode }); }
545
+ else {
546
+ if (!paneExitRecorded(state.name)) {
547
+ const failed = state.exitCode !== 0 || Boolean(state.signal);
548
+ let screen = null;
549
+ if (failed) { try { screen = this.#tmux.capturePane({ lines: 200, preserveEscapes: true, socket: this.#socket, target: state.paneId }); } catch { /* the pane is gone */ } }
550
+ recordPaneExit(state.name, state.exitCode, state.signal, screen, hookStamp());
551
+ }
552
+ this.#tmux.closeDeadPane({ paneId: state.paneId, session: state.name, socket: this.#socket });
553
+ this.#generation += 1;
554
+ }
500
555
  }
556
+ return names;
501
557
  }
502
558
  }
503
559
 
@@ -578,9 +634,23 @@ for (const event of ['end', 'close', 'error']) process.stdin.on(event, sweep);
578
634
  * signal=…` when it failed), under SUPERCODE_HOME or ~/.config/supercode: the machine daemon tells a clean finish from
579
635
  * a failure by it, and a ConPTY pane that ended cleanly is otherwise gone without a trace.
580
636
  */
581
- function recordPaneExit(contextKey, exitCode, signal, screen = null) {
637
+ /** Whether the pane-exit log (either home's) holds a line of this pane's end. */
638
+ function paneExitRecorded(pane) {
639
+ for (const root of new Set([process.env.SUPERCODE_HOME, join(homedir(), '.config', 'supercode')].filter(Boolean))) {
640
+ try { if (readFileSync(join(root, 'pane-exits.log'), 'utf8').split('\n').some((line) => line.endsWith(` ${pane} closed`) || line.includes(` ${pane} died `))) return true; }
641
+ catch { /* no log there */ }
642
+ }
643
+ return false;
644
+ }
645
+
646
+ /** Now as the tmux hooks stamp a line (`date +%FT%T`, the machine's own time): one log's lines are ordered by it. */
647
+ function hookStamp() {
648
+ const now = new Date(), two = (value) => String(value).padStart(2, '0');
649
+ return `${now.getFullYear()}-${two(now.getMonth() + 1)}-${two(now.getDate())}T${two(now.getHours())}:${two(now.getMinutes())}:${two(now.getSeconds())}`;
650
+ }
651
+
652
+ function recordPaneExit(contextKey, exitCode, signal, screen = null, at = new Date().toISOString().slice(0, 19)) {
582
653
  if (!contextKey) return;
583
- const at = new Date().toISOString().slice(0, 19);
584
654
  const line = exitCode === 0 && !signal ? `${at} ${contextKey} closed\n` : `${at} ${contextKey} died status=${exitCode ?? ''} signal=${signal || ''}\n`;
585
655
  try { appendFileSync(join(process.env.SUPERCODE_HOME || join(homedir(), '.config', 'supercode'), 'pane-exits.log'), line); } catch { /* no log is no worse than before */ }
586
656
  if ((exitCode !== 0 || signal) && typeof screen === 'string' && /^p_[A-Za-z0-9_-]+$/.test(contextKey)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-terminal",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "Optional leak-safe terminal and tmux host adapter for Volter Harness frontends",
6
6
  "exports": {
@@ -25,12 +25,13 @@ export type CreateSessionOptions = {
25
25
  panes: number;
26
26
  sessionOptions?: Record<string, string>;
27
27
  settings?: Record<string, string>;
28
+ optionalSettings?: Record<string, string>;
28
29
  socket?: string;
29
30
  };
30
31
  export declare function assertTmux(): void;
31
32
  export declare function targetForSession(sessionName: string): string;
32
33
  export declare function assertSession(name: string, socket?: string): void;
33
- export declare function createSession({ command, cwd, env, name, owner, ownerOption: ownershipOption, panes, sessionOptions, settings, socket }: CreateSessionOptions): Promise<{
34
+ export declare function createSession({ command, cwd, env, name, owner, ownerOption: ownershipOption, panes, sessionOptions, settings, optionalSettings, socket }: CreateSessionOptions): Promise<{
34
35
  name: string;
35
36
  panes: number;
36
37
  }>;
@@ -49,6 +50,33 @@ export declare function killSessionProcessTree({ session, socket }: {
49
50
  socket?: string;
50
51
  }): void;
51
52
  export declare function listTmuxSessionNames(socket?: string): string[];
53
+ export type TmuxSessionState = {
54
+ name: string;
55
+ /** The id (`%N`) of the session's pane. */
56
+ paneId: string;
57
+ /** Whether the pane's program has ended and tmux keeps the pane (`remain-on-exit`). */
58
+ dead: boolean;
59
+ /** The ended program's exit status, when it exited; null when a signal ended it or tmux does not say. */
60
+ exitCode: number | null;
61
+ /** The signal that ended the program, as tmux names it; empty when it exited. */
62
+ signal: string;
63
+ };
64
+ /**
65
+ * Every session with whether its pane's program has ended, in one tmux process. A session whose pane is dead is
66
+ * still a session to tmux (`list-sessions` names it, `has-session` finds it) though nothing runs in it: a caller
67
+ * that asks only for names takes it for a live one. Throws as `listTmuxSessionNames` does when there is no server.
68
+ */
69
+ export declare function listSessionStates(socket?: string): TmuxSessionState[];
70
+ /**
71
+ * Closes one dead pane, and only it: the kill is tmux's own decision, in the server, on the pane being dead and
72
+ * still in the named session, so a pane id that another session has since been given (a server that restarted) or a
73
+ * session started again under the same name is never closed. Answers nothing; a pane already gone is the same end.
74
+ */
75
+ export declare function closeDeadPane({ paneId, session, socket }: {
76
+ paneId: string;
77
+ session: string;
78
+ socket?: string;
79
+ }): void;
52
80
  export declare function selectOrphanSessions({ allSessions, ownedSessions, prefix }: {
53
81
  allSessions: string[];
54
82
  ownedSessions: Iterable<string>;
package/substrate/tmux.js CHANGED
@@ -41,7 +41,7 @@ export function assertSession(name, socket) {
41
41
  // createWindowSession), so every tmux invocation here uses runAsync (spawn, not
42
42
  // spawnSync) and the pane-ready poll below uses sleepAsync — see waitForPanesReady.
43
43
  // Nothing in this function may block the loop.
44
- export async function createSession({ command, cwd, env, name, owner, ownerOption: ownershipOption = ownerOption, panes, sessionOptions = {}, settings = {}, socket }) {
44
+ export async function createSession({ command, cwd, env, name, owner, ownerOption: ownershipOption = ownerOption, panes, sessionOptions = {}, settings = {}, optionalSettings = {}, socket }) {
45
45
  assertTmux();
46
46
  if (!name) {
47
47
  throw new Error("--name is required.");
@@ -98,12 +98,12 @@ export async function createSession({ command, cwd, env, name, owner, ownerOptio
98
98
  // session with it before `remain-on-exit` or the owner stamp is set, and
99
99
  // the next set-option's "no such session" replaces the program's own
100
100
  // failure in what the launcher reports.
101
- const setOnCreate = (option, value) => {
101
+ const setOnCreate = (option, value, quiet = false) => {
102
102
  assertOptionValue(value);
103
103
  // tmux splits commands at an argument that ends in `;`.
104
104
  if (value.endsWith(";"))
105
105
  throw new Error(`A tmux option value set at creation cannot end in ";": ${option}`);
106
- startArgs.push(";", "set-option", "-t", name, option, value);
106
+ startArgs.push(";", "set-option", ...(quiet ? ["-q"] : []), "-t", name, option, value);
107
107
  };
108
108
  if (owner)
109
109
  setOnCreate(ownershipOption, owner);
@@ -115,6 +115,10 @@ export async function createSession({ command, cwd, env, name, owner, ownerOptio
115
115
  assertSettingName(option);
116
116
  setOnCreate(option, value);
117
117
  }
118
+ for (const [option, value] of Object.entries(optionalSettings).sort(([left], [right]) => left.localeCompare(right))) {
119
+ assertSettingName(option);
120
+ setOnCreate(option, value, true);
121
+ }
118
122
  await runTmuxSessionCreateAsync(startArgs, { cwd, operation: "create tmux session", session: name, socket });
119
123
  await declareSessionColorEnvironmentAsync(socket);
120
124
  const target = targetForSession(name);
@@ -329,6 +333,34 @@ export function killSessionProcessTree({ session, socket }) {
329
333
  export function listTmuxSessionNames(socket) {
330
334
  return run("tmux", tmuxArgs(socket, ["list-sessions", "-F", "#{session_name}"])).split("\n").map((line) => line.trim()).filter(Boolean);
331
335
  }
336
+ // The name last: tmux guarantees a session name holds no colon, and the fields before it never do.
337
+ const sessionStateFormat = "#{pane_id}:#{pane_dead}:#{pane_dead_status}:#{pane_dead_signal}:#{session_name}";
338
+ /**
339
+ * Every session with whether its pane's program has ended, in one tmux process. A session whose pane is dead is
340
+ * still a session to tmux (`list-sessions` names it, `has-session` finds it) though nothing runs in it: a caller
341
+ * that asks only for names takes it for a live one. Throws as `listTmuxSessionNames` does when there is no server.
342
+ */
343
+ export function listSessionStates(socket) {
344
+ return run("tmux", tmuxArgs(socket, ["list-sessions", "-F", sessionStateFormat])).split("\n").filter((line) => line.trim()).map((line) => {
345
+ const [paneId = "", dead = "", status = "", signal = "", ...name] = line.split(":");
346
+ return { name: name.join(":"), paneId, dead: dead === "1", exitCode: /^\d+$/.test(status) ? Number(status) : null, signal };
347
+ });
348
+ }
349
+ /**
350
+ * Closes one dead pane, and only it: the kill is tmux's own decision, in the server, on the pane being dead and
351
+ * still in the named session, so a pane id that another session has since been given (a server that restarted) or a
352
+ * session started again under the same name is never closed. Answers nothing; a pane already gone is the same end.
353
+ */
354
+ export function closeDeadPane({ paneId, session, socket }) {
355
+ if (!/^%\d+$/.test(paneId) || !/^[A-Za-z0-9_-]+$/.test(session))
356
+ return;
357
+ try {
358
+ run("tmux", tmuxArgs(socket, ["if-shell", "-F", "-t", paneId, `#{&&:#{pane_dead},#{==:#{session_name},${session}}}`, `kill-pane -t ${paneId}`]));
359
+ }
360
+ catch {
361
+ // the pane is gone already
362
+ }
363
+ }
332
364
  // A session belongs to a provider's prefix iff it is the bare `prefix` or a
333
365
  // `prefix-*` child. The exact `-` boundary is load-bearing: prefix `worker` must
334
366
  // not claim `worker-pool-1`. The single home for that rule.
@@ -380,8 +412,17 @@ async function declareSessionColorEnvironmentAsync(socket) {
380
412
  // runs in the same invocation — `run` uses spawnSync without a shell, so the `;`
381
413
  // reaches tmux as a literal command separator, not a shell metacharacter.
382
414
  // A supercode pane (p_*) that ends leaves one line in the pane-exit log: its exit
383
- // status or signal when it failed (the session keeps a failed pane just long
384
- // enough for tmux to report it, then closes it), `closed` when it ended cleanly.
415
+ // status or signal when it failed, `closed` when it ended cleanly. The pane-died
416
+ // hook writes it from the dead pane's own status while the session still holds
417
+ // the pane (its host creates p_* sessions with `remain-on-exit on`), and only then
418
+ // closes the pane: a record written after the session is gone is lost when that
419
+ // session was the server's last, because a server with no session exits
420
+ // (`exit-empty`) before a hook's run-shell has started. The session-closed hook
421
+ // writes `closed` for a session closed from outside, where no pane dies; the
422
+ // host writes that line itself for the closes it performs.
423
+ // The hook reads the pane's state in one `display`, so the status and the signal
424
+ // are of one moment: a pane that is gone or not dead by then leaves no line.
425
+ // Its screen is kept only when it failed, and only from a capture that worked.
385
426
  // The pane-died hook acts on the pane that died, named by `#{hook_pane}`, and on
386
427
  // nothing else: when its session is already gone (a close kills the session, then
387
428
  // its processes), tmux resolves an untargeted `kill-pane` and `#{session_name}` to
@@ -389,8 +430,8 @@ async function declareSessionColorEnvironmentAsync(socket) {
389
430
  // Targets are not expanded as formats, so the shell that run-shell starts resolves
390
431
  // the pane and does nothing when it no longer belongs to a p_* session.
391
432
  const PANE_EXIT_LOG = '"${SUPERCODE_HOME:-$HOME/.config/supercode}/pane-exits.log"';
392
- const PANE_EXIT_CAPTURE = 'case "$s" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac; d="${SUPERCODE_HOME:-$HOME/.config/supercode}/pane-exits"; umask 077; mkdir -p "$d"; t capture-pane -p -e -S -200 -t "$p" 2>/dev/null | tail -c 32768 > "$d/$s.screen"';
393
- const PANE_DIED_HOOK = "run-shell 'p=\"#{hook_pane}\"; t() { tmux -S \"#{socket_path}\" \"$@\"; }; s=$(t display -p -t \"$p\" \"##{session_name}\" 2>/dev/null) || exit 0; case \"$s\" in p_*) ;; *) exit 0 ;; esac; printf \"%s %s died status=%s signal=%s\\n\" \"$(date +%FT%T)\" \"$s\" \"$(t display -p -t \"$p\" \"##{pane_dead_status}\")\" \"$(t display -p -t \"$p\" \"##{pane_dead_signal}\")\" >> " + PANE_EXIT_LOG + "; " + PANE_EXIT_CAPTURE + "; t kill-pane -t \"$p\"'";
433
+ const PANE_EXIT_CAPTURE = 'case "$s" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac; d="${SUPERCODE_HOME:-$HOME/.config/supercode}/pane-exits"; umask 077; mkdir -p "$d"; if t capture-pane -p -e -S -200 -t "$p" > "$d/$s.screen.part" 2>/dev/null; then tail -c 32768 "$d/$s.screen.part" > "$d/$s.screen"; fi; rm -f "$d/$s.screen.part"';
434
+ const PANE_DIED_HOOK = "run-shell 'p=\"#{hook_pane}\"; t() { tmux -S \"#{socket_path}\" \"$@\"; }; r=$(t display -p -t \"$p\" \"##{pane_dead}|##{pane_dead_status}|##{pane_dead_signal}|##{session_name}\" 2>/dev/null) || exit 0; case \"$r\" in 1\\|*) ;; *) exit 0 ;; esac; st=$(printf %s \"$r\" | cut -d\"|\" -f2); sg=$(printf %s \"$r\" | cut -d\"|\" -f3); s=$(printf %s \"$r\" | cut -d\"|\" -f4-); case \"$s\" in p_*) ;; *) exit 0 ;; esac; if [ \"$st\" = 0 ] && [ -z \"$sg\" ]; then printf \"%s %s closed\\n\" \"$(date +%FT%T)\" \"$s\" >> " + PANE_EXIT_LOG + "; else printf \"%s %s died status=%s signal=%s\\n\" \"$(date +%FT%T)\" \"$s\" \"$st\" \"$sg\" >> " + PANE_EXIT_LOG + "; (" + PANE_EXIT_CAPTURE + "); fi; t kill-pane -t \"$p\"'";
394
435
  const SESSION_CLOSED_HOOK = "if-shell -F '#{m:p_*,#{hook_session_name}}' { run-shell 'printf \"%s %s closed\\n\" \"$(date +%FT%T)\" \"#{hook_session_name}\" >> " + PANE_EXIT_LOG + "' }";
395
436
  function sessionServerOptionArgs() {
396
437
  return [
@@ -510,8 +551,10 @@ export async function waitForPanesReady({ expectedPanes, socket, target, timeout
510
551
  const startedAt = Date.now();
511
552
  while (Date.now() - startedAt < timeoutMs) {
512
553
  const panes = await inspectPanesAsync(target, socket);
554
+ // A pane whose program has already ended is not waited for: it will not become ready, and its end is its
555
+ // caller's answer.
513
556
  const ready = panes.length === expectedPanes
514
- && panes.every((pane) => pane.dead === false && pane.id);
557
+ && panes.every((pane) => pane.id);
515
558
  if (ready) {
516
559
  return panes;
517
560
  }