seshmux 0.1.3 → 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.
@@ -4710,11 +4710,146 @@ var init_search2 = __esm({
4710
4710
  }
4711
4711
  });
4712
4712
 
4713
+ // server/lib/update.ts
4714
+ function compareVersions(a, b) {
4715
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
4716
+ const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
4717
+ const len = Math.max(pa.length, pb.length);
4718
+ for (let i = 0; i < len; i++) {
4719
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
4720
+ if (d !== 0) return d < 0 ? -1 : 1;
4721
+ }
4722
+ return 0;
4723
+ }
4724
+ function isDaemonStale(daemonVersion, serverVersion) {
4725
+ if (!daemonVersion || !serverVersion) return false;
4726
+ return compareVersions(serverVersion, daemonVersion) > 0;
4727
+ }
4728
+ function detectInstallMethod(opts) {
4729
+ const p = opts.argvRealPath;
4730
+ if (p.includes("/_npx/") || p.includes("\\_npx\\")) return "npx";
4731
+ const prefix = realpath2(opts.globalPrefix);
4732
+ if (prefix && realpath2(p).startsWith(prefix)) return "global";
4733
+ return "local";
4734
+ }
4735
+ function realpath2(p) {
4736
+ if (!p) return "";
4737
+ try {
4738
+ return (0, import_node_fs9.realpathSync)(p);
4739
+ } catch {
4740
+ return p;
4741
+ }
4742
+ }
4743
+ function resolveArgvRealPath() {
4744
+ const raw = process.argv[1] ?? "";
4745
+ try {
4746
+ return (0, import_node_fs9.realpathSync)(raw);
4747
+ } catch {
4748
+ return raw;
4749
+ }
4750
+ }
4751
+ function defaultExec(cmd, args) {
4752
+ return new Promise((resolve, reject) => {
4753
+ (0, import_node_child_process7.execFile)(cmd, args, { timeout: 12e4, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
4754
+ if (err) reject(Object.assign(err, { stdout, stderr }));
4755
+ else resolve({ stdout, stderr });
4756
+ });
4757
+ });
4758
+ }
4759
+ async function defaultGlobalPrefix(exec) {
4760
+ try {
4761
+ const { stdout } = await exec("npm", ["prefix", "-g"]);
4762
+ return stdout.trim();
4763
+ } catch {
4764
+ return "";
4765
+ }
4766
+ }
4767
+ async function checkUpdate(deps = {}) {
4768
+ const current = deps.current ?? process.env.SESHMUX_VERSION ?? process.env.npm_package_version ?? "0.0.0";
4769
+ const fetchFn = deps.fetchFn ?? defaultFetch;
4770
+ const exec = deps.exec ?? defaultExec;
4771
+ const argvRealPath = deps.argvRealPath ?? resolveArgvRealPath();
4772
+ const globalPrefix = deps.globalPrefix ?? await defaultGlobalPrefix(exec);
4773
+ const installMethod = detectInstallMethod({ argvRealPath, globalPrefix });
4774
+ let latest = current;
4775
+ if (cache && Date.now() - cache.ts < CACHE_TTL_MS) {
4776
+ latest = cache.latest;
4777
+ } else {
4778
+ latest = await fetchLatest(fetchFn, current);
4779
+ cache = { ts: Date.now(), latest };
4780
+ }
4781
+ return {
4782
+ current,
4783
+ latest,
4784
+ updateAvailable: compareVersions(latest, current) > 0,
4785
+ installMethod
4786
+ };
4787
+ }
4788
+ async function fetchLatest(fetchFn, current) {
4789
+ const controller = new AbortController();
4790
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
4791
+ try {
4792
+ const res = await fetchFn(REGISTRY_URL, { signal: controller.signal });
4793
+ if (!res.ok) return current;
4794
+ const body = await res.json();
4795
+ return typeof body.version === "string" ? body.version : current;
4796
+ } catch {
4797
+ return current;
4798
+ } finally {
4799
+ clearTimeout(timer);
4800
+ }
4801
+ }
4802
+ async function applyUpdate(deps) {
4803
+ if (deps.installMethod === "npx") {
4804
+ throw new Error("cannot self-update an npx invocation \u2014 run `npx seshmux@latest` next time");
4805
+ }
4806
+ const exec = deps.exec ?? defaultExec;
4807
+ const previous = deps.current;
4808
+ const version2 = /^\d+\.\d+\.\d+[A-Za-z0-9.\-+]*$/.test(deps.target ?? "") ? deps.target : null;
4809
+ const spec = `seshmux@${version2 ?? "latest"}`;
4810
+ try {
4811
+ const { stdout, stderr } = await exec("npm", ["i", "-g", spec, "--prefer-online"]);
4812
+ return { ok: true, log: [stdout, stderr].filter(Boolean).join("\n"), previous };
4813
+ } catch (e) {
4814
+ const err = e;
4815
+ const log = [err.stdout, err.stderr, err.message].filter(Boolean).join("\n");
4816
+ return { ok: false, log, previous };
4817
+ }
4818
+ }
4819
+ var import_node_child_process7, import_node_fs9, REGISTRY_URL, FETCH_TIMEOUT_MS, CACHE_TTL_MS, defaultFetch, cache;
4820
+ var init_update = __esm({
4821
+ "server/lib/update.ts"() {
4822
+ "use strict";
4823
+ import_node_child_process7 = require("node:child_process");
4824
+ import_node_fs9 = require("node:fs");
4825
+ REGISTRY_URL = "https://registry.npmjs.org/seshmux/latest";
4826
+ FETCH_TIMEOUT_MS = 5e3;
4827
+ CACHE_TTL_MS = 6 * 60 * 60 * 1e3;
4828
+ defaultFetch = (url, init) => fetch(url, init);
4829
+ cache = null;
4830
+ }
4831
+ });
4832
+
4713
4833
  // server/routes/env.ts
