@volter/supercode-terminal 0.3.0 → 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 +87 -11
- package/package.json +1 -1
- package/substrate/tmux.d.ts +32 -1
- package/substrate/tmux.js +76 -13
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';
|
|
@@ -24,6 +23,14 @@ const SESSION_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
|
24
23
|
// relay clients.) Session-scoped, so other sessions on the same tmux server
|
|
25
24
|
// keep their own settings.
|
|
26
25
|
const TERMINAL_LIKE_SESSION = Object.freeze({ status: 'off', mouse: 'off', prefix: 'None', prefix2: 'None', 'remain-on-exit': 'failed' });
|
|
26
|
+
// A pane session (p_*) keeps its pane after its program ends however it ended, a clean end included, until the
|
|
27
|
+
// pane's end is in the pane-exit log: the record is written from the dead pane's own status, by the substrate's
|
|
28
|
+
// pane-died hook or by this host (#sessionNames), whichever reads the pane first, and the pane is closed after it.
|
|
29
|
+
// Only p_* sessions: they are the ones the hook and the host close.
|
|
30
|
+
const PANE_SESSION = Object.freeze({ ...TERMINAL_LIKE_SESSION, 'remain-on-exit': 'on' });
|
|
31
|
+
// 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
|
|
32
|
+
// for a clean end, whose pane is about to close, and tmux's own default line for a failed one.
|
|
33
|
+
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
34
|
|
|
28
35
|
function runTmuxInput(socket, args, input = null) {
|
|
29
36
|
return new Promise((resolve, reject) => {
|
|
@@ -167,6 +174,9 @@ export class TerminalWebSocketBridge {
|
|
|
167
174
|
|
|
168
175
|
export class TmuxTerminalHost {
|
|
169
176
|
#allowUnownedControl;
|
|
177
|
+
#answered = false;
|
|
178
|
+
#dead = new Map();
|
|
179
|
+
#homes = 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,10 @@ 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
|
+
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);
|
|
418
445
|
this.#generation += 1;
|
|
419
446
|
this.#catalog.delete(sessionId);
|
|
420
447
|
this.#idByName.delete(entry.name);
|
|
@@ -491,13 +518,50 @@ export class TmuxTerminalHost {
|
|
|
491
518
|
}
|
|
492
519
|
}
|
|
493
520
|
|
|
521
|
+
/**
|
|
522
|
+
* The sessions something runs in. A session whose pane's program has ended is still a session to tmux, kept by
|
|
523
|
+
* `remain-on-exit`; it is never answered as one here, to any caller (a listing, a liveness reading, an attachment,
|
|
524
|
+
* input, a close, a name's reuse). A pane session (p_*) in that state is finished off here as the pane-died hook
|
|
525
|
+
* would: its end written to the pane-exit log when no line names the pane yet (the screen with it when it failed),
|
|
526
|
+
* then the pane closed. So neither the record nor the pane's removal waits on a run-shell inside the server, which
|
|
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
|
|
532
|
+
* its failed pane for its last screen): it stays listed, marked `dead` with its `exitCode`, as the ConPTY host
|
|
533
|
+
* marks one.
|
|
534
|
+
*/
|
|
494
535
|
#sessionNames() {
|
|
536
|
+
let states;
|
|
537
|
+
this.#answered = false;
|
|
495
538
|
try {
|
|
496
|
-
|
|
539
|
+
states = this.#tmux.listSessionStates(this.#socket);
|
|
540
|
+
this.#answered = true;
|
|
497
541
|
} catch (error) {
|
|
498
|
-
if (isEmptyServerError(error))
|
|
499
|
-
|
|
542
|
+
if (!isEmptyServerError(error)) throw error;
|
|
543
|
+
states = [];
|
|
500
544
|
}
|
|
545
|
+
const names = [];
|
|
546
|
+
this.#dead.clear();
|
|
547
|
+
this.#homes.clear();
|
|
548
|
+
for (const state of states) {
|
|
549
|
+
const home = state.home || defaultTmux.paneExitHome();
|
|
550
|
+
this.#homes.set(state.name, home);
|
|
551
|
+
if (!state.dead) names.push(state.name);
|
|
552
|
+
else if (!state.name.startsWith('p_')) { names.push(state.name); this.#dead.set(state.name, { exitCode: state.exitCode }); }
|
|
553
|
+
else {
|
|
554
|
+
if (!paneExitRecorded(state.name, home)) {
|
|
555
|
+
const failed = state.exitCode !== 0 || Boolean(state.signal);
|
|
556
|
+
let screen = null;
|
|
557
|
+
if (failed) { try { screen = this.#tmux.capturePane({ lines: 200, preserveEscapes: true, socket: this.#socket, target: state.paneId }); } catch { /* the pane is gone */ } }
|
|
558
|
+
recordPaneExit(state.name, state.exitCode, state.signal, screen, hookStamp(), home);
|
|
559
|
+
}
|
|
560
|
+
this.#tmux.closeDeadPane({ paneId: state.paneId, session: state.name, socket: this.#socket });
|
|
561
|
+
this.#generation += 1;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return names;
|
|
501
565
|
}
|
|
502
566
|
}
|
|
503
567
|
|
|
@@ -575,16 +639,28 @@ for (const event of ['end', 'close', 'error']) process.stdin.on(event, sweep);
|
|
|
575
639
|
*/
|
|
576
640
|
/**
|
|
577
641
|
* A pane's end in the pane-exit log, as the tmux lane's hooks write it (`<time> <pane> closed`, or `died status=…
|
|
578
|
-
* signal=…` when it failed),
|
|
579
|
-
* 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.
|
|
580
645
|
*/
|
|
581
|
-
|
|
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; }
|
|
650
|
+
}
|
|
651
|
+
|
|
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. */
|
|
653
|
+
function hookStamp() {
|
|
654
|
+
const now = new Date(), two = (value) => String(value).padStart(2, '0');
|
|
655
|
+
return `${now.getFullYear()}-${two(now.getMonth() + 1)}-${two(now.getDate())}T${two(now.getHours())}:${two(now.getMinutes())}:${two(now.getSeconds())}`;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function recordPaneExit(contextKey, exitCode, signal, screen = null, at = new Date().toISOString().slice(0, 19), home = defaultTmux.paneExitHome()) {
|
|
582
659
|
if (!contextKey) return;
|
|
583
|
-
const at = new Date().toISOString().slice(0, 19);
|
|
584
660
|
const line = exitCode === 0 && !signal ? `${at} ${contextKey} closed\n` : `${at} ${contextKey} died status=${exitCode ?? ''} signal=${signal || ''}\n`;
|
|
585
|
-
try { appendFileSync(join(
|
|
661
|
+
try { appendFileSync(join(home, 'pane-exits.log'), line); } catch { /* no log is no worse than before */ }
|
|
586
662
|
if ((exitCode !== 0 || signal) && typeof screen === 'string' && /^p_[A-Za-z0-9_-]+$/.test(contextKey)) {
|
|
587
|
-
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}); }
|
|
588
664
|
catch { /* The receipt explicitly reports unavailable output. */ }
|
|
589
665
|
}
|
|
590
666
|
}
|
package/package.json
CHANGED
package/substrate/tmux.d.ts
CHANGED
|
@@ -25,12 +25,14 @@ 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
|
};
|
|
31
|
+
export declare function paneExitHome(env?: NodeJS.ProcessEnv): string;
|
|
30
32
|
export declare function assertTmux(): void;
|
|
31
33
|
export declare function targetForSession(sessionName: string): string;
|
|
32
34
|
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<{
|
|
35
|
+
export declare function createSession({ command, cwd, env, name, owner, ownerOption: ownershipOption, panes, sessionOptions, settings, optionalSettings, socket }: CreateSessionOptions): Promise<{
|
|
34
36
|
name: string;
|
|
35
37
|
panes: number;
|
|
36
38
|
}>;
|
|
@@ -49,6 +51,35 @@ export declare function killSessionProcessTree({ session, socket }: {
|
|
|
49
51
|
socket?: string;
|
|
50
52
|
}): void;
|
|
51
53
|
export declare function listTmuxSessionNames(socket?: string): string[];
|
|
54
|
+
export type TmuxSessionState = {
|
|
55
|
+
name: string;
|
|
56
|
+
/** The id (`%N`) of the session's pane. */
|
|
57
|
+
paneId: string;
|
|
58
|
+
/** Whether the pane's program has ended and tmux keeps the pane (`remain-on-exit`). */
|
|
59
|
+
dead: boolean;
|
|
60
|
+
/** The ended program's exit status, when it exited; null when a signal ended it or tmux does not say. */
|
|
61
|
+
exitCode: number | null;
|
|
62
|
+
/** The signal that ended the program, as tmux names it; empty when it exited. */
|
|
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;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Every session with whether its pane's program has ended, in one tmux process. A session whose pane is dead is
|
|
69
|
+
* still a session to tmux (`list-sessions` names it, `has-session` finds it) though nothing runs in it: a caller
|
|
70
|
+
* that asks only for names takes it for a live one. Throws as `listTmuxSessionNames` does when there is no server.
|
|
71
|
+
*/
|
|
72
|
+
export declare function listSessionStates(socket?: string): TmuxSessionState[];
|
|
73
|
+
/**
|
|
74
|
+
* Closes one dead pane, and only it: the kill is tmux's own decision, in the server, on the pane being dead and
|
|
75
|
+
* still in the named session, so a pane id that another session has since been given (a server that restarted) or a
|
|
76
|
+
* session started again under the same name is never closed. Answers nothing; a pane already gone is the same end.
|
|
77
|
+
*/
|
|
78
|
+
export declare function closeDeadPane({ paneId, session, socket }: {
|
|
79
|
+
paneId: string;
|
|
80
|
+
session: string;
|
|
81
|
+
socket?: string;
|
|
82
|
+
}): void;
|
|
52
83
|
export declare function selectOrphanSessions({ allSessions, ownedSessions, prefix }: {
|
|
53
84
|
allSessions: string[];
|
|
54
85
|
ownedSessions: Iterable<string>;
|
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
|
|
@@ -41,7 +50,7 @@ export function assertSession(name, socket) {
|
|
|
41
50
|
// createWindowSession), so every tmux invocation here uses runAsync (spawn, not
|
|
42
51
|
// spawnSync) and the pane-ready poll below uses sleepAsync — see waitForPanesReady.
|
|
43
52
|
// 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 }) {
|
|
53
|
+
export async function createSession({ command, cwd, env, name, owner, ownerOption: ownershipOption = ownerOption, panes, sessionOptions = {}, settings = {}, optionalSettings = {}, socket }) {
|
|
45
54
|
assertTmux();
|
|
46
55
|
if (!name) {
|
|
47
56
|
throw new Error("--name is required.");
|
|
@@ -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
|
}
|
|
@@ -98,15 +111,16 @@ export async function createSession({ command, cwd, env, name, owner, ownerOptio
|
|
|
98
111
|
// session with it before `remain-on-exit` or the owner stamp is set, and
|
|
99
112
|
// the next set-option's "no such session" replaces the program's own
|
|
100
113
|
// failure in what the launcher reports.
|
|
101
|
-
const setOnCreate = (option, value) => {
|
|
114
|
+
const setOnCreate = (option, value, quiet = false) => {
|
|
102
115
|
assertOptionValue(value);
|
|
103
116
|
// tmux splits commands at an argument that ends in `;`.
|
|
104
117
|
if (value.endsWith(";"))
|
|
105
118
|
throw new Error(`A tmux option value set at creation cannot end in ";": ${option}`);
|
|
106
|
-
startArgs.push(";", "set-option", "-t", name, option, value);
|
|
119
|
+
startArgs.push(";", "set-option", ...(quiet ? ["-q"] : []), "-t", name, option, value);
|
|
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);
|
|
@@ -115,6 +129,10 @@ export async function createSession({ command, cwd, env, name, owner, ownerOptio
|
|
|
115
129
|
assertSettingName(option);
|
|
116
130
|
setOnCreate(option, value);
|
|
117
131
|
}
|
|
132
|
+
for (const [option, value] of Object.entries(optionalSettings).sort(([left], [right]) => left.localeCompare(right))) {
|
|
133
|
+
assertSettingName(option);
|
|
134
|
+
setOnCreate(option, value, true);
|
|
135
|
+
}
|
|
118
136
|
await runTmuxSessionCreateAsync(startArgs, { cwd, operation: "create tmux session", session: name, socket });
|
|
119
137
|
await declareSessionColorEnvironmentAsync(socket);
|
|
120
138
|
const target = targetForSession(name);
|
|
@@ -329,6 +347,35 @@ export function killSessionProcessTree({ session, socket }) {
|
|
|
329
347
|
export function listTmuxSessionNames(socket) {
|
|
330
348
|
return run("tmux", tmuxArgs(socket, ["list-sessions", "-F", "#{session_name}"])).split("\n").map((line) => line.trim()).filter(Boolean);
|
|
331
349
|
}
|
|
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}}`;
|
|
353
|
+
/**
|
|
354
|
+
* Every session with whether its pane's program has ended, in one tmux process. A session whose pane is dead is
|
|
355
|
+
* still a session to tmux (`list-sessions` names it, `has-session` finds it) though nothing runs in it: a caller
|
|
356
|
+
* that asks only for names takes it for a live one. Throws as `listTmuxSessionNames` does when there is no server.
|
|
357
|
+
*/
|
|
358
|
+
export function listSessionStates(socket) {
|
|
359
|
+
return run("tmux", tmuxArgs(socket, ["list-sessions", "-F", sessionStateFormat])).split("\n").filter((line) => line.trim()).map((line) => {
|
|
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(":") };
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Closes one dead pane, and only it: the kill is tmux's own decision, in the server, on the pane being dead and
|
|
366
|
+
* still in the named session, so a pane id that another session has since been given (a server that restarted) or a
|
|
367
|
+
* session started again under the same name is never closed. Answers nothing; a pane already gone is the same end.
|
|
368
|
+
*/
|
|
369
|
+
export function closeDeadPane({ paneId, session, socket }) {
|
|
370
|
+
if (!/^%\d+$/.test(paneId) || !/^[A-Za-z0-9_-]+$/.test(session))
|
|
371
|
+
return;
|
|
372
|
+
try {
|
|
373
|
+
run("tmux", tmuxArgs(socket, ["if-shell", "-F", "-t", paneId, `#{&&:#{pane_dead},#{==:#{session_name},${session}}}`, `kill-pane -t ${paneId}`]));
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
// the pane is gone already
|
|
377
|
+
}
|
|
378
|
+
}
|
|
332
379
|
// A session belongs to a provider's prefix iff it is the bare `prefix` or a
|
|
333
380
|
// `prefix-*` child. The exact `-` boundary is load-bearing: prefix `worker` must
|
|
334
381
|
// not claim `worker-pool-1`. The single home for that rule.
|
|
@@ -380,20 +427,34 @@ async function declareSessionColorEnvironmentAsync(socket) {
|
|
|
380
427
|
// runs in the same invocation — `run` uses spawnSync without a shell, so the `;`
|
|
381
428
|
// reaches tmux as a literal command separator, not a shell metacharacter.
|
|
382
429
|
// A supercode pane (p_*) that ends leaves one line in the pane-exit log: its exit
|
|
383
|
-
// status or signal when it failed
|
|
384
|
-
//
|
|
430
|
+
// status or signal when it failed, `closed` when it ended cleanly. The pane-died
|
|
431
|
+
// hook writes it from the dead pane's own status while the session still holds
|
|
432
|
+
// the pane (its host creates p_* sessions with `remain-on-exit on`), and only then
|
|
433
|
+
// closes the pane: a record written after the session is gone is lost when that
|
|
434
|
+
// session was the server's last, because a server with no session exits
|
|
435
|
+
// (`exit-empty`) before a hook's run-shell has started. The session-closed hook
|
|
436
|
+
// writes `closed` for a session closed from outside, where no pane dies; the
|
|
437
|
+
// host writes that line itself for the closes it performs.
|
|
438
|
+
// The hook reads the pane's state in one `display`, so the status and the signal
|
|
439
|
+
// are of one moment: a pane that is gone or not dead by then leaves no line.
|
|
440
|
+
// Its screen is kept only when it failed, and only from a capture that worked.
|
|
385
441
|
// The pane-died hook acts on the pane that died, named by `#{hook_pane}`, and on
|
|
386
442
|
// nothing else: when its session is already gone (a close kills the session, then
|
|
387
443
|
// its processes), tmux resolves an untargeted `kill-pane` and `#{session_name}` to
|
|
388
444
|
// the most recently used session instead, and that session's program was killed.
|
|
389
445
|
// Targets are not expanded as formats, so the shell that run-shell starts resolves
|
|
390
446
|
// the pane and does nothing when it no longer belongs to a p_* session.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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) {
|
|
396
456
|
return [
|
|
457
|
+
"set-option", "-g", paneExitHomeOption, exitHome, ";",
|
|
397
458
|
"set-option", "-g", "history-limit", "50000", ";",
|
|
398
459
|
"set-option", "-g", "mouse", "on", ";",
|
|
399
460
|
// Every client draws into the viewer's normal screen, so lines that scroll
|
|
@@ -510,8 +571,10 @@ export async function waitForPanesReady({ expectedPanes, socket, target, timeout
|
|
|
510
571
|
const startedAt = Date.now();
|
|
511
572
|
while (Date.now() - startedAt < timeoutMs) {
|
|
512
573
|
const panes = await inspectPanesAsync(target, socket);
|
|
574
|
+
// A pane whose program has already ended is not waited for: it will not become ready, and its end is its
|
|
575
|
+
// caller's answer.
|
|
513
576
|
const ready = panes.length === expectedPanes
|
|
514
|
-
&& panes.every((pane) => pane.
|
|
577
|
+
&& panes.every((pane) => pane.id);
|
|
515
578
|
if (ready) {
|
|
516
579
|
return panes;
|
|
517
580
|
}
|