@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 +23 -17
- package/package.json +1 -1
- package/substrate/tmux.d.ts +3 -0
- package/substrate/tmux.js +31 -11
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
|
-
|
|
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.
|
|
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),
|
|
635
|
-
* a failure by it, and a ConPTY pane that ended cleanly is otherwise gone
|
|
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
|
|
638
|
-
function paneExitRecorded(pane) {
|
|
639
|
-
|
|
640
|
-
|
|
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(
|
|
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(
|
|
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
package/substrate/tmux.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
337
|
-
|
|
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 = "", ...
|
|
346
|
-
return { name
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|