teapot-coding-agent 0.27.2 → 0.28.1

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
@@ -5,8 +5,9 @@
5
5
  * Config may also come from TEAPOT_* env vars (see src/master.ts).
6
6
  * First run (no config file): boot anyway and finish setup in the web UI.
7
7
  */
8
- import { existsSync } from "node:fs";
8
+ import { existsSync, realpathSync } from "node:fs";
9
9
  import { createServer } from "node:net";
10
+ import { fileURLToPath } from "node:url";
10
11
  import { loadConfig, resolveConfigPath, Master } from "./master.js";
11
12
  import { buildApp, serveApp } from "./server/api.js";
12
13
  /**
@@ -90,24 +91,49 @@ function supervise(pid) {
90
91
  process.on(sig, () => forward(sig));
91
92
  }
92
93
  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]
94
+ /**
95
+ * Parse `teapot [--port N] [-p N] [--host addr] [--config file] [config.json]`.
96
+ *
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.
102
+ *
103
+ * The bare-word rule keeps the FIRST positional as the config path: a later bare
104
+ * word is a stray argument, not an override, and silently replacing the config
105
+ * with it would be worse than ignoring it.
106
+ */
107
+ export function parseArgs(argv) {
95
108
  let cfgArg;
96
109
  let portOverride;
97
110
  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("-"))
111
+ for (let i = 0; i < argv.length; i++) {
112
+ const a = argv[i];
113
+ if (a === "--port" || a === "-p") {
114
+ const raw = argv[++i];
115
+ const n = Number(raw);
116
+ // NaN is dropped rather than applied: `config.port = NaN` would fall through
117
+ // to the listen call and produce a far more confusing failure than ignoring
118
+ // a mistyped flag
119
+ if (!Number.isNaN(n))
120
+ portOverride = n;
121
+ }
122
+ else if (a === "--host") {
123
+ hostOverride = argv[++i];
124
+ }
125
+ else if (a === "--config" || a === "-c") {
126
+ cfgArg = argv[++i];
127
+ }
128
+ else if (!a.startsWith("-") && cfgArg === undefined) {
108
129
  cfgArg = a;
130
+ }
109
131
  }
