teapot-coding-agent 0.37.58 → 0.37.60
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/dist/acp/adapter.js +4 -72
- package/dist/agent/agent.js +52 -2246
- package/dist/agent/compact-rules.js +0 -53
- package/dist/agent/editorconfig.js +4 -56
- package/dist/agent/goal-audit.js +0 -34
- package/dist/agent/key-pool.js +0 -52
- package/dist/agent/kill.js +1 -13
- package/dist/agent/llm.js +8 -309
- package/dist/agent/mcp-oauth-manager.js +2 -255
- package/dist/agent/mcp-registry.js +1 -188
- package/dist/agent/mcp.js +1 -85
- package/dist/agent/meta-tools.js +0 -26
- package/dist/agent/plugins.js +1 -81
- package/dist/agent/provider-headers.js +1 -28
- package/dist/agent/skills.js +8 -94
- package/dist/agent/tools.js +16 -809
- package/dist/bus.js +1 -2
- package/dist/config-schema.js +0 -40
- package/dist/home.js +1 -19
- package/dist/index.js +0 -136
- package/dist/log/events.js +3 -195
- package/dist/master.js +31 -1176
- package/dist/migrations/index.js +0 -55
- package/dist/migrations/providers.js +0 -13
- package/dist/migrations/registry.js +0 -9
- package/dist/migrations/session-titles.js +3 -43
- package/dist/migrations/sessions.js +2 -20
- package/dist/model-meta.js +2 -74
- package/dist/scheduler/cron.js +2 -17
- package/dist/server/api.js +12 -778
- package/dist/server/image-resize.js +1 -64
- package/dist/server/term-size.js +1 -120
- package/dist/server/terminals.js +0 -159
- package/dist/session-title.js +2 -35
- package/package.json +1 -1
- package/public/assets/index-DhJx7VDq.js +36 -0
- package/public/assets/index-DhJx7VDq.js.map +1 -0
- package/public/assets/{shiki-BaLUHSNL.js → shiki-LBMkaWYb.js} +2 -2
- package/public/assets/shiki-LBMkaWYb.js.map +1 -0
- package/public/index.html +1 -1
- package/public/assets/index-D_f1aal3.js +0 -36
- package/public/assets/index-D_f1aal3.js.map +0 -1
- package/public/assets/shiki-BaLUHSNL.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}>`;
|
|
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}`;
|
package/dist/server/term-size.js
CHANGED
|
@@ -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;
|
|
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;
|
package/dist/server/terminals.js
CHANGED
|
@@ -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)
|