seshmux 0.1.4 → 0.1.6

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.
Files changed (27) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-build-manifest.json +6 -6
  3. package/.next/standalone/.next/build-manifest.json +5 -5
  4. package/.next/standalone/.next/prerender-manifest.json +3 -3
  5. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  6. package/.next/standalone/.next/server/app/page.js +3 -3
  7. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/middleware-build-manifest.js +1 -1
  9. package/.next/standalone/.next/server/pages/500.html +1 -1
  10. package/.next/standalone/.next/server/pages-manifest.json +1 -1
  11. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  12. package/.next/standalone/.next/static/chunks/app/page-6e6c6148c4d1c925.js +1 -0
  13. package/.next/standalone/.next/static/chunks/{webpack-f60e08036f4baea4.js → webpack-452b158709859445.js} +1 -1
  14. package/.next/standalone/.next/static/css/{87ea7bb93e0b1326.css → a41d278d539911dd.css} +1 -1
  15. package/.next/standalone/.next/static/css/{bc53104caace13e7.css → e4d6e5dc25191e2a.css} +1 -1
  16. package/.next/standalone/package.json +1 -1
  17. package/.next/standalone/seshmux-server.js +394 -109
  18. package/README.md +16 -0
  19. package/bin/seshmux.js +295 -36
  20. package/daemon/ensure.js +55 -0
  21. package/daemon/holder.js +248 -0
  22. package/daemon/index.js +4 -1
  23. package/daemon/pty-manager.js +341 -29
  24. package/package.json +1 -1
  25. package/.next/standalone/.next/static/chunks/app/page-cd707a4bc18d9497.js +0 -1
  26. /package/.next/standalone/.next/static/{Xjuy0Fy4Y1goqBeK0eRol → z5Dh_tm8C81dOurk7OuAw}/_buildManifest.js +0 -0
  27. /package/.next/standalone/.next/static/{Xjuy0Fy4Y1goqBeK0eRol → z5Dh_tm8C81dOurk7OuAw}/_ssgManifest.js +0 -0
package/README.md CHANGED
@@ -52,6 +52,22 @@ npx seshmux
52
52
 
53
53
  Opens `http://127.0.0.1:4700` in your browser. No account, no config, no server to host.
54
54
 