110
- if (process.argv.includes("--help") || process.argv.includes("-h")) {
132
+ return { cfgArg, portOverride, hostOverride, help: argv.includes("--help") || argv.includes("-h") };
133
+ }
134
+ async function main() {
135
+ const { cfgArg, portOverride, hostOverride, help } = parseArgs(process.argv.slice(2));
136
+ if (help) {
111
137
  console.log("teapot [--port N] [--host addr] [--config file.json] [config.json]");
112
138
  console.log(" --host 127.0.0.1 (default) · 0.0.0.0 exposes the UI to your network");
113
139
  console.log(" --acp speak the Agent Client Protocol on stdio instead of serving HTTP (#15)");
@@ -229,4 +255,40 @@ async function main() {
229
255
  process.on("SIGINT", () => void shutdown("SIGINT"));
230
256
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
231
257
  }
232
- void main();
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.
263
+ function isEntryPoint() {
264
+ const arg = process.argv[1];
265
+ if (!arg)
266
+ 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.
273
+ try {
274
+ const self = realpathSync(fileURLToPath(import.meta.url));
275
+ const entry = realpathSync(arg);
276
+ if (self === entry)
277
+ return true;
278
+ // `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
282
+ // 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
+ return false;
288
+ }
289
+ catch {
290
+ return false;
291
+ }
292
+ }
293
+ if (isEntryPoint())
294
+ void main();
package/dist/master.js CHANGED
@@ -109,7 +109,18 @@ const DATA_DIR = process.env.TEAPOT_DATA_DIR ??
109
109
  path.join(os.homedir(), ".local", "share", "teapot-coding-agent");
110
110
  const DEFAULT_CONFIG = {
111
111
  port: Number(process.env.TEAPOT_PORT ?? 7788),
112
- host: process.env.TEAPOT_HOST,
112
+ // #140: this was `process.env.TEAPOT_HOST`, i.e. UNDEFINED when unset — and an
113
+ // undefined hostname makes `@hono/node-server` bind every interface. So the
114
+ // default was the opposite of the documented one: `--help` says
115
+ // "127.0.0.1 (default) · 0.0.0.0 exposes the UI to your network", and yet the
116
+ // default WAS "expose to your network".
117
+ //
118
+ // Measured: with no --host the server answered on the machine's LAN address as
119
+ // well as loopback.
120
+ //
121
+ // Loopback is the right default for something holding API tokens and agent
122
+ // workspaces; exposing it has to be a deliberate act.
123
+ host: process.env.TEAPOT_HOST ?? "127.0.0.1",
113
124
  dataDir: DATA_DIR,
114
125
  llm: {
115
126
  baseUrl: process.env.TEAPOT_BASE_URL ?? "https://openrouter.ai/api/v1",
@@ -568,8 +579,30 @@ export class Master {
568
579
  // #130: a spawned child is `idle` until start() runs, and that lazy-load
569
580
  // flip is its FIRST transition — so requiring a prior `running` is what stops
570
581
  // the parent being told the child settled before it began.
582
+ // #135: reworded, because the old line was wrong in two ways.
583
+ //
584
+ // "Continue your task, or finish() if the work is done" was addressed to the
585
+ // PARENT — but the parent did not spawn this child for its own sake, and
586
+ // when the child DID call finish() the parent already receives a proper
587
+ // "Sub-agent X finished. Final report: …" (the `finished. Final report:`
588
+ // prompt below). So the nudge
589
+ // restated as an instruction what the report already stated as fact, and
590
+ // invited the parent to `finish()` ITS OWN goal over a child's status.
591
+ //
592
+ // The new wording says what happened and what to do about it, and points at
593
+ // the report when there is one:
594
+ //
595
+ // child gave a summary -> "…returned without calling finish(); its final
596
+ // message follows." (the report arrives separately)
597
+ // child said nothing -> "…went idle without reporting. If that is
598
+ // unexpected, message it or re-spawn it."
571
599
  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");
600
+ parent.enqueuePrompt(`[harness] Sub-agent @${agentId} is now ${status} without having called finish(), so it has not reported a result.` +
601
+ (status === "error"
602
+ ? ` Check its last output before deciding whether to re-spawn it.`
603
+ : status === "stopped"
604
+ ? ` It may have been stopped mid-task; message it to resume, or re-spawn it.`
605
+ : ` If you expected a result, message it or re-spawn it.`), "harness");
573
606
  if (status === "running")
574
607
  this.reportedRunning.add(agentId);
575
608
  else if (SETTLED.has(status))
@@ -742,29 +775,42 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
742
775
  /** Stop direct children (and their descendants by default) of an agent. */
743
776
  async stopChildrenFor(parentId, ids) {
744
777
  const stopped = [];
745
- const walk = (pid) => {
778
+ // `inScope` is true once we are INSIDE a subtree we were asked to stop, and
779
+ // stays true all the way down.
780
+ //
781
+ // #142: the old walk passed `ids` down unchanged, so after matching an
782
+ // explicit id every DESCENDANT failed `ids.includes(id)` and was skipped —
783
+ // which is why the explicit branch hand-rolled its own one-level loop, and
784
+ // why that loop could not reach a great-grandchild.
785
+ const walk = (pid, inScope) => {
746
786
  for (const { id, agent } of this.childrenOf(pid)) {
747
- if (ids && !ids.includes(id)) {
748
- // still descend: stopping a parent implies stopping its subtree
749
- walk(id);
787
+ const take = inScope || !ids || ids.includes(id);
788
+ if (!take) {
789
+ // out of scope, but its own subtree may hold something in scope
790
+ walk(id, false);
750
791
  continue;
751
792
  }
752
793
  agent.stop("stopped by parent agent");
753
794
  stopped.push(id);
754
- if (ids) {
755
- // explicit id → also take its subtree
756
- const sub = this.childrenOf(id);
757
- for (const { id: gid, agent: g } of sub) {
758
- g.stop("stopped with parent");
759
- stopped.push(gid);
760
- }
761
- }
762
- else {
763
- walk(id); // full-subtree default
764
- }
795
+ // #142: RECURSE for both modes.
796
+ //
797
+ // The explicit-id branch used to iterate `childrenOf(id)` exactly once, so
798
+ // a great-grandchild was never reached — stopping a child left its
799
+ // grandchild's child running, indefinitely, still holding a model
800
+ // connection and billing tokens:
801
+ //
802
+ // root-sub stopped
803
+ // root-sub-sub stopped
804
+ // root-sub-sub-sub running <-- orphaned
805
+ //
806
+ // `walk` already means "this subtree", and it stops every node it visits,
807
+ // so one code path serves both cases. The only difference between the two
808
+ // modes is the `ids && !ids.includes(id)` test at the top, which decides
809
+ // whether a node is in scope — not how deep the walk goes.
810
+ walk(id, true);
765
811
  }
766
812
  };
767
- walk(parentId);
813
+ walk(parentId, false);
768
814
  return { stopped };
769
815
  }
770
816
  /** Deliver a parent's message into a child's mailbox (and wake it). */
@@ -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.1",
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",