@mentiko/pty-mgr 1.2.3

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.
@@ -0,0 +1,2105 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * pty-manager.mjs - PTY-based agent session manager
4
+ *
5
+ * Uses:
6
+ * - Bun.spawn native PTY support (no python, no native addons)
7
+ * - @xterm/headless terminal emulator (parses escape codes into screen)
8
+ *
9
+ * capture() returns the actual rendered screen state, not raw output.
10
+ * spinners, progress bars, cursor movements, and TUI redraws are all
11
+ * resolved into clean text.
12
+ *
13
+ * API:
14
+ * mgr.spawn(name, cmd) create session
15
+ * mgr.sendKeys(name, text) send keystrokes
16
+ * mgr.capture(name, lines) capture rendered screen
17
+ * mgr.has(name) check if session exists
18
+ * mgr.kill(name) kill session
19
+ * mgr.list() list sessions
20
+ * mgr.pid(name) get child process pid
21
+ *
22
+ * Usage:
23
+ * import { PtyManager } from './pty-manager.mjs';
24
+ * const mgr = new PtyManager();
25
+ * mgr.spawn('agent-1', 'claude', ['--print']);
26
+ * mgr.sendKeys('agent-1', 'fix the bug\r');
27
+ * console.log(mgr.capture('agent-1', 40));
28
+ */
29
+
30
+ import { EventEmitter } from "node:events";
31
+ import { dirname, join } from "node:path";
32
+ import { createWriteStream, mkdirSync, existsSync, readSync } from "node:fs";
33
+
34
+ // replaced by --define at build time
35
+ export const VERSION = "1.2.3";
36
+ import xterm from "@xterm/headless";
37
+ import { SerializeAddon } from "@xterm/addon-serialize";
38
+
39
+ const { Terminal } = xterm;
40
+
41
+ export const SAFE_ENV_KEYS = ["PATH", "HOME", "USER", "TERM", "LANG", "SHELL",
42
+ "NAMESPACE_ID", "AGENT_CHAIN_ROOT", "AGENT_CHAIN_CLI", "PTY_DAEMON",
43
+ "ANTHROPIC_API_KEY", "OPENAI_API_KEY",
44
+ "TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID", "PTY_MGR_SESSION"];
45
+
46
+ const SESSION_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
47
+
48
+ export function validateSessionName(name) {
49
+ if (!name || typeof name !== "string") {
50
+ throw new Error("session name is required");
51
+ }
52
+ if (!SESSION_NAME_RE.test(name)) {
53
+ throw new Error(
54
+ `invalid session name '${name}': must be alphanumeric, dots, hyphens, underscores (1-128 chars, start with alnum)`
55
+ );
56
+ }
57
+ }
58
+
59
+ class PtySession {
60
+ constructor(name, opts = {}) {
61
+ this.name = name;
62
+ this.proc = null;
63
+ this._pty = null; // Bun terminal object (for writing input)
64
+ this.childPid = null;
65
+ this.bridgePid = null;
66
+ this.createdAt = new Date();
67
+ this.cwd = opts.cwd || process.cwd();
68
+ this.cmd = opts.cmd || "unknown";
69
+ this.exitCode = null;
70
+ this.exited = false;
71
+ this.exitedAt = null;
72
+ this._totalBytes = 0;
73
+
74
+ this.events = new EventEmitter();
75
+
76
+
77
+ // activity tracking (debounced, not per-write)
78
+ this._lastActivityTime = Date.now();
79
+ this._isIdle = false;
80
+ this._activityTimer = null;
81
+ this._activityDebounceMs = 500;
82
+ this._idleThresholdMs = 5000;
83
+
84
+ // logging
85
+ this._logStream = null;
86
+ this._logPath = null;
87
+ this._logFormat = null;
88
+
89
+ // headless terminal emulator -- this IS the screen buffer
90
+ const cols = opts.cols || 200;
91
+ const rows = opts.rows || 50;
92
+ this.terminal = new Terminal({
93
+ cols,
94
+ rows,
95
+ scrollback: opts.scrollback || 5000,
96
+ allowProposedApi: true,
97
+ });
98
+ this._serializer = new SerializeAddon();
99
+ this.terminal.loadAddon(this._serializer);
100
+
101
+ // persistent decoder so split multi-byte UTF-8 sequences
102
+ // don't produce U+FFFD replacement chars between data events
103
+ this._decoder = new TextDecoder("utf-8");
104
+ }
105
+
106
+ /** attach a Bun subprocess with terminal. called by PtyManager.spawn() */
107
+ _attach(proc) {
108
+ this.proc = proc;
109
+ this.childPid = proc.pid;
110
+ this.bridgePid = proc.pid;
111
+ this._pty = proc.terminal;
112
+
113
+ proc.exited.then((code) => {
114
+ if (this.exitCode === null) this.exitCode = code;
115
+ this.exited = true;
116
+ this.exitedAt = new Date();
117
+ this.events.emit("exit", { exitCode: this.exitCode, signal: null });
118
+ });
119
+ }
120
+
121
+ /** called by Bun terminal data callback */
122
+ _onData(str) {
123
+ this._totalBytes += str.length;
124
+ this.terminal.write(str);
125
+
126
+ // activity tracking: debounced, not per-write
127
+ const now = Date.now();
128
+ this._lastActivityTime = now;
129
+ if (this._isIdle) {
130
+ this._isIdle = false;
131
+ this.events.emit("activity", { type: "active", at: now });
132
+ }
133
+ clearTimeout(this._activityTimer);
134
+ this._activityTimer = setTimeout(() => {
135
+ this._isIdle = true;
136
+ this.events.emit("activity", { type: "idle", at: Date.now() });
137
+ }, this._idleThresholdMs);
138
+
139
+ this.events.emit("data", str);
140
+ }
141
+
142
+ /**
143
+ * capture the rendered screen buffer.
144
+ *
145
+ * returns what you'd actually see on the terminal right now.
146
+ * escape codes, cursor movements, line erases -- all resolved.
147
+ * equivalent of reading the full terminal screen buffer.
148
+ *
149
+ * @param {number} [tailLines] - last N lines (0 or omit = visible screen)
150
+ * @param {object} [opts] - options
151
+ * @param {boolean} [opts.screen] - only capture visible rows (skip scrollback)
152
+ * @returns {string}
153
+ */
154
+ capture(tailLines, opts = {}) {
155
+ const buf = this.terminal.buffer.active;
156
+ const lines = [];
157
+
158
+ const startLine = opts.screen ? buf.baseY : 0;
159
+ const totalLines = buf.baseY + this.terminal.rows;
160
+ for (let i = startLine; i < totalLines; i++) {
161
+ const line = buf.getLine(i);
162
+ if (line) {
163
+ lines.push(line.translateToString(true));
164
+ }
165
+ }
166
+
167
+ // trim trailing empty lines
168
+ while (lines.length > 0 && lines[lines.length - 1].trim() === "") {
169
+ lines.pop();
170
+ }
171
+
172
+ if (tailLines && tailLines > 0) {
173
+ return lines.slice(-tailLines).join("\n");
174
+ }
175
+ return lines.join("\n");
176
+ }
177
+
178
+ /**
179
+ * capture the rendered screen with ANSI color/attribute escape codes.
180
+ * uses @xterm/addon-serialize to preserve colors, bold, etc.
181
+ * @returns {string}
182
+ */
183
+ captureAnsi() {
184
+ return this._serializer.serialize();
185
+ }
186
+
187
+ /**
188
+ * capture visible content with ANSI colors, trimmed of empty lines.
189
+ * reads cells individually to build colored output line by line.
190
+ * result starts from line 0 (no leading empty rows).
191
+ * @param {number} [tailLines] - last N lines (0 = all content)
192
+ * @returns {string}
193
+ */
194
+ captureAnsiCompact(tailLines) {
195
+ const buf = this.terminal.buffer.active;
196
+ const totalLines = buf.baseY + this.terminal.rows;
197
+ const lines = [];
198
+
199
+ for (let y = 0; y < totalLines; y++) {
200
+ const line = buf.getLine(y);
201
+ if (!line) { lines.push(""); continue; }
202
+
203
+ let out = "";
204
+ let prevFg = -1, prevBg = -1, prevBold = false, prevDim = false;
205
+ let prevItalic = false, prevUnder = false, prevInverse = false;
206
+
207
+ for (let x = 0; x < line.length; x++) {
208
+ const cell = line.getCell(x);
209
+ if (!cell) continue;
210
+ const ch = cell.getChars();
211
+ if (!ch) continue;
212
+
213
+ const fg = cell.getFgColor();
214
+ const bg = cell.getBgColor();
215
+ const bold = cell.isBold() !== 0;
216
+ const dim = cell.isDim() !== 0;
217
+ const italic = cell.isItalic() !== 0;
218
+ const under = cell.isUnderline() !== 0;
219
+ const inverse = cell.isInverse() !== 0;
220
+ const fgMode = cell.getFgColorMode();
221
+ const bgMode = cell.getBgColorMode();
222
+
223
+ // emit SGR only when attributes change
224
+ if (fg !== prevFg || bg !== prevBg || bold !== prevBold ||
225
+ dim !== prevDim || italic !== prevItalic ||
226
+ under !== prevUnder || inverse !== prevInverse) {
227
+ const parts = ["0"]; // reset
228
+ if (bold) parts.push("1");
229
+ if (dim) parts.push("2");
230
+ if (italic) parts.push("3");
231
+ if (under) parts.push("4");
232
+ if (inverse) parts.push("7");
233
+ // foreground
234
+ if (fgMode === 1) parts.push(fg < 8 ? `${30 + fg}` : `${90 + fg - 8}`);
235
+ else if (fgMode === 2) parts.push(`38;5;${fg}`);
236
+ else if (fgMode === 3) {
237
+ const r = (fg >> 16) & 0xff, g = (fg >> 8) & 0xff, b = fg & 0xff;
238
+ parts.push(`38;2;${r};${g};${b}`);
239
+ }
240
+ // background
241
+ if (bgMode === 1) parts.push(bg < 8 ? `${40 + bg}` : `${100 + bg - 8}`);
242
+ else if (bgMode === 2) parts.push(`48;5;${bg}`);
243
+ else if (bgMode === 3) {
244
+ const r = (bg >> 16) & 0xff, g = (bg >> 8) & 0xff, b = bg & 0xff;
245
+ parts.push(`48;2;${r};${g};${b}`);
246
+ }
247
+ out += `\x1b[${parts.join(";")}m`;
248
+ prevFg = fg; prevBg = bg; prevBold = bold; prevDim = dim;
249
+ prevItalic = italic; prevUnder = under; prevInverse = inverse;
250
+ }
251
+ out += ch;
252
+ }
253
+ if (prevFg !== -1 || prevBold) out += "\x1b[0m";
254
+ lines.push(out);
255
+ }
256
+
257
+ // trim trailing empty lines
258
+ while (lines.length > 0 && lines[lines.length - 1].replace(/\x1b\[[^m]*m/g, "").trim() === "") {
259
+ lines.pop();
260
+ }
261
+ // trim leading empty lines
262
+ while (lines.length > 0 && lines[0].replace(/\x1b\[[^m]*m/g, "").trim() === "") {
263
+ lines.shift();
264
+ }
265
+
266
+ if (tailLines && tailLines > 0) {
267
+ return lines.slice(-tailLines).join("\r\n");
268
+ }
269
+ return lines.join("\r\n");
270
+ }
271
+
272
+ /**
273
+ * resize the PTY and headless terminal.
274
+ * @param {number} cols
275
+ * @param {number} rows
276
+ */
277
+ resize(cols, rows) {
278
+ cols = Math.max(20, Math.min(500, cols));
279
+ rows = Math.max(5, Math.min(200, rows));
280
+ this.terminal.resize(cols, rows);
281
+ if (this._pty && !this.exited) {
282
+ try { this._pty.resize(cols, rows); } catch {}
283
+ }
284
+ }
285
+
286
+ write(text) {
287
+ if (this.exited) {
288
+ throw new Error(`session '${this.name}' has exited`);
289
+ }
290
+ // bracket paste mode: wrap multiline text so the shell treats it
291
+ // as a single paste rather than executing each line on \n
292
+ if (text.includes("\n")) {
293
+ this._pty.write(`\x1b[200~${text}\x1b[201~`);
294
+ } else {
295
+ this._pty.write(text);
296
+ }
297
+ }
298
+
299
+ kill(signal = "SIGTERM") {
300
+ if (!this.exited) {
301
+ try { this.proc.kill(signal); } catch {}
302
+ try { this._pty?.close(); } catch {}
303
+ }
304
+ }
305
+
306
+ dispose() {
307
+ clearTimeout(this._activityTimer);
308
+ this.kill();
309
+ if (this._logStream) this.stopLog();
310
+ try { this.terminal.dispose(); } catch {}
311
+ }
312
+
313
+ isAlive() {
314
+ return !this.exited;
315
+ }
316
+
317
+ /**
318
+ * start logging session output to a file.
319
+ *
320
+ * format:
321
+ * "raw" - raw PTY bytes (escape codes included, replayable)
322
+ * "rendered" - clean screen snapshots on each data event
323
+ * "jsonl" - timestamped JSON lines { t, type, data }
324
+ *
325
+ * @param {string} logPath - file path to write to
326
+ * @param {string} [format="raw"] - log format
327
+ */
328
+ startLog(logPath, format = "raw") {
329
+ if (this._logStream) this.stopLog();
330
+
331
+ // ensure parent dir exists
332
+ mkdirSync(dirname(logPath), { recursive: true });
333
+
334
+ this._logPath = logPath;
335
+ this._logFormat = format;
336
+ this._logStream = createWriteStream(logPath, { flags: "a" });
337
+
338
+ // write header for jsonl
339
+ if (format === "jsonl") {
340
+ this._logStream.write(
341
+ JSON.stringify({
342
+ t: Date.now(),
343
+ type: "start",
344
+ name: this.name,
345
+ cmd: this.cmd,
346
+ cols: this.terminal.cols,
347
+ rows: this.terminal.rows,
348
+ }) + "\n"
349
+ );
350
+ }
351
+
352
+ // hook into data events
353
+ this._logHandler = (chunk) => {
354
+ if (!this._logStream) return;
355
+ switch (this._logFormat) {
356
+ case "raw":
357
+ this._logStream.write(chunk);
358
+ break;
359
+ case "rendered":
360
+ this._logStream.write(
361
+ `--- ${new Date().toISOString()} ---\n${this.capture()}\n\n`
362
+ );
363
+ break;
364
+ case "jsonl":
365
+ this._logStream.write(
366
+ JSON.stringify({ t: Date.now(), type: "o", data: chunk }) + "\n"
367
+ );
368
+ break;
369
+ }
370
+ };
371
+ this.events.on("data", this._logHandler);
372
+
373
+ // log input too for jsonl (send-keys)
374
+ if (format === "jsonl") {
375
+ this._origWrite = this.write.bind(this);
376
+ const session = this;
377
+ this.write = function (text) {
378
+ if (session._logStream) {
379
+ session._logStream.write(
380
+ JSON.stringify({ t: Date.now(), type: "i", data: text }) + "\n"
381
+ );
382
+ }
383
+ session._origWrite(text);
384
+ };
385
+ }
386
+
387
+ // log exit
388
+ this._logExitHandler = ({ exitCode }) => {
389
+ if (!this._logStream) return;
390
+ if (this._logFormat === "jsonl") {
391
+ this._logStream.write(
392
+ JSON.stringify({ t: Date.now(), type: "exit", exitCode }) + "\n"
393
+ );
394
+ } else if (this._logFormat === "rendered") {
395
+ this._logStream.write(
396
+ `--- EXIT (code: ${exitCode}) ${new Date().toISOString()} ---\n`
397
+ );
398
+ }
399
+ this.stopLog();
400
+ };
401
+ this.events.on("exit", this._logExitHandler);
402
+ }
403
+
404
+ stopLog() {
405
+ if (this._logHandler) {
406
+ this.events.off("data", this._logHandler);
407
+ this._logHandler = null;
408
+ }
409
+ if (this._logExitHandler) {
410
+ this.events.off("exit", this._logExitHandler);
411
+ this._logExitHandler = null;
412
+ }
413
+ if (this._origWrite) {
414
+ this.write = this._origWrite;
415
+ this._origWrite = null;
416
+ }
417
+ if (this._logStream) {
418
+ this._logStream.end();
419
+ this._logStream = null;
420
+ }
421
+ const path = this._logPath;
422
+ this._logPath = null;
423
+ this._logFormat = null;
424
+ return path;
425
+ }
426
+
427
+ info() {
428
+ return {
429
+ name: this.name,
430
+ pid: this.childPid || this.bridgePid,
431
+ bridgePid: this.bridgePid,
432
+ childPid: this.childPid,
433
+ cmd: this.cmd,
434
+ cwd: this.cwd,
435
+ alive: this.isAlive(),
436
+ exitCode: this.exitCode,
437
+ createdAt: this.createdAt.toISOString(),
438
+ exitedAt: this.exitedAt ? this.exitedAt.toISOString() : null,
439
+ terminalSize: `${this.terminal.cols}x${this.terminal.rows}`,
440
+ outputBytes: this._totalBytes,
441
+ logging: this._logPath ? { path: this._logPath, format: this._logFormat } : null,
442
+ };
443
+ }
444
+ }
445
+
446
+ export class PtyManager {
447
+ constructor() {
448
+ /** @type {Map<string, PtySession>} */
449
+ this.sessions = new Map();
450
+ }
451
+
452
+ /**
453
+ * spawn a command inside a real PTY with terminal emulation
454
+ *
455
+ * @param {string} name - unique session name
456
+ * @param {string} cmd - command to run
457
+ * @param {string[]} [args] - arguments
458
+ * @param {object} [opts]
459
+ * @param {string} [opts.cwd] - working directory
460
+ * @param {object} [opts.env] - extra env vars
461
+ * @param {number} [opts.cols] - terminal columns (default 200)
462
+ * @param {number} [opts.rows] - terminal rows (default 50)
463
+ * @param {number} [opts.scrollback] - scrollback lines (default 5000)
464
+ * @returns {string} session name
465
+ */
466
+ spawn(name, cmd, args = [], opts = {}) {
467
+ validateSessionName(name);
468
+ if (this.sessions.has(name)) {
469
+ throw new Error(`session '${name}' already exists`);
470
+ }
471
+
472
+ const cols = opts.cols || 100;
473
+ const rows = opts.rows || 35;
474
+
475
+ const session = new PtySession(name, {
476
+ cwd: opts.cwd || process.cwd(),
477
+ cmd: [cmd, ...args].join(" "),
478
+ cols,
479
+ rows,
480
+ scrollback: opts.scrollback,
481
+ });
482
+
483
+ const proc = Bun.spawn([cmd, ...args], {
484
+ cwd: opts.cwd || process.cwd(),
485
+ env: { TERM: "xterm-256color", ...process.env, ...opts.env },
486
+ terminal: {
487
+ cols,
488
+ rows,
489
+ data(_terminal, data) {
490
+ session._onData(session._decoder.decode(data, { stream: true }));
491
+ },
492
+ },
493
+ });
494
+
495
+ session._attach(proc);
496
+ this.sessions.set(name, session);
497
+ return name;
498
+ }
499
+
500
+ get(name) {
501
+ const s = this.sessions.get(name);
502
+ if (!s) throw new Error(`session '${name}' not found`);
503
+ return s;
504
+ }
505
+
506
+ /** send keystrokes. use \r for Enter, \x03 for Ctrl-C */
507
+ sendKeys(name, text) {
508
+ this.get(name).write(text);
509
+ }
510
+
511
+ /**
512
+ * capture rendered screen.
513
+ * returns clean text with all escape codes resolved.
514
+ */
515
+ capture(name, tailLines, opts = {}) {
516
+ return this.get(name).capture(tailLines, opts);
517
+ }
518
+
519
+ /** check if child process is alive */
520
+ isAlive(name) {
521
+ return this.get(name).isAlive();
522
+ }
523
+
524
+ /** get child process pid */
525
+ pid(name) {
526
+ const s = this.get(name);
527
+ return s.childPid || s.bridgePid;
528
+ }
529
+
530
+ /** kill session process (session stays in registry for inspection) */
531
+ kill(name) {
532
+ this.get(name).kill();
533
+ }
534
+
535
+ /** rename a session */
536
+ rename(oldName, newName) {
537
+ validateSessionName(newName);
538
+ const s = this.sessions.get(oldName);
539
+ if (!s) throw new Error(`session '${oldName}' not found`);
540
+ if (this.sessions.has(newName)) {
541
+ throw new Error(`session '${newName}' already exists`);
542
+ }
543
+ s.name = newName;
544
+ this.sessions.delete(oldName);
545
+ this.sessions.set(newName, s);
546
+ }
547
+
548
+ /** kill + remove session from registry */
549
+ remove(name) {
550
+ const s = this.sessions.get(name);
551
+ if (s) {
552
+ s.dispose();
553
+ this.sessions.delete(name);
554
+ }
555
+ }
556
+
557
+ /** check if session exists */
558
+ has(name) {
559
+ return this.sessions.has(name);
560
+ }
561
+
562
+ /** list sessions */
563
+ list(filter = {}) {
564
+ const results = [];
565
+ for (const s of this.sessions.values()) {
566
+ if (filter.alive !== undefined && s.isAlive() !== filter.alive) continue;
567
+ results.push(s.info());
568
+ }
569
+ return results;
570
+ }
571
+
572
+ /** wait for rendered screen to contain a matching line */
573
+ waitFor(name, pattern, timeoutMs = 30000) {
574
+ const session = this.get(name);
575
+ const re = typeof pattern === "string" ? new RegExp(pattern) : pattern;
576
+
577
+ return new Promise((resolve, reject) => {
578
+ const timeout = setTimeout(() => {
579
+ session.events.off("data", onData);
580
+ reject(new Error(`timeout waiting for: ${pattern}`));
581
+ }, timeoutMs);
582
+
583
+ // check current screen
584
+ for (const line of session.capture().split("\n")) {
585
+ if (re.test(line)) {
586
+ clearTimeout(timeout);
587
+ resolve(line);
588
+ return;
589
+ }
590
+ }
591
+
592
+ // poll on new data (re-read screen each time)
593
+ function onData() {
594
+ for (const line of session.capture().split("\n")) {
595
+ if (re.test(line)) {
596
+ clearTimeout(timeout);
597
+ session.events.off("data", onData);
598
+ resolve(line);
599
+ return;
600
+ }
601
+ }
602
+ }
603
+ session.events.on("data", onData);
604
+ });
605
+ }
606
+
607
+ /** wait for session to exit */
608
+ waitForExit(name, timeoutMs = 60000) {
609
+ const session = this.get(name);
610
+ if (session.exited) {
611
+ return Promise.resolve({ exitCode: session.exitCode, signal: null });
612
+ }
613
+
614
+ return new Promise((resolve, reject) => {
615
+ const timeout = setTimeout(() => {
616
+ session.events.off("exit", onExit);
617
+ reject(new Error("timeout waiting for exit"));
618
+ }, timeoutMs);
619
+
620
+ function onExit({ exitCode, signal }) {
621
+ clearTimeout(timeout);
622
+ resolve({ exitCode, signal });
623
+ }
624
+ session.events.on("exit", onExit);
625
+ });
626
+ }
627
+
628
+ /** kill and remove all sessions */
629
+ destroyAll() {
630
+ for (const [name] of this.sessions) {
631
+ this.remove(name);
632
+ }
633
+ }
634
+ }
635
+
636
+ // ─── CLI daemon (unix socket for persistent sessions) ────────────────
637
+
638
+ import { createServer, createConnection } from "node:net";
639
+ import { unlinkSync, chmodSync } from "node:fs";
640
+ import { tmpdir, homedir } from "node:os";
641
+
642
+ // daemon name from @name, --daemon flag, or PTY_DAEMON env var
643
+ function getDaemonName() {
644
+ const at = process.argv.find((a) => a.startsWith("@"));
645
+ if (at) return at.slice(1);
646
+ const idx = process.argv.indexOf("--daemon");
647
+ if (idx !== -1 && process.argv[idx + 1]) return process.argv[idx + 1];
648
+ return process.env.PTY_DAEMON || "default";
649
+ }
650
+
651
+ function socketPath(name) {
652
+ // use ~/.pty-manager/ instead of /tmp to avoid world-writable dir
653
+ const dir = join(homedir(), ".pty-manager");
654
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
655
+ return join(dir, `${name}.sock`);
656
+ }
657
+
658
+ const DAEMON_NAME = getDaemonName();
659
+ const SOCKET_PATH = socketPath(DAEMON_NAME);
660
+
661
+ /**
662
+ * daemon: long-running process that holds all sessions.
663
+ * clients connect via unix socket, send JSON commands, get JSON responses.
664
+ */
665
+ function startDaemon() {
666
+ if (existsSync(SOCKET_PATH)) {
667
+ // check if another daemon is running
668
+ try {
669
+ const probe = createConnection(SOCKET_PATH);
670
+ probe.on("connect", () => {
671
+ probe.end();
672
+ if (process.send) {
673
+ process.send({ ready: true });
674
+ } else {
675
+ console.log("daemon already running at", SOCKET_PATH);
676
+ }
677
+ process.exit(0);
678
+ });
679
+ probe.on("error", () => {
680
+ // stale socket, remove and continue
681
+ unlinkSync(SOCKET_PATH);
682
+ listen();
683
+ });
684
+ return;
685
+ } catch {
686
+ unlinkSync(SOCKET_PATH);
687
+ }
688
+ }
689
+ listen();
690
+
691
+ function listen() {
692
+ const mgr = new PtyManager();
693
+ const daemonStartedAt = new Date();
694
+ // daemon-level config
695
+ const config = {
696
+ cols: 100,
697
+ rows: 50,
698
+ capOnSend: false,
699
+ sendDelay: 1000,
700
+ };
701
+
702
+ const MAX_BUF = 1024 * 1024; // 1MB max request buffer
703
+
704
+ const server = createServer((conn) => {
705
+ let buf = "";
706
+ let attached = false; // true when in attach streaming mode
707
+
708
+ // suppress connection errors (client disconnect, etc.)
709
+ conn.on("error", () => {});
710
+
711
+ // timeout: close idle connections after 30s (non-attach)
712
+ conn.setTimeout(30000, () => {
713
+ if (!attached) conn.destroy();
714
+ });
715
+
716
+ conn.on("data", async (data) => {
717
+ // in attach mode, forward raw input to the pty
718
+ if (attached) {
719
+ attached.write(data.toString());
720
+ return;
721
+ }
722
+
723
+ buf += data.toString();
724
+ if (buf.length > MAX_BUF) {
725
+ conn.write(JSON.stringify({ ok: false, error: "request too large" }) + "\n");
726
+ conn.destroy();
727
+ return;
728
+ }
729
+ // process newline-delimited JSON
730
+ let nl;
731
+ while ((nl = buf.indexOf("\n")) !== -1) {
732
+ const line = buf.slice(0, nl);
733
+ buf = buf.slice(nl + 1);
734
+ try {
735
+ const req = JSON.parse(line);
736
+
737
+ // attach is special: switches to streaming mode
738
+ if (req.cmd === "attach") {
739
+ const session = mgr.get(req.name);
740
+ const cols = session.terminal.cols;
741
+ const rows = session.terminal.rows;
742
+ // send initial ack with terminal size
743
+ conn.write(JSON.stringify({ ok: true, mode: "attach", cols, rows }) + "\n");
744
+
745
+ // send current buffer as plain text, then SIGWINCH for colored redraw
746
+ conn.write("\x1b[2J\x1b[H");
747
+ const screen = session.capture(session.terminal.rows);
748
+ if (screen) conn.write(screen.replace(/\n/g, "\r\n") + "\r\n");
749
+
750
+ // force TUI apps to redraw on top
751
+ if (session.childPid) {
752
+ try { process.kill(session.childPid, "SIGWINCH"); } catch {}
753
+ }
754
+
755
+ // stream new pty output to client
756
+ const onData = (chunk) => {
757
+ try { conn.write(chunk); } catch {}
758
+ };
759
+ session.events.on("data", onData);
760
+
761
+ // when session exits, notify and close
762
+ const onExit = () => {
763
+ try {
764
+ conn.write("\r\n[session exited]\r\n");
765
+ conn.end();
766
+ } catch {}
767
+ };
768
+ session.events.on("exit", onExit);
769
+
770
+ // forward client input to pty
771
+ attached = session;
772
+
773
+ // cleanup on disconnect
774
+ conn.on("close", () => {
775
+ session.events.off("data", onData);
776
+ session.events.off("exit", onExit);
777
+ attached = false;
778
+ });
779
+ return;
780
+ }
781
+
782
+ // tg-wait holds the connection open - disable the idle timeout
783
+ if (req.cmd === "tg-wait") conn.setTimeout(0);
784
+ const res = await handleCommand(mgr, req, daemonStartedAt, config, tgState);
785
+ conn.write(JSON.stringify(res) + "\n");
786
+ } catch (err) {
787
+ conn.write(
788
+ JSON.stringify({ ok: false, error: err.message }) + "\n"
789
+ );
790
+ }
791
+ }
792
+ });
793
+ });
794
+
795
+ server.listen(SOCKET_PATH, () => {
796
+ // restrict socket to owner only (fixes world-writable /tmp vuln)
797
+ try { chmodSync(SOCKET_PATH, 0o600); } catch {}
798
+
799
+ // signal parent process that we're ready
800
+ if (process.send) {
801
+ process.send({ ready: true });
802
+ } else {
803
+ console.log(`pty-manager daemon (${DAEMON_NAME}) listening at`, SOCKET_PATH);
804
+ console.log("pid:", process.pid);
805
+ }
806
+ });
807
+
808
+ // telegram state (shared between poller and handleCommand)
809
+ const tgState = { waiter: null, lastUpdateId: 0, active: false, lastSession: null };
810
+
811
+ async function tgSend(chatId, text) {
812
+ const token = process.env.TELEGRAM_BOT_TOKEN;
813
+ // telegram max message length is 4096
814
+ const chunks = [];
815
+ for (let i = 0; i < text.length; i += 4000) chunks.push(text.slice(i, i + 4000));
816
+ for (const chunk of chunks) {
817
+ await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
818
+ method: "POST",
819
+ headers: { "content-type": "application/json" },
820
+ body: JSON.stringify({ chat_id: chatId, text: chunk }),
821
+ });
822
+ }
823
+ }
824
+
825
+ async function tgHandleCommand(chatId, text) {
826
+ const parts = text.trim().split(/\s+/);
827
+ const cmd = parts[0].replace(/^\//, "").toLowerCase().split("@")[0];
828
+ const args = parts.slice(1);
829
+ switch (cmd) {
830
+ case "start":
831
+ case "help":
832
+ await tgSend(chatId, [
833
+ "pty-mgr bot - commands:",
834
+ "/list list sessions",
835
+ "/capture <name> [lines] capture output",
836
+ "/send <name> <text> send text",
837
+ "/kill <name> kill session",
838
+ "/spawn <name> [cmd] spawn session",
839
+ "/status daemon info",
840
+ ].join("\n"));
841
+ break;
842
+ case "list":
843
+ case "ls": {
844
+ const sessions = mgr.list();
845
+ if (!sessions.length) { await tgSend(chatId, "no sessions"); break; }
846
+ const lines = sessions.map(s => `${s.name} ${s.alive ? "alive" : "dead"} ${s.cmd}`).join("\n");
847
+ await tgSend(chatId, lines);
848
+ break;
849
+ }
850
+ case "capture":
851
+ case "cap":
852
+ case "c": {
853
+ const capName = args[0] && mgr.has(args[0]) ? args[0] : tgState.lastSession;
854
+ const linesArg = args[0] && mgr.has(args[0]) ? args[1] : args[0];
855
+ const lines = linesArg ? parseInt(linesArg, 10) : 50;
856
+ if (!capName) { await tgSend(chatId, "usage: /capture <name> [lines]"); break; }
857
+ if (!mgr.has(capName)) { await tgSend(chatId, `not found: ${capName}`); break; }
858
+ tgState.lastSession = capName;
859
+ const output = mgr.capture(capName, lines);
860
+ await tgSend(chatId, output || "(empty)");
861
+ break;
862
+ }
863
+ case "send":
864
+ case "s": {
865
+ let name, sendText;
866
+ if (args[0] && mgr.has(args[0])) {
867
+ name = args[0];
868
+ sendText = args.slice(1).join(" ");
869
+ } else if (tgState.lastSession) {
870
+ name = tgState.lastSession;
871
+ sendText = args.join(" ");
872
+ }
873
+ if (!name || !sendText) { await tgSend(chatId, `usage: /send [name] <text>${tgState.lastSession ? `\nlast: ${tgState.lastSession}` : ""}`); break; }
874
+ if (!mgr.has(name)) { await tgSend(chatId, `not found: ${name}`); break; }
875
+ tgState.lastSession = name;
876
+ mgr.sendKeys(name, sendText);
877
+ await new Promise(r => setTimeout(r, 1000));
878
+ mgr.sendKeys(name, "\r");
879
+ await tgSend(chatId, `sent to ${name}`);
880
+ // auto-capture on idle - wait for session output to settle
881
+ { const session = mgr.get(name);
882
+ const cap = await new Promise(resolve => {
883
+ const timeout = setTimeout(() => {
884
+ session.events.off("activity", onIdle);
885
+ resolve(mgr.capture(name, 30));
886
+ }, 60000);
887
+ const onIdle = (ev) => {
888
+ if (ev.type === "idle") {
889
+ session.events.off("activity", onIdle);
890
+ clearTimeout(timeout);
891
+ resolve(mgr.capture(name, 30));
892
+ }
893
+ };
894
+ session.events.on("activity", onIdle);
895
+ });
896
+ if (cap) await tgSend(chatId, cap);
897
+ }
898
+ break;
899
+ }
900
+ case "kill":
901
+ case "k": {
902
+ const name = args[0] || tgState.lastSession;
903
+ if (!name) { await tgSend(chatId, "usage: /kill <name>"); break; }
904
+ if (!mgr.has(name)) { await tgSend(chatId, `not found: ${name}`); break; }
905
+ if (tgState.lastSession === name) tgState.lastSession = null;
906
+ mgr.kill(name);
907
+ await tgSend(chatId, `killed: ${name}`);
908
+ break;
909
+ }
910
+ case "spawn":
911
+ case "n": {
912
+ const name = args[0];
913
+ const spawnCmd = args[1] || "zsh";
914
+ const spawnArgs = args.slice(2);
915
+ if (!name) { await tgSend(chatId, "usage: /spawn <name> [cmd]"); break; }
916
+ const env = buildSafeEnv();
917
+ env.PTY_MGR_SESSION = name;
918
+ mgr.spawn(name, spawnCmd, spawnArgs, { env });
919
+ await tgSend(chatId, `spawned: ${name} pid=${mgr.pid(name)}`);
920
+ break;
921
+ }
922
+ case "status": {
923
+ const sessions = mgr.list();
924
+ const alive = sessions.filter(s => s.alive).length;
925
+ await tgSend(chatId, `sessions: ${sessions.length} (${alive} alive)`);
926
+ break;
927
+ }
928
+ default:
929
+ await tgSend(chatId, `unknown: ${cmd}\nuse /help`);
930
+ }
931
+ }
932
+
933
+ async function tgPoller() {
934
+ const token = process.env.TELEGRAM_BOT_TOKEN;
935
+ if (!token) return;
936
+ tgState.active = true;
937
+ while (tgState.active) {
938
+ let error = false;
939
+ try {
940
+ const url = `https://api.telegram.org/bot${token}/getUpdates`
941
+ + `?timeout=25&offset=${tgState.lastUpdateId + 1}`;
942
+ const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
943
+ const data = await res.json();
944
+ for (const update of data.result || []) {
945
+ tgState.lastUpdateId = update.update_id;
946
+ const text = update.message?.text;
947
+ const msgChatId = update.message?.chat?.id;
948
+ const userId = String(update.message?.from?.id);
949
+ const chatId = String(msgChatId);
950
+ const knownId = process.env.TELEGRAM_CHAT_ID;
951
+ const match = chatId === knownId || userId === knownId;
952
+ if (!text || !match) continue;
953
+ // commands always get handled directly
954
+ if (text.startsWith("/")) {
955
+ tgHandleCommand(msgChatId, text).catch(() => {});
956
+ continue;
957
+ }
958
+ // plain text: resolve waiter if pending, otherwise send to last session
959
+ if (tgState.waiter) {
960
+ const w = tgState.waiter;
961
+ tgState.waiter = null;
962
+ clearTimeout(w.timer);
963
+ w.resolve(text);
964
+ } else if (tgState.lastSession && mgr.has(tgState.lastSession)) {
965
+ const sn = tgState.lastSession;
966
+ mgr.sendKeys(sn, text);
967
+ await new Promise(r => setTimeout(r, 1000));
968
+ mgr.sendKeys(sn, "\r");
969
+ await tgSend(msgChatId, `sent to ${sn}`);
970
+ // auto-capture after send
971
+ await new Promise(r => setTimeout(r, 6000));
972
+ const cap = mgr.capture(sn, 30);
973
+ if (cap) await tgSend(msgChatId, cap);
974
+ }
975
+ }
976
+ } catch { error = true; }
977
+ if (error) await new Promise(r => setTimeout(r, 5000));
978
+ }
979
+ }
980
+
981
+ if (process.env.TELEGRAM_BOT_TOKEN) tgPoller();
982
+
983
+ // cleanup on exit
984
+ const cleanup = () => {
985
+ tgState.active = false;
986
+ mgr.destroyAll();
987
+ try { unlinkSync(SOCKET_PATH); } catch {}
988
+ process.exit(0);
989
+ };
990
+ process.on("SIGINT", cleanup);
991
+ process.on("SIGTERM", cleanup);
992
+ }
993
+ }
994
+
995
+ /**
996
+ * match session names against a pattern.
997
+ * supports: "all", "name*" (prefix glob), exact name.
998
+ */
999
+ function matchSessions(mgr, pattern) {
1000
+ if (pattern === "all") {
1001
+ return mgr.list().map((s) => s.name);
1002
+ }
1003
+ if (pattern.endsWith("*")) {
1004
+ const prefix = pattern.slice(0, -1);
1005
+ return mgr.list()
1006
+ .filter((s) => s.name.startsWith(prefix))
1007
+ .map((s) => s.name);
1008
+ }
1009
+ // exact match
1010
+ if (mgr.has(pattern)) return [pattern];
1011
+ return [];
1012
+ }
1013
+
1014
+ export function buildSafeEnv(extra) {
1015
+ const safeEnv = {};
1016
+ for (const k of SAFE_ENV_KEYS) {
1017
+ if (process.env[k]) safeEnv[k] = process.env[k];
1018
+ if (extra?.[k]) safeEnv[k] = extra[k];
1019
+ }
1020
+ return safeEnv;
1021
+ }
1022
+
1023
+ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1024
+ const { cmd, name, args } = req;
1025
+
1026
+ switch (cmd) {
1027
+ case "status": {
1028
+ const sessions = mgr.list();
1029
+ const alive = sessions.filter((s) => s.alive).length;
1030
+ const dead = sessions.length - alive;
1031
+ const uptimeMs = Date.now() - daemonStartedAt.getTime();
1032
+ return {
1033
+ ok: true,
1034
+ status: {
1035
+ name: DAEMON_NAME,
1036
+ pid: process.pid,
1037
+ socket: SOCKET_PATH,
1038
+ startedAt: daemonStartedAt.toISOString(),
1039
+ uptimeMs,
1040
+ uptime: formatUptime(uptimeMs),
1041
+ sessions: { total: sessions.length, alive, dead },
1042
+ config: { ...config },
1043
+ },
1044
+ };
1045
+ }
1046
+ case "config": {
1047
+ const key = args?.key;
1048
+ const value = args?.value;
1049
+ if (!key) {
1050
+ return { ok: true, config: { ...config } };
1051
+ }
1052
+ switch (key) {
1053
+ case "screen": {
1054
+ const match = value?.match(/^(\d+)x(\d+)$/);
1055
+ if (!match) return { ok: false, error: "format: <cols>x<rows> (e.g. 100x50)" };
1056
+ config.cols = parseInt(match[1], 10);
1057
+ config.rows = parseInt(match[2], 10);
1058
+ return { ok: true, config: { cols: config.cols, rows: config.rows } };
1059
+ }
1060
+ case "cap-on-send": {
1061
+ if (value === "on") config.capOnSend = true;
1062
+ else if (value === "off") config.capOnSend = false;
1063
+ else return { ok: false, error: "value must be 'on' or 'off'" };
1064
+ return { ok: true, config: { capOnSend: config.capOnSend } };
1065
+ }
1066
+ case "send-delay": {
1067
+ const ms = parseInt(value, 10);
1068
+ if (isNaN(ms) || ms < 0) return { ok: false, error: "value must be milliseconds (e.g. 1000)" };
1069
+ config.sendDelay = ms;
1070
+ return { ok: true, config: { sendDelay: config.sendDelay } };
1071
+ }
1072
+ default:
1073
+ return { ok: false, error: `unknown config key: ${key}. valid: screen, cap-on-send, send-delay` };
1074
+ }
1075
+ }
1076
+ case "spawn": {
1077
+ const cmdToRun = args?.cmd || "zsh";
1078
+ const cmdArgs = args?.args || [];
1079
+ // clamp terminal size to prevent memory exhaustion
1080
+ const cols = Math.max(20, Math.min(500, args?.cols || config.cols));
1081
+ const rows = Math.max(5, Math.min(200, args?.rows || config.rows));
1082
+ const safeEnv = buildSafeEnv(args?.env);
1083
+ safeEnv.PTY_MGR_SESSION = name;
1084
+ const opts = {
1085
+ cwd: args?.cwd,
1086
+ env: safeEnv,
1087
+ cols,
1088
+ rows,
1089
+ };
1090
+ mgr.spawn(name, cmdToRun, cmdArgs, opts);
1091
+ const res = { ok: true, name, pid: mgr.pid(name) };
1092
+ // auto-start logging if --log flag
1093
+ if (args?.log) {
1094
+ const logDir = join(process.cwd(), "agents", "logs");
1095
+ const logPath = join(logDir, `${name}-${Date.now()}.jsonl`);
1096
+ mgr.get(name).startLog(logPath, "jsonl");
1097
+ res.logPath = logPath;
1098
+ }
1099
+ return res;
1100
+ }
1101
+ case "wrap": {
1102
+ // client must send cwd -- daemon's cwd is not the user's cwd
1103
+ const clientCwd = args?.cwd || process.cwd();
1104
+ const rawBase = args?.base || clientCwd.split("/").pop() || "session";
1105
+ // sanitize base name for session naming (must start with alnum)
1106
+ const baseName = rawBase.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^[\._-]+/, "") || "session";
1107
+ const cmdToRun = args?.cmd || "zsh";
1108
+ const cmdArgs = args?.args || [];
1109
+
1110
+ // always use numeric suffix: baseName-1, baseName-2, ...
1111
+ let maxNum = 0;
1112
+ for (const s of mgr.sessions.keys()) {
1113
+ if (!s.startsWith(`${baseName}-`)) continue;
1114
+ const suffix = s.slice(baseName.length + 1);
1115
+ const num = parseInt(suffix, 10);
1116
+ if (!isNaN(num) && num >= maxNum) maxNum = num + 1;
1117
+ }
1118
+ if (maxNum === 0) maxNum = 1;
1119
+ const nextName = `${baseName}-${maxNum}`;
1120
+
1121
+ const cols = Math.max(20, Math.min(500, args?.cols || config.cols));
1122
+ const rows = Math.max(5, Math.min(200, args?.rows || config.rows));
1123
+
1124
+ // wrap spawns through user's login shell so shell functions/aliases work
1125
+ // (like tmux does). this means glm, nvm, conda, etc. all resolve.
1126
+ const userShell = process.env.SHELL || "/bin/zsh";
1127
+ let shellCmd, shellArgs;
1128
+ if (cmdToRun === "zsh" && cmdArgs.length === 0) {
1129
+ // bare wrap: just open a login shell
1130
+ shellCmd = userShell;
1131
+ shellArgs = ["-l"];
1132
+ } else {
1133
+ // wrap <cmd> [args]: run through login interactive shell
1134
+ const escaped = [cmdToRun, ...cmdArgs].map(a => a.includes(" ") ? `"${a}"` : a).join(" ");
1135
+ shellCmd = userShell;
1136
+ shellArgs = ["-lic", escaped];
1137
+ }
1138
+
1139
+ // pass full env for wrap (user expects their shell env, not a sandbox)
1140
+ const wrapEnv = { ...process.env, ...(args?.env || {}) };
1141
+
1142
+ mgr.spawn(nextName, shellCmd, shellArgs, { cwd: clientCwd, env: wrapEnv, cols, rows });
1143
+ const res = { ok: true, name: nextName, pid: mgr.pid(nextName) };
1144
+
1145
+ if (args?.log) {
1146
+ const logDir = join(clientCwd, "agents", "logs");
1147
+ const logPath = join(logDir, `${nextName}-${Date.now()}.jsonl`);
1148
+ mgr.get(nextName).startLog(logPath, "jsonl");
1149
+ res.logPath = logPath;
1150
+ }
1151
+ return res;
1152
+ }
1153
+ case "send": {
1154
+ const text = args?.text || "";
1155
+ const enter = args?.enter || false;
1156
+ const raw = args?.raw || false;
1157
+
1158
+ if (raw || !enter) {
1159
+ // raw mode or no enter: send as-is
1160
+ mgr.sendKeys(name, text);
1161
+ } else {
1162
+ mgr.sendKeys(name, text);
1163
+ await new Promise((r) => setTimeout(r, config.sendDelay));
1164
+ mgr.sendKeys(name, "\r");
1165
+ }
1166
+
1167
+ if (config.capOnSend) {
1168
+ await new Promise((r) => setTimeout(r, 1000));
1169
+ return { ok: true, output: mgr.capture(name, args?.capLines || 20) };
1170
+ }
1171
+ return { ok: true };
1172
+ }
1173
+ case "capture": {
1174
+ const capOpts = args?.screen ? { screen: true } : {};
1175
+ // "all" or glob pattern
1176
+ if (name === "all" || (name && name.endsWith("*"))) {
1177
+ const names = matchSessions(mgr, name);
1178
+ const results = {};
1179
+ for (const n of names) {
1180
+ results[n] = mgr.capture(n, args?.lines, capOpts);
1181
+ }
1182
+ return { ok: true, results };
1183
+ }
1184
+ const output = mgr.capture(name, args?.lines, capOpts);
1185
+ return { ok: true, output };
1186
+ }
1187
+ case "resize": {
1188
+ const cols = args?.cols;
1189
+ const rows = args?.rows;
1190
+ if (!cols || !rows) {
1191
+ return { ok: false, error: "resize requires cols and rows" };
1192
+ }
1193
+ const session = mgr.get(name);
1194
+ session.resize(cols, rows);
1195
+ return { ok: true, cols: session.terminal.cols, rows: session.terminal.rows };
1196
+ }
1197
+ case "kill": {
1198
+ const names = matchSessions(mgr, name);
1199
+ if (names.length === 0) {
1200
+ return { ok: false, error: `no sessions matching: ${name}` };
1201
+ }
1202
+ for (const n of names) mgr.kill(n);
1203
+ return { ok: true, killed: names };
1204
+ }
1205
+ case "remove": {
1206
+ const names = matchSessions(mgr, name);
1207
+ if (names.length === 0) {
1208
+ return { ok: false, error: `no sessions matching: ${name}` };
1209
+ }
1210
+ for (const n of names) mgr.remove(n);
1211
+ return { ok: true, removed: names };
1212
+ }
1213
+ case "alive": {
1214
+ return { ok: true, alive: mgr.isAlive(name) };
1215
+ }
1216
+ case "has": {
1217
+ return { ok: true, exists: mgr.has(name) };
1218
+ }
1219
+ case "list": {
1220
+ return { ok: true, sessions: mgr.list(args || {}) };
1221
+ }
1222
+ case "info": {
1223
+ return { ok: true, info: mgr.get(name).info() };
1224
+ }
1225
+ case "pid": {
1226
+ return { ok: true, pid: mgr.pid(name) };
1227
+ }
1228
+ case "rename": {
1229
+ const newName = args?.newName;
1230
+ if (!newName) return { ok: false, error: "newName is required" };
1231
+ mgr.rename(name, newName);
1232
+ return { ok: true, oldName: name, newName };
1233
+ }
1234
+ case "log": {
1235
+ const session = mgr.get(name);
1236
+ const action = args?.action || "on";
1237
+ if (action === "off" || action === "stop") {
1238
+ const path = session.stopLog();
1239
+ return { ok: true, stopped: true, path };
1240
+ }
1241
+ // default log dir: ./agents/logs/
1242
+ const format = args?.format || "jsonl";
1243
+ const ext = format === "jsonl" ? "jsonl" : format === "rendered" ? "log" : "raw";
1244
+ const logDir = args?.dir || join(process.cwd(), "agents", "logs");
1245
+ const ts = Date.now();
1246
+ const logPath = args?.path || join(logDir, `${name}-${ts}.${ext}`);
1247
+ session.startLog(logPath, format);
1248
+ return { ok: true, path: logPath, format };
1249
+ }
1250
+ case "shutdown": {
1251
+ mgr.destroyAll();
1252
+ // close server and exit after response is sent
1253
+ setTimeout(() => {
1254
+ try { unlinkSync(SOCKET_PATH); } catch {}
1255
+ process.exit(0);
1256
+ }, 50);
1257
+ return { ok: true, stopped: DAEMON_NAME, pid: process.pid };
1258
+ }
1259
+ case "tg-send": {
1260
+ const token = process.env.TELEGRAM_BOT_TOKEN;
1261
+ const chatId = process.env.TELEGRAM_CHAT_ID;
1262
+ if (!token || !chatId) return { ok: false, error: "NO_TOKEN: set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID" };
1263
+ const msg = req.args?.message;
1264
+ if (!msg) return { ok: false, error: "message required" };
1265
+ if (req.args?.session) tgState.lastSession = req.args.session;
1266
+ await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
1267
+ method: "POST",
1268
+ headers: { "content-type": "application/json" },
1269
+ body: JSON.stringify({ chat_id: chatId, text: msg }),
1270
+ });
1271
+ return { ok: true };
1272
+ }
1273
+ case "tg-wait": {
1274
+ const token = process.env.TELEGRAM_BOT_TOKEN;
1275
+ const chatId = process.env.TELEGRAM_CHAT_ID;
1276
+ if (!token || !chatId) return { ok: false, error: "NO_TOKEN: set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID" };
1277
+ if (tgState.waiter) return { ok: false, error: "ALREADY_WAITING" };
1278
+ const msg = req.args?.message;
1279
+ const timeoutMs = req.args?.timeout ?? 60000;
1280
+ await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
1281
+ method: "POST",
1282
+ headers: { "content-type": "application/json" },
1283
+ body: JSON.stringify({ chat_id: chatId, text: msg }),
1284
+ });
1285
+ const sessionName = req.args?.session;
1286
+ return new Promise((resolve) => {
1287
+ const timer = setTimeout(() => {
1288
+ tgState.waiter = null;
1289
+ resolve({ ok: false, error: "TIMEOUT" });
1290
+ }, timeoutMs);
1291
+ tgState.waiter = {
1292
+ resolve: (text) => {
1293
+ if (sessionName && mgr.has(sessionName)) {
1294
+ mgr.sendKeys(sessionName, text);
1295
+ setTimeout(() => mgr.sendKeys(sessionName, "\r"), 1000);
1296
+ }
1297
+ resolve({ ok: true, reply: text });
1298
+ },
1299
+ timer,
1300
+ };
1301
+ });
1302
+ }
1303
+ default:
1304
+ return { ok: false, error: `unknown command: ${cmd}` };
1305
+ }
1306
+ }
1307
+
1308
+ /**
1309
+ * client: send a single command to the daemon, print result.
1310
+ */
1311
+ function sendCommandTo(sock, req) {
1312
+ return new Promise((resolve, reject) => {
1313
+ const conn = createConnection(sock);
1314
+ conn.on("error", (err) => {
1315
+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED") {
1316
+ reject(new Error("daemon not running"));
1317
+ } else {
1318
+ reject(err);
1319
+ }
1320
+ });
1321
+ conn.on("connect", () => {
1322
+ conn.write(JSON.stringify(req) + "\n");
1323
+ });
1324
+ let buf = "";
1325
+ conn.on("data", (data) => {
1326
+ buf += data.toString();
1327
+ const nl = buf.indexOf("\n");
1328
+ if (nl !== -1) {
1329
+ const res = JSON.parse(buf.slice(0, nl));
1330
+ conn.end();
1331
+ resolve(res);
1332
+ }
1333
+ });
1334
+ });
1335
+ }
1336
+
1337
+ function sendCommand(req) {
1338
+ return new Promise((resolve, reject) => {
1339
+ const conn = createConnection(SOCKET_PATH);
1340
+ conn.on("error", (err) => {
1341
+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED") {
1342
+ reject(new Error("daemon not running. start with: pty-mgr daemon"));
1343
+ } else {
1344
+ reject(err);
1345
+ }
1346
+ });
1347
+ conn.on("connect", () => {
1348
+ conn.write(JSON.stringify(req) + "\n");
1349
+ });
1350
+ let buf = "";
1351
+ conn.on("data", (data) => {
1352
+ buf += data.toString();
1353
+ const nl = buf.indexOf("\n");
1354
+ if (nl !== -1) {
1355
+ const res = JSON.parse(buf.slice(0, nl));
1356
+ conn.end();
1357
+ resolve(res);
1358
+ }
1359
+ });
1360
+ });
1361
+ }
1362
+
1363
+ /**
1364
+ * attach: interactive streaming connection to a session.
1365
+ * puts terminal in raw mode, forwards keystrokes, streams output.
1366
+ * ctrl-] to detach.
1367
+ */
1368
+ function attachToSession(name) {
1369
+ return new Promise((resolve, reject) => {
1370
+ const conn = createConnection(SOCKET_PATH);
1371
+
1372
+ conn.on("error", (err) => {
1373
+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED") {
1374
+ reject(new Error("daemon not running. start with: pty-mgr daemon"));
1375
+ } else {
1376
+ reject(err);
1377
+ }
1378
+ });
1379
+
1380
+ conn.on("connect", () => {
1381
+ // send attach request
1382
+ conn.write(JSON.stringify({ cmd: "attach", name }) + "\n");
1383
+
1384
+ let gotAck = false;
1385
+ let headerBuf = "";
1386
+
1387
+ conn.on("data", (data) => {
1388
+ if (!gotAck) {
1389
+ // first line is the JSON ack
1390
+ headerBuf += data.toString();
1391
+ const nl = headerBuf.indexOf("\n");
1392
+ if (nl === -1) return;
1393
+
1394
+ const ackStr = headerBuf.slice(0, nl);
1395
+ const remainder = headerBuf.slice(nl + 1);
1396
+
1397
+ let ack;
1398
+ try {
1399
+ ack = JSON.parse(ackStr);
1400
+ if (!ack.ok) {
1401
+ console.error("error:", ack.error);
1402
+ conn.end();
1403
+ reject(new Error(ack.error));
1404
+ return;
1405
+ }
1406
+ } catch {
1407
+ console.error("bad ack from daemon");
1408
+ conn.end();
1409
+ reject(new Error("bad ack"));
1410
+ return;
1411
+ }
1412
+
1413
+ gotAck = true;
1414
+
1415
+ // resize client terminal to match session
1416
+ if (ack.cols && ack.rows) {
1417
+ // CSI 8 ; rows ; cols t = resize terminal window
1418
+ process.stdout.write(`\x1b[8;${ack.rows};${ack.cols}t`);
1419
+ }
1420
+
1421
+ // put terminal in raw mode
1422
+ process.stdin.setRawMode(true);
1423
+ process.stdin.resume();
1424
+
1425
+ console.log(`attached to '${name}' (ctrl-] to detach)\r`);
1426
+
1427
+ // write any remaining data after the ack
1428
+ if (remainder) {
1429
+ process.stdout.write(remainder);
1430
+ }
1431
+
1432
+ // forward keystrokes to daemon -> pty
1433
+ process.stdin.on("data", (key) => {
1434
+ // ctrl-] (0x1d) = detach
1435
+ if (key.length === 1 && key[0] === 0x1d) {
1436
+ detach();
1437
+ return;
1438
+ }
1439
+ conn.write(key);
1440
+ });
1441
+
1442
+ return;
1443
+ }
1444
+
1445
+ // streaming mode: write pty output to terminal
1446
+ process.stdout.write(data);
1447
+ });
1448
+
1449
+ conn.on("close", () => {
1450
+ detach();
1451
+ });
1452
+
1453
+ let detached = false;
1454
+ function detach() {
1455
+ if (detached) return;
1456
+ detached = true;
1457
+ if (process.stdin.isRaw) {
1458
+ process.stdin.setRawMode(false);
1459
+ process.stdin.pause();
1460
+ process.stdin.removeAllListeners("data");
1461
+ }
1462
+ conn.end();
1463
+ console.log("\r\ndetached");
1464
+ resolve();
1465
+ }
1466
+ });
1467
+ });
1468
+ }
1469
+
1470
+ // ─── CLI entry point ─────────────────────────────────────────────────
1471
+
1472
+ const USAGE = `pty-mgr - PTY session manager
1473
+
1474
+ usage:
1475
+ p daemon start daemon (background: &)
1476
+ p daemon @myproject named daemon (isolated sessions)
1477
+ p status daemon info + config
1478
+ p config show current config
1479
+ p config screen 100x50 set default terminal size
1480
+ p config cap-on-send on|off return capture with every send
1481
+ p config send-delay <ms> delay before enter (default 1000)
1482
+ p spawn <name> [cmd] [args...] create session
1483
+ p wrap [cmd] [args...] spawn with auto-incrementing cwd name
1484
+ p attach <name> interactive mode (ctrl-] detach)
1485
+ p send <name> <text> send text + enter
1486
+ p send <name> --raw <text> send text as-is (no enter)
1487
+ p capture <name> [lines] capture screen output
1488
+ p capture all [lines] capture from all sessions
1489
+ p capture <glob*> [lines] capture matching sessions
1490
+ p list list all sessions
1491
+ p alive <name> check if alive
1492
+ p info <name> session details
1493
+ p kill <name> kill session
1494
+ p kill all kill all sessions
1495
+ p kill <glob*> kill matching sessions
1496
+ p rename <old> <new> rename a session
1497
+ p remove <name|all|glob*> kill + remove
1498
+ p log <name> on [jsonl|raw|rendered] start logging
1499
+ p log <name> off stop logging
1500
+ p spawn <name> --log [cmd] spawn with logging (jsonl)
1501
+ p stop stop current daemon
1502
+ p stop all stop all daemons
1503
+ p setup wrap CLI tools (claude, etc.)
1504
+ p demo run self-test (no daemon needed)
1505
+ p tg <message> send telegram notification
1506
+ p tg <message> --reply send message and wait for reply (blocking)
1507
+ p tg <message> --reply --timeout N wait N seconds for reply (default: 60)
1508
+
1509
+ shortcuts:
1510
+ n|new = spawn w|wrap = wrap s = send c|cap = capture
1511
+ st = status a = attach k = kill
1512
+ l|ls = list i = info r|rm = remove
1513
+ mv|ren = rename
1514
+ d = daemon cfg = config x = stop
1515
+
1516
+ examples:
1517
+ p daemon &
1518
+ p @myproject daemon &
1519
+ p @myproject spawn agent-1 claude
1520
+ p spawn my-agent claude --print
1521
+ p wrap # spawns: pty-mgr-1
1522
+ p wrap # spawns: pty-mgr-2
1523
+ p wrap claude # spawns: pty-mgr-1 running claude
1524
+ p attach my-agent
1525
+ p send my-agent "fix the login bug"
1526
+ p capture my-agent 20
1527
+ p capture all 50
1528
+ p kill refa*
1529
+ p config screen 120x40`;
1530
+
1531
+ function formatUptime(ms) {
1532
+ const s = Math.floor(ms / 1000);
1533
+ const m = Math.floor(s / 60);
1534
+ const h = Math.floor(m / 60);
1535
+ const d = Math.floor(h / 24);
1536
+ if (d > 0) return `${d}d ${h % 24}h ${m % 60}m`;
1537
+ if (h > 0) return `${h}h ${m % 60}m ${s % 60}s`;
1538
+ if (m > 0) return `${m}m ${s % 60}s`;
1539
+ return `${s}s`;
1540
+ }
1541
+
1542
+ function sleep(ms) {
1543
+ return new Promise((r) => setTimeout(r, ms));
1544
+ }
1545
+
1546
+ async function runDemo() {
1547
+ const mgr = new PtyManager();
1548
+
1549
+ console.log("--- pty-manager demo (xterm-headless) ---\n");
1550
+
1551
+ console.log("1. spawn 'test-shell' (zsh in pty + xterm emulation)");
1552
+ mgr.spawn("test-shell", "zsh", [], { cols: 120, rows: 30 });
1553
+ await sleep(1000);
1554
+
1555
+ console.log("2. sendKeys: echo hello-from-pty");
1556
+ mgr.sendKeys("test-shell", "echo hello-from-pty\n");
1557
+ await sleep(600);
1558
+
1559
+ console.log("3. capture (last 5 lines):");
1560
+ console.log(" ---");
1561
+ for (const l of mgr.capture("test-shell", 5).split("\n")) {
1562
+ console.log(" | " + l);
1563
+ }
1564
+ console.log(" ---\n");
1565
+
1566
+ console.log("4. sendKeys: ls | head -3");
1567
+ mgr.sendKeys("test-shell", "ls | head -3\n");
1568
+ await sleep(800);
1569
+
1570
+ console.log("5. capture (last 8 lines):");
1571
+ console.log(" ---");
1572
+ for (const l of mgr.capture("test-shell", 8).split("\n")) {
1573
+ console.log(" | " + l);
1574
+ }
1575
+ console.log(" ---\n");
1576
+
1577
+ console.log("6. alive:", mgr.isAlive("test-shell"));
1578
+ console.log(" pid:", mgr.pid("test-shell"));
1579
+
1580
+ console.log("\n7. sessions:");
1581
+ for (const s of mgr.list()) {
1582
+ console.log(
1583
+ ` ${s.name} pid=${s.pid} ${s.terminalSize} alive=${s.alive}`
1584
+ );
1585
+ }
1586
+
1587
+ console.log("\n8. waitFor: echo MARKER_42");
1588
+ mgr.sendKeys("test-shell", "echo MARKER_42\n");
1589
+ const match = await mgr.waitFor("test-shell", /MARKER_42/, 5000);
1590
+ console.log(" matched:", match.trim());
1591
+
1592
+ console.log("\n9. tty check:");
1593
+ mgr.sendKeys(
1594
+ "test-shell",
1595
+ 'python3 -c "import sys; print(\'isatty:\', sys.stdout.isatty())"\n'
1596
+ );
1597
+ await sleep(800);
1598
+ const ttyLine = mgr
1599
+ .capture("test-shell", 5)
1600
+ .split("\n")
1601
+ .find((l) => l.includes("isatty:"));
1602
+ console.log(" " + (ttyLine || "(not found)").trim());
1603
+
1604
+ console.log("\n10. kill");
1605
+ mgr.kill("test-shell");
1606
+ await mgr.waitForExit("test-shell", 5000).catch(() => {});
1607
+ console.log(" alive:", mgr.isAlive("test-shell"));
1608
+
1609
+ console.log("\n11. post-mortem (last 3 lines):");
1610
+ for (const l of mgr.capture("test-shell", 3).split("\n")) {
1611
+ console.log(" | " + l);
1612
+ }
1613
+
1614
+ mgr.destroyAll();
1615
+ console.log("\n--- demo complete ---");
1616
+ }
1617
+
1618
+ function ask(question) {
1619
+ process.stdout.write(question);
1620
+ const byte = Buffer.alloc(1);
1621
+ let line = "";
1622
+ while (true) {
1623
+ const n = readSync(0, byte, 0, 1);
1624
+ if (n === 0) break;
1625
+ const ch = byte.toString("utf-8");
1626
+ if (ch === "\n") break;
1627
+ line += ch;
1628
+ }
1629
+ return line.trim();
1630
+ }
1631
+
1632
+ function wrapperFunction(cmd) {
1633
+ return `
1634
+ # pty-mgr: managed ${cmd} sessions
1635
+ ${cmd}() {
1636
+ command -v pty-mgr >/dev/null 2>&1 || command -v p >/dev/null 2>&1 || { command ${cmd} "$@"; return; }
1637
+ local _p
1638
+ _p=$(command -v p 2>/dev/null || command -v pty-mgr 2>/dev/null)
1639
+ $_p status >/dev/null 2>&1 || $_p daemon
1640
+ local _name
1641
+ _name=$($_p wrap ${cmd} "$@" 2>&1 | awk '{print $1}')
1642
+ if [ -z "$_name" ]; then
1643
+ echo "pty-mgr: wrap failed" >&2
1644
+ return 1
1645
+ fi
1646
+ $_p attach "$_name"
1647
+ }`;
1648
+ }
1649
+
1650
+ function detectRcFile() {
1651
+ const shell = process.env.SHELL || "";
1652
+ if (shell.endsWith("/zsh")) return join(homedir(), ".zshrc");
1653
+ if (shell.endsWith("/bash")) return join(homedir(), ".bashrc");
1654
+ // check both
1655
+ const zshrc = join(homedir(), ".zshrc");
1656
+ const bashrc = join(homedir(), ".bashrc");
1657
+ if (existsSync(zshrc)) return zshrc;
1658
+ if (existsSync(bashrc)) return bashrc;
1659
+ return join(homedir(), ".bashrc");
1660
+ }
1661
+
1662
+ async function runSetup() {
1663
+ const { appendFileSync, readFileSync } = await import("node:fs");
1664
+
1665
+ console.log("pty-mgr setup");
1666
+ console.log("wrap CLI tools in managed PTY sessions.\n");
1667
+ console.log("when you type a wrapped command (e.g. claude), pty-mgr will:");
1668
+ console.log(" - auto-start the daemon if needed");
1669
+ console.log(" - create a session named <folder>-1 (increments if taken)");
1670
+ console.log(" - attach you to it (ctrl-] to detach)\n");
1671
+
1672
+ const rcFile = detectRcFile();
1673
+ let rcContent = "";
1674
+ try { rcContent = readFileSync(rcFile, "utf-8"); } catch {}
1675
+
1676
+ const wrapped = [];
1677
+
1678
+ const SUGGESTIONS = ["claude", "codex", "gemini"];
1679
+ for (const cmd of SUGGESTIONS) {
1680
+ const answer = ask(`wrap '${cmd}'? [y/n] `);
1681
+ if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
1682
+ wrapped.push(cmd);
1683
+ }
1684
+ }
1685
+
1686
+ // ask for custom commands
1687
+ while (true) {
1688
+ const custom = ask("wrap another command? (enter name or 'no') ");
1689
+ if (!custom || custom.toLowerCase() === "no" || custom.toLowerCase() === "n") break;
1690
+ const cmd = custom.trim().split(/\s+/)[0];
1691
+ if (cmd) wrapped.push(cmd);
1692
+ }
1693
+
1694
+ if (wrapped.length === 0) {
1695
+
1696
+ console.log("\nno commands selected. you can run 'pty-mgr setup' again anytime.");
1697
+ return;
1698
+ }
1699
+
1700
+ // write to rc file
1701
+ let added = [];
1702
+ for (const cmd of wrapped) {
1703
+ const marker = `# pty-mgr: managed ${cmd} sessions`;
1704
+ if (rcContent.includes(marker)) {
1705
+ console.log(`'${cmd}' already in ${rcFile}, skipping`);
1706
+ continue;
1707
+ }
1708
+ const fn = wrapperFunction(cmd);
1709
+ appendFileSync(rcFile, "\n" + fn + "\n");
1710
+ added.push(cmd);
1711
+ }
1712
+
1713
+ closeAsk();
1714
+
1715
+ if (added.length > 0) {
1716
+ console.log(`\nadded to ${rcFile}: ${added.join(", ")}`);
1717
+ console.log("\nrestart your shell or run:");
1718
+ console.log(` source ${rcFile}`);
1719
+ } else {
1720
+ console.log("\nnothing new to add.");
1721
+ }
1722
+ }
1723
+
1724
+ async function cli() {
1725
+ // strip @name and --daemon <name> from argv (already consumed by getDaemonName)
1726
+ const cleaned = process.argv.slice(2).filter((a, i, arr) => {
1727
+ if (a.startsWith("@")) return false;
1728
+ if (a === "--daemon") return false;
1729
+ if (i > 0 && arr[i - 1] === "--daemon") return false;
1730
+ return true;
1731
+ });
1732
+ const [rawCommand, ...args] = cleaned;
1733
+
1734
+ // command aliases - short forms
1735
+ const ALIASES = {
1736
+ n: "spawn", new: "spawn",
1737
+ w: "wrap", wrap: "wrap",
1738
+ s: "send",
1739
+ c: "capture", cap: "capture",
1740
+ st: "status",
1741
+ a: "attach",
1742
+ k: "kill",
1743
+ l: "list", ls: "list",
1744
+ i: "info",
1745
+ r: "remove", rm: "remove",
1746
+ mv: "rename", ren: "rename",
1747
+ d: "daemon",
1748
+ cfg: "config",
1749
+ x: "stop",
1750
+ log: "log",
1751
+ };
1752
+ const command = ALIASES[rawCommand] || rawCommand;
1753
+
1754
+ if (!command || command === "--help" || command === "-h") {
1755
+ console.log(USAGE);
1756
+ process.exit(0);
1757
+ }
1758
+
1759
+ if (command === "-v" || command === "--version" || command === "version") {
1760
+ console.log(VERSION);
1761
+ process.exit(0);
1762
+ }
1763
+
1764
+ if (command === "demo") {
1765
+ await runDemo();
1766
+ return;
1767
+ }
1768
+
1769
+ if (command === "setup") {
1770
+ await runSetup();
1771
+ return;
1772
+ }
1773
+
1774
+ if (command === "tg") {
1775
+ const flags = { reply: false, timeout: 60 };
1776
+ const parts = [];
1777
+ for (let i = 0; i < args.length; i++) {
1778
+ if (args[i] === "--reply") { flags.reply = true; continue; }
1779
+ if (args[i] === "--timeout") { flags.timeout = parseInt(args[++i], 10); continue; }
1780
+ parts.push(args[i]);
1781
+ }
1782
+ const message = parts.join(" ");
1783
+ if (!message) {
1784
+ console.error("usage: p tg <message> [--reply] [--timeout <seconds>]");
1785
+ process.exit(1);
1786
+ }
1787
+ if (!flags.reply) {
1788
+ const res = await sendCommand({ cmd: "tg-send", args: { message, session: process.env.PTY_MGR_SESSION } });
1789
+ if (!res.ok) { console.error(res.error); process.exit(1); }
1790
+ process.exit(0);
1791
+ }
1792
+ const session = process.env.PTY_MGR_SESSION;
1793
+ const res = await sendCommand({ cmd: "tg-wait", args: { message, timeout: flags.timeout * 1000, session } });
1794
+ if (!res.ok) {
1795
+ if (res.error === "TIMEOUT") { console.error("timeout: no reply"); process.exit(2); }
1796
+ console.error(res.error);
1797
+ process.exit(1);
1798
+ }
1799
+ process.stdout.write(res.reply + "\n");
1800
+ process.exit(0);
1801
+ }
1802
+
1803
+ if (command === "daemon") {
1804
+ // fork into background as a true daemon (survives terminal close)
1805
+ if (!process.env.__PTY_DAEMON_CHILD) {
1806
+ const { spawn: cpSpawn } = await import("node:child_process");
1807
+ // re-exec ourselves with the same args
1808
+ // process.execPath = bun (dev) or the compiled binary
1809
+ // filter out internal /$bunfs/ paths from argv
1810
+ const realArgs = process.argv.slice(1).filter(a => !a.startsWith("/$bunfs/"));
1811
+ const child = cpSpawn(process.execPath, realArgs, {
1812
+ detached: true,
1813
+ stdio: ["ignore", "ignore", "ignore", "ipc"],
1814
+ env: { ...process.env, __PTY_DAEMON_CHILD: "1" },
1815
+ });
1816
+ // wait for daemon to report ready
1817
+ child.on("message", (msg) => {
1818
+ if (msg.ready) {
1819
+ console.log(`pty-manager daemon (${DAEMON_NAME}) started pid=${child.pid}`);
1820
+ child.unref();
1821
+ child.disconnect();
1822
+ process.exit(0);
1823
+ }
1824
+ });
1825
+ child.on("error", (err) => {
1826
+ console.error("failed to start daemon:", err.message);
1827
+ process.exit(1);
1828
+ });
1829
+ // timeout if daemon doesn't report ready
1830
+ setTimeout(() => {
1831
+ console.error("daemon startup timeout");
1832
+ process.exit(1);
1833
+ }, 5000);
1834
+ return;
1835
+ }
1836
+ // we ARE the forked child - start the daemon
1837
+ startDaemon();
1838
+ return;
1839
+ }
1840
+
1841
+ if (command === "stop") {
1842
+ const target = args[0]; // "all" or undefined (= current daemon)
1843
+ if (target === "all") {
1844
+ // find all pty-manager sockets and shut them down
1845
+ const { readdirSync } = await import("node:fs");
1846
+ const dir = join(homedir(), ".pty-manager");
1847
+ let socks = [];
1848
+ try {
1849
+ socks = readdirSync(dir).filter((f) => f.endsWith(".sock"));
1850
+ } catch { /* dir doesn't exist */ }
1851
+ // also check legacy /tmp/ location for old sockets
1852
+ try {
1853
+ const tmp = tmpdir();
1854
+ const legacy = readdirSync(tmp).filter((f) => f.startsWith("pty-manager-") && f.endsWith(".sock"));
1855
+ for (const s of legacy) socks.push("__legacy__/" + s);
1856
+ } catch {}
1857
+ if (socks.length === 0) {
1858
+ console.log("no daemons running");
1859
+ return;
1860
+ }
1861
+ const stopped = [];
1862
+ for (const sock of socks) {
1863
+ let sockFile, name;
1864
+ if (sock.startsWith("__legacy__/")) {
1865
+ const legacyName = sock.replace("__legacy__/", "");
1866
+ sockFile = join(tmpdir(), legacyName);
1867
+ name = legacyName.replace("pty-manager-", "").replace(".sock", "");
1868
+ } else {
1869
+ sockFile = join(dir, sock);
1870
+ name = sock.replace(".sock", "");
1871
+ }
1872
+ try {
1873
+ const res = await sendCommandTo(sockFile, { cmd: "shutdown" });
1874
+ if (res.ok) stopped.push(name);
1875
+ } catch {
1876
+ // stale socket, clean it up
1877
+ try { unlinkSync(sockFile); } catch {}
1878
+ stopped.push(name + " (stale)");
1879
+ }
1880
+ }
1881
+ console.log(`stopped: ${stopped.join(", ")}`);
1882
+ } else {
1883
+ // stop current daemon (based on @name or default)
1884
+ try {
1885
+ const res = await sendCommand({ cmd: "shutdown" });
1886
+ if (res.ok) console.log(`stopped: ${res.stopped}`);
1887
+ } catch {
1888
+ console.log("daemon not running");
1889
+ }
1890
+ }
1891
+ return;
1892
+ }
1893
+
1894
+ if (command === "attach") {
1895
+ const name = args[0];
1896
+ if (!name) {
1897
+ console.error("usage: pty-mgr attach <name>");
1898
+ process.exit(1);
1899
+ }
1900
+ await attachToSession(name);
1901
+ return;
1902
+ }
1903
+
1904
+ // all other commands go through the daemon
1905
+ const name = args[0];
1906
+
1907
+ let req;
1908
+ switch (command) {
1909
+ case "status":
1910
+ req = { cmd: "status" };
1911
+ break;
1912
+ case "config": {
1913
+ const key = args[0];
1914
+ const value = args[1];
1915
+ req = { cmd: "config", args: { key, value } };
1916
+ break;
1917
+ }
1918
+ case "spawn": {
1919
+ const hasLog = args.includes("--log");
1920
+ const spawnArgs = args.slice(1).filter((a) => a !== "--log");
1921
+ const cmd = spawnArgs[0] || "zsh";
1922
+ const cmdArgs = spawnArgs.slice(1);
1923
+ req = { cmd: "spawn", name, args: { cmd, args: cmdArgs, cwd: process.cwd(), log: hasLog } };
1924
+ break;
1925
+ }
1926
+ case "wrap": {
1927
+ const hasLog = args.includes("--log");
1928
+ const wrapArgs = args.filter((a) => a !== "--log");
1929
+ req = {
1930
+ cmd: "wrap",
1931
+ args: {
1932
+ cmd: wrapArgs[0] || "zsh",
1933
+ args: wrapArgs.slice(1),
1934
+ cwd: process.cwd(),
1935
+ log: hasLog,
1936
+ },
1937
+ };
1938
+ break;
1939
+ }
1940
+ case "send": {
1941
+ // --raw flag: send as-is, no typewriter, no enter
1942
+ const raw = args.includes("--raw");
1943
+ const textParts = args.slice(1).filter((a) => a !== "--raw");
1944
+ let text = textParts.join(" ");
1945
+ // replace literal \r and \n with actual control chars
1946
+ text = text.replace(/\\r/g, "\r").replace(/\\n/g, "\n");
1947
+ req = { cmd: "send", name, args: { text, raw, enter: !raw } };
1948
+ break;
1949
+ }
1950
+ case "capture": {
1951
+ // capture all 50, capture refa* 20, capture myagent 10
1952
+ // --screen: visible rows only (skip scrollback, good for TUI apps)
1953
+ const screenFlag = args.includes("--screen") || args.includes("-s");
1954
+ const capArgs = args.filter((a) => a !== "--screen" && a !== "-s");
1955
+ const lines = capArgs[1] ? parseInt(capArgs[1], 10) : undefined;
1956
+ req = { cmd: "capture", name, args: { lines, screen: screenFlag } };
1957
+ break;
1958
+ }
1959
+ case "list":
1960
+ req = { cmd: "list" };
1961
+ break;
1962
+ case "alive":
1963
+ req = { cmd: "alive", name };
1964
+ break;
1965
+ case "info":
1966
+ req = { cmd: "info", name };
1967
+ break;
1968
+ case "kill":
1969
+ req = { cmd: "kill", name };
1970
+ break;
1971
+ case "remove":
1972
+ req = { cmd: "remove", name };
1973
+ break;
1974
+ case "pid":
1975
+ req = { cmd: "pid", name };
1976
+ break;
1977
+ case "rename": {
1978
+ const newName = args[1];
1979
+ if (!newName) {
1980
+ console.error("usage: pty-mgr rename <old> <new>");
1981
+ process.exit(1);
1982
+ }
1983
+ req = { cmd: "rename", name, args: { newName } };
1984
+ break;
1985
+ }
1986
+ case "log": {
1987
+ // p log <name> on [format] / p log <name> off
1988
+ const action = args[1] || "on";
1989
+ const format = args[2] || "jsonl";
1990
+ req = { cmd: "log", name, args: { action, format } };
1991
+ break;
1992
+ }
1993
+ default:
1994
+ console.error(`unknown command: ${command}`);
1995
+ console.log(USAGE);
1996
+ process.exit(1);
1997
+ }
1998
+
1999
+ try {
2000
+ const res = await sendCommand(req);
2001
+
2002
+ if (!res.ok) {
2003
+ console.error("error:", res.error);
2004
+ process.exit(1);
2005
+ }
2006
+
2007
+
2008
+ // format output based on command
2009
+ if (command === "status") {
2010
+ const st = res.status;
2011
+ console.log(`pty-manager daemon (${st.name})`);
2012
+ console.log(` pid: ${st.pid}`);
2013
+ console.log(` socket: ${st.socket}`);
2014
+ console.log(` uptime: ${st.uptime}`);
2015
+ console.log(` sessions: ${st.sessions.alive} alive, ${st.sessions.dead} dead, ${st.sessions.total} total`);
2016
+ console.log(` screen: ${st.config.cols}x${st.config.rows}`);
2017
+ console.log(` cap-on-send: ${st.config.capOnSend ? "on" : "off"}`);
2018
+ } else if (command === "config") {
2019
+ if (res.config) {
2020
+ for (const [k, v] of Object.entries(res.config)) {
2021
+ console.log(`${k}: ${v}`);
2022
+ }
2023
+ }
2024
+ } else if (command === "capture") {
2025
+ if (res.results) {
2026
+ // multi-capture (all or glob)
2027
+ for (const [sname, output] of Object.entries(res.results)) {
2028
+ console.log(`--- ${sname} ---`);
2029
+ console.log(output);
2030
+ console.log();
2031
+ }
2032
+ } else {
2033
+ console.log(res.output);
2034
+ }
2035
+ } else if (command === "send") {
2036
+ if (res.output) {
2037
+ // cap-on-send enabled
2038
+ console.log(res.output);
2039
+ } else {
2040
+ console.log("ok");
2041
+ }
2042
+ } else if (command === "list") {
2043
+ if (res.sessions.length === 0) {
2044
+ console.log("no sessions");
2045
+ } else {
2046
+ for (const s of res.sessions) {
2047
+ const status = s.alive ? "alive" : `exited(${s.exitCode})`;
2048
+ console.log(
2049
+ `${s.name} pid=${s.pid} ${s.terminalSize} ${status} ${s.cmd}`
2050
+ );
2051
+ }
2052
+ }
2053
+ } else if (command === "kill") {
2054
+ if (res.killed) {
2055
+ console.log(`killed: ${res.killed.join(", ")}`);
2056
+ }
2057
+ } else if (command === "remove") {
2058
+ if (res.removed) {
2059
+ console.log(`removed: ${res.removed.join(", ")}`);
2060
+ }
2061
+ } else if (command === "rename") {
2062
+ console.log(`renamed: ${res.oldName} -> ${res.newName}`);
2063
+ } else if (command === "alive") {
2064
+ console.log(res.alive ? "alive" : "dead");
2065
+ } else if (command === "info") {
2066
+ console.log(JSON.stringify(res.info, null, 2));
2067
+ } else if (command === "spawn") {
2068
+ let out = `${name} pid=${res.pid}`;
2069
+ if (res.logPath) out += ` log=${res.logPath}`;
2070
+ console.log(out);
2071
+ } else if (command === "wrap") {
2072
+ console.log(`${res.name} pid=${res.pid}`);
2073
+ } else if (command === "log") {
2074
+ if (res.stopped) {
2075
+ console.log(`logging stopped${res.path ? ": " + res.path : ""}`);
2076
+ } else {
2077
+ console.log(`logging: ${res.path} format=${res.format}`);
2078
+ }
2079
+ } else if (command === "pid") {
2080
+ console.log(res.pid);
2081
+ } else {
2082
+ console.log("ok");
2083
+ }
2084
+ } catch (err) {
2085
+ if (command === "status") {
2086
+ console.log("pty-manager daemon: not running");
2087
+ } else {
2088
+ console.error(err.message);
2089
+ }
2090
+ process.exit(1);
2091
+ }
2092
+ }
2093
+
2094
+ const _basename0 = process.argv[0] && process.argv[0].split("/").pop();
2095
+ const _basename1 = process.argv[1] && process.argv[1].split("/").pop();
2096
+ const _pat = /^(pty-manager(\.mjs)?|pty-mgr(\.mjs)?|p)$/;
2097
+ const _isBunCompiled = process.versions?.bun && process.argv[1]?.startsWith("/$bunfs/");
2098
+ const isMain = _isBunCompiled || (_basename1 && _pat.test(_basename1)) || (_basename0 && _pat.test(_basename0));
2099
+
2100
+ if (isMain) {
2101
+ cli().catch((err) => {
2102
+ console.error(err);
2103
+ process.exit(1);
2104
+ });
2105
+ }