agentus 0.1.0 → 0.2.0
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 +19 -2
- package/README.zh-CN.md +396 -0
- package/docs/dev/01-dev-loop.md +186 -0
- package/docs/dev/02-verify.md +237 -0
- package/docs/dev/03-release.md +218 -0
- package/docs/dev/04-recover.md +199 -0
- package/docs/dev/05-agent-driven.md +114 -0
- package/docs/dev/README.md +65 -0
- package/docs/installation.md +366 -0
- package/docs/installation.zh-CN.md +350 -0
- package/package.json +5 -4
- package/packages/server/mock/agent.mjs +35 -0
- package/packages/server/src/fs.ts +216 -2
- package/packages/server/src/index.ts +87 -5
- package/packages/server/src/pty-helper.py +135 -0
- package/packages/server/src/term.ts +69 -28
- package/packages/web/dist/assets/index-D667PX2c.css +32 -0
- package/packages/web/dist/assets/index-d9NzwPFu.js +283 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-B52l55_4.css +0 -1
- package/packages/web/dist/assets/index-BRgC7oYt.js +0 -246
|
@@ -15,7 +15,7 @@ import { SessionManager, SessionUnavailable } from "./acp/session-manager.js";
|
|
|
15
15
|
import { describeAcpError } from "./acp/errors.js";
|
|
16
16
|
import { BACKENDS } from "./acp/backends.js";
|
|
17
17
|
import { classifyError, checkedHealth, coerceRow, inspectRow, planFor, seedRows, startupCheck } from "./acp/registry.js";
|
|
18
|
-
import { FsError, listDirs, readTextFile } from "./fs.js";
|
|
18
|
+
import { ChangedOnDiskError, FsError, listDirs, readTextFile, statFile, writeTextFile } from "./fs.js";
|
|
19
19
|
import { terms } from "./term.js";
|
|
20
20
|
import { VoiceError, listVoiceModels, setHotwordSource, synthesize, transcribe, voiceCapabilities } from "./voice.js";
|
|
21
21
|
import { hotwordsFor, vocabularyOf } from "./hotwords.js";
|
|
@@ -740,6 +740,79 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
740
740
|
throw e;
|
|
741
741
|
}
|
|
742
742
|
}
|
|
743
|
+
// What IS this file? The panel asks before reading: an image or a PDF should not be pulled
|
|
744
|
+
// through the text endpoint to learn that it is not text, and `kind` decides which renderer
|
|
745
|
+
// and whether an editor is offered. Cheap: a stat plus a hash, no bytes decoded.
|
|
746
|
+
if (url.pathname === "/api/fs/stat" && req.method === "GET") {
|
|
747
|
+
try {
|
|
748
|
+
return send(res, 200, statFile(url.searchParams.get("path")));
|
|
749
|
+
} catch (e) {
|
|
750
|
+
if (e instanceof FsError) {
|
|
751
|
+
return send(res, e.code === "not_found" ? 404 : 400, { error: e.message, code: e.code });
|
|
752
|
+
}
|
|
753
|
+
throw e;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
// The file's RAW BYTES, for the renderers that need the real thing: an <img> in a reply,
|
|
757
|
+
// a PDF in the preview panel, an audio/video tag. Before this existed there was no way to
|
|
758
|
+
// show a local image at all (the read endpoint decodes utf-8 text and calls binary files
|
|
759
|
+
// "binary file"), so every `` an agent wrote was a broken image.
|
|
760
|
+
//
|
|
761
|
+
// Same trust model as the endpoints above: any path the server process can read, because
|
|
762
|
+
// the operator can already point an agent at any directory and browse it via /api/fs/dirs.
|
|
763
|
+
// Behind the same auth gate. `inline` on purpose — a preview must not turn into a download.
|
|
764
|
+
if (url.pathname === "/api/fs/raw" && req.method === "GET") {
|
|
765
|
+
try {
|
|
766
|
+
const req0 = url.searchParams.get("path");
|
|
767
|
+
if (!req0) return send(res, 400, { error: "path is required", code: "bad_path" });
|
|
768
|
+
const info = statFile(req0);
|
|
769
|
+
res.writeHead(200, {
|
|
770
|
+
"content-type": info.contentType,
|
|
771
|
+
"content-length": String(info.size),
|
|
772
|
+
"cache-control": "no-store",
|
|
773
|
+
"content-disposition": "inline",
|
|
774
|
+
});
|
|
775
|
+
const stream = fs.createReadStream(info.path);
|
|
776
|
+
stream.on("error", () => res.destroy());
|
|
777
|
+
// The operator may close the panel mid-stream; an unhandled 'error' on the response
|
|
778
|
+
// would crash the process for a click that is not an error.
|
|
779
|
+
res.on("close", () => stream.destroy());
|
|
780
|
+
return stream.pipe(res);
|
|
781
|
+
} catch (e) {
|
|
782
|
+
if (e instanceof FsError) {
|
|
783
|
+
return send(res, e.code === "not_found" ? 404 : 400, { error: e.message, code: e.code });
|
|
784
|
+
}
|
|
785
|
+
throw e;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
// Save an edit made in the panel. 409 carries the CURRENT bytes when the file changed on
|
|
789
|
+
// disk while the operator was typing, so the UI can offer to reload instead of choosing
|
|
790
|
+
// silently whose work to lose (see writeTextFile).
|
|
791
|
+
if (url.pathname === "/api/fs/file" && (req.method === "PUT" || req.method === "POST")) {
|
|
792
|
+
const body = await readJson(req);
|
|
793
|
+
try {
|
|
794
|
+
const path0 = typeof body.path === "string" ? body.path : "";
|
|
795
|
+
if (!path0) return send(res, 400, { error: "path is required", code: "bad_path" });
|
|
796
|
+
const content = typeof body.content === "string" ? body.content : "";
|
|
797
|
+
const ifHash = typeof body.ifHash === "string" ? body.ifHash : undefined;
|
|
798
|
+
return send(res, 200, { ok: true, ...writeTextFile(path0, content, { ifHash }) });
|
|
799
|
+
} catch (e) {
|
|
800
|
+
if (e instanceof ChangedOnDiskError) {
|
|
801
|
+
return send(res, 409, {
|
|
802
|
+
error: "changed_on_disk",
|
|
803
|
+
path: e.target,
|
|
804
|
+
hash: e.currentHash,
|
|
805
|
+
content: e.currentContent,
|
|
806
|
+
truncated: e.truncated,
|
|
807
|
+
hint: "the file changed on disk since you opened it",
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
if (e instanceof FsError) {
|
|
811
|
+
return send(res, e.code === "not_found" ? 404 : 400, { error: e.message, code: e.code });
|
|
812
|
+
}
|
|
813
|
+
throw e;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
743
816
|
// The windows remembered per model (the settings page and scripts read this).
|
|
744
817
|
if (url.pathname === "/api/context-limits" && req.method === "GET") {
|
|
745
818
|
return send(res, 200, { limits: store.listModelLimits() });
|
|
@@ -1226,7 +1299,11 @@ termWss.on("connection", (ws: WebSocket, _req: IncomingMessage, url: URL) => {
|
|
|
1226
1299
|
}
|
|
1227
1300
|
let term: ReturnType<typeof terms.start> | null = null;
|
|
1228
1301
|
try {
|
|
1229
|
-
|
|
1302
|
+
// The client sends its viewport size on the socket URL: the pty must be born at the right size
|
|
1303
|
+
// (the shell reads it when it paints the first prompt). Later changes arrive as term-resize.
|
|
1304
|
+
const cols = Number(url.searchParams.get("cols") || 0);
|
|
1305
|
+
const rows = Number(url.searchParams.get("rows") || 0);
|
|
1306
|
+
term = terms.start(root.path, cols && rows ? { cols, rows } : undefined);
|
|
1230
1307
|
} catch (e) {
|
|
1231
1308
|
ws.send(JSON.stringify({ t: "term-error", error: String((e as Error)?.message ?? e) }));
|
|
1232
1309
|
ws.close();
|
|
@@ -1236,12 +1313,15 @@ termWss.on("connection", (ws: WebSocket, _req: IncomingMessage, url: URL) => {
|
|
|
1236
1313
|
const send = (msg: unknown): void => {
|
|
1237
1314
|
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
|
|
1238
1315
|
};
|
|
1239
|
-
|
|
1316
|
+
// The buffered tail goes out first, so a reconnect (or a second tab) lands on the scrollback the
|
|
1317
|
+
// way ttyd's RESUME does instead of a blank screen.
|
|
1318
|
+
if (session.buffer) send({ t: "term-data", id: session.id, data: session.buffer });
|
|
1319
|
+
send({ t: "term-ready", id: session.id, cwd: session.cwd, pid: session.pid, tty: session.tty });
|
|
1240
1320
|
const onData = (chunk: string): void => send({ t: "term-data", id: session.id, data: chunk });
|
|
1241
1321
|
session.subscribers.add(onData);
|
|
1242
1322
|
session.child.on("exit", () => send({ t: "term-exit", id: session.id }));
|
|
1243
1323
|
ws.on("message", (data) => {
|
|
1244
|
-
let cmd: { t?: string; data?: string };
|
|
1324
|
+
let cmd: { t?: string; data?: string; cols?: number; rows?: number };
|
|
1245
1325
|
try {
|
|
1246
1326
|
cmd = JSON.parse(String(data));
|
|
1247
1327
|
} catch {
|
|
@@ -1249,10 +1329,12 @@ termWss.on("connection", (ws: WebSocket, _req: IncomingMessage, url: URL) => {
|
|
|
1249
1329
|
}
|
|
1250
1330
|
if (cmd.t === "term-input" && typeof cmd.data === "string") {
|
|
1251
1331
|
try {
|
|
1252
|
-
session.child.stdin
|
|
1332
|
+
session.child.stdin?.write(cmd.data);
|
|
1253
1333
|
} catch (e) {
|
|
1254
1334
|
send({ t: "term-error", error: `write failed: ${String((e as Error)?.message ?? e)}` });
|
|
1255
1335
|
}
|
|
1336
|
+
} else if (cmd.t === "term-resize" && typeof cmd.cols === "number" && typeof cmd.rows === "number") {
|
|
1337
|
+
session.resize(cmd.cols, cmd.rows);
|
|
1256
1338
|
} else if (cmd.t === "term-close") {
|
|
1257
1339
|
terms.kill(session.id);
|
|
1258
1340
|
ws.close();
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run one command on a REAL pseudo-terminal and shovel bytes between it and stdio.
|
|
3
|
+
|
|
4
|
+
This is the whole trick behind a web terminal (ttyd does the same thing in C): `forkpty` gives the
|
|
5
|
+
child a controlling terminal, so the shell believes a human is at the keyboard — line editing,
|
|
6
|
+
prompts, colours, job control, full-screen programs (vim, top) all work. Without it (pipe stdio)
|
|
7
|
+
the shell is in non-interactive mode: no prompt on screen, no echo, no cursor, SIGWINCH meaningless,
|
|
8
|
+
and any terminal emulator in front of it is a lie.
|
|
9
|
+
|
|
10
|
+
Why a Python helper instead of node-pty: node-pty is a build-time native module and this project
|
|
11
|
+
stays dependency-free on install. Python 3 is present on macOS and essentially every Linux; its
|
|
12
|
+
stdlib already has forkpty + termios, which is all a PTY needs. If python3 is missing the server
|
|
13
|
+
falls back to pipes and says so in the panel.
|
|
14
|
+
|
|
15
|
+
Wire format, all raw bytes:
|
|
16
|
+
stdin (fd 0) -> keystrokes from the browser, written straight to the PTY master
|
|
17
|
+
stdout (fd 1) <- everything the PTY produces, written straight out (no filtering, no ANSI
|
|
18
|
+
stripping: a terminal emulator consumes the escape sequences, a <pre> cannot)
|
|
19
|
+
fd 3 <- control channel, one command per line: "resize <cols> <rows>"
|
|
20
|
+
|
|
21
|
+
Sizing: the initial size comes from AGENTUS_PTY_COLS / AGENTUS_PTY_ROWS, and `resize` applies
|
|
22
|
+
TIOCSWINSZ to the master — the kernel then signals the child's foreground process group (SIGWINCH),
|
|
23
|
+
which is what makes vim redraw at the new width.
|
|
24
|
+
|
|
25
|
+
Exit: when the child exits we exit with its status; when stdin hits EOF (the socket closed) or we
|
|
26
|
+
are signalled, we kill the child's process group first so no shell is orphaned.
|
|
27
|
+
"""
|
|
28
|
+
import errno
|
|
29
|
+
import fcntl
|
|
30
|
+
import os
|
|
31
|
+
import pty
|
|
32
|
+
import select
|
|
33
|
+
import signal
|
|
34
|
+
import struct
|
|
35
|
+
import sys
|
|
36
|
+
import termios
|
|
37
|
+
|
|
38
|
+
CTL_FD = 3
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def set_winsize(fd: int, cols: int, rows: int) -> None:
|
|
42
|
+
cols = max(1, min(cols, 1000))
|
|
43
|
+
rows = max(1, min(rows, 1000))
|
|
44
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main() -> int:
|
|
48
|
+
argv = sys.argv[1:] or [os.environ.get("SHELL") or "/bin/sh"]
|
|
49
|
+
try:
|
|
50
|
+
cols = int(os.environ.get("AGENTUS_PTY_COLS") or 80)
|
|
51
|
+
rows = int(os.environ.get("AGENTUS_PTY_ROWS") or 24)
|
|
52
|
+
except ValueError:
|
|
53
|
+
cols, rows = 80, 24
|
|
54
|
+
|
|
55
|
+
pid, master = pty.fork()
|
|
56
|
+
if pid == 0: # child: this IS the shell now
|
|
57
|
+
os.environ["TERM"] = os.environ.get("TERM") or "xterm-256color"
|
|
58
|
+
try:
|
|
59
|
+
os.execvp(argv[0], argv)
|
|
60
|
+
except Exception as exc: # noqa: BLE001 - report it on the terminal rather than dying mute
|
|
61
|
+
os.write(2, f"cannot start {argv[0]}: {exc}\r\n".encode())
|
|
62
|
+
os._exit(127)
|
|
63
|
+
|
|
64
|
+
set_winsize(master, cols, rows)
|
|
65
|
+
|
|
66
|
+
ctl = None
|
|
67
|
+
try:
|
|
68
|
+
ctl = os.fdopen(CTL_FD, "r", buffering=1)
|
|
69
|
+
except OSError:
|
|
70
|
+
ctl = None # no control channel: a fixed-size terminal is still a terminal
|
|
71
|
+
|
|
72
|
+
stdin_fd = 0
|
|
73
|
+
running = True
|
|
74
|
+
while running:
|
|
75
|
+
watch = [master, stdin_fd] + ([CTL_FD] if ctl else [])
|
|
76
|
+
try:
|
|
77
|
+
ready, _, _ = select.select(watch, [], [])
|
|
78
|
+
except OSError as exc:
|
|
79
|
+
if exc.errno == errno.EINTR:
|
|
80
|
+
continue
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
if master in ready:
|
|
84
|
+
try:
|
|
85
|
+
data = os.read(master, 65536)
|
|
86
|
+
except OSError:
|
|
87
|
+
data = b""
|
|
88
|
+
if not data:
|
|
89
|
+
break # the child closed the pty: it is gone
|
|
90
|
+
os.write(1, data)
|
|
91
|
+
|
|
92
|
+
if stdin_fd in ready:
|
|
93
|
+
try:
|
|
94
|
+
data = os.read(stdin_fd, 65536)
|
|
95
|
+
except OSError:
|
|
96
|
+
data = b""
|
|
97
|
+
if not data:
|
|
98
|
+
break # EOF: the browser went away, take the shell with us
|
|
99
|
+
os.write(master, data)
|
|
100
|
+
|
|
101
|
+
if ctl and CTL_FD in ready:
|
|
102
|
+
line = ctl.readline()
|
|
103
|
+
if line == "":
|
|
104
|
+
ctl.close()
|
|
105
|
+
ctl = None
|
|
106
|
+
continue
|
|
107
|
+
parts = line.split()
|
|
108
|
+
if parts and parts[0] == "resize" and len(parts) == 3 and parts[1].isdigit() and parts[2].isdigit():
|
|
109
|
+
set_winsize(master, int(parts[1]), int(parts[2]))
|
|
110
|
+
|
|
111
|
+
# Reap the child, then leave with its status so the server can report a real exit code.
|
|
112
|
+
status = 0
|
|
113
|
+
try:
|
|
114
|
+
_, raw = os.waitpid(pid, 0)
|
|
115
|
+
status = os.waitstatus_to_exitcode(raw) if hasattr(os, "waitstatus_to_exitcode") else 0
|
|
116
|
+
except ChildProcessError:
|
|
117
|
+
pass
|
|
118
|
+
except KeyboardInterrupt:
|
|
119
|
+
pass
|
|
120
|
+
return 0 if status is None else status
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _terminate(_signum, _frame): # noqa: ANN001 - signal handler
|
|
124
|
+
# The server kills our process group; this is the polite path when only we are signalled.
|
|
125
|
+
try:
|
|
126
|
+
os.kill(0, signal.SIGKILL)
|
|
127
|
+
except OSError:
|
|
128
|
+
pass
|
|
129
|
+
raise SystemExit(0)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
signal.signal(signal.SIGTERM, _terminate)
|
|
134
|
+
signal.signal(signal.SIGHUP, _terminate)
|
|
135
|
+
sys.exit(main())
|
|
@@ -19,10 +19,11 @@
|
|
|
19
19
|
// because that is the directory the operator pointed this slot at. It is not
|
|
20
20
|
// a sandbox and does not pretend to be: the person running the cockpit already
|
|
21
21
|
// owns the machine (same trust model as the agent processes).
|
|
22
|
-
import { spawn, type
|
|
22
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
23
23
|
import { randomUUID } from "node:crypto";
|
|
24
24
|
import fs from "node:fs";
|
|
25
25
|
import { homedir } from "node:os";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
26
27
|
|
|
27
28
|
export interface TermSession {
|
|
28
29
|
id: string;
|
|
@@ -30,31 +31,53 @@ export interface TermSession {
|
|
|
30
31
|
pid: number | null;
|
|
31
32
|
/** bytes of output kept for a late joiner / a reconnect */
|
|
32
33
|
buffer: string;
|
|
33
|
-
child:
|
|
34
|
+
child: ChildProcess;
|
|
34
35
|
subscribers: Set<(chunk: string) => void>;
|
|
35
36
|
exited: { code: number | null; signal: string | null } | null;
|
|
37
|
+
/** true when the child has a real tty (see shellCommand). The panel says which it got, because
|
|
38
|
+
* a pipe shell and a tty look nothing alike and the operator should not have to guess. */
|
|
39
|
+
tty: boolean;
|
|
40
|
+
/** Resize the tty (no-op on a pipe shell). The kernel then SIGWINCHes the child's foreground
|
|
41
|
+
* process group, which is what makes a full-screen program redraw at the new width. */
|
|
42
|
+
resize: (cols: number, rows: number) => void;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
const MAX_BUFFER = 256 * 1024;
|
|
39
46
|
|
|
40
|
-
/**
|
|
41
|
-
function
|
|
47
|
+
/** The PTY helper that ships with the server. */
|
|
48
|
+
function ptyHelperPath(): string | null {
|
|
49
|
+
for (const rel of ["./pty-helper.py", "../src/pty-helper.py"]) {
|
|
50
|
+
try {
|
|
51
|
+
const p = fileURLToPath(new URL(rel, import.meta.url));
|
|
52
|
+
if (fs.existsSync(p)) return p;
|
|
53
|
+
} catch {
|
|
54
|
+
/* not this layout */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** How to start the shell.
|
|
61
|
+
*
|
|
62
|
+
* Default is a REAL pty via `pty-helper.py` (python3 stdlib `forkpty`): the shell gets a controlling
|
|
63
|
+
* terminal, so prompts, echo, colours, job control and full-screen programs behave. That is the
|
|
64
|
+
* whole point of the panel — a terminal emulator in front of a pipe shell is a lie (no prompt lands
|
|
65
|
+
* on screen, keystrokes are never echoed, resize means nothing).
|
|
66
|
+
*
|
|
67
|
+
* Escapes: `AGENTUS_TERM_CMD` runs an arbitrary command line instead (pipes), `AGENTUS_TERM_PTY=0`
|
|
68
|
+
* forces the old pipe shell, and a machine without python3 degrades to it automatically. Whoever
|
|
69
|
+
* gets a pipe shell is told so in the panel rather than being shown a fake terminal. */
|
|
70
|
+
function shellCommand(shell: string): { cmd: string; args: string[]; tty: boolean } {
|
|
42
71
|
const override = (process.env.AGENTUS_TERM_CMD || "").trim();
|
|
43
72
|
if (override) {
|
|
44
73
|
const parts = override.split(/\s+/);
|
|
45
|
-
return { cmd: parts[0], args: parts.slice(1) };
|
|
74
|
+
return { cmd: parts[0], args: parts.slice(1), tty: false };
|
|
46
75
|
}
|
|
47
|
-
if (process.env.AGENTUS_TERM_PTY
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// prints the interpreter error into the terminal), which is better than
|
|
51
|
-
// silently degrading to a pipe shell that claims to be a tty.
|
|
52
|
-
return {
|
|
53
|
-
cmd: "python3",
|
|
54
|
-
args: ["-u", "-c", `import pty,sys; pty.spawn(${JSON.stringify([shell, "-i"])})`],
|
|
55
|
-
};
|
|
76
|
+
if (process.env.AGENTUS_TERM_PTY !== "0") {
|
|
77
|
+
const helper = ptyHelperPath();
|
|
78
|
+
if (helper) return { cmd: "python3", args: [helper, shell, "-i"], tty: true };
|
|
56
79
|
}
|
|
57
|
-
return { cmd: shell, args: ["-i"] };
|
|
80
|
+
return { cmd: shell, args: ["-i"], tty: false };
|
|
58
81
|
}
|
|
59
82
|
|
|
60
83
|
function pickShell(): string {
|
|
@@ -69,9 +92,11 @@ function pickShell(): string {
|
|
|
69
92
|
export class TermService {
|
|
70
93
|
#sessions = new Map<string, TermSession>();
|
|
71
94
|
|
|
72
|
-
start(cwd: string): TermSession {
|
|
95
|
+
start(cwd: string, size?: { cols: number; rows: number }): TermSession {
|
|
73
96
|
const shell = pickShell();
|
|
74
|
-
const { cmd, args } = shellCommand(shell);
|
|
97
|
+
const { cmd, args, tty } = shellCommand(shell);
|
|
98
|
+
const cols = Math.max(20, Math.min(size?.cols ?? 80, 1000));
|
|
99
|
+
const rows = Math.max(5, Math.min(size?.rows ?? 24, 1000));
|
|
75
100
|
const child = spawn(cmd, args, {
|
|
76
101
|
cwd: fs.existsSync(cwd) ? cwd : homedir(),
|
|
77
102
|
env: {
|
|
@@ -80,13 +105,19 @@ export class TermService {
|
|
|
80
105
|
PYTHONUNBUFFERED: "1",
|
|
81
106
|
// Let the shell know a machine is driving, so prompts stay plain.
|
|
82
107
|
AGENTUS_TERMINAL: "1",
|
|
108
|
+
// The pty is sized at start because the shell reads its size when it draws the first
|
|
109
|
+
// prompt — resizing afterwards still works, but a 80x24 flash on every open is not free.
|
|
110
|
+
AGENTUS_PTY_COLS: String(cols),
|
|
111
|
+
AGENTUS_PTY_ROWS: String(rows),
|
|
83
112
|
},
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
//
|
|
113
|
+
// fd 3 is the PTY helper's control channel (resize). A pipe shell has no use for it.
|
|
114
|
+
stdio: tty ? ["pipe", "pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],
|
|
115
|
+
// own process group: the shell (or the pty helper wrapping it) is killed as a group, never
|
|
116
|
+
// one process at a time (see kill()).
|
|
87
117
|
detached: true,
|
|
88
|
-
}) as
|
|
118
|
+
}) as ChildProcess;
|
|
89
119
|
|
|
120
|
+
const ctl = tty ? ((child.stdio[3] ?? null) as NodeJS.WritableStream | null) : null;
|
|
90
121
|
const session: TermSession = {
|
|
91
122
|
id: randomUUID(),
|
|
92
123
|
cwd,
|
|
@@ -95,6 +126,15 @@ export class TermService {
|
|
|
95
126
|
child,
|
|
96
127
|
subscribers: new Set(),
|
|
97
128
|
exited: null,
|
|
129
|
+
tty,
|
|
130
|
+
resize: (c: number, r: number): void => {
|
|
131
|
+
if (!ctl) return;
|
|
132
|
+
try {
|
|
133
|
+
ctl.write(`resize ${Math.max(1, Math.min(c, 1000))} ${Math.max(1, Math.min(r, 1000))}\n`);
|
|
134
|
+
} catch {
|
|
135
|
+
/* the helper is gone; the exit event says so */
|
|
136
|
+
}
|
|
137
|
+
},
|
|
98
138
|
};
|
|
99
139
|
const push = (chunk: string): void => {
|
|
100
140
|
session.buffer = (session.buffer + chunk).slice(-MAX_BUFFER);
|
|
@@ -106,17 +146,18 @@ export class TermService {
|
|
|
106
146
|
}
|
|
107
147
|
}
|
|
108
148
|
};
|
|
109
|
-
child.stdout
|
|
110
|
-
child.stderr
|
|
111
|
-
// stderr is part of the terminal picture (a missing command prints there)
|
|
112
|
-
|
|
113
|
-
child.
|
|
114
|
-
child.on("
|
|
149
|
+
child.stdout?.setEncoding("utf8");
|
|
150
|
+
child.stderr?.setEncoding("utf8");
|
|
151
|
+
// stderr is part of the terminal picture (a missing command prints there). With a pty both
|
|
152
|
+
// streams are already merged into the pty master, so only stdout carries them — harmless.
|
|
153
|
+
child.stdout?.on("data", (d: string) => push(d));
|
|
154
|
+
child.stderr?.on("data", (d: string) => push(d));
|
|
155
|
+
child.on("exit", (code: number | null, signal: string | null) => {
|
|
115
156
|
session.exited = { code, signal };
|
|
116
157
|
push(`\r\n[process exited${code != null ? ` with code ${code}` : ""}${signal ? ` (${signal})` : ""}]\r\n`);
|
|
117
158
|
this.#sessions.delete(session.id);
|
|
118
159
|
});
|
|
119
|
-
child.on("error", (err) => push(`\r\n[cannot start shell: ${err.message}]\r\n`));
|
|
160
|
+
child.on("error", (err: Error) => push(`\r\n[cannot start shell: ${err.message}]\r\n`));
|
|
120
161
|
this.#sessions.set(session.id, session);
|
|
121
162
|
return session;
|
|
122
163
|
}
|