seshmux 0.1.4 → 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.
@@ -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.5",
4
4
  "description": "Local-first mission control for AI coding agents (Claude Code + Codex)",
5
5
  "bin": {
6
6
  "seshmux": "bin/seshmux.js"