55
+ ### Commands
56
+
57
+ ```bash
58
+ seshmux # start (reuses a running instance if there is one)
59
+ seshmux --port 4800 # bind a different port ($PORT works too)
60
+ seshmux --no-open # don't open a browser
61
+
62
+ seshmux update # check npm for a newer release and install it
63
+ seshmux update --check # just tell me, don't install
64
+ seshmux --version
65
+
66
+ seshmux --restart-daemon # replace the session daemon (see below)
67
+ ```
68
+
69
+ Updating also upgrades the session daemon, but only when nothing would be lost — see [Updates that never kill a session](#updates-that-never-kill-a-session).
70
+
55
71
  ### Requirements
56
72
 
57
73
  | Dependency | Required? | What it unlocks |
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,174 @@ 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
+ // Read the version FRESH every time, never once. `require` caches, and this supervisor
57
+ // deliberately survives a self-update (it is what relaunches the server child) — so a cached
58
+ // read meant the updated server was told it was still the OLD version, and the "update
59
+ // available" banner stayed up forever. readFileSync, not require, precisely to dodge the cache.
60
+ function currentVersion() {
61
+ try {
62
+ return JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
63
+ } catch {
64
+ return '0.0.0';
65
+ }
66
+ }
67
+
68
+ // Latest published version, or null (offline, 404, timeout — never throws, never blocks).
69
+ function latestVersion(timeoutMs = 5000) {
70
+ return new Promise((resolve) => {
71
+ const req = require('node:https').get(
72
+ 'https://registry.npmjs.org/seshmux/latest',
73
+ { timeout: timeoutMs },
74
+ (res) => {
75
+ if (res.statusCode !== 200) {
76
+ res.resume();
77
+ return resolve(null);
78
+ }
79
+ let body = '';
80
+ res.on('data', (c) => (body += c));
81
+ res.on('end', () => {
82
+ try {
83
+ resolve(JSON.parse(body).version || null);
84
+ } catch {
85
+ resolve(null);
86
+ }
87
+ });
88
+ },
89
+ );
90
+ req.on('error', () => resolve(null));
91
+ req.on('timeout', () => {
92
+ req.destroy();
93
+ resolve(null);
94
+ });
95
+ });
96
+ }
97
+
98
+ // `seshmux update` / `seshmux update --check`.
99
+ //
100
+ // Installs the EXACT version the registry reported, with --prefer-online. Never `@latest`:
101
+ // npm re-resolves that tag through its CACHED packument, so anyone whose cache predates the
102
+ // release resolves latest -> X from the network and then dies with
103
+ // "ETARGET: No matching version found for seshmux@X" from the cache. That bug shipped once
104
+ // already (the in-app button announced updates it could not install); do not reintroduce it.
105
+ async function runUpdate(checkOnly) {
106
+ const current = currentVersion();
107
+ const latest = await latestVersion();
108
+ if (!latest) {
109
+ console.error("[seshmux] couldn't reach the npm registry (offline?) — nothing changed");
110
+ process.exit(1);
111
+ }
112
+ if (!versionLess(current, latest)) {
113
+ console.log(`[seshmux] already on the latest version (v${current})`);
114
+ return;
115
+ }
116
+ console.log(`[seshmux] update available: v${current} -> v${latest}`);
117
+ if (checkOnly) return;
118
+
119
+ const code = await new Promise((resolve) => {
120
+ const child = spawn('npm', ['i', '-g', `seshmux@${latest}`, '--prefer-online'], { stdio: 'inherit' });
121
+ child.on('exit', (c) => resolve(c ?? 1));
122
+ child.on('error', () => resolve(1));
123
+ });
124
+ if (code !== 0) {
125
+ console.error(`[seshmux] install failed (npm exited ${code}) — still on v${current}`);
126
+ process.exit(code);
127
+ }
128
+
129
+ // The package on disk is new; the daemon still runs the old code. Upgrade it too, but only
130
+ // when nothing dies (holder- and tmux-tier PTYs re-attach; a pre-holder plain PTY would not).
131
+ await autoUpgradeDaemon(currentVersion()).catch(() => {});
132
+
133
+ console.log(`[seshmux] updated to v${latest}`);
134
+ if (await probeHealth(4700)) console.log('[seshmux] a seshmux is running — restart it to use the new version');
135
+ }
136
+
137
+ // Numeric-segment version compare ("0.10.0" > "0.9.0"). Mirror of server/lib/update.ts's
138
+ // compareVersions — this file is plain CJS and cannot import the TS module.
139
+ function versionLess(a, b) {
140
+ const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
141
+ const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
142
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
143
+ const d = (pa[i] || 0) - (pb[i] || 0);
144
+ if (d !== 0) return d < 0;
145
+ }
146
+ return false;
147
+ }
148
+
149
+ // After a self-update the server is new but the daemon still runs the OLD code forever
150
+ // (ensureDaemon reuses any daemon that answers hello). Upgrade it here — but ONLY when no live
151
+ // session would die: tmux-tier PTYs rehydrate in the fresh daemon, plain-tier PTYs do not.
152
+ // Unreachable daemon / unknown versions (dev) → do nothing.
153
+ // Returns 'upgraded' | 'blocked' | 'noop'. quiet=true suppresses the blocked log (the retry
154
+ // loop below would otherwise repeat it forever).
155
+ async function autoUpgradeDaemon(ourVersion, quiet) {
156
+ const info = await daemonInfo(paths(configDir()).sock).catch(() => null);
157
+ if (!info || !info.version || !ourVersion || ourVersion === '0.0.0') return 'noop';
158
+ if (!versionLess(info.version, ourVersion)) return 'noop';
159
+
160
+ const { safe, plainCount } = canSafelyRestartDaemon(info.ptys);
161
+ if (!safe) {
162
+ if (!quiet) {
163
+ console.log(
164
+ `[seshmux] daemon stays on v${info.version} for now — ${plainCount} running session(s) are not tmux-backed ` +
165
+ 'and a restart would end them. It will upgrade itself as soon as they finish.',
166
+ );
167
+ }
168
+ return 'blocked';
169
+ }
170
+ const tmuxCount = info.ptys.filter((p) => p.tmuxName && p.alive !== false).length;
171
+ console.log(
172
+ `[seshmux] upgrading daemon v${info.version} -> v${ourVersion}` +
173
+ (tmuxCount ? ` (${tmuxCount} tmux session(s) will re-attach)` : ''),
174
+ );
175
+ await restartDaemon();
176
+ return 'upgraded';
177
+ }
178
+
179
+ // A blocked upgrade must not stay blocked forever. Without tmux a PTY simply cannot outlive the
180
+ // daemon that owns its master fd — that is the OS, not a bug we can fix — so the answer is NOT to
181
+ // demand the user install tmux, it is to never need the restart at a bad moment. The daemon is
182
+ // harmless while stale (protocol frozen at 1; newer RPCs degrade, they don't fail), so it can
183
+ // simply WAIT for a safe moment: every live session ended, or all remaining ones are tmux-backed.
184
+ // Then it upgrades itself, with nothing killed and nothing for the user to type.
185
+ const UPGRADE_RETRY_MS = Number(process.env.SESHMUX_UPGRADE_RETRY_MS) || 60_000;
186
+ function scheduleDaemonUpgrade(getVersion) {
187
+ let announced = false;
188
+ const tick = async () => {
189
+ const result = await autoUpgradeDaemon(getVersion(), announced).catch(() => 'noop');
190
+ if (result === 'blocked') {
191
+ announced = true; // say it once, then wait quietly
192
+ return;
193
+ }
194
+ clearInterval(timer); // upgraded, or nothing to do — stop checking
195
+ };
196
+ const timer = setInterval(tick, UPGRADE_RETRY_MS);
197
+ if (timer.unref) timer.unref(); // never hold the process open on this alone
198
+ tick();
199
+ }
200
+
33
201
  // Absolute path to THIS cli entry, inherited by the server child. The MCP
