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.
- 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 +2 -2
- package/.next/standalone/seshmux-server.js +160 -124
- package/bin/seshmux.js +232 -4
- 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 +2 -2
- package/.next/standalone/.next/static/chunks/app/page-7104066577bb8e5f.js +0 -1
- /package/.next/standalone/.next/static/{kPJ9M1iZAJK4qUIbW1h9b → INANHMG7kWM6Af6v_Vj9d}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{kPJ9M1iZAJK4qUIbW1h9b → INANHMG7kWM6Af6v_Vj9d}/_ssgManifest.js +0 -0
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
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;
|