teapot-coding-agent 0.37.59 → 0.37.61

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.
Files changed (42) hide show
  1. package/dist/acp/adapter.js +4 -72
  2. package/dist/agent/agent.js +61 -2247
  3. package/dist/agent/compact-rules.js +0 -53
  4. package/dist/agent/editorconfig.js +4 -56
  5. package/dist/agent/goal-audit.js +0 -34
  6. package/dist/agent/key-pool.js +0 -52
  7. package/dist/agent/kill.js +1 -13
  8. package/dist/agent/llm.js +9 -310
  9. package/dist/agent/mcp-oauth-manager.js +2 -255
  10. package/dist/agent/mcp-registry.js +1 -188
  11. package/dist/agent/mcp.js +1 -85
  12. package/dist/agent/meta-tools.js +0 -26
  13. package/dist/agent/plugins.js +1 -81
  14. package/dist/agent/provider-headers.js +1 -28
  15. package/dist/agent/skills.js +8 -94
  16. package/dist/agent/tools.js +16 -809
  17. package/dist/bus.js +1 -2
  18. package/dist/config-schema.js +0 -40
  19. package/dist/home.js +1 -19
  20. package/dist/index.js +0 -136
  21. package/dist/log/events.js +3 -195
  22. package/dist/master.js +31 -1180
  23. package/dist/migrations/index.js +0 -55
  24. package/dist/migrations/providers.js +0 -13
  25. package/dist/migrations/registry.js +0 -9
  26. package/dist/migrations/session-titles.js +3 -43
  27. package/dist/migrations/sessions.js +2 -20
  28. package/dist/model-meta.js +166 -142
  29. package/dist/scheduler/cron.js +2 -17
  30. package/dist/server/api.js +12 -778
  31. package/dist/server/image-resize.js +1 -64
  32. package/dist/server/term-size.js +1 -120
  33. package/dist/server/terminals.js +0 -159
  34. package/dist/session-title.js +2 -35
  35. package/package.json +1 -1
  36. package/public/assets/{index-KzHw4Sqo.js → index-C1eqcSbW.js} +4 -4
  37. package/public/assets/index-C1eqcSbW.js.map +1 -0
  38. package/public/assets/{shiki-BwDjoZdu.js → shiki-DaLn_bCe.js} +2 -2
  39. package/public/assets/shiki-DaLn_bCe.js.map +1 -0
  40. package/public/index.html +1 -1
  41. package/public/assets/index-KzHw4Sqo.js.map +0 -1
  42. package/public/assets/shiki-BwDjoZdu.js.map +0 -1
@@ -1,45 +1,14 @@
1
- /**
2
- * Downscale an uploaded background image with a NATIVE binary, if one exists.
3
- *
4
- * ## Why this is not done in the browser
5
- *
6
- * A wallpaper is displayed with `background-size: cover` at the window's size — a 6000x4000 phone
7
- * photo is never shown at more than ~2000x1200, but the browser still decodes the full thing and
8
- * keeps it decoded. Reported as "背景画像が大きいとブラウザがリサイズでかなり重くなっちゃう".
9
- * Resizing once on the server costs a few hundred milliseconds at upload and makes every later
10
- * repaint cheap.
11
- *
12
- * `ffmpeg.wasm` would avoid the external dependency at the cost of tens of MB of wasm to do work
13
- * that a native binary does in one call. So: use whatever native tool is present, and when none
14
- * is, keep the original. A missing optimiser must never fail an upload — the image still works,
15
- * it is just as heavy as before.
16
- *
17
- * ## Why execFile and not a shell
18
- *
19
- * The source path is derived from a server-generated `randomUUID`, but it still reaches a `-i`
20
- * argument, so arguments are passed as an ARRAY with no shell in between. Nothing about an
21
- * uploaded file can be read as a flag or a command.
22
- */
23
1
  import { execFile } from "node:child_process";
24
2
  import { existsSync, promises as fs } from "node:fs";
25
3
  import path from "node:path";
26
4
  import { promisify } from "node:util";
27
5
  const run = promisify(execFile);
28
- /** long edge, in pixels. Deliberately above any realistic window: `cover` on a 4K display at
29
- * devicePixelRatio 2 still lands near this, so this is a quality floor, not a UX cap. */
30
6
  export const MAX_EDGE = 2560;
