@mentiko/pty-mgr 1.4.1 → 1.4.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/CHANGELOG.md +60 -0
- package/lib/pty-manager.mjs +233 -179
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.3 - 2026-07-07
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- `p attach` now sizes the session to the attaching client's terminal instead of
|
|
8
|
+
resizing the client to the session. The old app-driven CSI-8 resize is ignored
|
|
9
|
+
inside tmux/iTerm panes (or resizes the whole window), so a session viewed in a
|
|
10
|
+
smaller pane kept its default winsize — which made a full-frame TUI (e.g. Claude
|
|
11
|
+
Code) flicker its status line and pushed its bottom row off-screen. The client
|
|
12
|
+
now sends its size in the attach request and the daemon resizes the session
|
|
13
|
+
(SIGWINCHing the child) to match.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Live-resize while attached: when the client's terminal changes size, the
|
|
18
|
+
session (and its child) resize to follow. The client sends an out-of-band APC
|
|
19
|
+
control frame that the daemon strips from the raw input stream, so it never
|
|
20
|
+
reaches the pty as keystrokes.
|
|
21
|
+
|
|
22
|
+
## 1.4.2 - 2026-07-07
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Internal refactor of `lib/pty-manager.mjs`: the two near-identical socket
|
|
27
|
+
clients collapse into one `requestSocket`, and the terminal-size clamps,
|
|
28
|
+
`--log` wiring, telegram send, capture-stability check, and transcript
|
|
29
|
+
listing move to shared helpers. No behavior or public API change.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- Hardened error handling on three paths that could take the daemon down: the
|
|
34
|
+
socket client now turns a malformed or truncated reply into a rejection
|
|
35
|
+
instead of an uncaught throw in its data handler; the log write stream gets an
|
|
36
|
+
`error` listener (a disk-full / permission error drops logging instead of
|
|
37
|
+
crashing); and attach-mode input is guarded so a keystroke sent to a
|
|
38
|
+
just-exited session no longer throws in the socket handler.
|
|
39
|
+
|
|
40
|
+
## 1.4.1 - 2026-07-07
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- `p attach` replays the session's full terminal state via the xterm
|
|
45
|
+
serialize-addon (scrollback, colors, cursor, modes). Normal-buffer sessions
|
|
46
|
+
(a shell, Claude Code, …) are no longer forced into the alternate screen —
|
|
47
|
+
which had discarded scrollback — so history stays scrollable in the client.
|
|
48
|
+
Alt-screen TUIs still get the alt-screen switch, and the client pops back to
|
|
49
|
+
its normal screen on detach.
|
|
50
|
+
|
|
51
|
+
## 1.4.0 - 2026-07-07
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- `p attach` replays full scrollback history on connect, not just the visible
|
|
56
|
+
screen.
|
|
57
|
+
- Layered flow config resolution: flows merge from the packaged defaults, the
|
|
58
|
+
XDG user config, and a project `pty-mgr.config.json` (project overrides user
|
|
59
|
+
overrides default). `p flow list` tags each flow with its source layer,
|
|
60
|
+
`p flow new [--global]` scaffolds a project (or user) flow, and `p open
|
|
61
|
+
config` opens the config directory.
|
|
62
|
+
|
|
3
63
|
## 1.3.1 - 2026-07-02
|
|
4
64
|
|
|
5
65
|
### Fixed
|
package/lib/pty-manager.mjs
CHANGED
|
@@ -1,30 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* pty-manager.mjs - PTY-based agent session manager
|
|
3
|
+
* pty-manager.mjs (v2) - PTY-based agent session manager
|
|
4
4
|
*
|
|
5
5
|
* Uses:
|
|
6
6
|
* - Bun.spawn native PTY support (no python, no native addons)
|
|
7
7
|
* - @xterm/headless terminal emulator (parses escape codes into screen)
|
|
8
8
|
*
|
|
9
|
-
* capture() returns the actual rendered screen state, not raw output
|
|
9
|
+
* capture() returns the actual rendered screen state, not raw output:
|
|
10
10
|
* spinners, progress bars, cursor movements, and TUI redraws are all
|
|
11
11
|
* resolved into clean text.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* mgr.kill(name) kill session
|
|
19
|
-
* mgr.list() list sessions
|
|
20
|
-
* mgr.pid(name) get child process pid
|
|
13
|
+
* v2 refactor: deduplicated the socket client, terminal-size clamps,
|
|
14
|
+
* capture-stability check, `--log` wiring, telegram send, and transcript
|
|
15
|
+
* listing; hardened error handling on the socket read path, the attach
|
|
16
|
+
* input path, and the log write stream. Behavior and the public API are
|
|
17
|
+
* unchanged (see the test suite).
|
|
21
18
|
*
|
|
22
|
-
*
|
|
23
|
-
* import { PtyManager } from './pty-manager.mjs';
|
|
19
|
+
* Library API:
|
|
24
20
|
* const mgr = new PtyManager();
|
|
25
|
-
* mgr.spawn('agent-1', 'claude', ['--print']);
|
|
26
|
-
* mgr.sendKeys('agent-1', 'fix the bug\r');
|
|
27
|
-
*
|
|
21
|
+
* mgr.spawn('agent-1', 'claude', ['--print']); // create session
|
|
22
|
+
* mgr.sendKeys('agent-1', 'fix the bug\r'); // send keystrokes
|
|
23
|
+
* mgr.capture('agent-1', 40); // rendered screen
|
|
24
|
+
* mgr.has(name) / mgr.kill(name) / mgr.list() / mgr.pid(name)
|
|
28
25
|
*/
|
|
29
26
|
|
|
30
27
|
import { EventEmitter } from "node:events";
|
|
@@ -39,12 +36,13 @@ import {
|
|
|
39
36
|
readdirSync,
|
|
40
37
|
statSync,
|
|
41
38
|
writeFileSync,
|
|
39
|
+
appendFileSync,
|
|
42
40
|
} from "node:fs";
|
|
43
41
|
import { fileURLToPath } from "node:url";
|
|
44
42
|
import { spawn as spawnChild } from "node:child_process";
|
|
45
43
|
|
|
46
44
|
// kept in sync by scripts/version-sync.cjs (rewrites this literal on release)
|
|
47
|
-
export const VERSION = "1.4.
|
|
45
|
+
export const VERSION = "1.4.3";
|
|
48
46
|
import xterm from "@xterm/headless";
|
|
49
47
|
import { SerializeAddon } from "@xterm/addon-serialize";
|
|
50
48
|
|
|
@@ -68,6 +66,15 @@ export function validateSessionName(name) {
|
|
|
68
66
|
}
|
|
69
67
|
}
|
|
70
68
|
|
|
69
|
+
// terminal-size clamps: keep buffers sane, prevent memory exhaustion from a
|
|
70
|
+
// hostile/typo'd size. shared by PtySession.resize and the daemon spawn/wrap.
|
|
71
|
+
function clampCols(value, fallback) {
|
|
72
|
+
return Math.max(20, Math.min(500, value || fallback));
|
|
73
|
+
}
|
|
74
|
+
function clampRows(value, fallback) {
|
|
75
|
+
return Math.max(5, Math.min(200, value || fallback));
|
|
76
|
+
}
|
|
77
|
+
|
|
71
78
|
class PtySession {
|
|
72
79
|
constructor(name, opts = {}) {
|
|
73
80
|
this.name = name;
|
|
@@ -152,16 +159,9 @@ class PtySession {
|
|
|
152
159
|
}
|
|
153
160
|
|
|
154
161
|
/**
|
|
155
|
-
* capture the rendered screen
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* escape codes, cursor movements, line erases -- all resolved.
|
|
159
|
-
* equivalent of reading the full terminal screen buffer.
|
|
160
|
-
*
|
|
161
|
-
* @param {number} [tailLines] - last N lines (0 or omit = visible screen)
|
|
162
|
-
* @param {object} [opts] - options
|
|
163
|
-
* @param {boolean} [opts.screen] - only capture visible rows (skip scrollback)
|
|
164
|
-
* @returns {string}
|
|
162
|
+
* capture the rendered screen: what you'd see now, with escape codes, cursor
|
|
163
|
+
* moves and line-erases all resolved. tailLines>0 returns the last N lines;
|
|
164
|
+
* opts.screen limits to visible rows (skips scrollback).
|
|
165
165
|
*/
|
|
166
166
|
capture(tailLines, opts = {}) {
|
|
167
167
|
const buf = this.terminal.buffer.active;
|
|
@@ -187,21 +187,15 @@ class PtySession {
|
|
|
187
187
|
return lines.join("\n");
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
/**
|
|
191
|
-
* capture the rendered screen with ANSI color/attribute escape codes.
|
|
192
|
-
* uses @xterm/addon-serialize to preserve colors, bold, etc.
|
|
193
|
-
* @returns {string}
|
|
194
|
-
*/
|
|
190
|
+
/** rendered screen with ANSI color/attribute codes (via addon-serialize). */
|
|
195
191
|
captureAnsi() {
|
|
196
192
|
return this._serializer.serialize();
|
|
197
193
|
}
|
|
198
194
|
|
|
199
195
|
/**
|
|
200
|
-
*
|
|
201
|
-
* reads cells individually
|
|
202
|
-
*
|
|
203
|
-
* @param {number} [tailLines] - last N lines (0 = all content)
|
|
204
|
-
* @returns {string}
|
|
196
|
+
* visible content with ANSI colors, trimmed of leading/trailing empty lines.
|
|
197
|
+
* reads cells individually (line by line) so colors/attributes survive.
|
|
198
|
+
* tailLines>0 = last N lines.
|
|
205
199
|
*/
|
|
206
200
|
captureAnsiCompact(tailLines) {
|
|
207
201
|
const buf = this.terminal.buffer.active;
|
|
@@ -289,14 +283,10 @@ class PtySession {
|
|
|
289
283
|
return lines.join("\r\n");
|
|
290
284
|
}
|
|
291
285
|
|
|
292
|
-
/**
|
|
293
|
-
* resize the PTY and headless terminal.
|
|
294
|
-
* @param {number} cols
|
|
295
|
-
* @param {number} rows
|
|
296
|
-
*/
|
|
286
|
+
/** resize the PTY and headless terminal (clamped to sane bounds). */
|
|
297
287
|
resize(cols, rows) {
|
|
298
|
-
cols =
|
|
299
|
-
rows =
|
|
288
|
+
cols = clampCols(cols, cols);
|
|
289
|
+
rows = clampRows(rows, rows);
|
|
300
290
|
this.terminal.resize(cols, rows);
|
|
301
291
|
if (this._pty && !this.exited) {
|
|
302
292
|
try { this._pty.resize(cols, rows); } catch {}
|
|
@@ -335,15 +325,10 @@ class PtySession {
|
|
|
335
325
|
}
|
|
336
326
|
|
|
337
327
|
/**
|
|
338
|
-
* start logging session output to a file.
|
|
339
|
-
*
|
|
340
|
-
* format:
|
|
328
|
+
* start logging session output to a file. format:
|
|
341
329
|
* "raw" - raw PTY bytes (escape codes included, replayable)
|
|
342
|
-
* "rendered"
|
|
343
|
-
* "jsonl"
|
|
344
|
-
*
|
|
345
|
-
* @param {string} logPath - file path to write to
|
|
346
|
-
* @param {string} [format="raw"] - log format
|
|
330
|
+
* "rendered" - clean screen snapshots on each data event
|
|
331
|
+
* "jsonl" - timestamped JSON lines { t, type, data }
|
|
347
332
|
*/
|
|
348
333
|
startLog(logPath, format = "raw") {
|
|
349
334
|
if (this._logStream) this.stopLog();
|
|
@@ -354,6 +339,10 @@ class PtySession {
|
|
|
354
339
|
this._logPath = logPath;
|
|
355
340
|
this._logFormat = format;
|
|
356
341
|
this._logStream = createWriteStream(logPath, { flags: "a" });
|
|
342
|
+
// a write error (disk full, perms, unlinked path) emits 'error'; without a
|
|
343
|
+
// listener node throws it as uncaught and takes the daemon down. drop
|
|
344
|
+
// logging on error rather than crash the session.
|
|
345
|
+
this._logStream.on("error", () => { try { this.stopLog(); } catch {} });
|
|
357
346
|
|
|
358
347
|
// write header for jsonl
|
|
359
348
|
if (format === "jsonl") {
|
|
@@ -470,18 +459,9 @@ export class PtyManager {
|
|
|
470
459
|
}
|
|
471
460
|
|
|
472
461
|
/**
|
|
473
|
-
* spawn a command inside a real PTY with terminal emulation
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
* @param {string} cmd - command to run
|
|
477
|
-
* @param {string[]} [args] - arguments
|
|
478
|
-
* @param {object} [opts]
|
|
479
|
-
* @param {string} [opts.cwd] - working directory
|
|
480
|
-
* @param {object} [opts.env] - extra env vars
|
|
481
|
-
* @param {number} [opts.cols] - terminal columns (default 200)
|
|
482
|
-
* @param {number} [opts.rows] - terminal rows (default 50)
|
|
483
|
-
* @param {number} [opts.scrollback] - scrollback lines (default 5000)
|
|
484
|
-
* @returns {string} session name
|
|
462
|
+
* spawn a command inside a real PTY with terminal emulation. opts: cwd, env
|
|
463
|
+
* (extra vars), cols (default 100), rows (default 35), scrollback (5000).
|
|
464
|
+
* returns the session name.
|
|
485
465
|
*/
|
|
486
466
|
spawn(name, cmd, args = [], opts = {}) {
|
|
487
467
|
validateSessionName(name);
|
|
@@ -720,6 +700,54 @@ function socketPath(name) {
|
|
|
720
700
|
const { daemon: DAEMON_NAME, args: CLI_ARGS } = splitDaemonArgs(process.argv.slice(2));
|
|
721
701
|
const SOCKET_PATH = socketPath(DAEMON_NAME);
|
|
722
702
|
|
|
703
|
+
// ── attach input: out-of-band resize control frames ──────────────────
|
|
704
|
+
// While attached, the socket carries raw keystrokes. A client signals a live
|
|
705
|
+
// terminal resize with an APC control frame the daemon pulls out of the stream
|
|
706
|
+
// instead of forwarding to the pty: ESC _ ptymgr:resize:<cols>:<rows> ESC \
|
|
707
|
+
// APC (ESC _) is ignored by terminals and is never produced by a keyboard, so
|
|
708
|
+
// it cannot collide with real input.
|
|
709
|
+
const RESIZE_PREFIX = Buffer.from("\x1b_ptymgr:resize:");
|
|
710
|
+
const RESIZE_ST = Buffer.from("\x1b\\");
|
|
711
|
+
|
|
712
|
+
// longest k (>=1) where buf's last k bytes equal prefix's first k bytes.
|
|
713
|
+
function suffixPrefixOverlap(buf, prefix) {
|
|
714
|
+
for (let k = Math.min(buf.length, prefix.length - 1); k > 0; k--) {
|
|
715
|
+
if (buf.subarray(buf.length - k).equals(prefix.subarray(0, k))) return k;
|
|
716
|
+
}
|
|
717
|
+
return 0;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Split raw attach input into pty keystrokes and resize frames. Returns
|
|
721
|
+
// { forward: Buffer for the pty, resizes: [{cols,rows}], rest: Buffer to
|
|
722
|
+
// prepend to the next chunk }. `rest` holds an incomplete frame, or a trailing
|
|
723
|
+
// "ESC _…" that may begin one, so a frame split across reads reassembles. A
|
|
724
|
+
// lone trailing ESC is NOT held (that's the Escape key or a CSI start) so it
|
|
725
|
+
// forwards immediately.
|
|
726
|
+
export function parseAttachInput(buf) {
|
|
727
|
+
const forward = [];
|
|
728
|
+
const resizes = [];
|
|
729
|
+
let i = 0;
|
|
730
|
+
while (i < buf.length) {
|
|
731
|
+
const start = buf.indexOf(RESIZE_PREFIX, i);
|
|
732
|
+
if (start === -1) break;
|
|
733
|
+
if (start > i) forward.push(buf.subarray(i, start));
|
|
734
|
+
const st = buf.indexOf(RESIZE_ST, start + RESIZE_PREFIX.length);
|
|
735
|
+
if (st === -1) {
|
|
736
|
+
// incomplete frame: forward what precedes it, hold the rest
|
|
737
|
+
return { forward: Buffer.concat(forward), resizes, rest: buf.subarray(start) };
|
|
738
|
+
}
|
|
739
|
+
const body = buf.subarray(start + RESIZE_PREFIX.length, st).toString("latin1");
|
|
740
|
+
const m = body.match(/^(\d+):(\d+)$/);
|
|
741
|
+
if (m) resizes.push({ cols: Number(m[1]), rows: Number(m[2]) });
|
|
742
|
+
i = st + RESIZE_ST.length;
|
|
743
|
+
}
|
|
744
|
+
const tail = buf.subarray(i);
|
|
745
|
+
let keep = suffixPrefixOverlap(tail, RESIZE_PREFIX);
|
|
746
|
+
if (keep < 2) keep = 0; // never hold a lone ESC (Escape key / CSI start)
|
|
747
|
+
if (tail.length > keep) forward.push(tail.subarray(0, tail.length - keep));
|
|
748
|
+
return { forward: Buffer.concat(forward), resizes, rest: tail.subarray(tail.length - keep) };
|
|
749
|
+
}
|
|
750
|
+
|
|
723
751
|
/**
|
|
724
752
|
* daemon: long-running process that holds all sessions.
|
|
725
753
|
* clients connect via unix socket, send JSON commands, get JSON responses.
|
|
@@ -766,6 +794,7 @@ function startDaemon() {
|
|
|
766
794
|
const server = createServer((conn) => {
|
|
767
795
|
let buf = "";
|
|
768
796
|
let attached = false; // true when in attach streaming mode
|
|
797
|
+
let attachInBuf = Buffer.alloc(0); // carries a split resize frame across reads
|
|
769
798
|
|
|
770
799
|
// suppress connection errors (client disconnect, etc.)
|
|
771
800
|
conn.on("error", () => {});
|
|
@@ -776,9 +805,21 @@ function startDaemon() {
|
|
|
776
805
|
});
|
|
777
806
|
|
|
778
807
|
conn.on("data", async (data) => {
|
|
779
|
-
// in attach mode
|
|
808
|
+
// in attach mode: pull out any resize control frames (applied to the
|
|
809
|
+
// session), forward the rest as raw keystrokes to the pty. the session
|
|
810
|
+
// can exit between attach and this write (write() throws once exited);
|
|
811
|
+
// swallow so a late keystroke can't crash the daemon's data handler.
|
|
780
812
|
if (attached) {
|
|
781
|
-
|
|
813
|
+
const { forward, resizes, rest } = parseAttachInput(
|
|
814
|
+
attachInBuf.length ? Buffer.concat([attachInBuf, data]) : data
|
|
815
|
+
);
|
|
816
|
+
attachInBuf = rest;
|
|
817
|
+
for (const { cols, rows } of resizes) {
|
|
818
|
+
try { attached.resize(cols, rows); } catch {}
|
|
819
|
+
}
|
|
820
|
+
if (forward.length) {
|
|
821
|
+
try { attached.write(forward.toString()); } catch {}
|
|
822
|
+
}
|
|
782
823
|
return;
|
|
783
824
|
}
|
|
784
825
|
|
|
@@ -799,6 +840,15 @@ function startDaemon() {
|
|
|
799
840
|
// attach is special: switches to streaming mode
|
|
800
841
|
if (req.cmd === "attach") {
|
|
801
842
|
const session = mgr.get(req.name);
|
|
843
|
+
// size the session to the attaching client's terminal so the
|
|
844
|
+
// child's winsize matches what the user actually sees. A pane
|
|
845
|
+
// (tmux/iTerm split) ignores an app's own CSI-8 window resize --
|
|
846
|
+
// and honoring it would resize the user's whole window -- so we
|
|
847
|
+
// resize the session TO the client, the way tmux/ssh do. resize()
|
|
848
|
+
// SIGWINCHes the child, which repaints at the new size; a mismatch
|
|
849
|
+
// here is what makes a full-frame TUI's status line flicker and
|
|
850
|
+
// pushes its bottom row off-screen. No size sent => leave as-is.
|
|
851
|
+
if (req.cols && req.rows) session.resize(req.cols, req.rows);
|
|
802
852
|
const cols = session.terminal.cols;
|
|
803
853
|
const rows = session.terminal.rows;
|
|
804
854
|
const alt = session.terminal.buffer.active.type === "alternate";
|
|
@@ -837,6 +887,7 @@ function startDaemon() {
|
|
|
837
887
|
|
|
838
888
|
// forward client input to pty
|
|
839
889
|
attached = session;
|
|
890
|
+
attachInBuf = Buffer.alloc(0);
|
|
840
891
|
|
|
841
892
|
// cleanup on disconnect
|
|
842
893
|
conn.on("close", () => {
|
|
@@ -878,15 +929,9 @@ function startDaemon() {
|
|
|
878
929
|
|
|
879
930
|
async function tgSend(chatId, text) {
|
|
880
931
|
const token = process.env.TELEGRAM_BOT_TOKEN;
|
|
881
|
-
// telegram max message length is 4096
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
for (const chunk of chunks) {
|
|
885
|
-
await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
|
886
|
-
method: "POST",
|
|
887
|
-
headers: { "content-type": "application/json" },
|
|
888
|
-
body: JSON.stringify({ chat_id: chatId, text: chunk }),
|
|
889
|
-
});
|
|
932
|
+
// telegram max message length is 4096; chunk under it
|
|
933
|
+
for (let i = 0; i < text.length; i += 4000) {
|
|
934
|
+
await telegramSend(token, chatId, text.slice(i, i + 4000));
|
|
890
935
|
}
|
|
891
936
|
}
|
|
892
937
|
|
|
@@ -1088,6 +1133,15 @@ export function buildSafeEnv(extra) {
|
|
|
1088
1133
|
return safeEnv;
|
|
1089
1134
|
}
|
|
1090
1135
|
|
|
1136
|
+
// start default jsonl logging for a session, under the session's own cwd (the
|
|
1137
|
+
// client's dir, not the daemon's frozen cwd). used by the `--log` shortcut on
|
|
1138
|
+
// spawn/wrap; the `log` command does its own format/dir handling. returns path.
|
|
1139
|
+
function startDefaultLog(mgr, name) {
|
|
1140
|
+
const logPath = join(mgr.get(name).cwd, "agents", "logs", `${name}-${Date.now()}.jsonl`);
|
|
1141
|
+
mgr.get(name).startLog(logPath, "jsonl");
|
|
1142
|
+
return logPath;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1091
1145
|
// POSIX single-quote escaping: wrap in '...' and replace each ' with '\''.
|
|
1092
1146
|
// Makes a token literal to the shell -- no expansion, no command substitution,
|
|
1093
1147
|
// no word splitting. Used to build the `zsh -lic` command line for `wrap`.
|
|
@@ -1095,6 +1149,17 @@ export function shellQuote(arg) {
|
|
|
1095
1149
|
return `'${String(arg).replace(/'/g, "'\\''")}'`;
|
|
1096
1150
|
}
|
|
1097
1151
|
|
|
1152
|
+
// one place that talks to the Telegram Bot API. returns the fetch promise so
|
|
1153
|
+
// callers keep their existing error semantics (handleCommand's try/catch for
|
|
1154
|
+
// the tg-send/tg-wait commands; the poller's own catch for notifications).
|
|
1155
|
+
function telegramSend(token, chatId, text) {
|
|
1156
|
+
return fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
|
1157
|
+
method: "POST",
|
|
1158
|
+
headers: { "content-type": "application/json" },
|
|
1159
|
+
body: JSON.stringify({ chat_id: chatId, text }),
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1098
1163
|
async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
|
|
1099
1164
|
const { cmd, name, args } = req;
|
|
1100
1165
|
|
|
@@ -1153,8 +1218,8 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
|
|
|
1153
1218
|
const cmdToRun = args?.cmd || "zsh";
|
|
1154
1219
|
const cmdArgs = args?.args || [];
|
|
1155
1220
|
// clamp terminal size to prevent memory exhaustion
|
|
1156
|
-
const cols =
|
|
1157
|
-
const rows =
|
|
1221
|
+
const cols = clampCols(args?.cols, config.cols);
|
|
1222
|
+
const rows = clampRows(args?.rows, config.rows);
|
|
1158
1223
|
const safeEnv = buildSafeEnv(args?.env);
|
|
1159
1224
|
safeEnv.PTY_MGR_SESSION = name;
|
|
1160
1225
|
const opts = {
|
|
@@ -1165,14 +1230,7 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
|
|
|
1165
1230
|
};
|
|
1166
1231
|
mgr.spawn(name, cmdToRun, cmdArgs, opts);
|
|
1167
1232
|
const res = { ok: true, name, pid: mgr.pid(name) };
|
|
1168
|
-
|
|
1169
|
-
// cwd (client's dir), not the daemon's frozen cwd.
|
|
1170
|
-
if (args?.log) {
|
|
1171
|
-
const logDir = join(mgr.get(name).cwd, "agents", "logs");
|
|
1172
|
-
const logPath = join(logDir, `${name}-${Date.now()}.jsonl`);
|
|
1173
|
-
mgr.get(name).startLog(logPath, "jsonl");
|
|
1174
|
-
res.logPath = logPath;
|
|
1175
|
-
}
|
|
1233
|
+
if (args?.log) res.logPath = startDefaultLog(mgr, name);
|
|
1176
1234
|
return res;
|
|
1177
1235
|
}
|
|
1178
1236
|
case "wrap": {
|
|
@@ -1195,8 +1253,8 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
|
|
|
1195
1253
|
if (maxNum === 0) maxNum = 1;
|
|
1196
1254
|
const nextName = `${baseName}-${maxNum}`;
|
|
1197
1255
|
|
|
1198
|
-
const cols =
|
|
1199
|
-
const rows =
|
|
1256
|
+
const cols = clampCols(args?.cols, config.cols);
|
|
1257
|
+
const rows = clampRows(args?.rows, config.rows);
|
|
1200
1258
|
|
|
1201
1259
|
// wrap spawns through user's login shell so shell functions/aliases work
|
|
1202
1260
|
// (like tmux does). this means glm, nvm, conda, etc. all resolve.
|
|
@@ -1223,13 +1281,7 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
|
|
|
1223
1281
|
|
|
1224
1282
|
mgr.spawn(nextName, shellCmd, shellArgs, { cwd: clientCwd, env: wrapEnv, cols, rows });
|
|
1225
1283
|
const res = { ok: true, name: nextName, pid: mgr.pid(nextName) };
|
|
1226
|
-
|
|
1227
|
-
if (args?.log) {
|
|
1228
|
-
const logDir = join(clientCwd, "agents", "logs");
|
|
1229
|
-
const logPath = join(logDir, `${nextName}-${Date.now()}.jsonl`);
|
|
1230
|
-
mgr.get(nextName).startLog(logPath, "jsonl");
|
|
1231
|
-
res.logPath = logPath;
|
|
1232
|
-
}
|
|
1284
|
+
if (args?.log) res.logPath = startDefaultLog(mgr, nextName);
|
|
1233
1285
|
return res;
|
|
1234
1286
|
}
|
|
1235
1287
|
case "send": {
|
|
@@ -1346,11 +1398,7 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
|
|
|
1346
1398
|
const msg = req.args?.message;
|
|
1347
1399
|
if (!msg) return { ok: false, error: "message required" };
|
|
1348
1400
|
if (req.args?.session) tgState.lastSession = req.args.session;
|
|
1349
|
-
await
|
|
1350
|
-
method: "POST",
|
|
1351
|
-
headers: { "content-type": "application/json" },
|
|
1352
|
-
body: JSON.stringify({ chat_id: chatId, text: msg }),
|
|
1353
|
-
});
|
|
1401
|
+
await telegramSend(token, chatId, msg);
|
|
1354
1402
|
return { ok: true };
|
|
1355
1403
|
}
|
|
1356
1404
|
case "tg-wait": {
|
|
@@ -1360,11 +1408,7 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
|
|
|
1360
1408
|
if (tgState.waiter) return { ok: false, error: "ALREADY_WAITING" };
|
|
1361
1409
|
const msg = req.args?.message;
|
|
1362
1410
|
const timeoutMs = req.args?.timeout ?? 60000;
|
|
1363
|
-
await
|
|
1364
|
-
method: "POST",
|
|
1365
|
-
headers: { "content-type": "application/json" },
|
|
1366
|
-
body: JSON.stringify({ chat_id: chatId, text: msg }),
|
|
1367
|
-
});
|
|
1411
|
+
await telegramSend(token, chatId, msg);
|
|
1368
1412
|
const sessionName = req.args?.session;
|
|
1369
1413
|
return new Promise((resolve) => {
|
|
1370
1414
|
const timer = setTimeout(() => {
|
|
@@ -1389,58 +1433,55 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
|
|
|
1389
1433
|
}
|
|
1390
1434
|
|
|
1391
1435
|
/**
|
|
1392
|
-
* client: send
|
|
1436
|
+
* client: send one newline-framed JSON command to a daemon socket and resolve
|
|
1437
|
+
* with the parsed JSON reply. Rejects with a friendly message when the daemon
|
|
1438
|
+
* is absent, and — unlike a bare JSON.parse in the data handler — turns a
|
|
1439
|
+
* malformed or truncated reply into a rejection instead of an uncaught throw.
|
|
1393
1440
|
*/
|
|
1394
|
-
function
|
|
1441
|
+
function requestSocket(sock, req, notRunningMsg) {
|
|
1395
1442
|
return new Promise((resolve, reject) => {
|
|
1396
1443
|
const conn = createConnection(sock);
|
|
1444
|
+
let buf = "";
|
|
1445
|
+
let settled = false;
|
|
1446
|
+
const settle = (fn, value) => {
|
|
1447
|
+
if (settled) return;
|
|
1448
|
+
settled = true;
|
|
1449
|
+
try { conn.end(); } catch {}
|
|
1450
|
+
fn(value);
|
|
1451
|
+
};
|
|
1452
|
+
const parseAndSettle = (chunk) => {
|
|
1453
|
+
try { settle(resolve, JSON.parse(chunk)); }
|
|
1454
|
+
catch (err) { settle(reject, new Error(`bad response from daemon: ${err.message}`)); }
|
|
1455
|
+
};
|
|
1397
1456
|
conn.on("error", (err) => {
|
|
1398
|
-
if (err.code === "ENOENT" || err.code === "ECONNREFUSED")
|
|
1399
|
-
|
|
1400
|
-
} else {
|
|
1401
|
-
reject(err);
|
|
1402
|
-
}
|
|
1457
|
+
if (err.code === "ENOENT" || err.code === "ECONNREFUSED") settle(reject, new Error(notRunningMsg));
|
|
1458
|
+
else settle(reject, err);
|
|
1403
1459
|
});
|
|
1404
1460
|
conn.on("connect", () => {
|
|
1405
|
-
conn.write(JSON.stringify(req) + "\n");
|
|
1461
|
+
try { conn.write(JSON.stringify(req) + "\n"); }
|
|
1462
|
+
catch (err) { settle(reject, err); }
|
|
1406
1463
|
});
|
|
1407
|
-
let buf = "";
|
|
1408
1464
|
conn.on("data", (data) => {
|
|
1409
1465
|
buf += data.toString();
|
|
1410
1466
|
const nl = buf.indexOf("\n");
|
|
1411
|
-
if (nl !== -1)
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1467
|
+
if (nl !== -1) parseAndSettle(buf.slice(0, nl));
|
|
1468
|
+
});
|
|
1469
|
+
conn.on("end", () => {
|
|
1470
|
+
if (settled) return;
|
|
1471
|
+
if (buf) parseAndSettle(buf); // reply without a trailing newline
|
|
1472
|
+
else settle(reject, new Error(notRunningMsg));
|
|
1416
1473
|
});
|
|
1417
1474
|
});
|
|
1418
1475
|
}
|
|
1419
1476
|
|
|
1477
|
+
// send to a specific socket file (used by `stop all` sweeping ~/.pty-manager).
|
|
1478
|
+
function sendCommandTo(sock, req) {
|
|
1479
|
+
return requestSocket(sock, req, "daemon not running");
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// send to the daemon selected by @name / --daemon / $PTY_DAEMON.
|
|
1420
1483
|
function sendCommand(req) {
|
|
1421
|
-
return
|
|
1422
|
-
const conn = createConnection(SOCKET_PATH);
|
|
1423
|
-
conn.on("error", (err) => {
|
|
1424
|
-
if (err.code === "ENOENT" || err.code === "ECONNREFUSED") {
|
|
1425
|
-
reject(new Error("daemon not running. start with: pty-mgr daemon"));
|
|
1426
|
-
} else {
|
|
1427
|
-
reject(err);
|
|
1428
|
-
}
|
|
1429
|
-
});
|
|
1430
|
-
conn.on("connect", () => {
|
|
1431
|
-
conn.write(JSON.stringify(req) + "\n");
|
|
1432
|
-
});
|
|
1433
|
-
let buf = "";
|
|
1434
|
-
conn.on("data", (data) => {
|
|
1435
|
-
buf += data.toString();
|
|
1436
|
-
const nl = buf.indexOf("\n");
|
|
1437
|
-
if (nl !== -1) {
|
|
1438
|
-
const res = JSON.parse(buf.slice(0, nl));
|
|
1439
|
-
conn.end();
|
|
1440
|
-
resolve(res);
|
|
1441
|
-
}
|
|
1442
|
-
});
|
|
1443
|
-
});
|
|
1484
|
+
return requestSocket(SOCKET_PATH, req, "daemon not running. start with: pty-mgr daemon");
|
|
1444
1485
|
}
|
|
1445
1486
|
|
|
1446
1487
|
/**
|
|
@@ -1465,11 +1506,19 @@ function attachToSession(name) {
|
|
|
1465
1506
|
});
|
|
1466
1507
|
|
|
1467
1508
|
conn.on("connect", () => {
|
|
1468
|
-
// send attach request
|
|
1469
|
-
|
|
1509
|
+
// send attach request with our real terminal size so the daemon can size
|
|
1510
|
+
// the session (and its child) to us. undefined when stdout isn't a TTY;
|
|
1511
|
+
// the daemon then leaves the session size unchanged.
|
|
1512
|
+
conn.write(JSON.stringify({
|
|
1513
|
+
cmd: "attach",
|
|
1514
|
+
name,
|
|
1515
|
+
cols: process.stdout.columns,
|
|
1516
|
+
rows: process.stdout.rows,
|
|
1517
|
+
}) + "\n");
|
|
1470
1518
|
|
|
1471
1519
|
let gotAck = false;
|
|
1472
1520
|
let headerBuf = "";
|
|
1521
|
+
let onStdoutResize = null;
|
|
1473
1522
|
|
|
1474
1523
|
// last-seen alt-screen state: detach pops the client back to its
|
|
1475
1524
|
// normal screen only if the session left it in the alternate buffer
|
|
@@ -1509,11 +1558,11 @@ function attachToSession(name) {
|
|
|
1509
1558
|
gotAck = true;
|
|
1510
1559
|
altActive = !!ack.alt;
|
|
1511
1560
|
|
|
1512
|
-
// resize
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1561
|
+
// NOTE: we do NOT resize our own terminal to the session. The daemon
|
|
1562
|
+
// has already sized the session to us (we sent our size in the attach
|
|
1563
|
+
// request). An app-driven CSI-8 window resize is ignored inside
|
|
1564
|
+
// tmux/iTerm panes and, where honored, resizes the user's entire
|
|
1565
|
+
// window -- which is what caused the flicker and the missing bottom row.
|
|
1517
1566
|
|
|
1518
1567
|
// put terminal in raw mode
|
|
1519
1568
|
process.stdin.setRawMode(true);
|
|
@@ -1537,6 +1586,15 @@ function attachToSession(name) {
|
|
|
1537
1586
|
conn.write(key);
|
|
1538
1587
|
});
|
|
1539
1588
|
|
|
1589
|
+
// live-resize: when our terminal changes size, tell the daemon so it
|
|
1590
|
+
// resizes the session (and its child's winsize) to match. Sent as an
|
|
1591
|
+
// out-of-band APC frame the daemon strips from the input stream.
|
|
1592
|
+
onStdoutResize = () => {
|
|
1593
|
+
const c = process.stdout.columns, r = process.stdout.rows;
|
|
1594
|
+
if (c && r) { try { conn.write(`\x1b_ptymgr:resize:${c}:${r}\x1b\\`); } catch {} }
|
|
1595
|
+
};
|
|
1596
|
+
process.stdout.on("resize", onStdoutResize);
|
|
1597
|
+
|
|
1540
1598
|
return;
|
|
1541
1599
|
}
|
|
1542
1600
|
|
|
@@ -1553,6 +1611,10 @@ function attachToSession(name) {
|
|
|
1553
1611
|
function detach() {
|
|
1554
1612
|
if (detached || failed) return;
|
|
1555
1613
|
detached = true;
|
|
1614
|
+
if (onStdoutResize) {
|
|
1615
|
+
process.stdout.removeListener("resize", onStdoutResize);
|
|
1616
|
+
onStdoutResize = null;
|
|
1617
|
+
}
|
|
1556
1618
|
if (process.stdin.isRaw) {
|
|
1557
1619
|
process.stdin.setRawMode(false);
|
|
1558
1620
|
process.stdin.pause();
|
|
@@ -1994,6 +2056,20 @@ function capturePayloadText(res) {
|
|
|
1994
2056
|
return res.output || "";
|
|
1995
2057
|
}
|
|
1996
2058
|
|
|
2059
|
+
// capture a session (or glob/all) twice, intervalMs apart, and report whether
|
|
2060
|
+
// the rendered bottom-100 lines are stable ("done") or still changing
|
|
2061
|
+
// ("working"). shared by `p watch` and the flow engine's turn-completion check.
|
|
2062
|
+
async function captureStability(name, intervalMs) {
|
|
2063
|
+
const capture = () => sendCommand({ cmd: "capture", name, args: { lines: 100 } });
|
|
2064
|
+
const first = await capture();
|
|
2065
|
+
if (!first.ok) throw new Error(first.error || `failed to capture ${name}`);
|
|
2066
|
+
const firstHash = hashText(capturePayloadText(first));
|
|
2067
|
+
await sleep(intervalMs);
|
|
2068
|
+
const second = await capture();
|
|
2069
|
+
if (!second.ok) throw new Error(second.error || `failed to capture ${name}`);
|
|
2070
|
+
return firstHash === hashText(capturePayloadText(second)) ? "done" : "working";
|
|
2071
|
+
}
|
|
2072
|
+
|
|
1997
2073
|
function expandHome(value) {
|
|
1998
2074
|
if (!value) return value;
|
|
1999
2075
|
if (value === "~") return homedir();
|
|
@@ -2191,11 +2267,14 @@ export function transcriptRootsForKind(kind, config, cwd = process.cwd()) {
|
|
|
2191
2267
|
.filter((root, index, roots) => existsSync(root) || index === roots.length - 1);
|
|
2192
2268
|
}
|
|
2193
2269
|
|
|
2270
|
+
// flatten every .jsonl transcript under a kind's configured roots.
|
|
2271
|
+
function transcriptFiles(kind, config, cwd = process.cwd()) {
|
|
2272
|
+
return transcriptRootsForKind(kind, config, cwd).flatMap((root) => walkJsonlFiles(root));
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2194
2275
|
export function findNewestTranscript({ kind, cwd = process.cwd(), sinceMs = 0, config }) {
|
|
2195
2276
|
const adapter = adapterForKind(kind, config);
|
|
2196
|
-
const
|
|
2197
|
-
const candidates = roots
|
|
2198
|
-
.flatMap((root) => walkJsonlFiles(root))
|
|
2277
|
+
const candidates = transcriptFiles(kind, config, cwd)
|
|
2199
2278
|
.map((path) => {
|
|
2200
2279
|
const mtimeMs = statSync(path).mtimeMs;
|
|
2201
2280
|
const startedMs = transcriptStartedAtMs(path, adapter);
|
|
@@ -2338,8 +2417,7 @@ export function findTranscriptForSentMessage({
|
|
|
2338
2417
|
}) {
|
|
2339
2418
|
if (file) return findSentMessageInTranscript(file, kind, text, config);
|
|
2340
2419
|
|
|
2341
|
-
const candidates =
|
|
2342
|
-
.flatMap((root) => walkJsonlFiles(root))
|
|
2420
|
+
const candidates = transcriptFiles(kind, config, cwd)
|
|
2343
2421
|
.map((path) => ({ path, mtimeMs: statSync(path).mtimeMs }))
|
|
2344
2422
|
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
2345
2423
|
|
|
@@ -2489,14 +2567,7 @@ async function sendFlowMessageConfirmed(meta, text, config, deps = {}) {
|
|
|
2489
2567
|
|
|
2490
2568
|
async function watchFlowSession(session, intervalMs, deps = {}) {
|
|
2491
2569
|
if (deps.watchSession) return deps.watchSession(session, intervalMs);
|
|
2492
|
-
|
|
2493
|
-
if (!first.ok) throw new Error(first.error || `failed to capture ${session}`);
|
|
2494
|
-
const firstHash = hashText(capturePayloadText(first));
|
|
2495
|
-
await sleep(intervalMs);
|
|
2496
|
-
const second = await sendCommand({ cmd: "capture", name: session, args: { lines: 100 } });
|
|
2497
|
-
if (!second.ok) throw new Error(second.error || `failed to capture ${session}`);
|
|
2498
|
-
const secondHash = hashText(capturePayloadText(second));
|
|
2499
|
-
return firstHash === secondHash ? "done" : "working";
|
|
2570
|
+
return captureStability(session, intervalMs);
|
|
2500
2571
|
}
|
|
2501
2572
|
|
|
2502
2573
|
async function waitForFlowMessage({
|
|
@@ -2674,8 +2745,6 @@ function detectRcFile() {
|
|
|
2674
2745
|
}
|
|
2675
2746
|
|
|
2676
2747
|
async function runSetup() {
|
|
2677
|
-
const { appendFileSync, readFileSync, writeFileSync } = await import("node:fs");
|
|
2678
|
-
|
|
2679
2748
|
console.log("pty-mgr setup");
|
|
2680
2749
|
console.log("wrap CLI tools in managed PTY sessions.\n");
|
|
2681
2750
|
console.log("when you type a wrapped command (e.g. claude), pty-mgr will:");
|
|
@@ -2952,12 +3021,11 @@ async function cli() {
|
|
|
2952
3021
|
if (command === "daemon") {
|
|
2953
3022
|
// fork into background as a true daemon (survives terminal close)
|
|
2954
3023
|
if (!process.env.__PTY_DAEMON_CHILD) {
|
|
2955
|
-
const { spawn: cpSpawn } = await import("node:child_process");
|
|
2956
3024
|
// re-exec ourselves with the same args
|
|
2957
3025
|
// process.execPath = bun (dev) or the compiled binary
|
|
2958
3026
|
// filter out internal /$bunfs/ paths from argv
|
|
2959
3027
|
const realArgs = process.argv.slice(1).filter(a => !a.startsWith("/$bunfs/"));
|
|
2960
|
-
const child =
|
|
3028
|
+
const child = spawnChild(process.execPath, realArgs, {
|
|
2961
3029
|
detached: true,
|
|
2962
3030
|
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
|
2963
3031
|
env: { ...process.env, __PTY_DAEMON_CHILD: "1" },
|
|
@@ -2991,7 +3059,6 @@ async function cli() {
|
|
|
2991
3059
|
const target = args[0]; // "all" or undefined (= current daemon)
|
|
2992
3060
|
if (target === "all") {
|
|
2993
3061
|
// find all pty-manager sockets and shut them down
|
|
2994
|
-
const { readdirSync } = await import("node:fs");
|
|
2995
3062
|
const dir = join(homedir(), ".pty-manager");
|
|
2996
3063
|
let socks = [];
|
|
2997
3064
|
try {
|
|
@@ -3079,20 +3146,7 @@ async function cli() {
|
|
|
3079
3146
|
}
|
|
3080
3147
|
|
|
3081
3148
|
try {
|
|
3082
|
-
|
|
3083
|
-
if (!first.ok) {
|
|
3084
|
-
console.error("error:", first.error);
|
|
3085
|
-
process.exit(1);
|
|
3086
|
-
}
|
|
3087
|
-
const firstHash = hashText(capturePayloadText(first));
|
|
3088
|
-
await sleep(intervalMs);
|
|
3089
|
-
const second = await sendCommand({ cmd: "capture", name, args: { lines: 100 } });
|
|
3090
|
-
if (!second.ok) {
|
|
3091
|
-
console.error("error:", second.error);
|
|
3092
|
-
process.exit(1);
|
|
3093
|
-
}
|
|
3094
|
-
const secondHash = hashText(capturePayloadText(second));
|
|
3095
|
-
console.log(firstHash === secondHash ? "done" : "working");
|
|
3149
|
+
console.log(await captureStability(name, intervalMs));
|
|
3096
3150
|
process.exit(0);
|
|
3097
3151
|
} catch (err) {
|
|
3098
3152
|
console.error(err.message);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mentiko/pty-mgr",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.3",
|
|
4
4
|
"description": "PTY session manager with terminal emulation for programmatic session control.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/pty-manager.mjs",
|
|
@@ -26,10 +26,10 @@
|
|
|
26
26
|
"@xterm/headless": "^6.0.0"
|
|
27
27
|
},
|
|
28
28
|
"optionalDependencies": {
|
|
29
|
-
"@mentiko/pty-mgr-linux-x64": "1.4.
|
|
30
|
-
"@mentiko/pty-mgr-linux-arm64": "1.4.
|
|
31
|
-
"@mentiko/pty-mgr-darwin-x64": "1.4.
|
|
32
|
-
"@mentiko/pty-mgr-darwin-arm64": "1.4.
|
|
29
|
+
"@mentiko/pty-mgr-linux-x64": "1.4.3",
|
|
30
|
+
"@mentiko/pty-mgr-linux-arm64": "1.4.3",
|
|
31
|
+
"@mentiko/pty-mgr-darwin-x64": "1.4.3",
|
|
32
|
+
"@mentiko/pty-mgr-darwin-arm64": "1.4.3"
|
|
33
33
|
},
|
|
34
34
|
"engines": {
|
|
35
35
|
"bun": ">=1.0"
|