34
202
  // bridge registration writes it into agent configs (`node <bin> mcp-bridge`) —
35
203
  // `npx seshmux` only resolves once the package is published to a registry.
@@ -147,6 +315,99 @@ function runMcpBridge(root) {
147
315
  child.on('exit', (code) => process.exit(code ?? 0));
148
316
  }
149
317
 
318
+ // Is a binary on PATH? Shell-free.
319
+ function have(bin) {
320
+ return new Promise((resolve) => {
321
+ execFile('which', [bin], (err, stdout) => resolve(!err && !!String(stdout).trim()));
322
+ });
323
+ }
324
+
325
+ // The package manager we can offer to install tmux with, or null when there is nothing to offer.
326
+ async function tmuxInstaller() {
327
+ if (process.platform === 'darwin' && (await have('brew'))) return { cmd: 'brew', args: ['install', 'tmux'] };
328
+ if (await have('apt-get')) return { cmd: 'sudo', args: ['apt-get', 'install', '-y', 'tmux'] };
329
+ if (await have('dnf')) return { cmd: 'sudo', args: ['dnf', 'install', '-y', 'tmux'] };
330
+ return null;
331
+ }
332
+
333
+ // Resolves 'yes' | 'no' | 'none'. 'none' = the user never actually answered (stdin hit EOF, or
334
+ // the terminal went away): rl.question's callback then NEVER fires, which would hang startup
335
+ // forever. Booting the app matters more than this prompt, so no answer means "skip it, ask again
336
+ // next time" — never a silent decline the user did not make.
337
+ function askYesNo(question) {
338
+ return new Promise((resolve) => {
339
+ const rl = require('node:readline').createInterface({ input: process.stdin, output: process.stdout });
340
+ let done = false;
341
+ const finish = (v) => {
342
+ if (done) return;
343
+ done = true;
344
+ rl.close();
345
+ resolve(v);
346
+ };
347
+ rl.on('close', () => finish('none')); // EOF / ^D / stdin closed
348
+ rl.question(question, (answer) => finish(/^n/i.test(String(answer).trim()) ? 'no' : 'yes'));
349
+ });
350
+ }
351
+
352
+ // Without tmux, a session's PTY is owned by the daemon and dies with it — a crash or a daemon
353
+ // restart ends the user's running agent. session-start.ts picks the tmux tier ONLY when tmux is
354
+ // on PATH, so a tmux-less machine silently gets the fragile tier and nobody says a word. A real
355
+ // user lost every session this way. Offer to fix it, once, and never nag again.
356
+ //
357
+ // NOT an npm postinstall: that needs a package manager we can't assume, is skipped entirely under
358
+ // `npm ci --ignore-scripts` (standard in CI), would run in Docker images where tmux is pointless,
359
+ // and shelling out to a system installer from an install hook is exactly what supply-chain
360
+ // scanners flag. A first-run prompt asks the person who is actually there.
361
+ async function offerTmux() {
362
+ if (await have('tmux')) return;
363
+ const ackFile = path.join(configDir(), 'tmux-declined');
364
+ if (fs.existsSync(ackFile)) return;
365
+
366
+ const durability =
367
+ '[seshmux] tmux is not installed.\n' +
368
+ ' Your agent sessions will end if seshmux restarts or crashes.\n' +
369
+ ' With tmux, they survive restarts, updates, and crashes.';
370
+
371
+ // Non-interactive (piped, CI, launched by a GUI): state it, never block on a prompt nobody
372
+ // can answer, and do not record a decline the user never made.
373
+ if (!process.stdin.isTTY) {
374
+ console.log(`${durability}\n Install tmux to make sessions durable.`);
375
+ return;
376
+ }
377
+
378
+ const installer = await tmuxInstaller();
379
+ if (!installer) {
380
+ console.log(`${durability}\n Install tmux with your package manager to make sessions durable.`);
381
+ return;
382
+ }
383
+
384
+ console.log(durability);
385
+ const answer = await askYesNo(`\n Install tmux now with ${installer.cmd}? [Y/n] `);
386
+ if (answer === 'none') return; // no answer given — boot anyway, ask again next launch
387
+ if (answer === 'no') {
388
+ try {
389
+ fs.mkdirSync(path.dirname(ackFile), { recursive: true });
390
+ fs.writeFileSync(ackFile, 'declined\n');
391
+ } catch {
392
+ /* best effort — worst case we ask again next launch */
393
+ }
394
+ console.log('[seshmux] continuing without tmux (sessions are not crash-safe). Not asking again.');
395
+ return;
396
+ }
397
+
398
+ console.log(`[seshmux] ${installer.cmd} ${installer.args.join(' ')}…`);
399
+ await new Promise((resolve) => {
400
+ const child = spawn(installer.cmd, installer.args, { stdio: 'inherit' });
401
+ child.on('exit', resolve);
402
+ child.on('error', resolve);
403
+ });
404
+ console.log(
405
+ (await have('tmux'))
406
+ ? '[seshmux] tmux installed — new sessions will survive restarts and crashes.'
407
+ : '[seshmux] tmux install did not complete; continuing without it.',
408
+ );
409
+ }
410
+
150
411
  async function main() {
151
412
  // Subcommand dispatch (before arg parsing / server flow).
152
413
  const argv = process.argv.slice(2);
@@ -155,36 +416,34 @@ async function main() {
155
416
  runMcpBridge(root);
156
417
  return;
157
418
  }
419
+ if (argv[0] === 'update') {
420
+ await runUpdate(argv.includes('--check'));
421
+ return;
422
+ }
423
+ if (argv[0] === 'version' || argv.includes('--version') || argv.includes('-v')) {
424
+ console.log(currentVersion());
425
+ return;
426
+ }
158
427
 
159
428
  const args = parseArgs(argv);
160
429
 
161
- // --restart-daemon: the ONLY way to upgrade the daemon. ensureDaemon() treats any daemon that
162
- // answers hello as 'ok' and reuses it (daemon/ensure.js classify()), which is what keeps your
163
- // sessions alive across server updates but it also means a daemon started months ago runs
164
- // forever, missing every RPC added since. Restarting seshmux does NOT replace it. This does.
430
+ // --restart-daemon: the manual escape hatch for upgrading the daemon (the update flow does it
431
+ // automatically, but ONLY when no plain PTY would die see autoUpgradeDaemon). ensureDaemon()
432
+ // treats any daemon that answers hello as 'ok' and reuses it (daemon/ensure.js classify()),
433
+ // which is what keeps your sessions alive across server updates restarting seshmux does NOT
434
+ // replace the daemon. This does.
165
435
  //
166
- // Destructive on purpose, so it is explicit and never automatic: tmux-tier sessions rehydrate
167
- // from `tmux ls` and survive, but PLAIN-tier PTYs die with the daemon. Hence a flag the user
168
- // types, not something the update flow does behind their back.
436
+ // Destructive on purpose: tmux-tier sessions rehydrate from `tmux ls` and survive, PLAIN-tier
437
+ // PTYs die with the daemon. The automatic path refuses to run in that case; this flag is the
438
+ // explicit "do it anyway, I accept losing them".
169
439
  if (args.restartDaemon) {
170
440
  const before = readDaemonPid();
171
441
  if (before) {
172
442
  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
443
  } else {
184
444
  console.log('[seshmux] no daemon running');
185
445
  }
186
- const { spawned } = await ensureDaemon();
187
- console.log(`[seshmux] daemon ${spawned ? 'restarted' : 'already up'} (pid ${readDaemonPid() ?? '?'})`);
446
+ if (!(await restartDaemon())) process.exit(1);
188
447
  }
189
448
 
190
449
  // If a healthy seshmux already runs on the requested port range, just open the
@@ -197,6 +456,11 @@ async function main() {
197
456
  return;
198
457
  }
199
458
 
459
+ // Ask about tmux BEFORE the daemon starts, so a yes takes effect for the very first session
460
+ // (session-start.ts picks the tmux tier only if tmux is on PATH at spawn time). Only reached
461
+ // when we are actually starting seshmux — never when we just hand off to a running instance.
462
+ await offerTmux().catch(() => {}); // never block startup on this
463
+
200
464
  // Ensure a responsive daemon BEFORE the server comes up. Spawns detached +
201
465
  // unref'd if needed; recovers a stale socket. Non-fatal if it can't start —
202
466
  // the app degrades to browse-only (no live terminals) rather than refusing.
@@ -242,20 +506,6 @@ async function main() {
242
506
  };
243
507
  if (isProd) env.NODE_ENV = 'production';
244
508
 
245
- // Read the version FRESH on every spawn, never once. `require` caches, and this supervisor
246
- // deliberately survives a self-update (it is what relaunches the server child) — so a cached
247
- // read meant the updated server was told it was still the OLD version. The user updated, the
248
- // new code ran, and the "update available" banner stayed up forever because current never
249
- // moved. Re-reading the file each spawn means the relaunch after an update sees the new
250
- // version. readFileSync, not require, precisely to dodge the module cache.
251
- function currentVersion() {
252
- try {
253
- return JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
254
- } catch {
255
- return '0.0.0';
256
- }
257
- }
258
-
259
509
  function spawnServer() {
260
510
  env.SESHMUX_VERSION = currentVersion();
261
511
  return isProd
@@ -270,6 +520,11 @@ async function main() {
270
520
  let child = spawnServer();
271
521
  if (!args.noOpen) setTimeout(() => openBrowser(url), 1500);
272
522
 
523
+ // Also on plain startup, not just after an update: a previous run may have deferred the upgrade
524
+ // (sessions were running), or the user updated and quit before it could finish. Without this, a
525
+ // daemon that was stale once could stay stale forever.
526
+ scheduleDaemonUpgrade(currentVersion);
527
+
273
528
  // NOTE: shutdown kills the SERVER child only — never the daemon. The daemon is
274
529
  // detached and holds live PTYs across this process's death (update-safety).
275
530
  let shuttingDown = false;
@@ -289,7 +544,7 @@ async function main() {
289
544
  // and printed by the server before it exits.
290
545
  const RESTART_CODE = 75;
291
546
  let restartTimes = [];
292
- const onExit = (code) => {
547
+ const onExit = async (code) => {
293
548
  if (shuttingDown) return;
294
549
  if (code === RESTART_CODE) {
295
550
  const now = Date.now();
@@ -302,7 +557,11 @@ async function main() {
302
557
  );
303
558
  process.exit(1);
304
559
  }
305
- console.log('[seshmux] server restarting for update (session-safe; daemon untouched)…');
560
+ console.log('[seshmux] server restarting for update (session-safe)…');
561
+ // One-click means one click: the new package is on disk now, so upgrade the daemon too —
562
+ // but only if every live PTY is tmux-backed (it re-attaches). If a plain session would die,
563
+ // this defers and the retry loop finishes the job the moment that session ends.
564
+ scheduleDaemonUpgrade(currentVersion);
306
565
  child = spawnServer();
307
566
  child.on('exit', onExit);
308
567
  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,