31
7
  const CANDIDATES = [
32
- // ffmpeg first: it is the one most likely to be on a machine that has neither imagemagick nor
33
- // a package manager, and `-vf scale` is a single pass.
34
8
  { name: "ffmpeg", kind: "ffmpeg", probe: ["-version"] },
35
9
  { name: "magick", kind: "magick", probe: ["-version"] },
36
- // ImageMagick 6 ships `convert` only; same invocation, different binary name
37
10
  { name: "convert", kind: "convert", probe: ["-version"] },
38
11
  ];
39
- /**
40
- * Which image tools exist? Probing runs them, so the answer is what actually works here rather
41
- * than what a PATH scan suggests — a broken symlink or a stub shim is not a usable optimiser.
42
- */
43
12
  export async function detectImageTools() {
44
13
  const found = [];
45
14
  let tool = null;
@@ -53,35 +22,19 @@ export async function detectImageTools() {
53
22
  present = false;
54
23
  }
55
24
  found.push({ name: c.name, present });
56
- // `convert` is a common name for unrelated programs; only take it after the two specific
57
- // ones, which is what the CANDIDATES order already does.
58
25
  if (present && !tool)
59
26
  tool = { name: c.name, kind: c.kind };
60
27
  }
61
28
  return { tool, found };
62
29
  }
63
- /**
64
- * build the argv for a downscale into `out`.
65
- *
66
- * `width` is the LONG EDGE to fit within — the same meaning as `srcset`'s `w` descriptors, which
67
- * is what lets the client's chosen width be honoured directly.
68
- */
69
30
  export function resizeArgs(tool, src, out, width = MAX_EDGE) {
70
31
  const scale = `scale='min(${width},iw)':'min(${width},ih)':force_original_aspect_ratio=decrease`;
71
32
  if (tool.kind === "ffmpeg") {
72
- // `-y` overwrite, `-frames:v 1` in case the input is an animated GIF (we want a still)
73
33
  return ["-y", "-loglevel", "error", "-i", src, "-vf", scale, "-frames:v", "1", out];
74
34
  }
75
- const geometry = `${width}x${width}>`; // only shrink, never enlarge
35
+ const geometry = `${width}x${width}>`;
76
36
  return [`${src}[0]`, "-resize", geometry, "-strip", out];
77
37
  }
