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.
- package/dist/agent/agent.js +30 -7
- package/dist/agent/tools.js +2 -1
- package/dist/index.js +77 -15
- package/dist/master.js +64 -18
- package/dist/server/api.js +24 -0
- package/package.json +1 -1
- package/public/assets/index-DTHHmNVF.js +30 -0
- package/public/assets/index-DTHHmNVF.js.map +1 -0
- package/public/assets/{shiki-DYylGhua.js → shiki-B3arOyuY.js} +2 -2
- package/public/assets/{shiki-DYylGhua.js.map → shiki-B3arOyuY.js.map} +1 -1
- package/public/index.html +1 -1
- package/public/assets/index-NorOxnzt.js +0 -30
- package/public/assets/index-NorOxnzt.js.map +0 -1
package/dist/agent/agent.js
CHANGED
|
@@ -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 =
|
|
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
|
-
:
|
|
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
|
package/dist/agent/tools.js
CHANGED
|
@@ -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" (
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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). */
|
package/dist/server/api.js
CHANGED
|
@@ -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 });
|