teapot-coding-agent 0.28.1 → 0.29.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.
@@ -46,26 +46,40 @@ export async function skillRootsFingerprint(roots) {
46
46
  continue;
47
47
  }
48
48
  parts.push(`${st.mtimeMs}:${st.size}`);
49
- let entries;
49
+ // stamp every SKILL.md the discovery walk could adopt (same depth bound):
50
+ // the one-level stat missed a NESTED skill's add/edit entirely, so the
51
+ // stamp never changed, refreshSkills early-returned, and the skill stayed
52
+ // invisible/stale in list_skills and the system-prompt catalogue
53
+ await stampSkillFiles(root.dir, 0, parts);
54
+ }
55
+ return parts.join("|");
56
+ }
57
+ /** recursive fingerprint walk — mirrors walkSkills' bound and containment rules */
58
+ async function stampSkillFiles(dir, depth, parts) {
59
+ if (depth > MAX_SKILL_DEPTH)
60
+ return;
61
+ let entries;
62
+ try {
63
+ entries = await fs.readdir(dir, { withFileTypes: true });
64
+ }
65
+ catch {
66
+ return;
67
+ }
68
+ for (const e of entries) {
69
+ if (e.name.startsWith("."))
70
+ continue;
71
+ const full = path.join(dir, e.name);
72
+ if (!e.isDirectory())
73
+ continue;
50
74
  try {
51
- entries = await fs.readdir(root.dir, { withFileTypes: true });
75
+ const s = await fs.stat(path.join(full, SKILL_FILE));
76
+ parts.push(`${full}=${s.mtimeMs}:${s.size}`);
52
77
  }
53
78
  catch {
54
- continue;
55
- }
56
- for (const e of entries) {
57
- if (!e.isDirectory() || e.name.startsWith("."))
58
- continue;
59
- try {
60
- const s = await fs.stat(path.join(root.dir, e.name, SKILL_FILE));
61
- parts.push(`${e.name}=${s.mtimeMs}:${s.size}`);
62
- }
63
- catch {
64
- parts.push(`${e.name}=none`);
65
- }
79
+ // not a skill dir itself — still a container to descend into
66
80
  }
81
+ await stampSkillFiles(full, depth + 1, parts);
67
82
  }
68
- return parts.join("|");
69
83
  }