78
- /**
79
- * Downscale `src` into `out`. Returns whether it did anything, and never throws.
80
- *
81
- * "Did nothing" is a normal outcome: no tool installed, an image already small enough, or a tool
82
- * that failed on this particular file. The caller keeps the original in every one of those cases,
83
- * so an upload is never lost to an optimiser problem.
84
- */
85
38
  export async function downscaleImage(src, out, tool, width = MAX_EDGE) {
86
39
  if (!tool)
87
40
  return { resized: false, reason: "no image tool installed" };
@@ -101,32 +54,16 @@ export async function downscaleImage(src, out, tool, width = MAX_EDGE) {
101
54
  if (!existsSync(out))
102
55
  return { resized: false, reason: `${tool.name} produced no output` };
103
56
  const bytesAfter = (await fs.stat(out)).size;
104
- /**
105
- * Keep whichever is SMALLER, and delete the other.
106
- *
107
- * A re-encode can GROW a file — an already-optimised WebP fed through a lossy JPEG encoder is
108
- * the usual way. Shipping the larger of the two would be strictly worse in both transfer and
109
- * decode, so the comparison decides and the loser is removed.
110
- */
111
57
  if (bytesAfter >= bytesBefore) {
112
58
  await fs.rm(out, { force: true }).catch(() => { });
113
59
  return { resized: false, reason: "re-encode was not smaller", bytesBefore, bytesAfter };
114
60
  }
115
61
  return { resized: true, bytesBefore, bytesAfter };
116
62
  }
117
- /** the scaled filename for an asset: same stem, `-w` suffix, same extension */
118
63
  export function scaledName(name) {
119
64
  const ext = path.extname(name);
120
65
  return `${path.basename(name, ext)}-w${ext}`;
121
66
  }
122
- /**
123
- * The cached filename for a specific width: `<stem>-w<width>.<ext>`.
124
- *
125
- * Kept within `assetName()`'s grammar (`[a-f0-9]{32}\.(ext)`) is NOT possible with a width in the
126
- * name, so these derived files are never served through `/api/assets/:name` directly — they are
127
- * read internally and their bytes returned. That is deliberate: the public route only accepts the
128
- * 32-hex form, so a derived name can never be requested as if it were an original.
129
- */
130
67
  export function widthName(name, width) {
131
68
  const ext = path.extname(name);
132
69
  return `${path.basename(name, ext)}-w${width}${ext}`;
@@ -1,106 +1,18 @@
1
- /**
2
- * Terminal resize plumbing for the web terminal (#20).
3
- *
4
- * A resize is a property of the TTY, not a command. `TermSizeTracker` decides
5
- * WHEN to apply one; `resolvePtySlave` finds the device to apply it TO.
6
- *
7
- * ## How the size is applied now
8
- *
9
- * `stty -F <pty-slave> rows R cols C` — an ioctl(TIOCSWINSZ) on the pty DEVICE.
10
- * Nothing is written to the child's stdin, so nothing can be typed into a
11
- * foreground program. MEASURED with `script -qec`:
12
- *
13
- * stty -F /dev/pts/0 rows 55 cols 190
14
- * readback: 55 190
15
- * foreground trap: WINCH fired, no command text in the output stream
16
- *
17
- * which is the same mechanism VS Code (node-pty, native ioctl) and Alacritty
18
- * (owns the pty) use — neither ever issues `stty` as TEXT.
19
- *
20
- * Before this, the size was applied by WRITING `stty rows R cols C\n` into the
21
- * child's stdin. That is only safe when a shell is reading: when an interactive
22
- * program owns the tty (vim, less, a python REPL) stdin belongs to the PROGRAM,
23
- * so the line arrived as keystrokes and landed in the operator's document. With
24
- * the device ioctl that text is gone entirely, so the machinery here is only
25
- * about avoiding redundant syscalls on a drag — a debounce and a no-op skip.
26
- *
27
- * It used to ALSO defer while the child was producing output (`markBusy`). That
28
- * existed solely to make the text path rarer, and it became a bug once the ioctl
29
- * removed the hazard: a full-screen program redraws continuously, so the busy
30
- * window never expired and the resize never applied at all (#172 — "nanoとか
31
- * 開いたらリサイズ効いてない"). Deferring is now wrong for every path.
32
- *
33
- * ## Why the slave path has to be discovered
34
- *
35
- * `script` is spawned by us, so we hold a pipe to its stdin/stdout, not the pty
36
- * master. The pty slave it created is reachable as our CHILD's fd 0 on Linux
37
- * (/proc/<pid>/fd/0 → /dev/pts/N). Where that cannot be resolved — macOS has no
38
- * /proc — the caller falls back to the stdin write, so no platform loses resize;
39
- * it only loses the improvement.
40
- *
41
- * `TermSizeTracker` still:
42
- * - debounces to the trailing edge of a drag (one ioctl per drag, not per pixel)
43
- * - remembers the applied size and skips no-op resizes entirely
44
- * - keys state by CHILD, not agent: one agent may hold up to 10 terminals,
45
- * each with its own size and its own foreground program
46
- */
47
1
  import { readFileSync, readdirSync, readlinkSync } from "node:fs";
48
2
  import { spawn, spawnSync } from "node:child_process";
49
- /**
50
- * The pty slave device behind a child, or null when it cannot be determined.
51
- *
52
- * ## Why this walks to a GRANDCHILD
53
- *
54
- * The child we spawn is `script -qec <shell> /dev/null` with piped stdio (we must
55
- * read its output), so the child's OWN fd 0 is a PIPE, not the pty. `script`
56
- * creates the pty and forks the shell onto the slave, so the device we want is
57
- * fd 0 of one of its children. MEASURED on util-linux 2.42:
58
- *
59
- * script fd0 -> pipe:[…] (our pipes)
60
- * script fd3 -> /dev/ptmx (the master)
61
- * script fd4 -> /dev/pts/0 (the slave, but script's own fd)
62
- * shell fd0 -> /dev/pts/0 (the shell's controlling tty — what we want)
63
- *
64
- * Any of `/dev/pts/N` found this way identifies the same pty, so the first match
65
- * wins. Returns null on any failure — non-Linux (no /proc), an exited child, the
66
- * plain-pipe fallback spawn where there is no pty at all — because a resize must
67
- * never throw out of a socket handler.
68
- */
69
3
  export function resolvePtySlave(child) {
70
4
  const pid = child?.pid;
71
5
  if (!pid)
72
6
  return null;
73
- // the slave may sit on the spawned child or on ITS child (script forks the
74
- // shell onto the pty), so try both before giving up
75
7
  for (const candidate of [pid, ...childrenOf(pid), ...childrenOf(pid).flatMap(childrenOf)]) {
76
8
  const found = ptySlaveOf(candidate);
77
9
  if (found)
78
10
  return found;
79
11
  }
80
- // No /proc (macOS): ask `ps` which tty the child's descendants are on. This is what makes
81
- // it safe to drop the old "type `stty` into stdin" fallback — that path put a command line
82
- // into the terminal itself, so a foreground program (vim, less, a REPL) received it as
83
- // KEYSTROKES and it landed in the operator's buffer. A tty name from `ps` can be resized
84
- // with `stty -F`, which emits nothing into the stream.
85
12
  return ptySlaveViaPs(pid);
86
13
  }
87
- /**
88
- * The controlling tty of `pid` or any descendant, via `ps` (for hosts without /proc).
89
- *
90
- * `ps -o tty=` prints "ttys003" or "pts/3"; both name a device under /dev. A process with no
91
- * controlling terminal prints "??" (Linux) or "-", and those are skipped.
92
- */
93
14
  function ptySlaveViaPs(pid) {
94
15
  try {
95
- /**
96
- * Only the child's OWN tty, never one inherited from our process.
97
- *
98
- * `ps -o tty=` reports the CONTROLLING terminal, which a plain-pipe child still inherits
99
- * from whoever started the server — so naively taking it returns the tty of the shell that
100
- * launched teapot, and a resize would then ioctl THAT. Measured: a piped `/bin/sh` "resolved"
101
- * to the test runner's own tty. So require that the process has a tty which is NOT ours and
102
- * NOT our parent's, or report nothing.
103
- */
104
16
  const self = process.pid;
105
17
  const out = spawnSync("ps", ["-e", "-o", "pid=,ppid=,tty="], { encoding: "utf8" }).stdout ?? "";
106
18
  const rows = out
@@ -109,7 +21,6 @@ function ptySlaveViaPs(pid) {
109
21
  .filter((c) => c.length >= 3 && Number.isInteger(Number(c[0])))
110
22
  .map((c) => ({ pid: Number(c[0]), ppid: Number(c[1]), tty: c[2] }));
111
23
  const ttyOf = (p) => rows.find((r) => r.pid === p)?.tty;
112
- // the tty we and our own shell are on is inherited by children and proves nothing
113
24
  const inherited = new Set([ttyOf(self), ttyOf(process.ppid)].filter(Boolean));
114
25
  const seen = new Set();
115
26
  const stack = [pid];
@@ -132,7 +43,6 @@ function ptySlaveViaPs(pid) {
132
43
  return null;
133
44
  }
134
45
  }
135
- /** pids whose parent is `pid` (empty on a host without /proc) */
136
46
  function childrenOf(pid) {
137
47
  try {
138
48
  return readFileSync(`/proc/${pid}/task/${pid}/children`, "utf8")
@@ -145,7 +55,6 @@ function childrenOf(pid) {
145
55
  return [];
146
56
  }
147
57
  }
148
- /** `/dev/pts/N` behind any fd of `pid`, or null */
149
58
  function ptySlaveOf(pid) {
150
59
  try {
151
60
  for (const fd of readdirSync(`/proc/${pid}/fd`)) {
@@ -154,27 +63,19 @@ function ptySlaveOf(pid) {
154
63
  target = readlinkSync(`/proc/${pid}/fd/${fd}`);
155
64
  }
156
65
  catch {
157
- continue; // fd closed between readdir and readlink — normal
66
+ continue;
158
67
  }
159
- // only a real pty device: a pipe readlinks as `pipe:[123]` and an anon
160
- // inode as `anon_inode:[…]`, neither of which accepts TIOCSWINSZ
161
68
  if (target.startsWith("/dev/pts/"))
162
69
  return target;
163
70
  }
164
71
  }
165
72
  catch {
166
- /* process gone, or no /proc (macOS) */
167
73
  }
168
74
  return null;
169
75
  }
170
- /** debounce window: long enough to collapse a drag, short enough to feel instant */
171
76
  export const TERM_RESIZE_DEBOUNCE_MS = 250;
172
77
  export class TermSizeTracker {
173
78
  states = new Map();
174
- /**
175
- * Request a resize; the sink write happens on the trailing edge of the
176
- * debounce.
177
- */
178
79
  request(child, rows, cols, write) {
179
80
  const st = this.states.get(child) ?? { rows: 0, cols: 0, timer: null };
180
81
  this.states.set(child, st);
@@ -182,7 +83,6 @@ export class TermSizeTracker {
182
83
  clearTimeout(st.timer);
183
84
  st.timer = null;
184
85
  }
185
- // already the applied size → nothing a shell could act on
186
86
  if (st.rows === rows && st.cols === cols)
187
87
  return;
188
88
  const apply = () => {
@@ -193,24 +93,20 @@ export class TermSizeTracker {
193
93
  write(rows, cols);
194
94
  }
195
95
  catch {
196
- /* child exited mid-debounce */
197
96
  }
198
97
  };
199
98
  st.timer = setTimeout(apply, TERM_RESIZE_DEBOUNCE_MS);
200
99
  }
201
- /** forget a child; cancels its pending resize so we can't write to a corpse */
202
100
  forget(child) {
203
101
  const st = this.states.get(child);
204
102
  if (st?.timer)
205
103
  clearTimeout(st.timer);
206
104
  this.states.delete(child);
207
105
  }
208
- /** test/diagnostic: the size currently applied for a child */
209
106
  applied(child) {
210
107
  const st = this.states.get(child);
211
108
  return st ? { rows: st.rows, cols: st.cols } : null;
212
109
  }
213
- /** test: cancel everything (keeps suites from leaking timers) */
214
110
  disposeAll() {
215
111
  for (const st of this.states.values())
216
112
  if (st.timer)
@@ -218,21 +114,6 @@ export class TermSizeTracker {
218
114
  this.states.clear();
219
115
  }
220
116
  }
221
- /**
222
- * Apply a size to a child's pty — the ONLY way this codebase resizes a terminal.
223
- *
224
- * It shells out to `stty -F <slave>`, which is ioctl(TIOCSWINSZ) on the pty DEVICE. Nothing is
225
- * written to the child's stdin, so nothing can be typed into a foreground program.
226
- *
227
- * That distinction is the whole point (reported twice): writing `stty rows R cols C\n` into
228
- * stdin only works while a SHELL is reading it. When vim, less or a REPL owns the tty, the
229
- * PROGRAM reads, so the line arrives as KEYSTROKES and lands in the operator's document.
230
- *
231
- * When the slave cannot be resolved this does NOTHING. A resize that cannot be applied is
232
- * strictly better than one that types into somebody's buffer.
233
- *
234
- * `spawnFn` is injectable so a test can observe what would have been executed.
235
- */
236
117
  export function applyPtySize(child, rows, cols, spawnFn = spawn) {
237
118
  if (!child || rows <= 0 || cols <= 0)
238
119
  return false;
@@ -1,68 +1,19 @@
1
- /**
2
- * Server-side terminals.
3
- *
4
- * ## Why this exists
5
- *
6
- * A terminal used to live entirely in one browser tab: the tab owned the list, the WebSocket
7
- * owned the pty, and `onClose` sent SIGHUP. So a reload destroyed the shells — reported as
8
- * "タブをリロードしたらターミナル消失したのがちょっと嬉しくない" — and two clients could never
9
- * see the same terminal.
10
- *
11
- * Now the SERVER owns them, keyed by an id, exactly as it owns agents: a terminal outlives any
12
- * one connection, several clients can attach to the same one, and a reload re-attaches and
13
- * replays what it missed.
14
- *
15
- * ## The three decisions that make sharing work
16
- *
17
- * 1. **A ring buffer of output.** A client that attaches late (a reload, a second window) needs
18
- * the screen as it is NOW, not a raw byte stream from the beginning. The buffer keeps the
19
- * last N bytes, which is enough to repaint a terminal after any realistic scrollback.
20
- * 2. **A disconnect is not a close.** `onClose` detaches; the pty keeps running. Only an
21
- * explicit close (or the idle reaper) ends it — otherwise a reload would still kill the
22
- * shell, just one step later.
23
- * 3. **One pty, many writers, last-resize-wins.** Input from any client is forwarded (that is
24
- * what "shared" means); a resize takes the SMALLEST attached client's geometry, because a
25
- * pty has one size and shrinking to the smallest keeps every viewer readable rather than
26
- * letting one large window force horizontal scrolling on the others.
27
- */
28
1
  import { spawn } from "node:child_process";
29
2
  import { existsSync } from "node:fs";
30
3
  import { toolShellFor } from "../agent/tools.js";
31
- /**
32
- * Which shell an interactive terminal should spawn.
33
- *
34
- * Exported so a test can call it: an earlier version of this rule lived inline in `open()`, and
35
- * the obvious unit test could only pin `toolShellFor`, which is shared with the `bash` tool — so
36
- * reverting THIS file to the buggy order left that test green. The rule has to be callable for
37
- * its own failure to be observable.
38
- *
39
- * Only the file name is taken from `toolShellFor` (a terminal is spawned with no `-c`, so the
40
- * args builder does not apply), but the "which shell" question is answered in one place so the
41
- * two cannot drift.
42
- */
43
4
  export function terminalShellFor(platform, env, exists = existsSync) {
44
5
  return toolShellFor(platform, env, exists).file;
45
6
  }
46
7
  let resizeFn = null;
47
8
  let slaveFn = null;
48
- /** wire the size applier once, at server construction */
49
9
  export function setTerminalResize(fn, slave) {
50
10
  resizeFn = fn;
51
11
  slaveFn = slave;
52
12
  }
53
- /**
54
- * The size a pty is seeded with before any client reports one.
55
- *
56
- * Only a placeholder: `script` starts the pty at 0x0, so the shell would print its first prompt
57
- * wrapped at zero columns. Chosen to be a plausible terminal so that prompt looks normal.
58
- */
59
13
  const SEED_ROWS = 30;
60
14
  const SEED_COLS = 100;
61
- /** keep this much recent output — enough for a full repaint after a reload */
62
15
  const BUFFER_BYTES = 200_000;
63
- /** how many terminals one agent may hold, matching the UI's 10-tab cap */
64
16
  const MAX_PER_AGENT = 10;
65
- /** an idle terminal with no client attached is reaped after this long */
66
17
  const IDLE_REAP_MS = 6 * 60 * 60_000;
67
18
  const terms = new Map();
68
19
  let seq = 0;
@@ -73,59 +24,22 @@ function emit(id, msg) {
73
24
  s(msg);
74
25
  }
75
26
  catch {
76
- /* that client is gone; onClose will unsubscribe it */
77
27
  }
78
28
  }
79
29
  }
80
- /** append to the ring buffer, keeping the newest BUFFER_BYTES */
81
30
  function append(t, chunk) {
82
31
  t.buffer += chunk;
83
32
  if (t.buffer.length > BUFFER_BYTES)
84
33
  t.buffer = t.buffer.slice(-BUFFER_BYTES);
85
34
  }
86
- /**
87
- * Start a terminal in an agent's workspace.
88
- *
89
- * Returns `null` (with a reason) instead of throwing, so the HTTP layer can answer 400/409
90
- * rather than 500 — a refused request is not a server fault.
91
- */
92
35
  export function createTerminal(agentId, workspace, title) {
93
36
  const live = [...terms.values()].filter((t) => t.agentId === agentId && !t.exited);
94
37
  if (live.length >= MAX_PER_AGENT) {
95
38
  return { ok: false, error: `too many terminals for this agent (max ${MAX_PER_AGENT})` };
96
39
  }
97
- /**
98
- * The shell to run, per platform.
99
- *
100
- * `process.env.SHELL || "/bin/bash"` was the whole story, which on Windows spawns a path that
101
- * does not exist — a terminal tab that opens and immediately dies, on a platform this package
102
- * claims to support. Windows also has no `script` and no pty (see `hasScript` below), so it
103
- * gets the plain-pipe path with `cmd.exe` (or `%COMSPEC%`), which is what the fallback branch
104
- * was always for.
105
- *
106
- * `%TEAPOT_SHELL%` overrides on both platforms, so an operator who wants `pwsh` or a specific
107
- * bash does not have to change their login shell.
108
- */
109
- /**
110
- * The SAME choice the `bash` tool makes — deliberately shared, not re-derived.
111
- *
112
- * `$SHELL` used to be consulted before the platform check. On Windows that is a trap: Git
113
- * Bash / MSYS / some CI images export `SHELL=/usr/bin/bash`, which `spawn()` cannot execute
114
- * there, so the tab opened and died with ENOENT. MEASURED: `SHELL=/usr/bin/bash` with
115
- * `COMSPEC=C:\Windows\system32\cmd.exe` resolved to `/usr/bin/bash` under the old chain.
116
- *
117
- * A terminal needs an INTERACTIVE shell (spawned with no `-c`), so only the file name is taken
118
- * from `toolShellFor`; the two answer the "which shell" question in one place so a fix to one
119
- * cannot leave the other broken.
120
- */
121
40
  const shell = terminalShellFor(process.platform, process.env);
122
41
  const hasScript = process.platform !== "win32" && existsSync("/usr/bin/script");
123
- // `script` reports a 0x0 winsize, so the shell falls back to these until a client resizes
124
42
  const env = { ...process.env, TERM: "xterm-256color", COLUMNS: "100", LINES: "30" };
125
- // `detached` puts the pty provider and the shell it forks into their OWN process group, so
126
- // closing the terminal can signal the GROUP. Signalling only `script` leaves the shell (and
127
- // anything running inside it) alive and holding the pipes open — which kept a test file from
128
- // exiting for a full minute after its work was done.
129
43
  const child = hasScript
130
44
  ? spawn("script", ["-qec", shell, "/dev/null"], { cwd: workspace, env, detached: true })
131
45
  : spawn(shell, [], { cwd: workspace, env: { ...env, TERM: "dumb" }, detached: true });
@@ -157,35 +71,10 @@ export function createTerminal(agentId, workspace, title) {
157
71
  t.code = code;
158
72
  emit(id, { kind: "exit", code });
159
73
  });
160
- /**
161
- * SEED the size.
162
- *
163
- * `script` gives the pty a 0x0 winsize, so a shell genuinely reports "0 0" and wraps at zero
164
- * columns until something sets it. A client resize would, but the shell prints its prompt
165
- * first — so the very first thing the operator sees is garbage wrapping. That is why the size
166
- * is seeded here rather than waiting for the socket.
167
- *
168
- * The slave does not exist until `script` has forked, so poll briefly; `script` forks within a
169
- * few ms and the fallback (no seed at all) is harmless if it never appears.
170
- */
171
74
  const seed = (tries) => {
172
75
  if (t.exited)
173
76
  return;
174
77
  if (slaveFn?.(child)) {
175
- /**
176
- * Seed the size ONLY if no client has reported one yet (#172).
177
- *
178
- * MEASURED bug: "稼働中のnanoにリサイズが効かない、閉じて再度開けば効く". The seed runs at
179
- * ~30ms, applying 30x100 to the pty. A client that had already attached and sent its real
180
- * size lost that race — and because the seed went through the same tracker, the client's
181
- * size was then deduped as "already applied" if it happened to equal the seed, or simply
182
- * overwritten by it. The program opened at the seed's geometry and only a NEW size (which
183
- * arrives when the tab is re-attached, i.e. "close and reopen nano") fixed it.
184
- *
185
- * The seed exists for one reason: `script` starts the pty at 0x0, so a shell prints its
186
- * prompt wrapped at zero columns before any client speaks. Once a client HAS spoken, its
187
- * size is the truth and seeding must stand down.
188
- */
189
78
  if (t.sizes.size === 0)
190
79
  resizeFn?.(child, SEED_ROWS, SEED_COLS);
191
80
  return;
@@ -206,21 +95,12 @@ function infoOf(t) {
206
95
  createdAt: t.createdAt,
207
96
  };
208
97
  }
209
- /** every live terminal, oldest first — the list the UI renders */
210
98
  export function listTerminals() {
211
99
  return [...terms.values()]
212
100
  .filter((t) => !t.exited)
213
101
  .sort((a, b) => a.createdAt - b.createdAt)
214
102
  .map(infoOf);
215
103
  }
216
- /**
217
- * Attach a client.
218
- *
219
- * The `replay` string is the buffered backlog, so the caller can paint the screen as it is now
220
- * before forwarding live output. Ordering matters: the sink is registered FIRST so nothing
221
- * produced between "snapshot the buffer" and "start forwarding" is lost; a duplicate line is
222
- * harmless, a missing one leaves the terminal visibly wrong.
223
- */
224
104
  export function attachTerminal(id, sink, size) {
225
105
  const t = terms.get(id);
226
106
  if (!t || t.exited)
@@ -237,7 +117,6 @@ export function attachTerminal(id, sink, size) {
237
117
  }
238
118
  return { ok: true, replay: t.buffer, terminal: infoOf(t) };
239
119
  }
240
- /** drop a client; the pty keeps running (a reload must not kill the shell) */
241
120
  export function detachTerminal(id, sink) {
242
121
  const set = sinks.get(id);
243
122
  if (!set)
@@ -246,15 +125,12 @@ export function detachTerminal(id, sink) {
246
125
  if (set.size === 0)
247
126
  sinks.delete(id);
248
127
  }
249
- /** one client's view of the size; the pty gets the SMALLEST of them */
250
128
  export function resizeTerminal(id, sink, rows, cols) {
251
129
  const t = terms.get(id);
252
130
  if (!t || t.exited)
253
131
  return;
254
132
  if (rows <= 0 || cols <= 0)
255
133
  return;
256
- // keyed by the SINK: that is the per-client identity, so two windows can each report their
257
- // own size without overwriting one another
258
134
  t.sizes.set(sink, { rows, cols });
259
135
  applySize(t);
260
136
  }
@@ -271,7 +147,6 @@ function applySize(t) {
271
147
  return;
272
148
  resizeFn?.(t.child, rows, cols);
273
149
  }
274
- /** close one terminal for good, reporting whether it existed */
275
150
  export function closeTerminal(id) {
276
151
  const t = terms.get(id);
277
152
  if (!t)
@@ -283,7 +158,6 @@ export function closeTerminal(id) {
283
158
  console.log(`[teapot] ⌨ terminal closed: ${id}`);
284
159
  return true;
285
160
  }
286
- /** close everything for one agent — used when the agent is archived or the master stops */
287
161
  export function closeTerminalsFor(agentId) {
288
162
  let n = 0;
289
163
  for (const t of [...terms.values()]) {
@@ -292,7 +166,6 @@ export function closeTerminalsFor(agentId) {
292
166
  }
293
167
  return n;
294
168
  }
295
- /** close every terminal (harness shutdown / dispose) */
296
169
  export function closeAllTerminals() {
297
170
  let n = 0;
298
171
  for (const id of [...terms.keys()])
@@ -300,14 +173,6 @@ export function closeAllTerminals() {
300
173
  n++;
301
174
  return n;
302
175
  }
303
- /**
304
- * Reap terminals nobody has attached to for a long time.
305
- *
306
- * Without this, every abandoned tab keeps a shell alive for the life of the process: the whole
307
- * point of making terminals outlive a disconnect is that a reload re-attaches, but a closed tab
308
- * never will. The window is deliberately long (6h) — a terminal is the operator's workspace,
309
- * and reaping one they meant to come back to is much worse than holding a sleeping pty.
310
- */
311
176
  export function reapIdleTerminals(now = Date.now()) {
312
177
  let n = 0;
313
178
  for (const t of [...terms.values()]) {
@@ -321,14 +186,6 @@ export function reapIdleTerminals(now = Date.now()) {
321
186
  }
322
187
  return n;
323
188
  }
324
- /** test seam: forget everything without touching processes */
325
- /**
326
- * Signal the whole pty process group, not just `script`.
327
- *
328
- * `script` execs/forks the shell onto the pty; killing only the process we spawned orphans the
329
- * shell, which then keeps running with nobody attached and keeps the parent's pipes open. The
330
- * group is negative-pid, which is why the spawn is `detached`.
331
- */
332
189
  function killGroup(t) {
333
190
  const pid = t.child.pid;
334
191
  const signal = (sig) => {
@@ -343,19 +200,10 @@ function killGroup(t) {
343
200
  t.child.kill(sig);
344
201
  }
345
202
  catch {
346
- /* already gone */
347
203
  }
348
204
  }
349
205
  };
350
206
  signal("SIGHUP");
351
- /**
352
- * SIGHUP alone is not enough to release the pty.
353
- *
354
- * `script` holds the pty MASTER open; while it does, this process cannot exit because its
355
- * pipes are still attached — measured as a test file sitting idle for a full minute after
356
- * its work was done. Escalating to SIGKILL after a short grace is what actually reaps it.
357
- * `unref` on the timer keeps this from being a reason to stay alive itself.
358
- */
359
207
  const t2 = setTimeout(() => signal("SIGKILL"), 300);
360
208
  t2.unref?.();
361
209
  }
@@ -366,7 +214,6 @@ export function _resetTerminalsForTest() {
366
214
  sinks.clear();
367
215
  seq = 0;
368
216
  }
369
- /** forward keystrokes to a terminal; a dead pty simply drops them */
370
217
  export function writeTerminal(id, data) {
371
218
  const t = terms.get(id);
372
219
  if (!t || t.exited)
@@ -381,12 +228,6 @@ export function writeTerminal(id, data) {
381
228
  return false;
382
229
  }
383
230
  }
384
- /**
385
- * Stop letting one client's window size constrain the pty.
386
- *
387
- * A detaching client must be forgotten, or its (now stale) small geometry would keep forcing
388
- * every remaining attached client into a narrow pty forever.
389
- */
390
231
  export function forgetTerminalSize(id, sink) {
391
232
  const t = terms.get(id);
392
233
  if (!t)