teapot-coding-agent 0.28.2 → 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, unlinkSync, openSync, closeSync, fsyncSync, } 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";
@@ -178,8 +178,14 @@ export function loadConfig(configPath) {
178
178
  */
179
179
  function isRegularFileTarget(p) {
180
180
  try {
181
- const st = statSync(p);
182
- return st.isFile();
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();
183
189
  }
184
190
  catch {
185
191
  // does not exist yet — the parent decides; a real config path will be created
@@ -442,6 +448,27 @@ export class Master {
442
448
  this.config.onError = patch.onError;
443
449
  if (patch.retryDelayMs !== undefined)
444
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
+ }
445
472
  if (patch.tasks) {
446
473
  this.config.tasks = patch.tasks;
447
474
  // rebuild schedule table live
@@ -953,10 +980,13 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
953
980
  : this.childrenOf(parentId).map((c) => c.id)).filter((id) => this.agents.has(id));
954
981
  if (targets.length === 0)
955
982
  return Promise.resolve({ note: "no live sub-agents to wait for" });
956
- const active = () => targets.filter((id) => {
957
- const s = this.agents.get(id)?.status;
958
- return s === "running" || s === "waiting";
959
- });
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());
960
990
  const first = active();
961
991
  if (first.length === 0)
962
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.2",
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",