70
84
  export function isValidSkillName(name) {
71
85
  return /^[a-z0-9][a-z0-9._-]{0,63}$/.test(name);
@@ -1645,9 +1645,30 @@ export const TOOLS = [
1645
1645
  return { ok: false, result: "sub-agents are not available here" };
1646
1646
  const ids = Array.isArray(args.ids) ? args.ids.map(String) : undefined;
1647
1647
  const ms = Math.min(Math.max(num(args.timeout_ms, 300_000), 1_000), 3_600_000);
1648
+ // #140: a prompt ALREADY in the queue must be consumed FIRST. The
1649
+ // enqueue-time wake only fires for prompts arriving DURING the park —
1650
+ // one queued before the park is invisible to it, and parking over it
1651
+ // strands the operator's message ("idle" display, nothing processed)
1652
+ // until a child settles. Stand down and let the next turn boundary
1653
+ // drain it; the model can call wait_children again afterwards.
1654
+ if (ctx.hasPendingPrompts?.()) {
1655
+ ctx.onProgressGateReset?.();
1656
+ return {
1657
+ ok: true,
1658
+ result: "A prompt is already queued for you — handle it first, then call wait_children again if you still need to wait.",
1659
+ };
1660
+ }
1648
1661
  // park VISIBLY: the UI shows idle ("waiting on sub-agents") instead of a
1649
1662
  // running spinner, and any user prompt / stop wakes us instantly
1650
- ctx.onIdlePark?.(`waiting on sub-agent${ids?.length === 1 ? ` ${ids[0]}` : "s"}`);
1663
+ const parked = ctx.onIdlePark?.(`waiting on sub-agent${ids?.length === 1 ? ` ${ids[0]}` : "s"}`);
1664
+ if (parked === false) {
1665
+ // the agent refused the park (a prompt raced in) — same answer as above
1666
+ ctx.onProgressGateReset?.();
1667
+ return {
1668
+ ok: true,
1669
+ result: "A prompt arrived while you were about to wait — handle it first, then call wait_children again if you still need to wait.",
1670
+ };
1671
+ }
1651
1672
  // the child's report is about to land in our mailbox — a progress report
1652
1673
  // right after it would be pure noise, so re-arm the gate here
1653
1674
  ctx.onProgressGateReset?.();
@@ -39,7 +39,8 @@ export const ConfigPatchSchema = z.object({
39
39
  progressMinChars: z.number().int().min(100).optional(),
40
40
  // null clears a previously pinned compact budget → back to per-model derivation
41
41
  contextTokenBudget: z.number().int().min(1_000).nullable().optional(),
42
- contextWindowTokens: z.number().int().min(1_000).optional(),
42
+ // null clears a saved window pin → back to per-model inference (same contract as contextTokenBudget)
43
+ contextWindowTokens: z.number().int().min(1_000).nullable().optional(),
43
44
  maxSpawnDepth: z.number().int().min(0).max(8).optional(),
44
45
  // soft cap on LLM turns in one round; reaching it nudges the model to wrap
45
46
  // up (not an error) — 0 disables the cap
package/dist/master.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * Agents are in-process async loops (I/O bound only); all CPU-heavy work is
4
4
  * delegated to subprocesses managed by the bash tool with hard timeouts.
5
5
  */
6
- import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSync, renameSync } from "node:fs";
6
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSync, lstatSync, renameSync, unlinkSync, openSync, closeSync, fsyncSync, } from "node:fs";
7
7
  import { randomUUID } from "node:crypto";
8
8
  import path from "node:path";
9
9
  import os from "node:os";
@@ -168,6 +168,30 @@ export function loadConfig(configPath) {
168
168
  };
169
169
  }
170
170
  /** Raw parsed user config (for lossless persistence of web edits). */
171
+ /**
172
+ * Can this path be replaced by an atomic rename?
173
+ *
174
+ * #144: `/dev/null`, a fifo, or a directory are all legitimate values for
175
+ * `configPath`, and `rename` over them either fails outright (EACCES for a temp
176
+ * beside `/dev/null`) or is not what the caller wants. Only a regular file gets
177
+ * the write-temp-then-rename treatment.
178
+ */
179
+ function isRegularFileTarget(p) {
180
+ try {
181
+ // lstat, not stat: a SYMLINK must take the plain-write path. statSync
182
+ // follows the link, so a symlink→regular-file passed this check and the
183
+ // atomic rename then REPLACED THE LINK with a real file — the link's
184
+ // target (a dotfile repo, stow/chezmoi) kept the old value and a restart
185
+ // resurrected it. Same reasoning covers hardlinks (rename splits the
186
+ // inode; the write must go through the existing directory entry).
187
+ const st = lstatSync(p);
188
+ return st.isFile() && !st.isSymbolicLink();
189
+ }
190
+ catch {
191
+ // does not exist yet — the parent decides; a real config path will be created
192
+ return true;
193
+ }
194
+ }
171
195
  export function loadedRaw() {
172
196
  return masterRawConfig;
173
197
  }
@@ -289,8 +313,30 @@ export class Master {
289
313
  this.config = config;
290
314
  this.configPath = configPath;
291
315
  }
292
- /** Persist current logical config back to disk (lossless via raw user config). */
316
+ /**
317
+ * Persist current logical config back to disk (lossless via raw user config).
318
+ *
319
+ * #144: this was a bare `writeFileSync`, which TRUNCATES the target before it
320
+ * writes. config.json holds every agent, provider and scheduled task, so any
321
+ * interruption during the write — SIGINT, a crash, a full disk — left a ZERO-LENGTH
322
+ * file, and the next boot read `{}` and came up empty.
323
+ *
324
+ * Boot made this far more likely than it sounds: every agent that mints a
325
+ * session calls `recordSession` → `saveConfig`, so a machine with 30 configured
326
+ * agents rewrites the whole file 30 times before the server listens. A user
327
+ * pressing Ctrl-C during that window lost the lot.
328
+ *
329
+ * Now: write a sibling temp file, fsync it, then `rename` over the target.
330
+ * `rename` is atomic within a filesystem, so the file is either the old content
331
+ * or the new — never empty. A crash mid-write leaves the temp file behind and
332
+ * the real config untouched.
333
+ */
293
334
  saveConfig() {
335
+ // #144: during boot, mark dirty instead of writing; flushed once at the end
336
+ if (this.deferConfigWrites) {
337
+ this.configDirty = true;
338
+ return;
339
+ }
294
340
  this.raw.agents = this.config.agents;
295
341
  this.raw.providers = this.config.providers;
296
342
  this.raw.defaultProvider = this.config.defaultProvider;
@@ -299,7 +345,75 @@ export class Master {
299
345
  this.raw.progressIntervalMs = this.config.progressIntervalMs;
300
346
  if (this.raw.progressMinChars === undefined && this.config.progressMinChars !== undefined)
301
347
  this.raw.progressMinChars = this.config.progressMinChars;
302
- writeFileSync(this.configPath, JSON.stringify(this.raw, null, 2) + "\n");
348
+ this.writeConfigAtomic(JSON.stringify(this.raw, null, 2) + "\n");
349
+ }
350
+ /**
351
+ * Coalesce config writes. #144.
352
+ *
353
+ * Boot calls `addAgent` for every configured agent, and any agent whose recorded
354
+ * session is gone mints a new one — which calls `recordSession` → `saveConfig`.
355
+ * A machine with 30 agents therefore rewrote the whole file 30 times before the
356
+ * server listened, and each was a window in which the file could be truncated.
357
+ *
358
+ * With the write now atomic that is no longer dangerous, but it is still 30
359
+ * unnecessary writes of a 148KB file. During boot, saves are marked dirty and
360
+ * flushed ONCE at the end. Outside boot (an edit, a spawn, a rename) behaviour is
361
+ * unchanged: synchronous and immediate.
362
+ */
363
+ deferConfigWrites = false;
364
+ configDirty = false;
365
+ /** write once and clear the dirty flag — used at the end of a batched section */
366
+ flushConfig() {
367
+ this.configDirty = false;
368
+ this.saveConfig();
369
+ }
370
+ /** write-and-rename, so config.json is never observed empty or half-written */
371
+ writeConfigAtomic(body) {
372
+ // #144: `/dev/null` is a real, intentional value here — many callers (and
373
+ // every test that does not care about persistence) pass it as "no config
374
+ // file". The old `writeFileSync("/dev/null", …)` succeeded and discarded.
375
+ //
376
+ // Atomic writing cannot honour that: the temp file would be
377
+ // `/dev/.config.json.tmp-…`, which is EACCES for a normal user, so 22 tests
378
+ // failed with a bare 400 from POST /api/agents. Keep the old behaviour for a
379
+ // non-regular target, and only do write-rename where rename means something.
380
+ if (!isRegularFileTarget(this.configPath)) {
381
+ writeFileSync(this.configPath, body);
382
+ return;
383
+ }
384
+ const dir = path.dirname(this.configPath);
385
+ // same directory as the target, so rename() stays within one filesystem
386
+ const tmp = path.join(dir, `.config.json.tmp-${process.pid}-${Date.now()}`);
387
+ let fd;
388
+ try {
389
+ fd = openSync(tmp, "w");
390
+ writeFileSync(fd, body);
391
+ // fsync BEFORE the rename: without it the rename can land while the data is
392
+ // still in the page cache, which is the same data-loss window on a crash
393
+ fsyncSync(fd);
394
+ closeSync(fd);
395
+ fd = undefined;
396
+ renameSync(tmp, this.configPath);
397
+ }
398
+ catch (err) {
399
+ if (fd !== undefined) {
400
+ try {
401
+ closeSync(fd);
402
+ }
403
+ catch {
404
+ /* already closed */
405
+ }
406
+ }
407
+ try {
408
+ unlinkSync(tmp);
409
+ }
410
+ catch {
411
+ /* nothing to clean up */
412
+ }
413
+ // Losing the new state is survivable; losing the file is not, so rethrow
414
+ // rather than pretending the save succeeded
415
+ throw err;
416
+ }
303
417
  }
304
418
  raw = loadedRaw();
305
419
  /** Apply partial config edits from the web UI and persist them. */
@@ -334,6 +448,27 @@ export class Master {
334
448
  this.config.onError = patch.onError;
335
449
  if (patch.retryDelayMs !== undefined)
336
450
  this.config.retryDelayMs = patch.retryDelayMs;
451
+ // #PUT-config: an explicit null clears a saved window pin → back to
452
+ // per-model inference; a positive number pins it (same contract as
453
+ // contextTokenBudget above)
454
+ if (patch.contextWindowTokens !== undefined) {
455
+ this.config.contextWindowTokens =
456
+ (patch.contextWindowTokens ?? 0) > 0 ? patch.contextWindowTokens : undefined;
457
+ }
458
+ // persist the scalar edits: saveConfig only mirrors agents/providers/etc.
459
+ // into raw, so these would otherwise pass validation, live for one
460
+ // process, and silently vanish from the file (a restart resurrected the
461
+ // old values). undefined DELETES the key so a null-clear also persists.
462
+ for (const k of [
463
+ "contextTokenBudget", "contextWindowTokens", "maxSpawnDepth",
464
+ "maxTurnsPerRound", "onError", "retryDelayMs",
465
+ ]) {
466
+ const v = this.config[k];
467
+ if (v === undefined)
468
+ delete this.raw[k];
469
+ else
470
+ this.raw[k] = v;
471
+ }
337
472
  if (patch.tasks) {
338
473
  this.config.tasks = patch.tasks;
339
474
  // rebuild schedule table live
@@ -347,28 +482,48 @@ export class Master {
347
482
  }
348
483
  async start() {
349
484
  mkdirSync(this.config.dataDir, { recursive: true });
350
- for (const ac of this.config.agents) {
351
- try {
352
- await this.addAgent(ac);
485
+ // #144: ONE config write for the whole boot instead of one per agent.
486
+ //
487
+ // Every agent whose recorded session is gone mints a new one, and minting
488
+ // calls recordSession → saveConfig. With ~30 configured agents the user's log
489
+ // showed ~30 "session … is gone" lines, and therefore ~30 full rewrites of a
490
+ // 148KB config.json — all before the server ever listened. That is the window
491
+ // in which Ctrl-C emptied the file.
492
+ //
493
+ // `finally` so a throw mid-boot still flushes what it already changed: a
494
+ // deferred write that never flushes would leave config.json describing the PREVIOUS
495
+ // boot, which is its own silent data loss.
496
+ this.deferConfigWrites = true;
497
+ this.configDirty = false;
498
+ try {
499
+ for (const ac of this.config.agents) {
500
+ try {
501
+ await this.addAgent(ac);
502
+ }
503
+ catch (err) {
504
+ // one bad entry (duplicate id, unknown provider, …) must not take the
505
+ // whole master down — skip it loudly and keep serving the rest
506
+ console.error(`[teapot] skipping agent "${ac.id}": ${err.message}`);
507
+ }
353
508
  }
354
- catch (err) {
355
- // one bad entry (duplicate id, unknown provider, …) must not take the
356
- // whole master down — skip it loudly and keep serving the rest
357
- console.error(`[teapot] skipping agent "${ac.id}": ${err.message}`);
509
+ for (const t of this.config.tasks ?? []) {
510
+ try {
511
+ this.tasks.push({
512
+ task: t,
513
+ schedule: parseSchedule(t.schedule),
514
+ lastRunMin: t.lastRunMin ?? -1,
515
+ });
516
+ }
517
+ catch (err) {
518
+ // a hand-edited schedule typo must not stop the server from listening
519
+ console.error(`[teapot] skipping task "${t.id}": ${err.message}`);
520
+ }
358
521
  }
359
522
  }
360
- for (const t of this.config.tasks ?? []) {
361
- try {
362
- this.tasks.push({
363
- task: t,
364
- schedule: parseSchedule(t.schedule),
365
- lastRunMin: t.lastRunMin ?? -1,
366
- });
367
- }
368
- catch (err) {
369
- // a hand-edited schedule typo must not stop the server from listening
370
- console.error(`[teapot] skipping task "${t.id}": ${err.message}`);
371
- }
523
+ finally {
524
+ this.deferConfigWrites = false;
525
+ if (this.configDirty)
526
+ this.flushConfig();
372
527
  }
373
528
  // single low-frequency tick for everything periodic (idle cost ≈ 0)
374
529
  setInterval(() => void this.tick(), 15_000).unref();
@@ -825,10 +980,13 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
825
980
  : this.childrenOf(parentId).map((c) => c.id)).filter((id) => this.agents.has(id));
826
981
  if (targets.length === 0)
827
982
  return Promise.resolve({ note: "no live sub-agents to wait for" });
828
- const active = () => targets.filter((id) => {
829
- const s = this.agents.get(id)?.status;
830
- return s === "running" || s === "waiting";
831
- });
983
+ // isBusy(), not the raw status: parkForTool overwrites a parked child's
984
+ // status to "idle" (spinner suppression) while its loop is very much
985
+ // alive — the raw check counted that child as settled and let the parent
986
+ // resume over unfinished work. isBusy() sees parked tools, compactions
987
+ // and an open ask_user; queued prompts are DORMANT (isLive()'s "stop vs
988
+ // start" extra) and must not hold the wait. A stop request reads settled.
989
+ const active = () => targets.filter((id) => this.agents.get(id)?.isBusy());
832
990
  const first = active();
833
991
  if (first.length === 0)
834
992
  return Promise.resolve({ note: `all sub-agents already settled: ${targets.join(", ")}` });
@@ -88,12 +88,14 @@ export function matches(schedule, d) {
88
88
  }
89
89
  /**
90
90
  * Next fire time after `from`, scanning minute by minute (cheap: a handful of
91
- * integer compares per minute). Returns null when nothing fires within ~31
92
- * days (e.g. a Feb-29-only cron in August).
91
+ * integer compares per minute). Returns null when nothing fires within a year
92
+ * — annual crons (a December date seen in August) used to fall outside the old
93
+ * 31-day horizon and showed `next: null` in the UI even though they fire.
94
+ * A Feb-29-only cron can still legitimately report null most of the year.
93
95
  */
94
96
  export function nextFireAt(schedule, from = new Date()) {
95
97
  const startMinute = Math.floor(from.getTime() / 60_000) + 1; // next whole minute
96
- const horizon = 60 * 24 * 31;
98
+ const horizon = 60 * 24 * 366;
97
99
  for (let i = 0; i < horizon; i++) {
98
100
  const d = new Date((startMinute + i) * 60_000);
99
101
  if (matches(schedule, d))
@@ -103,7 +103,14 @@ export function buildApp(master) {
103
103
  // Optional bearer auth for LAN exposure — TEAPOT_API_TOKEN env wins, else
104
104
  // the config's `password` field. Static files stay public; only /api/* is
105
105
  // gated.
106
- const apiToken = process.env.TEAPOT_API_TOKEN || master.config.password || "";
106
+ // Optional bearer auth for LAN exposure — TEAPOT_API_TOKEN env wins, else
107
+ // the config's `password` field. Static files stay public; only /api/* is
108
+ // gated.
109
+ //
110
+ // Read DYNAMICALLY: the first-run wizard sets `config.password` through
111
+ // /api/setup AFTER buildApp ran — a const captured here once meant a
112
+ // freshly-set-up server stayed wide open until the next process restart.
113
+ const apiToken = () => process.env.TEAPOT_API_TOKEN || master.config.password || "";
107
114
  /**
108
115
  * Routes where `?token=` is accepted (#79).
109
116
  *
@@ -119,19 +126,21 @@ export function buildApp(master) {
119
126
  * also accept the query form.
120
127
  */
121
128
  const tokenInQueryOk = (path) => path === "/api/ws" || /^\/api\/agents\/[^/]+\/term$/.test(path);
122
- if (apiToken)
123
- app.use("/api/*", async (c, next) => {
124
- const h = c.req.header("authorization");
125
- const provided = h?.startsWith("Bearer ") ? h.slice(7) : undefined;
126
- if (provided && provided === apiToken)
129
+ app.use("/api/*", async (c, next) => {
130
+ const want = apiToken();
131
+ if (!want)
132
+ return next(); // nothing configured yet — the API stays open
133
+ const h = c.req.header("authorization");
134
+ const provided = h?.startsWith("Bearer ") ? h.slice(7) : undefined;
135
+ if (provided && provided === want)
136
+ return next();
137
+ if (tokenInQueryOk(c.req.path)) {
138
+ const q = c.req.query("token");
139
+ if (q && q === want)
127
140
  return next();
128
- if (tokenInQueryOk(c.req.path)) {
129
- const q = c.req.query("token");
130
- if (q && q === apiToken)
131
- return next();
132
- }
133
- return c.json({ error: "unauthorized" }, 401);
134
- });
141
+ }
142
+ return c.json({ error: "unauthorized" }, 401);
143
+ });
135
144
  // per-agent terminal spawn guard
136
145
  const termCounts = new Map();
137
146
  /**
@@ -748,6 +757,12 @@ export function buildApp(master) {
748
757
  progressIntervalMs: patch.progressIntervalMs,
749
758
  progressMinChars: patch.progressMinChars,
750
759
  contextTokenBudget: patch.contextTokenBudget,
760
+ // these four were validated (200 ok) and then DROPPED — the settings
761
+ // modal's context window never reached the config at all
762
+ contextWindowTokens: patch.contextWindowTokens,
763
+ maxTurnsPerRound: patch.maxTurnsPerRound,
764
+ onError: patch.onError,
765
+ retryDelayMs: patch.retryDelayMs,
751
766
  maxSpawnDepth: patch.maxSpawnDepth,
752
767
  tasks: patch.tasks,
753
768
  });
@@ -1036,14 +1051,20 @@ export function buildApp(master) {
1036
1051
  if (!body)
1037
1052
  return c.json({ error: "invalid JSON" }, 400);
1038
1053
  await a.setTodo(body.text ?? "");
1039
- if (body.notify !== false && body.text?.trim())
1054
+ // #123: a notification-bearing save must actually WAKE the idle agent (the
1055
+ // queued prompt used to sit forever).
1056
+ //
1057
+ // #123 residual: but the START follows the NOTIFICATION, not the save. The
1058
+ // original fix left `a.start(...)` outside the gate — a brace-less if only
1059
+ // gates the enqueue — so `notify:false` (whose entire meaning is "save
1060
+ // silently") booted a stopped agent, and so did an EMPTY list. A todo save
1061
+ // is note-taking; booting an incarnation is a side effect.
1062
+ const shouldNotify = body.notify !== false && !!body.text?.trim();
1063
+ if (shouldNotify) {
1040
1064
  a.enqueuePrompt(`[harness] The operator updated the task list:\n\n${body.text}\n\nWork through it (get_todo() always has the latest).`, "harness");
1041
- // #123: an idle agent must actually START on the queued prompt. The goal
1042
- // route above learned this (the queued prompt sat forever otherwise); the
1043
- // todo route did not, so saving a task list on an idle session looked like
1044
- // it had been ignored.
1045
- if (a.status !== "running")
1046
- a.start("todo set");
1065
+ if (a.status !== "running")
1066
+ a.start("todo set");
1067
+ }
1047
1068
  return c.json({ ok: true });
1048
1069
  });
1049
1070
  // edit a previously-sent prompt: forks there, optionally summarizes the tail
@@ -20,7 +20,7 @@ export class TermSizeTracker {
20
20
  if (st.rows === rows && st.cols === cols)
21
21
  return;
22
22
  const wait = Math.max(0, st.busyUntil - Date.now());
23
- st.timer = setTimeout(() => {
23
+ const apply = () => {
24
24
  st.timer = null;
25
25
  st.rows = rows;
26
26
  st.cols = cols;
@@ -30,6 +30,18 @@ export class TermSizeTracker {
30
30
  catch {
31
31
  /* child exited mid-debounce */
32
32
  }
33
+ };
34
+ st.timer = setTimeout(() => {
35
+ // output may have CONTINUED past the deadline this timer was computed
36
+ // from (markBusy only extends busyUntil — it cannot reach an already
37
+ // pending timer). Writing now would type stty into a running program,
38
+ // so defer to whatever busy window remains.
39
+ const remain = st.busyUntil - Date.now();
40
+ if (remain > 0) {
41
+ st.timer = setTimeout(apply, remain);
42
+ return;
43
+ }
44
+ apply();
33
45
  }, wait > 0 ? wait : TERM_RESIZE_DEBOUNCE_MS);
34
46
  }
35
47
  /** a child produced output — defer resizes so we never type into it */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.28.1",
3
+ "version": "0.29.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",