4714
4834
  var env_exports = {};
4715
4835
  __export(env_exports, {
4716
4836
  default: () => envRoutes
4717
4837
  });
4838
+ async function realDaemonInfo() {
4839
+ let conn = null;
4840
+ try {
4841
+ conn = await dial();
4842
+ const [{ version: version2 }, { ptys }] = await Promise.all([conn.hello(), conn.list()]);
4843
+ return {
4844
+ version: version2 || null,
4845
+ plainPtys: ptys.filter((p) => p.alive !== false && !p.tmuxName).length
4846
+ };
4847
+ } catch {
4848
+ return { version: null, plainPtys: 0 };
4849
+ } finally {
4850
+ conn?.close();
4851
+ }
4852
+ }
4718
4853
  function commandPreview(commands) {
4719
4854
  return {
4720
4855
  fresh: commands.fresh("").join(" "),
@@ -4725,12 +4860,17 @@ function commandPreview(commands) {
4725
4860
  }
4726
4861
  async function envRoutes(f, deps = {}) {
4727
4862
  const bridgeStatus2 = deps.bridgeStatus ?? (() => bridgeStatus());
4863
+ const daemonInfo = deps.daemonInfo ?? realDaemonInfo;
4864
+ const serverVersion = deps.serverVersion ?? (() => process.env.SESHMUX_VERSION ?? "");
4728
4865
  f.get("/api/env", async () => {
4729
- const [env, bridge, providers] = await Promise.all([
4866
+ const [env, bridge, providers, dInfo] = await Promise.all([
4730
4867
  detectEnv(),
4731
4868
  bridgeStatus2().catch(() => ({ claude: false, codex: false })),
4732
- getProviders()
4869
+ getProviders(),
4870
+ daemonInfo().catch(() => ({ version: null, plainPtys: 0 }))
4733
4871
  ]);
4872
+ const dVersion = dInfo.version;
4873
+ const sVersion = serverVersion();
4734
4874
  const commands = {};
4735
4875
  const teams = {};
4736
4876
  for (const p of providers) {
@@ -4744,7 +4884,16 @@ async function envRoutes(f, deps = {}) {
4744
4884
  codex: { registered: bridge.codex }
4745
4885
  },
4746
4886
  commands,
4747
- teams
4887
+ teams,
4888
+ // The daemon outlives server updates by design, so it can be older than us. The NEXT update
4889
+ // upgrades it automatically — unless plain (non-tmux) PTYs are live, which a restart would
4890
+ // kill. plainPtys is what lets the UI tell those two states apart.
4891
+ daemon: {
4892
+ version: dVersion,
4893
+ serverVersion: sVersion || null,
4894
+ stale: isDaemonStale(dVersion, sVersion),
4895
+ plainPtys: dInfo.plainPtys
4896
+ }
4748
4897
  };
4749
4898
  });
4750
4899
  }
@@ -4754,6 +4903,8 @@ var init_env = __esm({
4754
4903
  init_detect();
4755
4904
  init_registry();
4756
4905
  init_types();
4906
+ init_daemon_client();
4907
+ init_update();
4757
4908
  }
4758
4909
  });
4759
4910
 
@@ -5016,10 +5167,11 @@ async function termRoutes(f, deps = {}) {
5016
5167
  try {
5017
5168
  conn = await (deps.dialFn ?? dial)();
5018
5169
  const { data } = await withTimeout(conn.history(req.params.ptyId, lines), 5e3, "history timed out");
5019
- return reply.send({ data });
5170
+ return reply.send({ supported: true, data });
5020
5171
  } catch (e) {
5021
5172
  const msg = e.message || String(e);
5022
- return reply.code(msg.includes("unknown method") ? 501 : 500).send({ error: msg });
5173
+ if (msg.includes("unknown method")) return reply.send({ supported: false, data: "" });
5174
+ return reply.code(500).send({ error: msg });
5023
5175
  } finally {
5024
5176
  conn?.close();
5025
5177
  }
@@ -5602,7 +5754,7 @@ async function notifyRoutes(f) {
5602
5754
  return { ok: true, delivered: false, reason: "disabled" };
5603
5755
  }
5604
5756
  await new Promise((resolve) => {
5605
- const child = (0, import_node_child_process7.execFile)(
5757
+ const child = (0, import_node_child_process8.execFile)(
5606
5758
  "osascript",
5607
5759
  [
5608
5760
  "-e",
@@ -5620,11 +5772,11 @@ async function notifyRoutes(f) {
5620
5772
  return { ok: true, delivered: true };
5621
5773
  });
5622
5774
  }
5623
- var import_node_child_process7, import_promises18, import_node_path22, import_node_os10;
5775
+ var import_node_child_process8, import_promises18, import_node_path22, import_node_os10;
5624
5776
  var init_notify = __esm({
5625
5777
  "server/routes/notify.ts"() {
5626
5778
  "use strict";
5627
- import_node_child_process7 = require("node:child_process");
5779
+ import_node_child_process8 = require("node:child_process");
5628
5780
  import_promises18 = require("node:fs/promises");
5629
5781
  import_node_path22 = __toESM(require("node:path"));
5630
5782
  import_node_os10 = __toESM(require("node:os"));
@@ -5656,122 +5808,6 @@ var init_approval = __esm({
5656
5808
  }
5657
5809
  });
5658
5810
 
5659
- // server/lib/update.ts
5660
- function compareVersions(a, b) {
5661
- const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
5662
- const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
5663
- const len = Math.max(pa.length, pb.length);
5664
- for (let i = 0; i < len; i++) {
5665
- const d = (pa[i] ?? 0) - (pb[i] ?? 0);
5666
- if (d !== 0) return d < 0 ? -1 : 1;
5667
- }
5668
- return 0;
5669
- }
5670
- function detectInstallMethod(opts) {
5671
- const p = opts.argvRealPath;
5672
- if (p.includes("/_npx/") || p.includes("\\_npx\\")) return "npx";
5673
- const prefix = realpath2(opts.globalPrefix);
5674
- if (prefix && realpath2(p).startsWith(prefix)) return "global";
5675
- return "local";
5676
- }
5677
- function realpath2(p) {
5678
- if (!p) return "";
5679
- try {
5680
- return (0, import_node_fs9.realpathSync)(p);
5681
- } catch {
5682
- return p;
5683
- }
5684
- }
5685
- function resolveArgvRealPath() {
5686
- const raw = process.argv[1] ?? "";
5687
- try {
5688
- return (0, import_node_fs9.realpathSync)(raw);
5689
- } catch {
5690
- return raw;
5691
- }
5692
- }
5693
- function defaultExec(cmd, args) {
5694
- return new Promise((resolve, reject) => {
5695
- (0, import_node_child_process8.execFile)(cmd, args, { timeout: 12e4, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
5696
- if (err) reject(Object.assign(err, { stdout, stderr }));
5697
- else resolve({ stdout, stderr });
5698
- });
5699
- });
5700
- }
5701
- async function defaultGlobalPrefix(exec) {
5702
- try {
5703
- const { stdout } = await exec("npm", ["prefix", "-g"]);
5704
- return stdout.trim();
5705
- } catch {
5706
- return "";
5707
- }
5708
- }
5709
- async function checkUpdate(deps = {}) {
5710
- const current = deps.current ?? process.env.SESHMUX_VERSION ?? process.env.npm_package_version ?? "0.0.0";
5711
- const fetchFn = deps.fetchFn ?? defaultFetch;
5712
- const exec = deps.exec ?? defaultExec;
5713
- const argvRealPath = deps.argvRealPath ?? resolveArgvRealPath();
5714
- const globalPrefix = deps.globalPrefix ?? await defaultGlobalPrefix(exec);
5715
- const installMethod = detectInstallMethod({ argvRealPath, globalPrefix });
5716
- let latest = current;
5717
- if (cache && Date.now() - cache.ts < CACHE_TTL_MS) {
5718
- latest = cache.latest;
5719
- } else {
5720
- latest = await fetchLatest(fetchFn, current);
5721
- cache = { ts: Date.now(), latest };
5722
- }
5723
- return {
5724
- current,
5725
- latest,
5726
- updateAvailable: compareVersions(latest, current) > 0,
5727
- installMethod
5728
- };
5729
- }
5730
- async function fetchLatest(fetchFn, current) {
5731
- const controller = new AbortController();
5732
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
5733
- try {
5734
- const res = await fetchFn(REGISTRY_URL, { signal: controller.signal });
5735
- if (!res.ok) return current;
5736
- const body = await res.json();
5737
- return typeof body.version === "string" ? body.version : current;
5738
- } catch {
5739
- return current;
5740
- } finally {
5741
- clearTimeout(timer);
5742
- }
5743
- }
5744
- async function applyUpdate(deps) {
5745
- if (deps.installMethod === "npx") {
5746
- throw new Error("cannot self-update an npx invocation \u2014 run `npx seshmux@latest` next time");
5747
- }
5748
- const exec = deps.exec ?? defaultExec;
5749
- const previous = deps.current;
5750
- const version2 = /^\d+\.\d+\.\d+[A-Za-z0-9.\-+]*$/.test(deps.target ?? "") ? deps.target : null;
5751
- const spec = `seshmux@${version2 ?? "latest"}`;
5752
- try {
5753
- const { stdout, stderr } = await exec("npm", ["i", "-g", spec, "--prefer-online"]);
5754
- return { ok: true, log: [stdout, stderr].filter(Boolean).join("\n"), previous };
5755
- } catch (e) {
5756
- const err = e;
5757
- const log = [err.stdout, err.stderr, err.message].filter(Boolean).join("\n");
5758
- return { ok: false, log, previous };
5759
- }
5760
- }
5761
- var import_node_child_process8, import_node_fs9, REGISTRY_URL, FETCH_TIMEOUT_MS, CACHE_TTL_MS, defaultFetch, cache;
5762
- var init_update = __esm({
5763
- "server/lib/update.ts"() {
5764
- "use strict";
5765
- import_node_child_process8 = require("node:child_process");
5766
- import_node_fs9 = require("node:fs");
5767
- REGISTRY_URL = "https://registry.npmjs.org/seshmux/latest";
5768
- FETCH_TIMEOUT_MS = 5e3;
5769
- CACHE_TTL_MS = 6 * 60 * 60 * 1e3;
5770
- defaultFetch = (url, init) => fetch(url, init);
5771
- cache = null;
5772
- }
5773
- });
5774
-
5775
5811
  // server/routes/update.ts
5776
5812
  var update_exports = {};
5777
5813
  __export(update_exports, {
package/bin/seshmux.js CHANGED
@@ -18,7 +18,104 @@ 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 } = require('../daemon/ensure');
21
+ const { ensureDaemon, pidAlive, paths, configDir, daemonInfo, canSafelyRestartDaemon } = require('../daemon/ensure');
22
+
23
+ // The daemon's pid, from the pidfile it writes in the config dir. null when it isn't running.
24
+ function readDaemonPid() {
25
+ try {
26
+ const pid = Number(fs.readFileSync(paths(configDir()).pid, 'utf8').trim());
27
+ return Number.isInteger(pid) && pid > 0 && pidAlive(pid) ? pid : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
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
+ }
22
119
 
23
120
  // Absolute path to THIS cli entry, inherited by the server child. The MCP
24
121
  // bridge registration writes it into agent configs (`node <bin> mcp-bridge`) —
@@ -88,12 +185,17 @@ function parseArgs(argv) {
88
185
  // $PORT is honoured because server/index.ts already does — without this the CLI
89
186
  // silently ignored it and booted on 4700 anyway. --port still wins over the env.
90
187
  const envPort = Number(process.env.PORT);
91
- const args = { port: Number.isInteger(envPort) && envPort > 0 ? envPort : 4700, noOpen: false };
188
+ const args = {
189
+ port: Number.isInteger(envPort) && envPort > 0 ? envPort : 4700,
190
+ noOpen: false,
191
+ restartDaemon: false,
192
+ };
92
193
  for (let i = 0; i < argv.length; i++) {
93
194
  const a = argv[i];
94
195
  if (a === '--port' || a === '-p') args.port = Number(argv[++i]);
95
196
  else if (a.startsWith('--port=')) args.port = Number(a.slice(7));
96
197
  else if (a === '--no-open') args.noOpen = true;
198
+ else if (a === '--restart-daemon') args.restartDaemon = true;
97
199
  }
98
200
  if (!Number.isInteger(args.port) || args.port <= 0) args.port = 4700;
99
201
  return args;
@@ -132,6 +234,99 @@ function runMcpBridge(root) {
132
234
  child.on('exit', (code) => process.exit(code ?? 0));
133
235
  }
134
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
+
135
330
  async function main() {
136
331
  // Subcommand dispatch (before arg parsing / server flow).
137
332
  const argv = process.argv.slice(2);
@@ -143,6 +338,25 @@ async function main() {
143
338
 
144
339
  const args = parseArgs(argv);
145
340
 
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.
346
+ //
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".
350
+ if (args.restartDaemon) {
351
+ const before = readDaemonPid();
352
+ if (before) {
353
+ console.log(`[seshmux] stopping daemon ${before} — tmux-backed sessions survive; any non-tmux PTYs will end`);
354
+ } else {
355
+ console.log('[seshmux] no daemon running');
356
+ }
357
+ if (!(await restartDaemon())) process.exit(1);
358
+ }
359
+
146
360
  // If a healthy seshmux already runs on the requested port range, just open the
147
361
  // browser to it — no duplicate server, no port fight.
148
362
  const reuse = await resolvePort(args.port);
@@ -153,6 +367,11 @@ async function main() {
153
367
  return;
154
368
  }
155
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
+
156
375
  // Ensure a responsive daemon BEFORE the server comes up. Spawns detached +
157
376
  // unref'd if needed; recovers a stale socket. Non-fatal if it can't start —
158
377
  // the app degrades to browse-only (no live terminals) rather than refusing.
@@ -226,6 +445,11 @@ async function main() {
226
445
  let child = spawnServer();
227
446
  if (!args.noOpen) setTimeout(() => openBrowser(url), 1500);
228
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
+
229
453
  // NOTE: shutdown kills the SERVER child only — never the daemon. The daemon is
230
454
  // detached and holds live PTYs across this process's death (update-safety).
231
455
  let shuttingDown = false;
@@ -245,7 +469,7 @@ async function main() {
245
469
  // and printed by the server before it exits.
246
470
  const RESTART_CODE = 75;
247
471
  let restartTimes = [];
248
- const onExit = (code) => {
472
+ const onExit = async (code) => {
249
473
  if (shuttingDown) return;
250
474
  if (code === RESTART_CODE) {
251
475
  const now = Date.now();
@@ -258,7 +482,11 @@ async function main() {
258
482
  );
259
483
  process.exit(1);
260
484
  }
261
- console.log('[seshmux] server restarting for update (session-safe; daemon untouched)…');
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);
262
490
  child = spawnServer();
263
491
  child.on('exit', onExit);
264
492
  return;