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
@@ -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
- // Re-hydrate any surviving tmux-tier sessions (no-op if tmux absent).
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 };
@@ -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
- * - plain PTY: reattach survives any number of server connections via the
10
- * in-memory ring buffer, but dies with the daemon.
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 { TMUX_PREFIX, RING_BUFFER_LINES, RING_BUFFER_BYTES } = require('./protocol');
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._configTag = opts.configDir || defaultConfigDirTag();
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
- /** Stop the background sweep. Called by the daemon on close(). */
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 file;
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
- } else {
334
- file = args[0];
335
- argv = args.slice(1);
336
- }
337
-
338
- const baseEnv = tmuxName ? tmuxEnv() : { ...process.env };
339
- baseEnv.SESHMUX_PTY_ID = ptyId;
340
-
341
- const proc = pty.spawn(file, argv, {
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seshmux",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Local-first mission control for AI coding agents (Claude Code + Codex)",
5
5
  "bin": {
6
6
  "seshmux": "bin/seshmux.js"