seshmux 0.1.4 → 0.1.5
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/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/app-build-manifest.json +1 -1
- package/.next/standalone/.next/build-manifest.json +2 -2
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/page.js +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/static/chunks/app/page-b9c3810608a2c3d4.js +1 -0
- package/.next/standalone/package.json +1 -1
- package/.next/standalone/seshmux-server.js +16 -10
- package/bin/seshmux.js +206 -22
- package/daemon/ensure.js +55 -0
- package/daemon/holder.js +248 -0
- package/daemon/index.js +4 -1
- package/daemon/pty-manager.js +341 -29
- package/package.json +1 -1
- package/.next/standalone/.next/static/chunks/app/page-cd707a4bc18d9497.js +0 -1
- /package/.next/standalone/.next/static/{Xjuy0Fy4Y1goqBeK0eRol → INANHMG7kWM6Af6v_Vj9d}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{Xjuy0Fy4Y1goqBeK0eRol → INANHMG7kWM6Af6v_Vj9d}/_ssgManifest.js +0 -0
package/bin/seshmux.js
CHANGED
|
@@ -18,7 +18,7 @@ const http = require('node:http');
|
|
|
18
18
|
const { randomBytes } = require('node:crypto');
|
|
19
19
|
const path = require('node:path');
|
|
20
20
|
const fs = require('node:fs');
|
|
21
|
-
const { ensureDaemon, pidAlive, paths, configDir } = require('../daemon/ensure');
|
|
21
|
+
const { ensureDaemon, pidAlive, paths, configDir, daemonInfo, canSafelyRestartDaemon } = require('../daemon/ensure');
|
|
22
22
|
|
|
23
23
|
// The daemon's pid, from the pidfile it writes in the config dir. null when it isn't running.
|
|
24
24
|
function readDaemonPid() {
|
|
@@ -30,6 +30,93 @@ function readDaemonPid() {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// Stop the running daemon (if any) and start a fresh one. The ONLY kill+respawn path —
|
|
34
|
+
// shared by --restart-daemon (explicit, may end plain PTYs) and the post-update auto-upgrade
|
|
35
|
+
// (which only calls this once canSafelyRestartDaemon() says every live PTY is tmux-backed).
|
|
36
|
+
// Returns false if the old daemon refused to die (we never start a second one).
|
|
37
|
+
async function restartDaemon() {
|
|
38
|
+
const before = readDaemonPid();
|
|
39
|
+
if (before) {
|
|
40
|
+
try {
|
|
41
|
+
process.kill(before, 'SIGTERM');
|
|
42
|
+
} catch {
|
|
43
|
+
/* already gone */
|
|
44
|
+
}
|
|
45
|
+
for (let i = 0; i < 40 && pidAlive(before); i++) await new Promise((r) => setTimeout(r, 100));
|
|
46
|
+
if (pidAlive(before)) {
|
|
47
|
+
console.error(`[seshmux] daemon ${before} did not stop; not starting a second one`);
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const { spawned } = await ensureDaemon();
|
|
52
|
+
console.log(`[seshmux] daemon ${spawned ? 'restarted' : 'already up'} (pid ${readDaemonPid() ?? '?'})`);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Numeric-segment version compare ("0.10.0" > "0.9.0"). Mirror of server/lib/update.ts's
|
|
57
|
+
// compareVersions — this file is plain CJS and cannot import the TS module.
|
|
58
|
+
function versionLess(a, b) {
|
|
59
|
+
const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
|
|
60
|
+
const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
|
|
61
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
62
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
63
|
+
if (d !== 0) return d < 0;
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// After a self-update the server is new but the daemon still runs the OLD code forever
|
|
69
|
+
// (ensureDaemon reuses any daemon that answers hello). Upgrade it here — but ONLY when no live
|
|
70
|
+
// session would die: tmux-tier PTYs rehydrate in the fresh daemon, plain-tier PTYs do not.
|
|
71
|
+
// Unreachable daemon / unknown versions (dev) → do nothing.
|
|
72
|
+
// Returns 'upgraded' | 'blocked' | 'noop'. quiet=true suppresses the blocked log (the retry
|
|
73
|
+
// loop below would otherwise repeat it forever).
|
|
74
|
+
async function autoUpgradeDaemon(ourVersion, quiet) {
|
|
75
|
+
const info = await daemonInfo(paths(configDir()).sock).catch(() => null);
|
|
76
|
+
if (!info || !info.version || !ourVersion || ourVersion === '0.0.0') return 'noop';
|
|
77
|
+
if (!versionLess(info.version, ourVersion)) return 'noop';
|
|
78
|
+
|
|
79
|
+
const { safe, plainCount } = canSafelyRestartDaemon(info.ptys);
|
|
80
|
+
if (!safe) {
|
|
81
|
+
if (!quiet) {
|
|
82
|
+
console.log(
|
|
83
|
+
`[seshmux] daemon stays on v${info.version} for now — ${plainCount} running session(s) are not tmux-backed ` +
|
|
84
|
+
'and a restart would end them. It will upgrade itself as soon as they finish.',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return 'blocked';
|
|
88
|
+
}
|
|
89
|
+
const tmuxCount = info.ptys.filter((p) => p.tmuxName && p.alive !== false).length;
|
|
90
|
+
console.log(
|
|
91
|
+
`[seshmux] upgrading daemon v${info.version} -> v${ourVersion}` +
|
|
92
|
+
(tmuxCount ? ` (${tmuxCount} tmux session(s) will re-attach)` : ''),
|
|
93
|
+
);
|
|
94
|
+
await restartDaemon();
|
|
95
|
+
return 'upgraded';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A blocked upgrade must not stay blocked forever. Without tmux a PTY simply cannot outlive the
|
|
99
|
+
// daemon that owns its master fd — that is the OS, not a bug we can fix — so the answer is NOT to
|
|
100
|
+
// demand the user install tmux, it is to never need the restart at a bad moment. The daemon is
|
|
101
|
+
// harmless while stale (protocol frozen at 1; newer RPCs degrade, they don't fail), so it can
|
|
102
|
+
// simply WAIT for a safe moment: every live session ended, or all remaining ones are tmux-backed.
|
|
103
|
+
// Then it upgrades itself, with nothing killed and nothing for the user to type.
|
|
104
|
+
const UPGRADE_RETRY_MS = Number(process.env.SESHMUX_UPGRADE_RETRY_MS) || 60_000;
|
|
105
|
+
function scheduleDaemonUpgrade(getVersion) {
|
|
106
|
+
let announced = false;
|
|
107
|
+
const tick = async () => {
|
|
108
|
+
const result = await autoUpgradeDaemon(getVersion(), announced).catch(() => 'noop');
|
|
109
|
+
if (result === 'blocked') {
|
|
110
|
+
announced = true; // say it once, then wait quietly
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
clearInterval(timer); // upgraded, or nothing to do — stop checking
|
|
114
|
+
};
|
|
115
|
+
const timer = setInterval(tick, UPGRADE_RETRY_MS);
|
|
116
|
+
if (timer.unref) timer.unref(); // never hold the process open on this alone
|
|
117
|
+
tick();
|
|
118
|
+
}
|
|
119
|
+
|
|
33
120
|
// Absolute path to THIS cli entry, inherited by the server child. The MCP
|
|
34
121
|
// bridge registration writes it into agent configs (`node <bin> mcp-bridge`) —
|
|
35
122
|
// `npx seshmux` only resolves once the package is published to a registry.
|
|
@@ -147,6 +234,99 @@ function runMcpBridge(root) {
|
|
|
147
234
|
child.on('exit', (code) => process.exit(code ?? 0));
|
|
148
235
|
}
|
|
149
236
|
|
|
237
|
+
// Is a binary on PATH? Shell-free.
|
|
238
|
+
function have(bin) {
|
|
239
|
+
return new Promise((resolve) => {
|
|
240
|
+
execFile('which', [bin], (err, stdout) => resolve(!err && !!String(stdout).trim()));
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// The package manager we can offer to install tmux with, or null when there is nothing to offer.
|
|
245
|
+
async function tmuxInstaller() {
|
|
246
|
+
if (process.platform === 'darwin' && (await have('brew'))) return { cmd: 'brew', args: ['install', 'tmux'] };
|
|
247
|
+
if (await have('apt-get')) return { cmd: 'sudo', args: ['apt-get', 'install', '-y', 'tmux'] };
|
|
248
|
+
if (await have('dnf')) return { cmd: 'sudo', args: ['dnf', 'install', '-y', 'tmux'] };
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Resolves 'yes' | 'no' | 'none'. 'none' = the user never actually answered (stdin hit EOF, or
|
|
253
|
+
// the terminal went away): rl.question's callback then NEVER fires, which would hang startup
|
|
254
|
+
// forever. Booting the app matters more than this prompt, so no answer means "skip it, ask again
|
|
255
|
+
// next time" — never a silent decline the user did not make.
|
|
256
|
+
function askYesNo(question) {
|
|
257
|
+
return new Promise((resolve) => {
|
|
258
|
+
const rl = require('node:readline').createInterface({ input: process.stdin, output: process.stdout });
|
|
259
|
+
let done = false;
|
|
260
|
+
const finish = (v) => {
|
|
261
|
+
if (done) return;
|
|
262
|
+
done = true;
|
|
263
|
+
rl.close();
|
|
264
|
+
resolve(v);
|
|
265
|
+
};
|
|
266
|
+
rl.on('close', () => finish('none')); // EOF / ^D / stdin closed
|
|
267
|
+
rl.question(question, (answer) => finish(/^n/i.test(String(answer).trim()) ? 'no' : 'yes'));
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Without tmux, a session's PTY is owned by the daemon and dies with it — a crash or a daemon
|
|
272
|
+
// restart ends the user's running agent. session-start.ts picks the tmux tier ONLY when tmux is
|
|
273
|
+
// on PATH, so a tmux-less machine silently gets the fragile tier and nobody says a word. A real
|
|
274
|
+
// user lost every session this way. Offer to fix it, once, and never nag again.
|
|
275
|
+
//
|
|
276
|
+
// NOT an npm postinstall: that needs a package manager we can't assume, is skipped entirely under
|
|
277
|
+
// `npm ci --ignore-scripts` (standard in CI), would run in Docker images where tmux is pointless,
|
|
278
|
+
// and shelling out to a system installer from an install hook is exactly what supply-chain
|
|
279
|
+
// scanners flag. A first-run prompt asks the person who is actually there.
|
|
280
|
+
async function offerTmux() {
|
|
281
|
+
if (await have('tmux')) return;
|
|
282
|
+
const ackFile = path.join(configDir(), 'tmux-declined');
|
|
283
|
+
if (fs.existsSync(ackFile)) return;
|
|
284
|
+
|
|
285
|
+
const durability =
|
|
286
|
+
'[seshmux] tmux is not installed.\n' +
|
|
287
|
+
' Your agent sessions will end if seshmux restarts or crashes.\n' +
|
|
288
|
+
' With tmux, they survive restarts, updates, and crashes.';
|
|
289
|
+
|
|
290
|
+
// Non-interactive (piped, CI, launched by a GUI): state it, never block on a prompt nobody
|
|
291
|
+
// can answer, and do not record a decline the user never made.
|
|
292
|
+
if (!process.stdin.isTTY) {
|
|
293
|
+
console.log(`${durability}\n Install tmux to make sessions durable.`);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const installer = await tmuxInstaller();
|
|
298
|
+
if (!installer) {
|
|
299
|
+
console.log(`${durability}\n Install tmux with your package manager to make sessions durable.`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
console.log(durability);
|
|
304
|
+
const answer = await askYesNo(`\n Install tmux now with ${installer.cmd}? [Y/n] `);
|
|
305
|
+
if (answer === 'none') return; // no answer given — boot anyway, ask again next launch
|
|
306
|
+
if (answer === 'no') {
|
|
307
|
+
try {
|
|
308
|
+
fs.mkdirSync(path.dirname(ackFile), { recursive: true });
|
|
309
|
+
fs.writeFileSync(ackFile, 'declined\n');
|
|
310
|
+
} catch {
|
|
311
|
+
/* best effort — worst case we ask again next launch */
|
|
312
|
+
}
|
|
313
|
+
console.log('[seshmux] continuing without tmux (sessions are not crash-safe). Not asking again.');
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
console.log(`[seshmux] ${installer.cmd} ${installer.args.join(' ')}…`);
|
|
318
|
+
await new Promise((resolve) => {
|
|
319
|
+
const child = spawn(installer.cmd, installer.args, { stdio: 'inherit' });
|
|
320
|
+
child.on('exit', resolve);
|
|
321
|
+
child.on('error', resolve);
|
|
322
|
+
});
|
|
323
|
+
console.log(
|
|
324
|
+
(await have('tmux'))
|
|
325
|
+
? '[seshmux] tmux installed — new sessions will survive restarts and crashes.'
|
|
326
|
+
: '[seshmux] tmux install did not complete; continuing without it.',
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
150
330
|
async function main() {
|
|
151
331
|
// Subcommand dispatch (before arg parsing / server flow).
|
|
152
332
|
const argv = process.argv.slice(2);
|
|
@@ -158,33 +338,23 @@ async function main() {
|
|
|
158
338
|
|
|
159
339
|
const args = parseArgs(argv);
|
|
160
340
|
|
|
161
|
-
// --restart-daemon: the
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
341
|
+
// --restart-daemon: the manual escape hatch for upgrading the daemon (the update flow does it
|
|
342
|
+
// automatically, but ONLY when no plain PTY would die — see autoUpgradeDaemon). ensureDaemon()
|
|
343
|
+
// treats any daemon that answers hello as 'ok' and reuses it (daemon/ensure.js classify()),
|
|
344
|
+
// which is what keeps your sessions alive across server updates — restarting seshmux does NOT
|
|
345
|
+
// replace the daemon. This does.
|
|
165
346
|
//
|
|
166
|
-
// Destructive on purpose
|
|
167
|
-
//
|
|
168
|
-
//
|
|
347
|
+
// Destructive on purpose: tmux-tier sessions rehydrate from `tmux ls` and survive, PLAIN-tier
|
|
348
|
+
// PTYs die with the daemon. The automatic path refuses to run in that case; this flag is the
|
|
349
|
+
// explicit "do it anyway, I accept losing them".
|
|
169
350
|
if (args.restartDaemon) {
|
|
170
351
|
const before = readDaemonPid();
|
|
171
352
|
if (before) {
|
|
172
353
|
console.log(`[seshmux] stopping daemon ${before} — tmux-backed sessions survive; any non-tmux PTYs will end`);
|
|
173
|
-
try {
|
|
174
|
-
process.kill(before, 'SIGTERM');
|
|
175
|
-
} catch {
|
|
176
|
-
/* already gone */
|
|
177
|
-
}
|
|
178
|
-
for (let i = 0; i < 40 && pidAlive(before); i++) await new Promise((r) => setTimeout(r, 100));
|
|
179
|
-
if (pidAlive(before)) {
|
|
180
|
-
console.error(`[seshmux] daemon ${before} did not stop; not starting a second one`);
|
|
181
|
-
process.exit(1);
|
|
182
|
-
}
|
|
183
354
|
} else {
|
|
184
355
|
console.log('[seshmux] no daemon running');
|
|
185
356
|
}
|
|
186
|
-
|
|
187
|
-
console.log(`[seshmux] daemon ${spawned ? 'restarted' : 'already up'} (pid ${readDaemonPid() ?? '?'})`);
|
|
357
|
+
if (!(await restartDaemon())) process.exit(1);
|
|
188
358
|
}
|
|
189
359
|
|
|
190
360
|
// If a healthy seshmux already runs on the requested port range, just open the
|
|
@@ -197,6 +367,11 @@ async function main() {
|
|
|
197
367
|
return;
|
|
198
368
|
}
|
|
199
369
|
|
|
370
|
+
// Ask about tmux BEFORE the daemon starts, so a yes takes effect for the very first session
|
|
371
|
+
// (session-start.ts picks the tmux tier only if tmux is on PATH at spawn time). Only reached
|
|
372
|
+
// when we are actually starting seshmux — never when we just hand off to a running instance.
|
|
373
|
+
await offerTmux().catch(() => {}); // never block startup on this
|
|
374
|
+
|
|
200
375
|
// Ensure a responsive daemon BEFORE the server comes up. Spawns detached +
|
|
201
376
|
// unref'd if needed; recovers a stale socket. Non-fatal if it can't start —
|
|
202
377
|
// the app degrades to browse-only (no live terminals) rather than refusing.
|
|
@@ -270,6 +445,11 @@ async function main() {
|
|
|
270
445
|
let child = spawnServer();
|
|
271
446
|
if (!args.noOpen) setTimeout(() => openBrowser(url), 1500);
|
|
272
447
|
|
|
448
|
+
// Also on plain startup, not just after an update: a previous run may have deferred the upgrade
|
|
449
|
+
// (sessions were running), or the user updated and quit before it could finish. Without this, a
|
|
450
|
+
// daemon that was stale once could stay stale forever.
|
|
451
|
+
scheduleDaemonUpgrade(currentVersion);
|
|
452
|
+
|
|
273
453
|
// NOTE: shutdown kills the SERVER child only — never the daemon. The daemon is
|
|
274
454
|
// detached and holds live PTYs across this process's death (update-safety).
|
|
275
455
|
let shuttingDown = false;
|
|
@@ -289,7 +469,7 @@ async function main() {
|
|
|
289
469
|
// and printed by the server before it exits.
|
|
290
470
|
const RESTART_CODE = 75;
|
|
291
471
|
let restartTimes = [];
|
|
292
|
-
const onExit = (code) => {
|
|
472
|
+
const onExit = async (code) => {
|
|
293
473
|
if (shuttingDown) return;
|
|
294
474
|
if (code === RESTART_CODE) {
|
|
295
475
|
const now = Date.now();
|
|
@@ -302,7 +482,11 @@ async function main() {
|
|
|
302
482
|
);
|
|
303
483
|
process.exit(1);
|
|
304
484
|
}
|
|
305
|
-
console.log('[seshmux] server restarting for update (session-safe
|
|
485
|
+
console.log('[seshmux] server restarting for update (session-safe)…');
|
|
486
|
+
// One-click means one click: the new package is on disk now, so upgrade the daemon too —
|
|
487
|
+
// but only if every live PTY is tmux-backed (it re-attaches). If a plain session would die,
|
|
488
|
+
// this defers and the retry loop finishes the job the moment that session ends.
|
|
489
|
+
scheduleDaemonUpgrade(currentVersion);
|
|
306
490
|
child = spawnServer();
|
|
307
491
|
child.on('exit', onExit);
|
|
308
492
|
return;
|
package/daemon/ensure.js
CHANGED
|
@@ -109,6 +109,59 @@ function tryHello(sockPath, timeoutMs = HELLO_TIMEOUT_MS) {
|
|
|
109
109
|
});
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* PURE predicate (unit-tested): may we restart the daemon without ending a live agent session?
|
|
114
|
+
* tmux-tier PTYs rehydrate from `tmux ls` in the fresh daemon and survive; PLAIN-tier PTYs
|
|
115
|
+
* (tmuxName null — machine without tmux) die with it. So: safe only when every LIVE pty is
|
|
116
|
+
* tmux-backed, or there are none. Dead entries can't be killed twice, so they don't block.
|
|
117
|
+
* @param {{tmuxName: string|null, alive?: boolean}[]} ptys — the daemon's `list` result
|
|
118
|
+
* @returns {{safe: boolean, plainCount: number}}
|
|
119
|
+
*/
|
|
120
|
+
function canSafelyRestartDaemon(ptys) {
|
|
121
|
+
const live = (ptys || []).filter((p) => p && p.alive !== false);
|
|
122
|
+
const plainCount = live.filter((p) => !p.tmuxName).length;
|
|
123
|
+
return { safe: plainCount === 0, plainCount };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* One dial: hello + list. Resolves { version, ptys } or null if the daemon isn't reachable.
|
|
128
|
+
* Used by the supervisor's auto-upgrade decision (bin/seshmux.js) — never throws.
|
|
129
|
+
*/
|
|
130
|
+
function daemonInfo(sockPath, timeoutMs = HELLO_TIMEOUT_MS) {
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
let settled = false;
|
|
133
|
+
let version = null;
|
|
134
|
+
const done = (v) => {
|
|
135
|
+
if (settled) return;
|
|
136
|
+
settled = true;
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
try {
|
|
139
|
+
sock.destroy();
|
|
140
|
+
} catch {
|
|
141
|
+
/* ignore */
|
|
142
|
+
}
|
|
143
|
+
resolve(v);
|
|
144
|
+
};
|
|
145
|
+
const timer = setTimeout(() => done(null), timeoutMs);
|
|
146
|
+
const decoder = createDecoder();
|
|
147
|
+
const sock = net.connect(sockPath);
|
|
148
|
+
sock.setEncoding('utf8');
|
|
149
|
+
sock.on('connect', () => {
|
|
150
|
+
sock.write(encode({ id: 1, method: 'hello' }));
|
|
151
|
+
sock.write(encode({ id: 2, method: 'list' }));
|
|
152
|
+
});
|
|
153
|
+
sock.on('data', (chunk) => {
|
|
154
|
+
for (const msg of decoder.push(chunk)) {
|
|
155
|
+
if (!msg || !msg.result) continue;
|
|
156
|
+
if (msg.id === 1) version = msg.result.version || null;
|
|
157
|
+
if (msg.id === 2) done({ version, ptys: msg.result.ptys || [] });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
sock.on('error', () => done(null));
|
|
161
|
+
sock.on('close', () => done(null));
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
112
165
|
const sleep = (ms) =>
|
|
113
166
|
new Promise((r) => {
|
|
114
167
|
setTimeout(r, ms);
|
|
@@ -218,6 +271,8 @@ async function ensureDaemon(opts = {}) {
|
|
|
218
271
|
|
|
219
272
|
module.exports = {
|
|
220
273
|
classify,
|
|
274
|
+
canSafelyRestartDaemon,
|
|
275
|
+
daemonInfo,
|
|
221
276
|
pidAlive,
|
|
222
277
|
tryHello,
|
|
223
278
|
ensureDaemon,
|
package/daemon/holder.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* seshmux PTY holder — a tiny detached process that OWNS one PTY.
|
|
4
|
+
*
|
|
5
|
+
* Why: the daemon used to `pty.spawn` directly, so it held the master fd. Kill
|
|
6
|
+
* the daemon (crash, restart, upgrade) and the fd closed, the child got SIGHUP,
|
|
7
|
+
* and the user's agent died. Only the tmux tier survived, because tmux owned
|
|
8
|
+
* the process. The holder is the tmux tier for machines without tmux: it sits
|
|
9
|
+
* between the daemon and the PTY, is spawned detached+setsid+unref'd, ignores
|
|
10
|
+
* SIGHUP, and keeps buffering output while no daemon is attached.
|
|
11
|
+
*
|
|
12
|
+
* Plain CJS, zero build step, node-pty is the only dep (same rules as daemon/).
|
|
13
|
+
*
|
|
14
|
+
* Wire (NDJSON, same framing helpers as the daemon protocol — this is the
|
|
15
|
+
* holder<->daemon link, NOT the frozen daemon<->server protocol):
|
|
16
|
+
* holder -> daemon: {event:'ready', ptyId} first frame to the accepted client
|
|
17
|
+
* {event:'busy'} a client is already attached; go away
|
|
18
|
+
* {event:'data', data} replay (one frame) then live output
|
|
19
|
+
* {event:'exit', code} the PTY exited
|
|
20
|
+
* daemon -> holder: {method:'write', data}
|
|
21
|
+
* {method:'resize', cols, rows}
|
|
22
|
+
* {method:'kill'}
|
|
23
|
+
*
|
|
24
|
+
* Exactly ONE client at a time (that's the no-double-attach guarantee). A
|
|
25
|
+
* client disconnect never touches the PTY.
|
|
26
|
+
*
|
|
27
|
+
* Argv: node holder.js '<json spec>' where spec =
|
|
28
|
+
* { holderDir, ptyId, sock, cwd, args, cols, rows, env }
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const net = require('node:net');
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
const pty = require('@homebridge/node-pty-prebuilt-multiarch');
|
|
35
|
+
const {
|
|
36
|
+
RING_BUFFER_LINES,
|
|
37
|
+
RING_BUFFER_BYTES,
|
|
38
|
+
encode,
|
|
39
|
+
createDecoder,
|
|
40
|
+
} = require('./protocol');
|
|
41
|
+
|
|
42
|
+
// After the PTY exits we keep the socket up so a daemon that reconnects can
|
|
43
|
+
// still learn the exit code. Long grace when nobody knew; short when a live
|
|
44
|
+
// client already got the exit frame (or explicitly asked for the kill), so we
|
|
45
|
+
// don't leave a node process loitering for a minute per closed session.
|
|
46
|
+
const EXIT_GRACE_MS = 60 * 1000;
|
|
47
|
+
const EXIT_GRACE_KNOWN_MS = 5 * 1000;
|
|
48
|
+
|
|
49
|
+
// The daemon's death must not be ours. (detached+stdio:'ignore' covers the fd
|
|
50
|
+
// side; this covers the signal side.)
|
|
51
|
+
process.on('SIGHUP', () => {});
|
|
52
|
+
|
|
53
|
+
const spec = JSON.parse(process.argv[2] || '{}');
|
|
54
|
+
const { holderDir, ptyId, sock: sockPath, cwd, args, cols, rows, env } = spec;
|
|
55
|
+
const jsonPath = path.join(holderDir, ptyId + '.json');
|
|
56
|
+
|
|
57
|
+
const proc = pty.spawn(args[0], args.slice(1), {
|
|
58
|
+
name: 'xterm-256color',
|
|
59
|
+
cols: cols || 80,
|
|
60
|
+
rows: rows || 24,
|
|
61
|
+
cwd,
|
|
62
|
+
env: { ...process.env, ...(env || {}) },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Same ring semantics (and same caps) as the daemon's — bytes are replayed
|
|
66
|
+
// verbatim, never re-lined, so escape sequences survive.
|
|
67
|
+
const ring = [];
|
|
68
|
+
let ringLines = 0;
|
|
69
|
+
let ringBytes = 0;
|
|
70
|
+
|
|
71
|
+
function countNewlines(str) {
|
|
72
|
+
let n = 0;
|
|
73
|
+
for (let i = 0; i < str.length; i++) if (str.charCodeAt(i) === 10) n++;
|
|
74
|
+
return n;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function appendRing(chunk) {
|
|
78
|
+
ring.push(chunk);
|
|
79
|
+
ringLines += countNewlines(chunk);
|
|
80
|
+
ringBytes += chunk.length;
|
|
81
|
+
while (ring.length > 1 && (ringLines > RING_BUFFER_LINES || ringBytes > RING_BUFFER_BYTES)) {
|
|
82
|
+
const dropped = ring.shift();
|
|
83
|
+
ringLines -= countNewlines(dropped);
|
|
84
|
+
ringBytes -= dropped.length;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @type {net.Socket|null} the single attached client (the daemon) */
|
|
89
|
+
let client = null;
|
|
90
|
+
/** @type {{code:number}|null} */
|
|
91
|
+
let exited = null;
|
|
92
|
+
let exitKnown = false; // a client saw the exit, or asked for the kill
|
|
93
|
+
let cleaning = false;
|
|
94
|
+
let cleanupTimer = null;
|
|
95
|
+
|
|
96
|
+
/** (Re)arm the post-exit grace. Shortened once a client has learned the exit —
|
|
97
|
+
* a short-lived process can exit before the daemon even finishes connecting,
|
|
98
|
+
* so the grace is re-armed on connect, not decided once at exit time. */
|
|
99
|
+
function scheduleCleanup(ms) {
|
|
100
|
+
if (cleanupTimer) clearTimeout(cleanupTimer);
|
|
101
|
+
cleanupTimer = setTimeout(cleanup, ms);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function send(msg) {
|
|
105
|
+
if (client && !client.destroyed) client.write(encode(msg));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
proc.onData((data) => {
|
|
109
|
+
appendRing(data);
|
|
110
|
+
send({ event: 'data', data });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
proc.onExit(({ exitCode }) => {
|
|
114
|
+
exited = { code: exitCode };
|
|
115
|
+
if (client && !client.destroyed) exitKnown = true;
|
|
116
|
+
send({ event: 'exit', code: exitCode });
|
|
117
|
+
scheduleCleanup(exitKnown ? EXIT_GRACE_KNOWN_MS : EXIT_GRACE_MS);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/** Remove socket + json and go. Never leave orphan files behind. */
|
|
121
|
+
function cleanup() {
|
|
122
|
+
if (cleaning) return;
|
|
123
|
+
cleaning = true;
|
|
124
|
+
try {
|
|
125
|
+
server.close();
|
|
126
|
+
} catch {
|
|
127
|
+
// ignore
|
|
128
|
+
}
|
|
129
|
+
for (const p of [sockPath, jsonPath]) {
|
|
130
|
+
try {
|
|
131
|
+
fs.unlinkSync(p);
|
|
132
|
+
} catch {
|
|
133
|
+
// ignore
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
process.exit(0);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function handle(msg) {
|
|
140
|
+
switch (msg && msg.method) {
|
|
141
|
+
case 'write':
|
|
142
|
+
try {
|
|
143
|
+
proc.write(msg.data);
|
|
144
|
+
} catch {
|
|
145
|
+
// pty already gone
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
case 'resize':
|
|
149
|
+
try {
|
|
150
|
+
proc.resize(msg.cols || cols || 80, msg.rows || rows || 24);
|
|
151
|
+
} catch {
|
|
152
|
+
// pty already gone
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
case 'kill':
|
|
156
|
+
exitKnown = true;
|
|
157
|
+
try {
|
|
158
|
+
proc.kill();
|
|
159
|
+
} catch {
|
|
160
|
+
// already dead
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
default:
|
|
164
|
+
// ignore unknown
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const server = net.createServer((s) => {
|
|
169
|
+
// Single-client rule: a second daemon can never attach the same holder.
|
|
170
|
+
if (client && !client.destroyed) {
|
|
171
|
+
s.write(encode({ event: 'busy' }));
|
|
172
|
+
s.end();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
client = s;
|
|
176
|
+
const decoder = createDecoder();
|
|
177
|
+
s.on('data', (chunk) => {
|
|
178
|
+
for (const m of decoder.push(chunk.toString('utf8'))) handle(m);
|
|
179
|
+
});
|
|
180
|
+
s.on('error', () => {
|
|
181
|
+
if (client === s) client = null;
|
|
182
|
+
});
|
|
183
|
+
s.on('close', () => {
|
|
184
|
+
if (client === s) client = null;
|
|
185
|
+
// The daemon that knew about the exit has gone; nothing left to tell.
|
|
186
|
+
if (exited && exitKnown) cleanup();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ready -> replay -> live, all in this tick: nothing can slip into the gap.
|
|
190
|
+
s.write(encode({ event: 'ready', ptyId }));
|
|
191
|
+
const replay = ring.join('');
|
|
192
|
+
if (replay) s.write(encode({ event: 'data', data: replay }));
|
|
193
|
+
if (exited) {
|
|
194
|
+
exitKnown = true;
|
|
195
|
+
s.write(encode({ event: 'exit', code: exited.code }));
|
|
196
|
+
scheduleCleanup(EXIT_GRACE_KNOWN_MS); // this client now knows; don't loiter
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
for (const sig of ['SIGTERM', 'SIGINT']) {
|
|
201
|
+
process.on(sig, () => {
|
|
202
|
+
try {
|
|
203
|
+
proc.kill();
|
|
204
|
+
} catch {
|
|
205
|
+
// ignore
|
|
206
|
+
}
|
|
207
|
+
cleanup();
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
fs.unlinkSync(sockPath);
|
|
213
|
+
} catch {
|
|
214
|
+
// no stale socket — fine
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
server.listen(sockPath, () => {
|
|
218
|
+
try {
|
|
219
|
+
fs.chmodSync(sockPath, 0o600);
|
|
220
|
+
} catch {
|
|
221
|
+
// best effort
|
|
222
|
+
}
|
|
223
|
+
// Written AFTER listen, so a json on disk implies a socket to dial.
|
|
224
|
+
fs.writeFileSync(
|
|
225
|
+
jsonPath,
|
|
226
|
+
JSON.stringify({
|
|
227
|
+
ptyId,
|
|
228
|
+
pid: process.pid,
|
|
229
|
+
sock: sockPath,
|
|
230
|
+
cwd,
|
|
231
|
+
args,
|
|
232
|
+
cols: cols || 80,
|
|
233
|
+
rows: rows || 24,
|
|
234
|
+
startedAt: Date.now(),
|
|
235
|
+
})
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
server.on('error', () => {
|
|
240
|
+
// Can't listen (path too long, dir gone): the PTY is unreachable, so don't
|
|
241
|
+
// strand it — kill it and exit rather than leaving an invisible child.
|
|
242
|
+
try {
|
|
243
|
+
proc.kill();
|
|
244
|
+
} catch {
|
|
245
|
+
// ignore
|
|
246
|
+
}
|
|
247
|
+
cleanup();
|
|
248
|
+
});
|
package/daemon/index.js
CHANGED
|
@@ -243,7 +243,10 @@ async function startDaemon(opts = {}) {
|
|
|
243
243
|
|
|
244
244
|
fs.writeFileSync(pidPath, String(process.pid));
|
|
245
245
|
|
|
246
|
-
//
|
|
246
|
+
// Adopt PTYs that outlived the previous daemon: holder tier first (ptyIds are
|
|
247
|
+
// preserved there, and they were already reserved in the PtyManager ctor),
|
|
248
|
+
// then tmux-tier sessions (no-op if tmux absent).
|
|
249
|
+
await ptyManager.rehydrateHolders();
|
|
247
250
|
await ptyManager.rehydrateTmux();
|
|
248
251
|
|
|
249
252
|
return { server, ptyManager, sockPath, pidPath, close };
|