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 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
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](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
 
@@ -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
- Session state is not injected into prompts — fetch it with tools instead:
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
- /** Rescan skill roots (cheap: a readdir per root, done once per turn). */
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 */
@@ -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
  }
@@ -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
- child.on("close", (code) => {
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
- sh.code = code;
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
- for (const w of writes) {
516
- await fs.mkdir(path.dirname(w.abs), { recursive: true });
517
- await fs.writeFile(w.abs, w.content, "utf8");
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
- throw new Error(`not your sub-agent: ${childId}`);
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}`);
@@ -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) => send({ kind: "data", data: b.toString("utf8") }));
205
- child.stderr?.on("data", (b) => send({ kind: "data", data: b.toString("utf8") }));
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.stdin.write(`stty rows ${r} cols ${cl} >/dev/null 2>&1\n`);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.22.13",
3
+ "version": "0.23.0",
4
4
  "description": "A lightweight, always-on multi-agent harness for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",