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.
@@ -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 `![shot](/Users/…/shot.png)` 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
- term = terms.start(root.path);
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
- send({ t: "term-ready", id: session.id, cwd: session.cwd, pid: session.pid });
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.write(cmd.data);
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 ChildProcessWithoutNullStreams } from "node:child_process";
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: ChildProcessWithoutNullStreams;
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
- /** How to start the shell. Default: pipes (no native deps). PTY on request. */
41
- function shellCommand(shell: string): { cmd: string; args: string[] } {
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 === "1") {
48
- // python3's stdlib pty module is the one pty allocation available without a
49
- // native build step. Missing python3 fails loudly in the panel (the child
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
- stdio: ["pipe", "pipe", "pipe"],
85
- // own process group: `script` wraps the shell, so only a group kill reliably
86
- // takes both down (see kill()).
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 ChildProcessWithoutNullStreams;
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.setEncoding("utf8");
110
- child.stderr.setEncoding("utf8");
111
- // stderr is part of the terminal picture (a missing command prints there)
112
- child.stdout.on("data", (d: string) => push(d));
113
- child.stderr.on("data", (d: string) => push(d));
114
- child.on("exit", (code, signal) => {
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
  }