@volter/supercode-terminal 0.3.1 → 0.3.2

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
@@ -1,7 +1,6 @@
1
1
  import { execFile, spawn, spawnSync } from 'node:child_process';
2
2
  import { randomBytes, randomUUID } from 'node:crypto';
3
3
  import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
- import { homedir } from 'node:os';
5
4
  import { dirname, join } from 'node:path';
6
5
  import { connect as netConnect } from 'node:net';
7
6
  import { createRequire } from 'node:module';
@@ -177,6 +176,7 @@ export class TmuxTerminalHost {
177
176
  #allowUnownedControl;
178
177
  #answered = false;
179
178
  #dead = new Map();
179
+ #homes = new Map();
180
180
  #generation = 0;
181
181
  #listing = null;
182
182
  #attachments = new Map();
@@ -440,7 +440,8 @@ export class TmuxTerminalHost {
440
440
  this.#tmux.killSessionProcessTree({ session: entry.name, socket: this.#socket });
441
441
  // The close's own record: tmux's session-closed hook writes it too, but not when this session was the server's
442
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());
443
+ const home = this.#homes.get(entry.name) || defaultTmux.paneExitHome();
444
+ if (entry.name.startsWith('p_') && !paneExitRecorded(entry.name, home)) recordPaneExit(entry.name, 0, null, null, hookStamp(), home);
444
445
  this.#generation += 1;
445
446
  this.#catalog.delete(sessionId);
446
447
  this.#idByName.delete(entry.name);
@@ -523,7 +524,11 @@ export class TmuxTerminalHost {
523
524
  * input, a close, a name's reuse). A pane session (p_*) in that state is finished off here as the pane-died hook
524
525
  * would: its end written to the pane-exit log when no line names the pane yet (the screen with it when it failed),
525
526
  * 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
+ * a server without `tmux` on its PATH cannot run. The log is the pane's own, in the home of the host that made it
528
+ * (the session's `@supercode-home`), whichever host on the socket reads the pane first: a host finishing off
529
+ * another home's pane writes where that home's daemon reads, never under its own. The log is appended to and never
530
+ * rewritten, so a line is never lost; two writers that both find no line each append one, and every reader takes a
531
+ * pane's repeated line as one end. A session of another name is not this host's to close (tmux keeps
527
532
  * its failed pane for its last screen): it stays listed, marked `dead` with its `exitCode`, as the ConPTY host
528
533
  * marks one.
529
534
  */
@@ -539,15 +544,18 @@ export class TmuxTerminalHost {
539
544
  }
540
545
  const names = [];
541
546
  this.#dead.clear();
547
+ this.#homes.clear();
542
548
  for (const state of states) {
549
+ const home = state.home || defaultTmux.paneExitHome();
550
+ this.#homes.set(state.name, home);
543
551
  if (!state.dead) names.push(state.name);
544
552
  else if (!state.name.startsWith('p_')) { names.push(state.name); this.#dead.set(state.name, { exitCode: state.exitCode }); }
545
553
  else {
546
- if (!paneExitRecorded(state.name)) {
554
+ if (!paneExitRecorded(state.name, home)) {
547
555
  const failed = state.exitCode !== 0 || Boolean(state.signal);
548
556
  let screen = null;
549
557
  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());
558
+ recordPaneExit(state.name, state.exitCode, state.signal, screen, hookStamp(), home);
551
559
  }
552
560
  this.#tmux.closeDeadPane({ paneId: state.paneId, session: state.name, socket: this.#socket });
553
561
  this.#generation += 1;
@@ -631,16 +639,14 @@ for (const event of ['end', 'close', 'error']) process.stdin.on(event, sweep);
631
639
  */
632
640
  /**
633
641
  * A pane's end in the pane-exit log, as the tmux lane's hooks write it (`<time> <pane> closed`, or `died status=…
634
- * signal=…` when it failed), under SUPERCODE_HOME or ~/.config/supercode: the machine daemon tells a clean finish from
635
- * a failure by it, and a ConPTY pane that ended cleanly is otherwise gone without a trace.
642
+ * signal=…` when it failed), in the home of the host that made the pane (SUPERCODE_HOME or ~/.config/supercode): the
643
+ * machine daemon tells a clean finish from a failure by it, and a ConPTY pane that ended cleanly is otherwise gone
644
+ * without a trace.
636
645
  */
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;
646
+ /** Whether `home`'s pane-exit log holds a line of this pane's end. */
647
+ function paneExitRecorded(pane, home) {
648
+ try { return readFileSync(join(home, 'pane-exits.log'), 'utf8').split('\n').some((line) => line.endsWith(` ${pane} closed`) || line.includes(` ${pane} died `)); }
649
+ catch { return false; }
644
650
  }
645
651
 
646
652
  /** Now as the tmux hooks stamp a line (`date +%FT%T`, the machine's own time): one log's lines are ordered by it. */
@@ -649,12 +655,12 @@ function hookStamp() {
649
655
  return `${now.getFullYear()}-${two(now.getMonth() + 1)}-${two(now.getDate())}T${two(now.getHours())}:${two(now.getMinutes())}:${two(now.getSeconds())}`;
650
656
  }
651
657
 
652
- function recordPaneExit(contextKey, exitCode, signal, screen = null, at = new Date().toISOString().slice(0, 19)) {
658
+ function recordPaneExit(contextKey, exitCode, signal, screen = null, at = new Date().toISOString().slice(0, 19), home = defaultTmux.paneExitHome()) {
653
659
  if (!contextKey) return;
654
660
  const line = exitCode === 0 && !signal ? `${at} ${contextKey} closed\n` : `${at} ${contextKey} died status=${exitCode ?? ''} signal=${signal || ''}\n`;
655
- try { appendFileSync(join(process.env.SUPERCODE_HOME || join(homedir(), '.config', 'supercode'), 'pane-exits.log'), line); } catch { /* no log is no worse than before */ }
661
+ try { appendFileSync(join(home, 'pane-exits.log'), line); } catch { /* no log is no worse than before */ }
656
662
  if ((exitCode !== 0 || signal) && typeof screen === 'string' && /^p_[A-Za-z0-9_-]+$/.test(contextKey)) {
657
- try { const dir=join(process.env.SUPERCODE_HOME || join(homedir(), '.config', 'supercode'),'pane-exits');mkdirSync(dir,{recursive:true,mode:0o700});writeFileSync(join(dir,`${contextKey}.screen`),Buffer.from(screen).subarray(-32768),{mode:0o600}); }
663
+ try { const dir=join(home,'pane-exits');mkdirSync(dir,{recursive:true,mode:0o700});writeFileSync(join(dir,`${contextKey}.screen`),Buffer.from(screen).subarray(-32768),{mode:0o600}); }
658
664
  catch { /* The receipt explicitly reports unavailable output. */ }
659
665
  }
660
666
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-terminal",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "description": "Optional leak-safe terminal and tmux host adapter for Volter Harness frontends",
6
6
  "exports": {
@@ -28,6 +28,7 @@ export type CreateSessionOptions = {
28
28
  optionalSettings?: Record<string, string>;
29
29
  socket?: string;
30
30
  };
31
+ export declare function paneExitHome(env?: NodeJS.ProcessEnv): string;
31
32
  export declare function assertTmux(): void;
32
33
  export declare function targetForSession(sessionName: string): string;
33
34
  export declare function assertSession(name: string, socket?: string): void;
@@ -60,6 +61,8 @@ export type TmuxSessionState = {
60
61
  exitCode: number | null;
61
62
  /** The signal that ended the program, as tmux names it; empty when it exited. */
62
63
  signal: string;
64
+ /** The home the pane's end is recorded in (`paneExitHome` of the host that made it); empty when tmux names none. */
65
+ home: string;
63
66
  };
64
67
  /**
65
68
  * Every session with whether its pane's program has ended, in one tmux process. A session whose pane is dead is
package/substrate/tmux.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
- import { tmpdir } from "node:os";
2
+ import { homedir, tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { isHarnessMarker } from "./harness-env.js";
5
5
  import { requireCommand, run, runAsync, runWithInputAsync, spawnInherited } from "./internal/exec.js";
@@ -12,6 +12,15 @@ const PTY_ALLOCATION_HINT = [
12
12
  ].join(" ");
13
13
  // Session-scoped tmux user option that records which provider created a session.
14
14
  const ownerOption = "@supercode-owner";
15
+ // Where a pane's end is recorded: the home of the host that made it. Every session carries it in this user option,
16
+ // set at creation, so the server's hooks write where that host reads, whatever environment the server was started
17
+ // with and whichever other homes have panes on the socket. The server-wide value is the last host's to create a
18
+ // session: what a session made before the option answers with, and what tmux gives the session-closed hook, which
19
+ // runs after its session's own options are gone.
20
+ const paneExitHomeOption = "@supercode-home";
21
+ export function paneExitHome(env = process.env) {
22
+ return env.SUPERCODE_HOME || join(homedir(), ".config", "supercode");
23
+ }
15
24
  // Optional dedicated tmux server (`tmux -L <socket>`). A `-L` server is fully
16
25
  // separate from the user's DEFAULT server — the default can't see it and
17
26
  // `kill-server` on it can't escape — so a provider (or a test) can run in
@@ -88,7 +97,11 @@ export async function createSession({ command, cwd, env, name, owner, ownerOptio
88
97
  return writeSessionEnvironmentBootstrap(directory, pane, sessionEnv, inherited, commandArgs.length === 0 ? [shell, "-l"] : commandArgs);
89
98
  };
90
99
  try {
91
- const startArgs = [...sessionServerOptionArgs(), "new-session", "-d", "-s", name];
100
+ const exitHome = paneExitHome();
101
+ assertOptionValue(exitHome);
102
+ if (exitHome.endsWith(";"))
103
+ throw new Error(`The home a pane's end is recorded in cannot end in ";": ${exitHome}`);
104
+ const startArgs = [...sessionServerOptionArgs(exitHome), "new-session", "-d", "-s", name];
92
105
  if (cwd) {
93
106
  startArgs.push("-c", cwd);
94
107
  }
@@ -107,6 +120,7 @@ export async function createSession({ command, cwd, env, name, owner, ownerOptio
107
120
  };
108
121
  if (owner)
109
122
  setOnCreate(ownershipOption, owner);
123
+ setOnCreate(paneExitHomeOption, exitHome);
110
124
  for (const [option, value] of Object.entries(sessionOptions).sort(([left], [right]) => left.localeCompare(right))) {
111
125
  assertUserOption(option);
112
126
  setOnCreate(option, value);
@@ -333,8 +347,9 @@ export function killSessionProcessTree({ session, socket }) {
333
347
  export function listTmuxSessionNames(socket) {
334
348
  return run("tmux", tmuxArgs(socket, ["list-sessions", "-F", "#{session_name}"])).split("\n").map((line) => line.trim()).filter(Boolean);
335
349
  }
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}";
350
+ // The home last, after the name: tmux guarantees a session name holds no colon, the fields before it never do, and a
351
+ // path may.
352
+ const sessionStateFormat = `#{pane_id}:#{pane_dead}:#{pane_dead_status}:#{pane_dead_signal}:#{session_name}:#{${paneExitHomeOption}}`;
338
353
  /**
339
354
  * Every session with whether its pane's program has ended, in one tmux process. A session whose pane is dead is
340
355
  * still a session to tmux (`list-sessions` names it, `has-session` finds it) though nothing runs in it: a caller
@@ -342,8 +357,8 @@ const sessionStateFormat = "#{pane_id}:#{pane_dead}:#{pane_dead_status}:#{pane_d
342
357
  */
343
358
  export function listSessionStates(socket) {
344
359
  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 };
360
+ const [paneId = "", dead = "", status = "", signal = "", name = "", ...home] = line.split(":");
361
+ return { name, paneId, dead: dead === "1", exitCode: /^\d+$/.test(status) ? Number(status) : null, signal, home: home.join(":") };
347
362
  });
348
363
  }
349
364
  /**
@@ -429,12 +444,17 @@ async function declareSessionColorEnvironmentAsync(socket) {
429
444
  // the most recently used session instead, and that session's program was killed.
430
445
  // Targets are not expanded as formats, so the shell that run-shell starts resolves
431
446
  // the pane and does nothing when it no longer belongs to a p_* session.
432
- const PANE_EXIT_LOG = '"${SUPERCODE_HOME:-$HOME/.config/supercode}/pane-exits.log"';
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\"'";
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 + "' }";
436
- function sessionServerOptionArgs() {
447
+ // The record goes to the pane's own home (`paneExitHomeOption`), read with the pane's
448
+ // state into a shell variable: a pane that names none is left as it is, for its host
449
+ // to finish off. The session-closed hook's session is gone, so tmux expands the option
450
+ // there against the server (`q:` quotes it for the shell).
451
+ const PANE_EXIT_LOG = '"$h/pane-exits.log"';
452
+ const PANE_EXIT_CAPTURE = 'case "$s" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac; d="$h/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"';
453
+ 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}|##{" + paneExitHomeOption + "}\" 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); h=$(printf %s \"$r\" | cut -d\"|\" -f5-); case \"$s\" in p_*) ;; *) exit 0 ;; esac; [ -n \"$h\" ] || exit 0; 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\"'";
454
+ 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}\" >> #{q:" + paneExitHomeOption + "}/pane-exits.log' }";
455
+ function sessionServerOptionArgs(exitHome) {
437
456
  return [
457
+ "set-option", "-g", paneExitHomeOption, exitHome, ";",
438
458
  "set-option", "-g", "history-limit", "50000", ";",
439
459
  "set-option", "-g", "mouse", "on", ";",
440
460
  // Every client draws into the viewer's normal screen, so lines that scroll