teapot-coding-agent 0.29.7 → 0.29.9

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/index.js CHANGED
@@ -37,15 +37,6 @@ async function waitForPortFree(port, host, timeoutMs) {
37
37
  return false;
38
38
  }
39
39
  /* ---------- live-update handover: keeping the operator's terminal (#35) ---------- */
40
- /**
41
- * The pid of the process that replaced US, or null if we are the server.
42
- *
43
- * `Master.restartServer()` spawns the replacement and records its pid here, so
44
- * the outgoing process can supervise its own successor instead of exiting and
45
- * handing the shell a prompt. A field on the master rather than an env var: both
46
- * processes are separate, but this value is written and read in the SAME
47
- * process, so there is no environment for the two of them to disagree over.
48
- */
49
40
  /** is `pid` still running? (EPERM means alive but not ours to signal) */
50
41
  function pidAlive(pid) {
51
42
  try {
@@ -60,16 +51,13 @@ function pidAlive(pid) {
60
51
  * Watch the replacement on the operator's behalf.
61
52
  *
62
53
  * The terminal is still wired to THIS process, so their Ctrl-C arrives here
63
- * first. Two things must happen, and the old one-shot shutdown() could do
64
- * neither:
65
- *
66
- * 1. forward the signal, so the replacement actually stops; and
67
- * 2. when the replacement does exit, leave too — otherwise the shell waits on
68
- * a supervisor with nothing left to supervise and Ctrl-C looks broken.
54
+ * first: forward the signal so the replacement actually stops, and leave once
55
+ * it exits — otherwise the shell waits on a supervisor with nothing left to
56
+ * supervise and Ctrl-C looks broken.
69
57
  *
70
- * The original shutdown() cannot be reused for this: its `shuttingDown` latch
71
- * is already set by the SIGTERM that started the handover, so a later Ctrl-C
72
- * would be swallowed and this process would never exit.
58
+ * shutdown() cannot be reused for this: its `shuttingDown` latch is already set
59
+ * by the SIGTERM that started the handover, so a later Ctrl-C would be swallowed
60
+ * and this process would never exit.
73
61
  */
74
62
  function supervise(pid) {
75
63
  let signalled = false;
@@ -94,11 +82,9 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
94
82
  /**
95
83
  * Parse `teapot [--port N] [-p N] [--host addr] [--config file] [config.json]`.
96
84
  *
97
- * Extracted from main() and exported so it can be tested. It was inline before,
98
- * which left `index.ts` at 48% branch coverage with NO test importing it at all —
99
- * the parser decides which config file is read and which port is bound, so a
100
- * regression here starts the wrong server on the wrong port with no error anyone
101
- * would read.
85
+ * Exported so it can be tested: the parser decides which config file is read and
86
+ * which port is bound, so a regression here starts the wrong server on the wrong
87
+ * port with no error anyone would read.
102
88
  *
103
89
  * The bare-word rule keeps the FIRST positional as the config path: a later bare
104
90
  * word is a stray argument, not an override, and silently replacing the config
@@ -160,18 +146,13 @@ async function main() {
160
146
  if (!Number.isNaN(fromPid) && fromPid !== process.pid) {
161
147
  console.log(`[teapot] restart: releasing port from old process ${fromPid}...`);
162
148
  try {
163
- // Flush the old process's agents and let it release the socket. It does
164
- // NOT wait for us (that would deadlock against our bind below) — it
165
- // lingers only long enough for its own HTTP response to flush, then exits.
149
+ // Flush the old process's agents and let it release the socket. It must
150
+ // NOT wait for us — that would deadlock against our bind below.
166
151
  process.kill(fromPid, "SIGTERM");
167
152
  }
168
153
  catch {
169
154
  /* already gone */
170
155
  }
171
- // Wait until the socket is genuinely free. Binding while the old process
172
- // still holds it fails with EADDRINUSE, and because index.ts swallows
173
- // uncaught exceptions that left two processes running while neither served
174
- // anything (#35).
175
156
  if (!(await waitForPortFree(config.port, config.host, 30_000)))
176
157
  console.warn(`[teapot] warning: could not confirm ${config.host}:${config.port} is free — binding anyway`);
177
158
  }
@@ -209,32 +190,22 @@ async function main() {
209
190
  if (shuttingDown)
210
191
  return;
211
192
  shuttingDown = true;
212
- // SUPERVISOR MODE. When a live update replaced us, we are no longer the
213
- // server — but we are still the process the SHELL is waiting on, and it
214
- // is still this process group that owns the terminal. Exiting here ended
215
- // the shell's foreground job, so the operator was dumped back to the
216
- // prompt mid-session while the new server kept running in the background
217
- // ("teapot live restart returns to the shell", #35).
218
- //
219
- // So instead of exiting we hand over: keep the job slot, forward signals
220
- // to the replacement, and only leave once it is gone. The operator's
221
- // terminal — and their Ctrl-C — follow the server across the restart.
222
- //
223
- // The replacement is the child we spawned ourselves, so we hold it directly:
224
- // no pid passed through the environment, and no way for the two processes
225
- // to disagree about who supervises whom.
193
+ // SUPERVISOR MODE. When a live update replaced us we are no longer the
194
+ // server, but we are still the process the SHELL waits on and the one that
195
+ // owns the terminal; exiting here dumped the operator back to the prompt
196
+ // mid-session while the new server kept running in the background (#35). So
197
+ // we keep the job slot, forward signals to the replacement, and leave only
198
+ // once it is gone. The replacement is the child we spawned ourselves, so we
199
+ // hold it directly — no pid through the environment for the two processes to
200
+ // disagree over.
226
201
  if (master.replacementPid !== null) {
227
202
  const replacement = master.replacementPid;
228
203
  console.log(`[teapot] handing the terminal to the new server (pid ${replacement})...`);
229
- // Release the port FIRST. The replacement is blocked in waitForPortFree
230
- // waiting for exactly this, and it will SIGTERM us again if we do not go
231
- // — but the difference now is that we stop SERVING rather than exiting,
232
- // so the shell's foreground job survives the handover.
204
+ // Release the port FIRST: the replacement is blocked in waitForPortFree
205
+ // waiting for exactly this, and it will SIGTERM us again if we do not go.
233
206
  await server.close();
234
207
  console.log("[teapot] port released; supervising the new server");
235
208
  supervise(replacement);
236
- // Stay attached until the replacement exits. The shell is still waiting
237
- // on US, so leaving early is exactly the bug.
238
209
  while (pidAlive(replacement))
239
210
  await sleep(500);
240
211
  console.log("[teapot] new server exited; releasing the terminal");
@@ -255,35 +226,27 @@ async function main() {
255
226
  process.on("SIGINT", () => void shutdown("SIGINT"));
256
227
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
257
228
  }
258
- // Only when RUN, not when imported. `void main()` at module scope meant importing
259
- // this file for `parseArgs` started a whole server — which is why index.ts had no
260
- // test importing it and sat at 48% branch coverage. The check is the standard
261
- // ESM "am I the entry point" test, and `node --experimental-strip-types src/index.ts`
262
- // and `node dist/index.js` both still take this branch.
229
+ // Only when RUN, not when imported: `void main()` at module scope meant importing
230
+ // this file for `parseArgs` started a whole server. `node
231
+ // --experimental-strip-types src/index.ts` and `node dist/index.js` both take
232
+ // this branch.
263
233
  function isEntryPoint() {
264
234
  const arg = process.argv[1];
265
235
  if (!arg)
266
236
  return false;
267
- // `npm i -g teapot-coding-agent` puts a SYMLINK at node_modules/.bin/teapot, and
268
- // npm invokes the target THROUGH it. So `process.argv[1]` is the symlink path
269
- // (…/node_modules/.bin/teapot), NOT the file it points at — which matched none
270
- // of the checks below, `main()` never ran, and the installed command exited 0
271
- // in silence. Reporting `teapot` did nothing and needing `node dist/index.js`
272
- // was the only symptom. `realpathSync` is what resolves it.
237
+ // `npm i -g teapot-coding-agent` puts a SYMLINK at node_modules/.bin/teapot and
238
+ // npm invokes the target THROUGH it, so `process.argv[1]` is the symlink path,
239
+ // NOT the file it points at: the installed command then exited 0 in silence.
240
+ // `realpathSync` is what resolves it.
273
241
  try {
274
242
  const self = realpathSync(fileURLToPath(import.meta.url));
275
243
  const entry = realpathSync(arg);
276
244
  if (self === entry)
277
245
  return true;
278
246
  // `self === entry` is the ONLY correct test: it is the definition of being the
279
- // entry point. The suffix checks that used to follow were a fallback for the
280
- // symlink case, and they were far too loose — `endsWith("/index.ts")` is true
281
- // for EVERY test file that imports this module, so importing it booted a real
247
+ // entry point. A suffix check such as `endsWith("/index.ts")` is true for
248
+ // EVERY test file that imports this module, so importing it booted a real
282
249
  // server on port 7788 and `cli-args.test.ts` failed with EADDRINUSE.
283
- //
284
- // Keep a basename check as a safety net for a bundler that rewrites paths, but
285
- // require BOTH that we are the module AND that nothing else is: an argv[1] that
286
- // resolves to a DIFFERENT file is by definition not this module's entry.
287
250
  return false;
288
251
  }
289
252
  catch {
package/dist/master.js CHANGED
@@ -835,40 +835,19 @@ export class Master {
835
835
  if (!parent)
836
836
  return;
837
837
  // the waiter re-checks whether ITS targets are all settled, so waking on
838
- // every movement is correct and cheap
839
- this.wakeParentWaiters(ac.parent);
840
- // #130: only report a SETTLED child, never its birth.
838
+ // every movement is correct and cheap. ANCESTORS too: a waiter's targets
839
+ // now span the whole subtree, so a grandchild's movement must reach it.
840
+ this.wakeAncestorWaiters(ac.parent);
841
+ // #130: report only a SETTLED child, never its birth. A freshly spawned
842
+ // child is `idle` before `start()` runs, so that first idle->running flip
843
+ // would tell the parent "@kid is now idle" the instant it existed.
844
+ // Requiring a prior `running` is what filters it out; the wake above is
845
+ // unconditional, so `wait_children` is unaffected.
841
846
  //
842
- // A freshly spawned child is `idle` for a moment before `start()` runs, and
843
- // the FIRST status transition it ever makes is therefore idle->running via
844
- // that idle. Reporting it told the parent "[harness] @kid is now idle" the
845
- // instant the child existed — before it had done anything at all. Measured:
846
- // transitions after a spawn are ["idle","running","idle"], so the first is
847
- // always the spurious one.
848
- //
849
- // So the message waits for a child that has actually STARTED. A real
850
- // completion still reports (it goes running -> idle), and the wake above is
851
- // unconditional, so `wait_children` is unaffected either way.
852
- // #130: a spawned child is `idle` until start() runs, and that lazy-load
853
- // flip is its FIRST transition — so requiring a prior `running` is what stops
854
- // the parent being told the child settled before it began.
855
- // #135: reworded, because the old line was wrong in two ways.
856
- //
857
- // "Continue your task, or finish() if the work is done" was addressed to the
858
- // PARENT — but the parent did not spawn this child for its own sake, and
859
- // when the child DID call finish() the parent already receives a proper
860
- // "Sub-agent X finished. Final report: …" (the `finished. Final report:`
861
- // prompt below). So the nudge
862
- // restated as an instruction what the report already stated as fact, and
863
- // invited the parent to `finish()` ITS OWN goal over a child's status.
864
- //
865
- // The new wording says what happened and what to do about it, and points at
866
- // the report when there is one:
867
- //
868
- // child gave a summary -> "…returned without calling finish(); its final
869
- // message follows." (the report arrives separately)
870
- // child said nothing -> "…went idle without reporting. If that is
871
- // unexpected, message it or re-spawn it."
847
+ // #135: the wording addresses the CHILD's outcome, not the parent's goal.
848
+ // "Continue your task, or finish()..." invited the parent to finish its own
849
+ // goal over a child's status; the child's report already states what
850
+ // happened, so this points at it instead.
872
851
  if (this.reportedRunning.has(agentId) && SETTLED.has(status))
873
852
  parent.enqueuePrompt(`[harness] Sub-agent @${agentId} is now ${status} without having called finish(), so it has not reported a result.` +
874
853
  (status === "error"
@@ -887,11 +866,28 @@ export class Master {
887
866
  const hooks = {
888
867
  depth: myDepth,
889
868
  spawn: (o) => this.spawnChildFor(self, o),
890
- list: () => this.childrenOf(ac.id).map((c) => ({
891
- id: c.id,
892
- status: c.agent.status,
893
- goal: c.agent.goal.text,
894
- })),
869
+ list: () =>
870
+ // DIRECT children are the ones this agent can address (stop/message are
871
+ // refused for anything deeper), but a descendant still working is
872
+ // invisible to the parent unless it is LISTED — and the parent then
873
+ // resumes over it. So the whole subtree is listed, each row naming its
874
+ // own `parent` and flagged `nested`, which is what tells the model who
875
+ // to address instead of guessing a level (the error `message_agent`
876
+ // raises names the owner for the same reason).
877
+ this.subtreeOf(ac.id).flatMap(({ id, depth, parent }) => {
878
+ const inst = this.agents.get(id);
879
+ if (!inst)
880
+ return []; // suspended: not addressable, so not listed
881
+ return [
882
+ {
883
+ id,
884
+ status: inst.status,
885
+ goal: inst.goal.text,
886
+ parent,
887
+ nested: depth > 1,
888
+ },
889
+ ];
890
+ }),
895
891
  stop: (ids) => this.stopChildrenFor(ac.id, ids),
896
892
  message: (id, text) => this.messageChild(ac.id, id, text),
897
893
  wait: (ids, ms) =>
@@ -959,6 +955,35 @@ export class Master {
959
955
  }
960
956
  return out;
961
957
  }
958
+ /**
959
+ * Every configured descendant of `id`, at any depth, with its depth (1 = direct
960
+ * child) and its own parent id.
961
+ *
962
+ * A parent's work is not finished while anything it caused is still running,
963
+ * and that includes a sub-agent's sub-agent: C can delegate to G and return its
964
+ * own turn, leaving C idle while G works. Deciding from DIRECT children alone
965
+ * therefore let an ancestor report "all settled" over a live grandchild — the
966
+ * reported "it delegates further and the parent never knows".
967
+ *
968
+ * Returns configured ids (not instances) so a suspended descendant still counts
969
+ * as part of the subtree. `seen` guards a cycle in `parent`.
970
+ */
971
+ subtreeOf(id, depth = 1, seen = new Set()) {
972
+ const out = [];
973
+ for (const a of this.config.agents) {
974
+ if (a.parent !== id)
975
+ continue;
976
+ if (seen.has(a.id))
977
+ continue;
978
+ seen.add(a.id);
979
+ out.push({ id: a.id, depth, parent: id }, ...this.subtreeOf(a.id, depth + 1, seen));
980
+ }
981
+ return out;
982
+ }
983
+ /** the ids of every descendant of `id`, at any depth */
984
+ descendantsOf(id, seen = new Set()) {
985
+ return this.subtreeOf(id, 1, seen).map((x) => x.id);
986
+ }
962
987
  /**
963
988
  * Create a sub-agent on behalf of `parent`. context "fork" writes a
964
989
  * sub_fork header into the child log pointing at the parent's current
@@ -1098,9 +1123,12 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1098
1123
  * while parked. Falls back to a timeout so waits can never hang forever.
1099
1124
  */
1100
1125
  waitChildren(parentId, ids, timeoutMs, signal) {
1101
- const targets = (ids && ids.length
1102
- ? ids
1103
- : this.childrenOf(parentId).map((c) => c.id)).filter((id) => this.agents.has(id));
1126
+ // The whole DESCENDANT subtree, not just direct children: C can delegate to G
1127
+ // and return its own turn, so C reads idle while G works. Waiting on C must
1128
+ // therefore mean "C and everything C caused" — otherwise an ancestor resumes
1129
+ // over live work it cannot see (see descendantsOf).
1130
+ const named = ids && ids.length ? ids : this.descendantsOf(parentId);
1131
+ const targets = [...new Set(named.flatMap((id) => [id, ...this.descendantsOf(id)]))].filter((id) => this.agents.has(id));
1104
1132
  if (targets.length === 0)
1105
1133
  return Promise.resolve({ note: "no live sub-agents to wait for" });
1106
1134
  // isBusy(), not the raw status: parkForTool overwrites a parked child's
@@ -1158,6 +1186,28 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1158
1186
  catch { /* ignore */ }
1159
1187
  }
1160
1188
  }
1189
+ /**
1190
+ * Wake the waiters of EVERY ancestor of an agent, not just its direct parent.
1191
+ *
1192
+ * A parked `wait_children` is woken by lifecycle events, and its targets now
1193
+ * span the whole subtree — so a grandchild settling has to reach the
1194
+ * grandparent, or the wake never arrives and the ancestor sits until its
1195
+ * timeout even though the work finished.
1196
+ *
1197
+ * `parentId` is passed in rather than looked up: the caller already has it, and
1198
+ * a spawned child that is not PERSISTED (a test, or a transient spawn) is absent
1199
+ * from `config.agents`, so a lookup would find no parent at all and the wake
1200
+ * would be silently skipped.
1201
+ */
1202
+ wakeAncestorWaiters(parentId) {
1203
+ const seen = new Set();
1204
+ let cur = parentId;
1205
+ while (cur && !seen.has(cur)) {
1206
+ seen.add(cur);
1207
+ this.wakeParentWaiters(cur);
1208
+ cur = this.config.agents.find((a) => a.id === cur)?.parent;
1209
+ }
1210
+ }
1161
1211
  async messageChild(parentId, childId, text) {
1162
1212
  // childId is normally the live agent id — but an internal session id
1163
1213
  // (sessions/<id>, the stable timeline handle) also resolves, so callers
@@ -1219,9 +1269,9 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1219
1269
  if (!parentId)
1220
1270
  return;
1221
1271
  // any child lifecycle movement wakes a parked wait_children (the waiter
1222
- // re-checks whether its targets are all settled)
1223
- if (parentId)
1224
- this.wakeParentWaiters(parentId);
1272
+ // re-checks whether its targets are all settled) — ancestors included, since
1273
+ // a waiter's targets span its whole subtree
1274
+ this.wakeAncestorWaiters(parentId);
1225
1275
  const parent = this.agents.get(parentId);
1226
1276
  if (!parent)
1227
1277
  return;
@@ -1257,29 +1307,12 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1257
1307
  if (parent.status !== "running")
1258
1308
  parent.start(`sub-agent ${ac.id} error`);
1259
1309
  }
1260
- // #63: a child's activity is NOT mirrored into the parent's log.
1261
- //
1262
- // This used to write a `sub` row per child prompt/message/tool_call/…, so
1263
- // the parent's timeline filled with the child's entire transcript. Two
1264
- // problems, in order of discovery:
1265
- //
1266
- // 1. The rows were invisible, because `"sub"` was never in the web UI's
1267
- // FEED_TYPES — so the filter dropped them and the feature did nothing
1268
- // for as long as it existed (#40).
1269
- // 2. Once it was made to work (#40), the parent's timeline was flooded:
1270
- // the operator reads a delegation as "what is this chat doing", not as
1271
- // a firehose of a child's work.
1272
- //
1273
- // The mirroring bought nothing. `sub` rows are display-only:
1274
- // `rebuildMessagesFrom` never reads them, so the model never saw them and
1275
- // the child's own history was never duplicated into the parent's context.
1276
- // What the parent actually needs — the child's report — already arrives as
1277
- // a harness prompt above, which is the channel the model reads.
1278
- //
1279
- // So the right fix is to stop writing them. A chat's timeline is its own.
1280
- // If per-child detail is wanted in the UI later, it belongs behind an
1281
- // explicit filter ("show sub-agent activity"), reading each child's own log
1282
- // rather than interleaving a copy into the parent's.
1310
+ // #63: a child's activity is NOT mirrored into the parent's log. Mirroring
1311
+ // bought nothing — `sub` rows are display-only (`rebuildMessagesFrom` never
1312
+ // read them, so the model never saw them) and once they DID render (#40) the
1313
+ // parent's timeline was flooded with a child's transcript. What the parent
1314
+ // needs is the child's report, which already arrives as a harness prompt
1315
+ // above. A chat's timeline is its own.
1283
1316
  }
1284
1317
  /** sessions root + helpers */
1285
1318
  sessionsRoot() {
@@ -3,7 +3,6 @@
3
3
  * Used to auto-populate contextWindowTokens so compaction budgets derive
4
4
  * from the real window instead of the safe default.
5
5
  */
6
- import fs from "node:fs";
7
6
  import { providerHeaders } from "./agent/llm.js";
8
7
  const cache = new Map();
9
8
  const inFlight = new Map();
@@ -155,8 +154,3 @@ export function resolveReasoningEffort(opts) {
155
154
  return undefined;
156
155
  return opts.effort;
157
156
  }
158
- /** test hook */
159
- export function clearModelListCache() {
160
- cache.clear();
161
- void fs;
162
- }
@@ -13,8 +13,8 @@ const BOUNDS = [
13
13
  function parseField(part, min, max) {
14
14
  const out = new Set();
15
15
  const clampOrThrow = (v) => {
16
- // out-of-range values used to slip through silently and produce a set
17
- // that NEVER matched (e.g. "99 * * * *" ran never) — reject loudly
16
+ // accepting an out-of-range value produces a set that never matches, i.e. a
17
+ // cron that silently never fires ("99 * * * *")
18
18
  if (!Number.isInteger(v) || v < min || v > max)
19
19
  throw new Error(`bad cron field: ${part} (${min}-${max} expected)`);
20
20
  return v;
@@ -88,10 +88,9 @@ 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 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.
91
+ * integer compares per minute). The horizon is a year so an annual cron (a
92
+ * December date seen in August) still resolves; a Feb-29-only cron can
93
+ * legitimately report null most of the year.
95
94
  */
96
95
  export function nextFireAt(schedule, from = new Date()) {
97
96
  const startMinute = Math.floor(from.getTime() / 60_000) + 1; // next whole minute
@@ -5,9 +5,8 @@ export const TERM_BUSY_MS = 400;
5
5
  export class TermSizeTracker {
6
6
  states = new Map();
7
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.
8
+ * Request a resize; the sink write happens on the trailing edge of the
9
+ * debounce.
11
10
  */
12
11
  request(child, rows, cols, write) {
13
12
  const st = this.states.get(child) ?? { rows: 0, cols: 0, timer: null, busyUntil: 0 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.29.7",
3
+ "version": "0.29.9",
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",