teapot-coding-agent 0.27.2 → 0.28.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.
@@ -27,11 +27,35 @@ import { bus } from "../bus.js";
27
27
  * satisfied the request — sent a voluntary report_progress right after, and
28
28
  * every requested report appeared TWICE on the timeline.
29
29
  */
30
- export const AUTO_CONTINUE_NUDGE = "Continue working toward the current goal. If you are blocked, explain why briefly. " +
30
+ export const AUTO_CONTINUE_NUDGE =
31
+ // #136: the prefix marks this as harness text, not something the operator typed.
32
+ // Unprefixed, the model read it as a request to reply — and prose ends the round
33
+ // without finishing, which starts the next one.
34
+ "[harness] " +
35
+ "Continue working toward the current goal. If you are blocked, explain why briefly. " +
31
36
  // name the tool explicitly, the way the sub-agent nudge already does
32
37
  "When the goal is genuinely met — or truly blocked — call finish() " +
33
38
  "(finish(goalComplete=true) when met, finish(goalComplete=false) when blocked) " +
34
39
  "instead of replying in prose; prose here does not stop the loop.";
40
+ /**
41
+ * The auto-continue nudge with the CURRENT GOAL restated.
42
+ *
43
+ * #136: the nudge said "the current goal" and never said what it was. The sub-agent
44
+ * variant has always included its task text for exactly this reason — a forked
45
+ * child otherwise drifts back into the inherited conversation and keeps going
46
+ * forever (#106) — but the ROOT nudge had no such reminder, so a root agent that
47
+ * had lost the thread had nothing to re-anchor on and simply continued until the
48
+ * `maxConsecutiveIdleRounds` valve tripped.
49
+ *
50
+ * The goal is clipped like the sub-agent's: a 20k-character goal would otherwise
51
+ * re-enter the context on EVERY round.
52
+ */
53
+ export function autoContinueNudge(goalText) {
54
+ const goal = goalText.trim();
55
+ if (!goal)
56
+ return AUTO_CONTINUE_NUDGE;
57
+ return `${AUTO_CONTINUE_NUDGE}\nCurrent goal:\n=====\n${goal.slice(0, 1500)}\n=====`;
58
+ }
35
59
  export const PROGRESS_REQUEST =
36
60
  // The leading sentence is kept VERBATIM: it is what existing harness prompts,
37
61
  // tests and any operator muscle-memory key off. #45 changed the INSTRUCTION
@@ -1586,9 +1610,13 @@ export class Agent {
1586
1610
  break;
1587
1611
  }
1588
1612
  const isSub = !!this.opts.parent;
1613
+ // #136: the ROOT nudge now restates the goal too, exactly as the sub-agent
1614
+ // one always has. Both branches need it — a child drifts back into the
1615
+ // inherited conversation, a root agent loses the thread, and in both cases
1616
+ // "continue toward the current goal" is unactionable without the goal text.
1589
1617
  const nudge = isSub
1590
1618
  ? `[harness] Auto-nudge. Re-anchor on YOUR task — and only that:\n\n${this.goal.text.slice(0, 1500)}\n\nThe inherited conversation is reference only; other agents own any older tasks in it. If your task is complete, call finish() now instead of continuing. If blocked, explain why briefly and finish().`
