teapot-coding-agent 0.22.13 → 0.23.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 +29 -0
- package/dist/agent/agent.js +20 -4
- package/dist/agent/skills.js +47 -0
- package/dist/agent/tools.js +144 -12
- package/dist/master.js +12 -2
- package/dist/server/api.js +23 -3
- package/dist/server/term-size.js +62 -0
- package/package.json +1 -1
- package/public/assets/{index-Bg8Y4MGZ.js → index-CNmd6GUS.js} +9 -9
- package/public/assets/index-CNmd6GUS.js.map +1 -0
- package/public/assets/index-QCwmIYsM.css +1 -0
- package/public/assets/{shiki-B-DhHuic.js → shiki-NOpdigkY.js} +2 -2
- package/public/assets/{shiki-B-DhHuic.js.map → shiki-NOpdigkY.js.map} +1 -1
- package/public/index.html +2 -2
- package/public/assets/index-B-RJz1_I.css +0 -1
- package/public/assets/index-Bg8Y4MGZ.js.map +0 -1
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
> Try it in 5 minutes → [Quick start](#-quick-start) · Details live in the [Reference](#-reference) section
|
|
6
6
|
|
|
7
|
+
[](https://deepwiki.com/akku1139/teapot)
|
|
8
|
+
|
|
7
9
|
[日本語版はこちら](README.ja.md)
|
|
8
10
|
|
|
9
11
|
---
|
|
@@ -204,6 +206,33 @@ Lookup order: CLI arg → `$TEAPOT_CONFIG` → `~/.config/teapot-coding-agent/co
|
|
|
204
206
|
|
|
205
207
|
</details>
|
|
206
208
|
|
|
209
|
+
<details>
|
|
210
|
+
<summary><b>Several model presets on one API key</b></summary>
|
|
211
|
+
|
|
212
|
+
A provider entry is just `{ baseUrl, apiKey, model }`, so "one key, many
|
|
213
|
+
models" is one entry per model — repeat the same `baseUrl`/`apiKey` and vary
|
|
214
|
+
`model`. Each agent then names the preset it wants:
|
|
215
|
+
|
|
216
|
+
```json
|
|
217
|
+
{
|
|
218
|
+
"providers": {
|
|
219
|
+
"or-sonnet": { "baseUrl": "https://openrouter.ai/api/v1", "apiKey": "sk-or-...", "model": "anthropic/claude-sonnet-4" },
|
|
220
|
+
"or-opus": { "baseUrl": "https://openrouter.ai/api/v1", "apiKey": "sk-or-...", "model": "anthropic/claude-opus-4" },
|
|
221
|
+
"or-haiku": { "baseUrl": "https://openrouter.ai/api/v1", "apiKey": "sk-or-...", "model": "anthropic/claude-haiku-4" }
|
|
222
|
+
},
|
|
223
|
+
"defaultProvider": "or-sonnet",
|
|
224
|
+
"agents": [
|
|
225
|
+
{ "id": "main", "workspace": "~/proj", "provider": "or-sonnet" },
|
|
226
|
+
{ "id": "deep", "workspace": "~/proj", "provider": "or-opus" }
|
|
227
|
+
]
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Switching an agent's model in the right panel never disturbs the others, and
|
|
232
|
+
an agent may still pin its own `model` to override the preset's.
|
|
233
|
+
|
|
234
|
+
</details>
|
|
235
|
+
|
|
207
236
|
<details>
|
|
208
237
|
<summary><b>Mixing providers</b></summary>
|
|
209
238
|
|
package/dist/agent/agent.js
CHANGED
|
@@ -11,6 +11,7 @@ import path from "node:path";
|
|
|
11
11
|
import { EventLog, readEvents } from "../log/events.js";
|
|
12
12
|
import { chat, chatStream } from "./llm.js";
|
|
13
13
|
import { executeTool, toolSpecs, currentSkills, safeJoin, isContextOverflow, hasRunningBgShells } from "./tools.js";
|
|
14
|
+
import { skillRootsFingerprint } from "./skills.js";
|
|
14
15
|
import { bus } from "../bus.js";
|
|
15
16
|
/**
|
|
16
17
|
* SYSTEM_TEMPLATE must stay byte-identical across every request of a session:
|
|
@@ -21,7 +22,7 @@ import { bus } from "../bus.js";
|
|
|
21
22
|
*/
|
|
22
23
|
const SYSTEM_TEMPLATE = `You are a coding agent working autonomously inside a workspace.
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
Most session state is not injected into prompts — fetch it with tools instead:
|
|
25
26
|
- get_goal() → current objective + status. Call at session start, after a
|
|
26
27
|
compaction notice, or whenever you lose the thread.
|
|
27
28
|
- set_goal(text) → change the objective itself (not routine updates).
|
|
@@ -35,10 +36,11 @@ Session state is not injected into prompts — fetch it with tools instead:
|
|
|
35
36
|
- Log significant choices with record_decision(decision, rationale,
|
|
36
37
|
alternatives?) — compaction forgets reasoning, decisions.md doesn't.
|
|
37
38
|
- list_skills() / load_skill(name) / save_skill(...) → reusable playbooks.
|
|
38
|
-
- AGENTS.md in the workspace root (optional) holds project knowledge — read it
|
|
39
|
-
with read_file at session start when present, keep it current.
|
|
40
39
|
|
|
41
40
|
## Rules
|
|
41
|
+
- Project instructions from AGENTS.md (workspace root) are EMBEDDED in this
|
|
42
|
+
prompt below when present — read them here, don't re-read_file it. Keep the
|
|
43
|
+
file current by editing it when it goes stale.
|
|
42
44
|
- Work step by step with tools. Verify results (run tests/builds) before claiming progress.
|
|
43
45
|
- File changes — pick by scope: write_file (one new file / full rewrite) ·
|
|
44
46
|
edit_file (exactly one small unique replacement) · apply_patch (several
|
|
@@ -179,10 +181,24 @@ export class Agent {
|
|
|
179
181
|
}
|
|
180
182
|
skillRoots;
|
|
181
183
|
skillsCache = [];
|
|
182
|
-
/**
|
|
184
|
+
/** fingerprint of the roots that produced skillsCache ("" = not scanned yet) */
|
|
185
|
+
skillsStamp = "";
|
|
186
|
+
/**
|
|
187
|
+
* Rescan skill roots — but only when something actually changed (#14).
|
|
188
|
+
* This used to run a readdir per root plus a read of every SKILL.md at the
|
|
189
|
+
* top of EVERY turn; the cache is only ever read by list_skills, so that
|
|
190
|
+
* was pure I/O on the hot path. A cheap stat fingerprint of the roots and
|
|
191
|
+
* their SKILL.md files gates the real scan, so a turn only pays when a
|
|
192
|
+
* skill was added, edited or removed (save_skill writes one, so a
|
|
193
|
+
* self-created skill still shows up on the very next turn).
|
|
194
|
+
*/
|
|
183
195
|
async refreshSkills() {
|
|
184
196
|
try {
|
|
197
|
+
const stamp = await skillRootsFingerprint(this.skillRoots);
|
|
198
|
+
if (stamp === this.skillsStamp)
|
|
199
|
+
return; // unchanged → reuse the cache
|
|
185
200
|
this.skillsCache = await currentSkills(this.toolCtx);
|
|
201
|
+
this.skillsStamp = stamp;
|
|
186
202
|
}
|
|
187
203
|
catch {
|
|
188
204
|
/* keep previous cache */
|
package/dist/agent/skills.js
CHANGED
|
@@ -19,6 +19,53 @@
|
|
|
19
19
|
import { promises as fs } from "node:fs";
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
export const SKILL_FILE = "SKILL.md";
|
|
22
|
+
/**
|
|
23
|
+
* Cheap change-detector for a set of skill roots. Returns a stable string
|
|
24
|
+
* that changes whenever a root is added, removed, or modified — so callers
|
|
25
|
+
* can skip a full rescan (a readdir per root plus a read of every SKILL.md)
|
|
26
|
+
* on the overwhelmingly common "nothing changed" path.
|
|
27
|
+
*
|
|
28
|
+
* The fingerprint covers each root's own mtime/size AND the mtime/size of
|
|
29
|
+
* every SKILL.md one level down, because writing SKILL.md does not bump the
|
|
30
|
+
* parent directory's mtime. Helper scripts edited in place are covered by
|
|
31
|
+
* the SKILL.md stat only in aggregate; a rewritten SKILL.md always lands
|
|
32
|
+
* here too (saveSkill rewrites it), so bundled-file edits ride along with
|
|
33
|
+
* the next skill write rather than triggering their own rescan.
|
|
34
|
+
*/
|
|
35
|
+
export async function skillRootsFingerprint(roots) {
|
|
36
|
+
const parts = [];
|
|
37
|
+
for (const root of roots) {
|
|
38
|
+
parts.push(root.dir);
|
|
39
|
+
let st = null;
|
|
40
|
+
try {
|
|
41
|
+
st = await fs.stat(root.dir);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
parts.push("-"); // root missing → no skills here (same as discoverSkills)
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
parts.push(`${st.mtimeMs}:${st.size}`);
|
|
48
|
+
let entries;
|
|
49
|
+
try {
|
|
50
|
+
entries = await fs.readdir(root.dir, { withFileTypes: true });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
for (const e of entries) {
|
|
56
|
+
if (!e.isDirectory() || e.name.startsWith("."))
|
|
57
|
+
continue;
|
|
58
|
+
try {
|
|
59
|
+
const s = await fs.stat(path.join(root.dir, e.name, SKILL_FILE));
|
|
60
|
+
parts.push(`${e.name}=${s.mtimeMs}:${s.size}`);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
parts.push(`${e.name}=none`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return parts.join("|");
|
|
68
|
+
}
|
|
22
69
|
export function isValidSkillName(name) {
|
|
23
70
|
return /^[a-z0-9][a-z0-9._-]{0,63}$/.test(name);
|
|
24
71
|
}
|
package/dist/agent/tools.js
CHANGED
|
@@ -16,6 +16,38 @@ function clip(s, max) {
|
|
|
16
16
|
async function readText(p) {
|
|
17
17
|
return fs.readFile(p, "utf8");
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Optimistic-concurrency guard (#5). The model reads a file, thinks, then
|
|
21
|
+
* writes — and in between, a sub-agent, a background job or the operator may
|
|
22
|
+
* have changed it. Anchored edits usually fail safe (old_text is gone, so the
|
|
23
|
+
* call errors), but two paths silently clobber work nobody looked at:
|
|
24
|
+
* `replace_all` rewrites every occurrence present NOW, including ones that
|
|
25
|
+
* appeared after the model formed its plan, and write_file/apply_patch replace
|
|
26
|
+
* whole files unconditionally.
|
|
27
|
+
*
|
|
28
|
+
* A caller that passes `base_content` (the bytes it actually read) therefore
|
|
29
|
+
* gets a refusal instead of a lost update — the same contract the web file
|
|
30
|
+
* editor already enforces (see api.ts PUT /file). The check is advisory by
|
|
31
|
+
* design: omitting the parameter keeps today's behaviour, so this can never
|
|
32
|
+
* break a working agent loop that does not use it.
|
|
33
|
+
*/
|
|
34
|
+
function staleGuard(pathArg, base, current) {
|
|
35
|
+
if (typeof base !== "string")
|
|
36
|
+
return null; // not supplied → no check
|
|
37
|
+
if (base === current)
|
|
38
|
+
return null; // unchanged → proceed
|
|
39
|
+
const rel = pathArg;
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
result: `${rel} changed on disk since you read it — nothing was written.\n` +
|
|
43
|
+
`your base_content: ${base.length} bytes, on disk now: ${current === null ? "(deleted)" : `${current.length} bytes`}.\n` +
|
|
44
|
+
`Re-read ${rel}, redo the edit against the current content, and pass the fresh text as base_content.`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Read the file for a guarded write, or null when it does not exist. */
|
|
48
|
+
async function currentOrNull(p) {
|
|
49
|
+
return fs.readFile(p, "utf8").catch(() => null);
|
|
50
|
+
}
|
|
19
51
|
/** Resolve a path inside the workspace; reject escapes (incl. symlink targets).
|
|
20
52
|
* KNOWN LIMIT (review finding, accepted): the check-then-open pattern has a
|
|
21
53
|
* TOCTOU window — a symlink swapped in between safeJoin() and fs.open() could
|
|
@@ -83,14 +115,31 @@ function startBackgroundShell(cmd, ctx) {
|
|
|
83
115
|
};
|
|
84
116
|
child.stdout?.on("data", collect);
|
|
85
117
|
child.stderr?.on("data", collect);
|
|
86
|
-
|
|
118
|
+
// "exit" (process gone) and "close" (all stdio drained) are DIFFERENT events.
|
|
119
|
+
// A backgrounded grandchild that inherits stdout — `npm run dev &`,
|
|
120
|
+
// `docker compose up &`, a script that forks a watcher — keeps the pipe open,
|
|
121
|
+
// so "close" can stay pending long after the shell itself is dead. Keying
|
|
122
|
+
// the exit state off "close" alone left such jobs marked running FOREVER:
|
|
123
|
+
// hasRunningBgShells() said yes, the auto-continue loop kept breaking out on
|
|
124
|
+
// "background job still running", and the agent sat in "running" with the
|
|
125
|
+
// job long dead and no notification ever delivered (#23).
|
|
126
|
+
//
|
|
127
|
+
// So: "exit" is authoritative for state and for the completion
|
|
128
|
+
// notification; "close" only means "output finally drained" and is used to
|
|
129
|
+
// capture the tail. The notify() helper keeps this exactly-once.
|
|
130
|
+
let exitNotified = false;
|
|
131
|
+
const notify = (code) => {
|
|
87
132
|
sh.exited = true;
|
|
88
|
-
|
|
133
|
+
if (code !== null)
|
|
134
|
+
sh.code = code;
|
|
135
|
+
if (exitNotified)
|
|
136
|
+
return;
|
|
137
|
+
exitNotified = true;
|
|
89
138
|
// tell the harness so the agent learns the outcome WITHOUT polling
|
|
90
139
|
try {
|
|
91
140
|
ctx.onBackgroundExit?.({
|
|
92
141
|
id,
|
|
93
|
-
code,
|
|
142
|
+
code: sh.code,
|
|
94
143
|
cmd,
|
|
95
144
|
durationMs: Date.now() - sh.startedAt,
|
|
96
145
|
outputTail: sh.out.slice(-2000),
|
|
@@ -99,7 +148,9 @@ function startBackgroundShell(cmd, ctx) {
|
|
|
99
148
|
catch {
|
|
100
149
|
/* notification is best-effort */
|
|
101
150
|
}
|
|
102
|
-
}
|
|
151
|
+
};
|
|
152
|
+
child.on("exit", (code) => notify(code));
|
|
153
|
+
child.on("close", (code) => notify(code));
|
|
103
154
|
map.set(id, sh);
|
|
104
155
|
return id;
|
|
105
156
|
}
|
|
@@ -511,15 +562,80 @@ async function applyPatch(patch, ctx) {
|
|
|
511
562
|
catch (e) {
|
|
512
563
|
return { ok: false, result: `patch validation failed: ${e.message}` };
|
|
513
564
|
}
|
|
514
|
-
// phase 2 — commit
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
565
|
+
// phase 2 — commit.
|
|
566
|
+
//
|
|
567
|
+
// Phase 1 validates every hunk in memory, so a CONTENT mismatch never
|
|
568
|
+
// lands halfway. An I/O failure still could: the old loop wrote straight
|
|
569
|
+
// to the final paths, so a failure on write k of n left writes 1..k-1
|
|
570
|
+
// already on disk while the tool reported "patch failed". apply_patch is
|
|
571
|
+
// documented as atomic, so make the commit phase live up to it:
|
|
572
|
+
// 1. back up the current bytes of every path we are about to change
|
|
573
|
+
// 2. write each new file to a sibling temp file, then rename() over the
|
|
574
|
+
// target (rename within a directory is atomic, so a reader never
|
|
575
|
+
// observes a half-written file)
|
|
576
|
+
// 3. on ANY failure, restore the backups and remove temp files
|
|
577
|
+
// Deletes are only performed once every write has landed, and a failed
|
|
578
|
+
// delete is now reported instead of silently swallowed.
|
|
579
|
+
const backups = new Map();
|
|
580
|
+
const temps = [];
|
|
581
|
+
try {
|
|
582
|
+
for (const w of writes) {
|
|
583
|
+
if (!backups.has(w.abs))
|
|
584
|
+
backups.set(w.abs, await fs.readFile(w.abs, "utf8").catch(() => null));
|
|
585
|
+
await fs.mkdir(path.dirname(w.abs), { recursive: true });
|
|
586
|
+
const tmp = `${w.abs}.teapot-patch-${process.pid}-${temps.length}.tmp`;
|
|
587
|
+
temps.push(tmp);
|
|
588
|
+
await fs.writeFile(tmp, w.content, "utf8");
|
|
589
|
+
await fs.rename(tmp, w.abs);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
catch (e) {
|
|
593
|
+
await rollbackWrites(backups, temps);
|
|
594
|
+
return {
|
|
595
|
+
ok: false,
|
|
596
|
+
result: `patch commit failed and was rolled back (no files changed): ${e.message}\n` +
|
|
597
|
+
"re-read the files and regenerate the patch",
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
// writes are in — now the deletes. A delete that fails is a real, visible
|
|
601
|
+
// inconsistency (the old content moved/updated but the source survived),
|
|
602
|
+
// so roll the whole patch back rather than report a false success.
|
|
603
|
+
const removed = [];
|
|
604
|
+
try {
|
|
605
|
+
for (const d of deletes) {
|
|
606
|
+
await fs.rm(d);
|
|
607
|
+
removed.push(d);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
catch (e) {
|
|
611
|
+
for (const d of removed) {
|
|
612
|
+
const orig = await fs.readFile(d, "utf8").catch(() => null);
|
|
613
|
+
if (orig !== null)
|
|
614
|
+
await fs.writeFile(d, orig, "utf8").catch(() => { });
|
|
615
|
+
}
|
|
616
|
+
await rollbackWrites(backups, temps);
|
|
617
|
+
return {
|
|
618
|
+
ok: false,
|
|
619
|
+
result: `patch commit failed and was rolled back (no files changed): ${e.message}`,
|
|
620
|
+
};
|
|
518
621
|
}
|
|
519
|
-
for (const d of deletes)
|
|
520
|
-
await fs.rm(d).catch(() => { });
|
|
521
622
|
return { ok: true, result: `patch applied:\n${summary.join("\n")}` };
|
|
522
623
|
}
|
|
624
|
+
/**
|
|
625
|
+
* Undo a partially committed patch: restore each target's original bytes
|
|
626
|
+
* (null = it did not exist before, so remove the file we created) and clean
|
|
627
|
+
* up any temp file that never made it through its rename.
|
|
628
|
+
*/
|
|
629
|
+
async function rollbackWrites(backups, temps) {
|
|
630
|
+
for (const t of temps)
|
|
631
|
+
await fs.rm(t, { force: true }).catch(() => { });
|
|
632
|
+
for (const [abs, orig] of backups) {
|
|
633
|
+
if (orig === null)
|
|
634
|
+
await fs.rm(abs, { force: true }).catch(() => { });
|
|
635
|
+
else
|
|
636
|
+
await fs.writeFile(abs, orig, "utf8").catch(() => { });
|
|
637
|
+
}
|
|
638
|
+
}
|
|
523
639
|
export const TOOLS = [
|
|
524
640
|
{
|
|
525
641
|
name: "read_file",
|
|
@@ -588,12 +704,17 @@ export const TOOLS = [
|
|
|
588
704
|
name: "write_file",
|
|
589
705
|
description: "Create ONE new file, or replace a file's entire content (parent dirs auto-created). " +
|
|
590
706
|
"Creating files as part of a larger batch of edits → one apply_patch instead. " +
|
|
591
|
-
"Partial changes to an existing file → edit_file."
|
|
707
|
+
"Partial changes to an existing file → edit_file. " +
|
|
708
|
+
"Pass base_content (the bytes you read) when rewriting a file that may have changed under you — the write is refused rather than clobbering a concurrent edit.",
|
|
592
709
|
parameters: {
|
|
593
710
|
type: "object",
|
|
594
711
|
properties: {
|
|
595
712
|
path: { type: "string" },
|
|
596
713
|
content: { type: "string" },
|
|
714
|
+
base_content: {
|
|
715
|
+
type: "string",
|
|
716
|
+
description: "Optional: the exact bytes you read before writing. The write is REFUSED if the file changed since — re-read and retry instead of clobbering someone else's edit.",
|
|
717
|
+
},
|
|
597
718
|
},
|
|
598
719
|
required: ["path", "content"],
|
|
599
720
|
},
|
|
@@ -610,6 +731,9 @@ export const TOOLS = [
|
|
|
610
731
|
};
|
|
611
732
|
}
|
|
612
733
|
const p = safeJoin(ctx.cwd, str(args.path));
|
|
734
|
+
const stale = staleGuard(str(args.path), args.base_content, await currentOrNull(p));
|
|
735
|
+
if (stale)
|
|
736
|
+
return stale;
|
|
613
737
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
614
738
|
await fs.writeFile(p, args.content, "utf8");
|
|
615
739
|
return { ok: true, result: `wrote ${p} (${args.content.length} bytes)` };
|
|
@@ -620,7 +744,8 @@ export const TOOLS = [
|
|
|
620
744
|
description: "Make exactly ONE small, unique replacement in one existing file — the cheapest tool for a single spot change. " +
|
|
621
745
|
"Copy old_text from the file contents (NOT from read_file's `N| ` prefixed display); it must appear exactly once — " +
|
|
622
746
|
"if it matches several places, add surrounding lines or pass replace_all=true. " +
|
|
623
|
-
"Two or more changes (or a rename/delete) → use apply_patch instead."
|
|
747
|
+
"Two or more changes (or a rename/delete) → use apply_patch instead. " +
|
|
748
|
+
"Pass base_content (the bytes you read) whenever the file may have changed under you — especially with replace_all, which otherwise rewrites occurrences that appeared after you read.",
|
|
624
749
|
parameters: {
|
|
625
750
|
type: "object",
|
|
626
751
|
properties: {
|
|
@@ -628,12 +753,19 @@ export const TOOLS = [
|
|
|
628
753
|
old_text: { type: "string" },
|
|
629
754
|
new_text: { type: "string" },
|
|
630
755
|
replace_all: { type: "boolean", description: "replace every occurrence instead of requiring uniqueness" },
|
|
756
|
+
base_content: {
|
|
757
|
+
type: "string",
|
|
758
|
+
description: "Optional: the exact bytes you read before editing. Refuses the edit if the file changed since — important with replace_all, which otherwise rewrites occurrences that appeared after you read.",
|
|
759
|
+
},
|
|
631
760
|
},
|
|
632
761
|
required: ["path", "old_text", "new_text"],
|
|
633
762
|
},
|
|
634
763
|
async run(args, ctx) {
|
|
635
764
|
const p = safeJoin(ctx.cwd, str(args.path));
|
|
636
765
|
let text = await readText(p);
|
|
766
|
+
const stale = staleGuard(str(args.path), args.base_content, text);
|
|
767
|
+
if (stale)
|
|
768
|
+
return stale;
|
|
637
769
|
const oldText = str(args.old_text);
|
|
638
770
|
const newText = str(args.new_text);
|
|
639
771
|
if (!oldText)
|
package/dist/master.js
CHANGED
|
@@ -651,8 +651,18 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
651
651
|
if (owned)
|
|
652
652
|
targetId = owned.agentId;
|
|
653
653
|
const cfg = this.config.agents.find((a) => a.id === targetId);
|
|
654
|
-
if (!cfg || cfg.parent !== parentId)
|
|
655
|
-
|
|
654
|
+
if (!cfg || cfg.parent !== parentId) {
|
|
655
|
+
// Scoping is correct — only the DIRECT parent may message a child, so a
|
|
656
|
+
// grandchild is rightly refused. But "not your sub-agent: <id>" reads
|
|
657
|
+
// like a bug when the agent simply guessed the wrong level of the tree.
|
|
658
|
+
// Name the real owner so the model can re-address the message (#11).
|
|
659
|
+
const actual = cfg?.parent;
|
|
660
|
+
const live = actual ? this.agents.get(actual) : undefined;
|
|
661
|
+
const detail = actual
|
|
662
|
+
? ` its parent is @${actual}${live ? ` (message @${actual} instead, or spawn your own sub-agent for it)` : ""}.`
|
|
663
|
+
: " no such sub-agent exists.";
|
|
664
|
+
throw new Error(`not your sub-agent: ${childId}.${detail}`);
|
|
665
|
+
}
|
|
656
666
|
const child = this.agents.get(targetId);
|
|
657
667
|
if (!child)
|
|
658
668
|
throw new Error(`sub-agent not running: ${targetId}`);
|
package/dist/server/api.js
CHANGED
|
@@ -10,6 +10,7 @@ import { spawn } from "node:child_process";
|
|
|
10
10
|
import { promises as fs } from "node:fs";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
12
|
import { parseSchedule } from "../scheduler/cron.js";
|
|
13
|
+
import { TermSizeTracker } from "./term-size.js";
|
|
13
14
|
import { SUB_PERSONAS } from "../master.js";
|
|
14
15
|
import { ConfigPatchSchema, formatZodError } from "../config-schema.js";
|
|
15
16
|
import { providerHeaders } from "../agent/llm.js";
|
|
@@ -105,6 +106,14 @@ export function buildApp(master) {
|
|
|
105
106
|
});
|
|
106
107
|
// per-agent terminal spawn guard
|
|
107
108
|
const termCounts = new Map();
|
|
109
|
+
// keyed by CHILD, not agent: one agent may have up to 10 concurrent
|
|
110
|
+
// terminals, each with its own size and its own foreground program
|
|
111
|
+
const termSizes = new TermSizeTracker();
|
|
112
|
+
const setTermSize = (child, rows, cols) => termSizes.request(child, rows, cols, (r, c) => {
|
|
113
|
+
if (!child.stdin?.writable)
|
|
114
|
+
return;
|
|
115
|
+
child.stdin.write(`stty rows ${r} cols ${c} >/dev/null 2>&1\n`);
|
|
116
|
+
});
|
|
108
117
|
// ---- realtime events over WebSocket (replaces SSE for the web UI) ----
|
|
109
118
|
app.get("/api/ws", upgradeWebSocket(() => {
|
|
110
119
|
let onUpdate = null;
|
|
@@ -201,8 +210,14 @@ export function buildApp(master) {
|
|
|
201
210
|
? spawn("script", ["-qec", shell, "/dev/null"], { cwd: agent.workspace, env })
|
|
202
211
|
: spawn(shell, [], { cwd: agent.workspace, env: { ...env, TERM: "dumb" } });
|
|
203
212
|
console.log(`[teapot] ⌨ terminal open: ${agentId} @ ${agent.workspace} (${hasScript ? "pty" : "pipe"})`);
|
|
204
|
-
child.stdout?.on("data", (b) =>
|
|
205
|
-
|
|
213
|
+
child.stdout?.on("data", (b) => {
|
|
214
|
+
termSizes.markBusy(child);
|
|
215
|
+
send({ kind: "data", data: b.toString("utf8") });
|
|
216
|
+
});
|
|
217
|
+
child.stderr?.on("data", (b) => {
|
|
218
|
+
termSizes.markBusy(child);
|
|
219
|
+
send({ kind: "data", data: b.toString("utf8") });
|
|
220
|
+
});
|
|
206
221
|
child.on("close", (code) => {
|
|
207
222
|
send({ kind: "exit", code });
|
|
208
223
|
console.log(`[teapot] ⌨ terminal exit: ${agentId} (${code ?? "signal"})`);
|
|
@@ -225,10 +240,15 @@ export function buildApp(master) {
|
|
|
225
240
|
const r = Number(m.rows) | 0;
|
|
226
241
|
const cl = Number(m.cols) | 0;
|
|
227
242
|
if (r > 0 && cl > 0)
|
|
228
|
-
child
|
|
243
|
+
setTermSize(child, r, cl);
|
|
229
244
|
}
|
|
230
245
|
},
|
|
231
246
|
onClose() {
|
|
247
|
+
// capture BEFORE cleanup() nulls the child out
|
|
248
|
+
const dead = child;
|
|
249
|
+
// drop the pending resize timer so we can't write into a dead child
|
|
250
|
+
if (dead)
|
|
251
|
+
termSizes.forget(dead);
|
|
232
252
|
cleanup();
|
|
233
253
|
const n = (termCounts.get(agentId) ?? 1) - 1;
|
|
234
254
|
if (n <= 0)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** debounce window: long enough to collapse a drag, short enough to feel instant */
|
|
2
|
+
export const TERM_RESIZE_DEBOUNCE_MS = 250;
|
|
3
|
+
/** how long after output a terminal counts as "a program is running" */
|
|
4
|
+
export const TERM_BUSY_MS = 400;
|
|
5
|
+
export class TermSizeTracker {
|
|
6
|
+
states = new Map();
|
|
7
|
+
/**
|
|
8
|
+
* Request a resize. Returns the number of `stty` lines that will actually
|
|
9
|
+
* be written for this call (0 while a burst is still collapsing) — exposed
|
|
10
|
+
* for tests and diagnostics.
|
|
11
|
+
*/
|
|
12
|
+
request(child, rows, cols, write) {
|
|
13
|
+
const st = this.states.get(child) ?? { rows: 0, cols: 0, timer: null, busyUntil: 0 };
|
|
14
|
+
this.states.set(child, st);
|
|
15
|
+
if (st.timer) {
|
|
16
|
+
clearTimeout(st.timer);
|
|
17
|
+
st.timer = null;
|
|
18
|
+
}
|
|
19
|
+
// already the applied size → nothing a shell could act on
|
|
20
|
+
if (st.rows === rows && st.cols === cols)
|
|
21
|
+
return;
|
|
22
|
+
const wait = Math.max(0, st.busyUntil - Date.now());
|
|
23
|
+
st.timer = setTimeout(() => {
|
|
24
|
+
st.timer = null;
|
|
25
|
+
st.rows = rows;
|
|
26
|
+
st.cols = cols;
|
|
27
|
+
try {
|
|
28
|
+
write(rows, cols);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* child exited mid-debounce */
|
|
32
|
+
}
|
|
33
|
+
}, wait > 0 ? wait : TERM_RESIZE_DEBOUNCE_MS);
|
|
34
|
+
}
|
|
35
|
+
/** a child produced output — defer resizes so we never type into it */
|
|
36
|
+
markBusy(child, ms = TERM_BUSY_MS) {
|
|
37
|
+
// create the entry if absent: output usually arrives long before the first
|
|
38
|
+
// resize, and dropping the signal then let the resize land in the program
|
|
39
|
+
const st = this.states.get(child) ?? { rows: 0, cols: 0, timer: null, busyUntil: 0 };
|
|
40
|
+
this.states.set(child, st);
|
|
41
|
+
st.busyUntil = Date.now() + ms;
|
|
42
|
+
}
|
|
43
|
+
/** forget a child; cancels its pending resize so we can't write to a corpse */
|
|
44
|
+
forget(child) {
|
|
45
|
+
const st = this.states.get(child);
|
|
46
|
+
if (st?.timer)
|
|
47
|
+
clearTimeout(st.timer);
|
|
48
|
+
this.states.delete(child);
|
|
49
|
+
}
|
|
50
|
+
/** test/diagnostic: the size currently applied for a child */
|
|
51
|
+
applied(child) {
|
|
52
|
+
const st = this.states.get(child);
|
|
53
|
+
return st ? { rows: st.rows, cols: st.cols } : null;
|
|
54
|
+
}
|
|
55
|
+
/** test: cancel everything (keeps suites from leaking timers) */
|
|
56
|
+
disposeAll() {
|
|
57
|
+
for (const st of this.states.values())
|
|
58
|
+
if (st.timer)
|
|
59
|
+
clearTimeout(st.timer);
|
|
60
|
+
this.states.clear();
|
|
61
|
+
}
|
|
62
|
+
}
|