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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/app-build-manifest.json +6 -6
- package/.next/standalone/.next/build-manifest.json +5 -5
- 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 +3 -3
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/pages-manifest.json +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/static/chunks/app/page-6e6c6148c4d1c925.js +1 -0
- package/.next/standalone/.next/static/chunks/{webpack-f60e08036f4baea4.js → webpack-452b158709859445.js} +1 -1
- package/.next/standalone/.next/static/css/{87ea7bb93e0b1326.css → a41d278d539911dd.css} +1 -1
- package/.next/standalone/.next/static/css/{bc53104caace13e7.css → e4d6e5dc25191e2a.css} +1 -1
- package/.next/standalone/package.json +1 -1
- package/.next/standalone/seshmux-server.js +394 -109
- package/README.md +16 -0
- package/bin/seshmux.js +295 -36
- 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 → z5Dh_tm8C81dOurk7OuAw}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{Xjuy0Fy4Y1goqBeK0eRol → z5Dh_tm8C81dOurk7OuAw}/_ssgManifest.js +0 -0
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 };
|
package/daemon/pty-manager.js
CHANGED
|
@@ -5,21 +5,221 @@
|
|
|
5
5
|
* Provider-agnostic: spawns whatever argv it is handed (args come from the
|
|
6
6
|
* server's provider.commands). NO agent binary names appear here.
|
|
7
7
|
*
|
|
8
|
-
* Two persistence tiers:
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
8
|
+
* Two persistence tiers, BOTH of which survive the daemon's death:
|
|
9
|
+
* - holder tier (default): a detached `daemon/holder.js` process owns the
|
|
10
|
+
* PTY and speaks NDJSON over `<configDir>/holders/<ptyId>.sock`. The daemon
|
|
11
|
+
* is just a client. Kill the daemon and the agent never notices; the next
|
|
12
|
+
* daemon re-adopts the holder under its ORIGINAL ptyId (rehydrateHolders).
|
|
11
13
|
* - tmux tier (tmuxName present): `tmux new-session -A -s seshmux-<name>`,
|
|
12
14
|
* so the session survives a daemon restart and can be re-hydrated from
|
|
13
|
-
* `tmux ls` on startup.
|
|
15
|
+
* `tmux ls` on startup. Unchanged.
|
|
16
|
+
*
|
|
17
|
+
* Externally (spawn/write/resize/kill/list/history RPCs, data/exit events, the
|
|
18
|
+
* ring buffer served on attach) nothing about this changed — the holder tier is
|
|
19
|
+
* internal re-plumbing. Daemon<->server protocol stays FROZEN at 1.
|
|
14
20
|
*
|
|
15
21
|
* Only dependency: @homebridge/node-pty-prebuilt-multiarch.
|
|
16
22
|
*/
|
|
17
23
|
|
|
18
24
|
const pty = require('@homebridge/node-pty-prebuilt-multiarch');
|
|
19
|
-
const { execFile } = require('node:child_process');
|
|
25
|
+
const { execFile, spawn: spawnProcess } = require('node:child_process');
|
|
26
|
+
const net = require('node:net');
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const crypto = require('node:crypto');
|
|
20
29
|
const path = require('node:path');
|
|
21
30
|
const os = require('node:os');
|
|
22
|
-
const {
|
|
31
|
+
const {
|
|
32
|
+
TMUX_PREFIX,
|
|
33
|
+
RING_BUFFER_LINES,
|
|
34
|
+
RING_BUFFER_BYTES,
|
|
35
|
+
encode,
|
|
36
|
+
createDecoder,
|
|
37
|
+
} = require('./protocol');
|
|
38
|
+
|
|
39
|
+
const HOLDER_ENTRY = path.join(__dirname, 'holder.js');
|
|
40
|
+
// Connect retries while a freshly-spawned holder boots node + binds its socket.
|
|
41
|
+
const HOLDER_CONNECT_TRIES = 100;
|
|
42
|
+
const HOLDER_CONNECT_DELAY_MS = 100;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Path of a holder's unix socket. macOS caps sun_path at ~104 bytes, and a
|
|
46
|
+
* config dir can be arbitrarily deep (tests use mkdtemp under /var/folders/...),
|
|
47
|
+
* so fall back to a short /tmp name keyed by a hash of the holder dir when the
|
|
48
|
+
* natural path would overflow. The holder records the path it actually bound in
|
|
49
|
+
* its .json, so adoption never has to re-derive it.
|
|
50
|
+
*/
|
|
51
|
+
function holderSockPath(holderDir, ptyId) {
|
|
52
|
+
const natural = path.join(holderDir, ptyId + '.sock');
|
|
53
|
+
if (Buffer.byteLength(natural) <= 100) return natural;
|
|
54
|
+
const base = process.platform === 'win32' ? os.tmpdir() : '/tmp';
|
|
55
|
+
const h = crypto.createHash('sha1').update(holderDir).digest('hex').slice(0, 8);
|
|
56
|
+
return path.join(base, `smx-${h}-${ptyId}.sock`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function pidAlive(pid) {
|
|
60
|
+
try {
|
|
61
|
+
process.kill(pid, 0);
|
|
62
|
+
return true;
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return err && err.code === 'EPERM'; // alive, just not ours to signal
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function unlinkQuiet(p) {
|
|
69
|
+
try {
|
|
70
|
+
fs.unlinkSync(p);
|
|
71
|
+
} catch {
|
|
72
|
+
// ignore
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Client side of the holder link, shaped like a node-pty process (onData /
|
|
78
|
+
* onExit / write / resize / kill) so PtyManager entries and _wireProc don't
|
|
79
|
+
* care which tier they're on.
|
|
80
|
+
*
|
|
81
|
+
* Connects with retries (a just-spawned holder needs a moment to bind), queues
|
|
82
|
+
* writes until connected, and buffers inbound frames until onData/onExit are
|
|
83
|
+
* registered (adoption registers them AFTER awaiting ready()).
|
|
84
|
+
*/
|
|
85
|
+
class HolderClient {
|
|
86
|
+
constructor(sockPath, opts = {}) {
|
|
87
|
+
this._sockPath = sockPath;
|
|
88
|
+
this._tries = opts.tries || HOLDER_CONNECT_TRIES;
|
|
89
|
+
this._sock = null;
|
|
90
|
+
this._queue = [];
|
|
91
|
+
this._pendingData = [];
|
|
92
|
+
this._pendingExit = null;
|
|
93
|
+
this._onData = null;
|
|
94
|
+
this._onExit = null;
|
|
95
|
+
this._done = false; // exit delivered/queued — stop reconnecting
|
|
96
|
+
this._detached = false;
|
|
97
|
+
this._ready = false;
|
|
98
|
+
this._readyResolve = null;
|
|
99
|
+
this._readyPromise = new Promise((r) => {
|
|
100
|
+
this._readyResolve = r;
|
|
101
|
+
});
|
|
102
|
+
this._connect(0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @returns {Promise<boolean>} true once the holder accepted us as ITS client. */
|
|
106
|
+
ready() {
|
|
107
|
+
return this._readyPromise;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
_settleReady(ok) {
|
|
111
|
+
if (this._readyResolve) {
|
|
112
|
+
const r = this._readyResolve;
|
|
113
|
+
this._readyResolve = null;
|
|
114
|
+
r(ok);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_connect(attempt) {
|
|
119
|
+
const s = net.connect(this._sockPath);
|
|
120
|
+
const decoder = createDecoder();
|
|
121
|
+
s.on('connect', () => {
|
|
122
|
+
this._sock = s;
|
|
123
|
+
for (const frame of this._queue) s.write(frame);
|
|
124
|
+
this._queue = [];
|
|
125
|
+
});
|
|
126
|
+
s.on('data', (chunk) => {
|
|
127
|
+
for (const m of decoder.push(chunk.toString('utf8'))) this._handle(m);
|
|
128
|
+
});
|
|
129
|
+
s.on('error', () => {});
|
|
130
|
+
s.on('close', () => {
|
|
131
|
+
if (this._sock === s) this._sock = null;
|
|
132
|
+
if (this._done || this._detached) return;
|
|
133
|
+
// Attached-then-dropped means the holder itself died — its PTY died with
|
|
134
|
+
// it (master fd closed). Anything else is a not-yet-listening socket.
|
|
135
|
+
if (this._ready || attempt >= this._tries) {
|
|
136
|
+
this._settleReady(false);
|
|
137
|
+
this._fail(1);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
setTimeout(() => this._connect(attempt + 1), HOLDER_CONNECT_DELAY_MS);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_handle(msg) {
|
|
145
|
+
switch (msg && msg.event) {
|
|
146
|
+
case 'ready':
|
|
147
|
+
this._ready = true;
|
|
148
|
+
this._settleReady(true);
|
|
149
|
+
return;
|
|
150
|
+
case 'busy':
|
|
151
|
+
// Another daemon owns this holder. Never double-attach: give up on it.
|
|
152
|
+
// (Adoption checks ready() and drops the client before it ever becomes
|
|
153
|
+
// an entry; a spawn that somehow lost the race goes dead rather than
|
|
154
|
+
// silently mute.)
|
|
155
|
+
this._settleReady(false);
|
|
156
|
+
this._fail(1);
|
|
157
|
+
return;
|
|
158
|
+
case 'data':
|
|
159
|
+
if (this._onData) this._onData(msg.data);
|
|
160
|
+
else this._pendingData.push(msg.data);
|
|
161
|
+
return;
|
|
162
|
+
case 'exit':
|
|
163
|
+
this._fail(msg.code);
|
|
164
|
+
return;
|
|
165
|
+
default:
|
|
166
|
+
// ignore
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
_fail(code) {
|
|
171
|
+
if (this._done) return;
|
|
172
|
+
this._done = true;
|
|
173
|
+
if (this._onExit) this._onExit({ exitCode: code });
|
|
174
|
+
else this._pendingExit = { exitCode: code };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
_send(msg) {
|
|
178
|
+
const frame = encode(msg);
|
|
179
|
+
if (this._sock && !this._sock.destroyed) this._sock.write(frame);
|
|
180
|
+
else if (!this._done) this._queue.push(frame);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
onData(fn) {
|
|
184
|
+
this._onData = fn;
|
|
185
|
+
const pending = this._pendingData;
|
|
186
|
+
this._pendingData = [];
|
|
187
|
+
for (const d of pending) fn(d);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
onExit(fn) {
|
|
191
|
+
this._onExit = fn;
|
|
192
|
+
if (this._pendingExit) {
|
|
193
|
+
const e = this._pendingExit;
|
|
194
|
+
this._pendingExit = null;
|
|
195
|
+
fn(e);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
write(data) {
|
|
200
|
+
this._send({ method: 'write', data });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
resize(cols, rows) {
|
|
204
|
+
this._send({ method: 'resize', cols, rows });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
kill() {
|
|
208
|
+
this._send({ method: 'kill' });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Let go of the holder without touching its PTY (daemon shutting down). */
|
|
212
|
+
detach() {
|
|
213
|
+
this._detached = true;
|
|
214
|
+
if (this._sock) {
|
|
215
|
+
try {
|
|
216
|
+
this._sock.end(); // end(), not destroy(): flush a queued kill first
|
|
217
|
+
} catch {
|
|
218
|
+
// ignore
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
23
223
|
|
|
24
224
|
// Dead PTY entries (alive=false) linger so a recently-exited session can still
|
|
25
225
|
// be re-attached / rehydrated, then get swept once past this grace window —
|
|
@@ -141,7 +341,14 @@ class PtyManager {
|
|
|
141
341
|
this._nextId = 1;
|
|
142
342
|
/** listeners: (event) => void, where event is a {event,...} object */
|
|
143
343
|
this._onEvent = null;
|
|
144
|
-
this.
|
|
344
|
+
this._configDir = opts.configDir || defaultConfigDirTag();
|
|
345
|
+
this._configTag = this._configDir;
|
|
346
|
+
this._holderDir = path.join(this._configDir, 'holders');
|
|
347
|
+
// ID stability: surviving holders keep their ORIGINAL ptyId when adopted
|
|
348
|
+
// (the server and browser hold ptyIds). Reserve those ids synchronously
|
|
349
|
+
// HERE — rehydrateHolders() is async and the daemon's socket is already
|
|
350
|
+
// listening by then, so a spawn racing it must not hand out a colliding id.
|
|
351
|
+
this._reserveHolderIds();
|
|
145
352
|
// Unref'd so it never keeps the daemon process alive on its own.
|
|
146
353
|
this._sweepTimer = setInterval(() => void this._sweepDead(), SWEEP_INTERVAL_MS);
|
|
147
354
|
this._sweepTimer.unref();
|
|
@@ -166,9 +373,27 @@ class PtyManager {
|
|
|
166
373
|
}
|
|
167
374
|
}
|
|
168
375
|
|
|
169
|
-
/**
|
|
376
|
+
/** Bump _nextId past every ptyId already claimed by a holder on disk. */
|
|
377
|
+
_reserveHolderIds() {
|
|
378
|
+
let files = [];
|
|
379
|
+
try {
|
|
380
|
+
files = fs.readdirSync(this._holderDir);
|
|
381
|
+
} catch {
|
|
382
|
+
return; // no holders dir yet
|
|
383
|
+
}
|
|
384
|
+
for (const f of files) {
|
|
385
|
+
const m = /^pty-(\d+)\.json$/.exec(f);
|
|
386
|
+
if (m && Number(m[1]) >= this._nextId) this._nextId = Number(m[1]) + 1;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Stop the background sweep, and let go of holders WITHOUT killing them —
|
|
391
|
+
* their PTYs are the whole point: they outlive this daemon. */
|
|
170
392
|
close() {
|
|
171
393
|
clearInterval(this._sweepTimer);
|
|
394
|
+
for (const e of this._ptys.values()) {
|
|
395
|
+
if (e.proc && typeof e.proc.detach === 'function') e.proc.detach();
|
|
396
|
+
}
|
|
172
397
|
}
|
|
173
398
|
|
|
174
399
|
/** Register the sink that receives {event:'data'|'exit', ...} objects. */
|
|
@@ -285,6 +510,34 @@ class PtyManager {
|
|
|
285
510
|
return true;
|
|
286
511
|
}
|
|
287
512
|
|
|
513
|
+
/**
|
|
514
|
+
* Launch a detached holder for this PTY and return a node-pty-shaped client
|
|
515
|
+
* for it. detached + stdio:'ignore' + unref() + the holder's SIGHUP handler
|
|
516
|
+
* are what make `kill -9 <daemon>` a non-event for the agent.
|
|
517
|
+
*/
|
|
518
|
+
_spawnHolder({ ptyId, cwd, args, cols, rows }) {
|
|
519
|
+
fs.mkdirSync(this._holderDir, { recursive: true, mode: 0o700 });
|
|
520
|
+
const sock = holderSockPath(this._holderDir, ptyId);
|
|
521
|
+
const spec = {
|
|
522
|
+
holderDir: this._holderDir,
|
|
523
|
+
ptyId,
|
|
524
|
+
sock,
|
|
525
|
+
cwd,
|
|
526
|
+
args,
|
|
527
|
+
cols,
|
|
528
|
+
rows,
|
|
529
|
+
env: { SESHMUX_PTY_ID: ptyId },
|
|
530
|
+
};
|
|
531
|
+
const child = spawnProcess(process.execPath, [HOLDER_ENTRY, JSON.stringify(spec)], {
|
|
532
|
+
detached: true,
|
|
533
|
+
stdio: 'ignore',
|
|
534
|
+
cwd,
|
|
535
|
+
env: process.env,
|
|
536
|
+
});
|
|
537
|
+
child.unref();
|
|
538
|
+
return new HolderClient(sock);
|
|
539
|
+
}
|
|
540
|
+
|
|
288
541
|
/**
|
|
289
542
|
* Spawn a PTY running the given argv.
|
|
290
543
|
* @param {{cwd?:string, args:string[], cols?:number, rows?:number, tmuxName?:string}} params
|
|
@@ -304,8 +557,7 @@ class PtyManager {
|
|
|
304
557
|
// mapping. Additive env var, not a wire-protocol change.
|
|
305
558
|
const ptyId = this._nextPtyId();
|
|
306
559
|
|
|
307
|
-
let
|
|
308
|
-
let argv;
|
|
560
|
+
let proc;
|
|
309
561
|
let fullTmuxName = null;
|
|
310
562
|
if (tmuxName) {
|
|
311
563
|
// tmux tier: attach-or-create a named session running the argv.
|
|
@@ -324,34 +576,28 @@ class PtyManager {
|
|
|
324
576
|
// so its hook writes to a now-stale status file and that session
|
|
325
577
|
// degrades to heuristics until it's respawned — graceful, not fatal).
|
|
326
578
|
fullTmuxName = TMUX_PREFIX + tmuxName;
|
|
327
|
-
file = 'tmux';
|
|
328
579
|
const envFlags = ['-e', `SESHMUX_PTY_ID=${ptyId}`];
|
|
329
580
|
if (process.env.SESHMUX_CONFIG_DIR) {
|
|
330
581
|
envFlags.push('-e', `SESHMUX_CONFIG_DIR=${process.env.SESHMUX_CONFIG_DIR}`);
|
|
331
582
|
}
|
|
332
|
-
argv = ['new-session', '-A', '-s', fullTmuxName, ...envFlags, '--', ...args];
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
name: 'xterm-256color',
|
|
343
|
-
cols: columns,
|
|
344
|
-
rows: lines,
|
|
345
|
-
cwd: cwdResolved,
|
|
346
|
-
env: baseEnv,
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
if (fullTmuxName) {
|
|
583
|
+
const argv = ['new-session', '-A', '-s', fullTmuxName, ...envFlags, '--', ...args];
|
|
584
|
+
const baseEnv = tmuxEnv();
|
|
585
|
+
baseEnv.SESHMUX_PTY_ID = ptyId;
|
|
586
|
+
proc = pty.spawn('tmux', argv, {
|
|
587
|
+
name: 'xterm-256color',
|
|
588
|
+
cols: columns,
|
|
589
|
+
rows: lines,
|
|
590
|
+
cwd: cwdResolved,
|
|
591
|
+
env: baseEnv,
|
|
592
|
+
});
|
|
350
593
|
// Hide tmux's own status bar for THIS session only — seshmux draws its
|
|
351
594
|
// own statusbar, so the blue tmux chrome is redundant noise. Scoped to
|
|
352
595
|
// the session (not -g) because we share the user's default tmux server.
|
|
353
596
|
hideTmuxStatus(fullTmuxName);
|
|
354
597
|
markTmuxConfig(fullTmuxName, this._configTag);
|
|
598
|
+
} else {
|
|
599
|
+
// Holder tier: a detached process owns the PTY, we're only its client.
|
|
600
|
+
proc = this._spawnHolder({ ptyId, cwd: cwdResolved, args, cols: columns, rows: lines });
|
|
355
601
|
}
|
|
356
602
|
|
|
357
603
|
const entry = {
|
|
@@ -491,6 +737,72 @@ class PtyManager {
|
|
|
491
737
|
}
|
|
492
738
|
}
|
|
493
739
|
|
|
740
|
+
/**
|
|
741
|
+
* On daemon startup, adopt every surviving holder under its ORIGINAL ptyId.
|
|
742
|
+
*
|
|
743
|
+
* For each `<holderDir>/<ptyId>.json`:
|
|
744
|
+
* - pid dead -> crash leftovers; delete json + socket, no entry
|
|
745
|
+
* - socket refuses -> holder wedged; leave the files, no entry
|
|
746
|
+
* - {event:'busy'} -> another daemon already holds it; skip (no double-attach)
|
|
747
|
+
* - ready -> adopt: the holder replays its ring buffer, so bytes
|
|
748
|
+
* produced while NO daemon was attached still reach
|
|
749
|
+
* the client on reattach.
|
|
750
|
+
*/
|
|
751
|
+
async rehydrateHolders() {
|
|
752
|
+
let files = [];
|
|
753
|
+
try {
|
|
754
|
+
files = fs.readdirSync(this._holderDir);
|
|
755
|
+
} catch {
|
|
756
|
+
return this.count();
|
|
757
|
+
}
|
|
758
|
+
for (const f of files) {
|
|
759
|
+
if (!f.endsWith('.json')) continue;
|
|
760
|
+
const jsonPath = path.join(this._holderDir, f);
|
|
761
|
+
let meta;
|
|
762
|
+
try {
|
|
763
|
+
meta = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
|
764
|
+
} catch {
|
|
765
|
+
unlinkQuiet(jsonPath);
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
if (!meta || !meta.ptyId || !meta.pid || !meta.sock) {
|
|
769
|
+
unlinkQuiet(jsonPath);
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (this._ptys.has(meta.ptyId)) continue; // already ours
|
|
773
|
+
if (!pidAlive(meta.pid)) {
|
|
774
|
+
unlinkQuiet(jsonPath);
|
|
775
|
+
unlinkQuiet(meta.sock);
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
// Short retry budget: an existing holder is either listening now or isn't.
|
|
779
|
+
const client = new HolderClient(meta.sock, { tries: 3 });
|
|
780
|
+
if (!(await client.ready())) {
|
|
781
|
+
client.detach();
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
const entry = {
|
|
785
|
+
ptyId: meta.ptyId,
|
|
786
|
+
proc: client,
|
|
787
|
+
cwd: meta.cwd,
|
|
788
|
+
args: meta.args,
|
|
789
|
+
tmuxName: null,
|
|
790
|
+
cols: meta.cols || 80,
|
|
791
|
+
rows: meta.rows || 24,
|
|
792
|
+
alive: true,
|
|
793
|
+
deadAt: null,
|
|
794
|
+
ring: [],
|
|
795
|
+
ringLines: 0,
|
|
796
|
+
ringBytes: 0,
|
|
797
|
+
};
|
|
798
|
+
this._ptys.set(meta.ptyId, entry);
|
|
799
|
+
this._wireProc(entry); // registers onData -> flushes the replayed ring
|
|
800
|
+
const n = Number(String(meta.ptyId).replace('pty-', ''));
|
|
801
|
+
if (Number.isFinite(n) && n >= this._nextId) this._nextId = n + 1;
|
|
802
|
+
}
|
|
803
|
+
return this.count();
|
|
804
|
+
}
|
|
805
|
+
|
|
494
806
|
/**
|
|
495
807
|
* On daemon startup, re-hydrate tmux-tier sessions by attaching a fresh PTY
|
|
496
808
|
* to each existing `seshmux-` tmux session. Ring buffers start empty (tmux
|