1591
- : AUTO_CONTINUE_NUDGE;
1619
+ : autoContinueNudge(this.goal.text);
1592
1620
  await this.log.append("prompt", this.currentSession, this.currentBranch, {
1593
1621
  source: "harness",
1594
1622
  text: nudge,
@@ -1632,11 +1660,6 @@ export class Agent {
1632
1660
  if (!this.stopRequested && !this.awaitingUser)
1633
1661
  this.setStatus("idle", "round complete");
1634
1662
  }
1635
- /**
1636
- * One LLM call with a fresh abort controller so stop() can interrupt it
1637
- * immediately, plus loop-level retries for provider flakiness (the SDK
1638
- * already backsoff 429/5xx; this covers exhausted rate limits and 400s).
1639
- */
1640
1663
  /**
1641
1664
  * One LLM call with a fresh abort controller so stop() can interrupt it
1642
1665
  * immediately, plus loop-level retries for provider flakiness (the SDK
@@ -1195,7 +1195,8 @@ export const TOOLS = [
1195
1195
  // #126: the single most common edit_file failure in the real logs (22
1196
1196
  // occurrences) said only "old_text is required", while the tool's own
1197
1197
  // schema already documents the better form. Name it and say what to do —
1198
- // the pattern master.ts uses for "not your sub-agent" (master.ts:979),
1198
+ // the pattern master.ts uses for "not your sub-agent" (its
1199
+ // `messageChild`),
1199
1200
  // which turned a confusing refusal into a re-addressable one.
1200
1201
  if (!oldText)
1201
1202
  return {
package/dist/index.js CHANGED
@@ -90,24 +90,49 @@ function supervise(pid) {
90
90
  process.on(sig, () => forward(sig));
91
91
  }
92
92
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
93
- async function main() {
94
- // CLI: teapot [--port N] [-p N] [--config file] [-c file] [config.json]
93
+ /**
94
+ * Parse `teapot [--port N] [-p N] [--host addr] [--config file] [config.json]`.
95
+ *
96
+ * Extracted from main() and exported so it can be tested. It was inline before,
97
+ * which left `index.ts` at 48% branch coverage with NO test importing it at all —
98
+ * the parser decides which config file is read and which port is bound, so a
99
+ * regression here starts the wrong server on the wrong port with no error anyone
100
+ * would read.
101
+ *
102
+ * The bare-word rule keeps the FIRST positional as the config path: a later bare
103
+ * word is a stray argument, not an override, and silently replacing the config
104
+ * with it would be worse than ignoring it.
105
+ */
106
+ export function parseArgs(argv) {
95
107
  let cfgArg;
96
108
  let portOverride;
97
109
  let hostOverride;
98
- const args = process.argv.slice(2);
99
- for (let i = 0; i < args.length; i++) {
100
- const a = args[i];
101
- if (a === "--port" || a === "-p")
102
- portOverride = Number(args[++i]);
103
- else if (a === "--host")
104
- hostOverride = args[++i];
105
- else if (a === "--config" || a === "-c")
106
- cfgArg = args[++i];
107
- else if (!cfgArg && !a.startsWith("-"))
110
+ for (let i = 0; i < argv.length; i++) {
111
+ const a = argv[i];
112
+ if (a === "--port" || a === "-p") {
113
+ const raw = argv[++i];
114
+ const n = Number(raw);
115
+ // NaN is dropped rather than applied: `config.port = NaN` would fall through
116
+ // to the listen call and produce a far more confusing failure than ignoring
117
+ // a mistyped flag
118
+ if (!Number.isNaN(n))
119
+ portOverride = n;
120
+ }
121
+ else if (a === "--host") {
122
+ hostOverride = argv[++i];
123
+ }
124
+ else if (a === "--config" || a === "-c") {
125
+ cfgArg = argv[++i];
126
+ }
127
+ else if (!a.startsWith("-") && cfgArg === undefined) {
108
128
  cfgArg = a;
129
+ }
109
130
  }
110
- if (process.argv.includes("--help") || process.argv.includes("-h")) {
131
+ return { cfgArg, portOverride, hostOverride, help: argv.includes("--help") || argv.includes("-h") };
132
+ }
133
+ async function main() {
134
+ const { cfgArg, portOverride, hostOverride, help } = parseArgs(process.argv.slice(2));
135
+ if (help) {
111
136
  console.log("teapot [--port N] [--host addr] [--config file.json] [config.json]");
112
137
  console.log(" --host 127.0.0.1 (default) · 0.0.0.0 exposes the UI to your network");
113
138
  console.log(" --acp speak the Agent Client Protocol on stdio instead of serving HTTP (#15)");
@@ -229,4 +254,15 @@ async function main() {
229
254
  process.on("SIGINT", () => void shutdown("SIGINT"));
230
255
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
231
256
  }
232
- void main();
257
+ // Only when RUN, not when imported. `void main()` at module scope meant importing
258
+ // this file for `parseArgs` started a whole server — which is why index.ts had no
259
+ // test importing it and sat at 48% branch coverage. The check is the standard
260
+ // ESM "am I the entry point" test, and `node --experimental-strip-types src/index.ts`
261
+ // and `node dist/index.js` both still take this branch.
262
+ const isEntry = process.argv[1] !== undefined &&
263
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
264
+ (process.argv[1] === new URL(import.meta.url).pathname ||
265
+ process.argv[1].endsWith("/index.js") ||
266
+ process.argv[1].endsWith("/index.ts"));
267
+ if (isEntry)
268
+ void main();
package/dist/master.js CHANGED
@@ -568,8 +568,30 @@ export class Master {
568
568
  // #130: a spawned child is `idle` until start() runs, and that lazy-load
569
569
  // flip is its FIRST transition — so requiring a prior `running` is what stops
570
570
  // the parent being told the child settled before it began.
571
+ // #135: reworded, because the old line was wrong in two ways.
572
+ //
573
+ // "Continue your task, or finish() if the work is done" was addressed to the
574
+ // PARENT — but the parent did not spawn this child for its own sake, and
575
+ // when the child DID call finish() the parent already receives a proper
576
+ // "Sub-agent X finished. Final report: …" (the `finished. Final report:`
577
+ // prompt below). So the nudge
578
+ // restated as an instruction what the report already stated as fact, and
579
+ // invited the parent to `finish()` ITS OWN goal over a child's status.
580
+ //
581
+ // The new wording says what happened and what to do about it, and points at
582
+ // the report when there is one:
583
+ //
584
+ // child gave a summary -> "…returned without calling finish(); its final
585
+ // message follows." (the report arrives separately)
586
+ // child said nothing -> "…went idle without reporting. If that is
587
+ // unexpected, message it or re-spawn it."
571
588
  if (this.reportedRunning.has(agentId) && SETTLED.has(status))
572
- parent.enqueuePrompt(`[harness] @${agentId} is now ${status}. Continue your task, or finish() if the work is done.`, "harness");
589
+ parent.enqueuePrompt(`[harness] Sub-agent @${agentId} is now ${status} without having called finish(), so it has not reported a result.` +
590
+ (status === "error"
591
+ ? ` Check its last output before deciding whether to re-spawn it.`
592
+ : status === "stopped"
593
+ ? ` It may have been stopped mid-task; message it to resume, or re-spawn it.`
594
+ : ` If you expected a result, message it or re-spawn it.`), "harness");
573
595
  if (status === "running")
574
596
  this.reportedRunning.add(agentId);
575
597
  else if (SETTLED.has(status))
@@ -1134,6 +1134,30 @@ export function buildApp(master) {
1134
1134
  // only used for the "load older" affordance, where approximate is fine.
1135
1135
  const branch = c.req.query("branch");
1136
1136
  const before = c.req.query("before");
1137
+ // #54: `?after=<id>` returns only what landed AFTER that event.
1138
+ //
1139
+ // This is the cursor the reconnect path was missing. Without it a dropped
1140
+ // socket is recovered by re-fetching the whole tail — 2,000 events by default
1141
+ // — and throwing away all but the few that are new. That is what makes the
1142
+ // architecture REST-authoritative: the frontend can only learn "something
1143
+ // changed" and must re-read to find out what.
1144
+ //
1145
+ // It is also the only cursor that can be cheap here: the tail path scans
1146
+ // backwards from EOF, so an `after` lookup that lands near the end reads a
1147
+ // small window instead of parsing the whole log.
1148
+ const after = c.req.query("after");
1149
+ if (after && !before && !branch) {
1150
+ const all = await readEventsCached(filePath);
1151
+ const idx = all.findIndex((e) => e.id === after);
1152
+ // an unknown cursor means the client's view is not a prefix of ours (a
1153
+ // compacted or rotated log), so a full tail is the only safe answer
1154
+ if (idx === -1) {
1155
+ const tail = await readEventsTail(filePath, limit);
1156
+ return c.json({ events: tail.events, total: tail.total, partial: tail.truncated, gap: true });
1157
+ }
1158
+ const events = all.slice(idx + 1);
1159
+ return c.json({ events, total: all.length, appended: events.length > 0 });
1160
+ }
1137
1161
  if (!before && !branch) {
1138
1162
  const tail = await readEventsTail(filePath, limit);
1139
1163
  return c.json({ events: tail.events, total: tail.total, partial: tail.truncated });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.27.2",
3
+ "version": "0.28.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",