@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.
- package/README.md +206 -0
- package/bin/pty-mgr +71 -0
- package/lib/pty-manager.mjs +2105 -0
- package/package.json +55 -0
|
@@ -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
|
+
}
|