teapot-coding-agent 0.26.14 → 0.27.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/README.md CHANGED
@@ -6,8 +6,6 @@
6
6
 
7
7
  [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/akku1139/teapot)
8
8
 
9
- [日本語版はこちら](README.ja.md)
10
-
11
9
  ---
12
10
 
13
11
  ## 🚀 Quick start
@@ -167,7 +165,7 @@ Agents can run `bash` and rewrite files — including `rm` and `npm install`.
167
165
 
168
166
  - Designed to run under a **dedicated Linux user**
169
167
  - `git commit` before handing over a repo you care about
170
- - File operations outside the workspace are rejected (`safeJoin`)
168
+ - File operations outside the workspace are rejected (`safeJoin`), but agents can use `bash` outside of the worktree
171
169
  - teapot listens on localhost only by default. If you expose it, set **both** `--host 0.0.0.0` and an API token (`TEAPOT_API_TOKEN` env or config `password`):
172
170
 
173
171
  ```sh
@@ -1227,23 +1227,48 @@ export class Agent {
1227
1227
  }
1228
1228
  if (p.source === "user") {
1229
1229
  const id = p.id ?? `u${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
1230
- this.deliveredPrompts.push(id);
1231
- // cap: UI only needs recent confirmations
1232
- if (this.deliveredPrompts.length > 64)
1233
- this.deliveredPrompts.shift();
1234
- void this.log
1235
- .append("system_note", this.currentSession, this.currentBranch, {
1236
- event: "prompt-delivered",
1237
- promptId: id,
1238
- preview: p.text.slice(0, 80),
1239
- })
1240
- .then(() => bus.emit("update", { kind: "agent-update", agentId: this.opts.id }))
1241
- .catch(() => { });
1230
+ // #37: queued here, MARKED at the request boundary instead — see
1231
+ // markDeliveredPrompts(). Text is kept so the marker can carry the same
1232
+ // preview the UI shows.
1233
+ this.undeliveredPrompts.push({ id, preview: p.text.slice(0, 80) });
1242
1234
  }
1243
1235
  }
1244
1236
  }
1245
1237
  /** ids of user prompts that have entered an LLM call payload (recent first-capped) */
1246
1238
  deliveredPrompts = [];
1239
+ /**
1240
+ * User prompts consumed from the queue but not yet MARKED as delivered (#37).
1241
+ *
1242
+ * They are drained at a turn boundary, but the `prompt-delivered` marker must
1243
+ * wait until the request that carries them actually starts — which is later,
1244
+ * and after `maybeCompact()`, which can itself call the LLM.
1245
+ */
1246
+ undeliveredPrompts = [];
1247
+ /**
1248
+ * Log `prompt-delivered` for every prompt whose outbound request is starting.
1249
+ *
1250
+ * #37: the marker's meaning is "the request carrying this prompt has begun".
1251
+ * It used to fire in drainPendingPrompts, i.e. before the request, so the
1252
+ * timeline reordered by a moment the API never saw.
1253
+ */
1254
+ async markDeliveredPrompts() {
1255
+ if (this.undeliveredPrompts.length === 0)
1256
+ return;
1257
+ const pending = this.undeliveredPrompts;
1258
+ this.undeliveredPrompts = [];
1259
+ for (const { id, preview } of pending) {
1260
+ this.deliveredPrompts.push(id);
1261
+ // cap: the UI only needs recent confirmations
1262
+ if (this.deliveredPrompts.length > 64)
1263
+ this.deliveredPrompts.shift();
1264
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
1265
+ event: "prompt-delivered",
1266
+ promptId: id,
1267
+ preview,
1268
+ });
1269
+ }
1270
+ bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
1271
+ }
1247
1272
  /**
1248
1273
  * Background jobs that exited since the last LLM call. Their outcomes are
1249
1274
  * folded into ONE harness message at the turn boundary — the agent learns
@@ -1366,9 +1391,25 @@ export class Agent {
1366
1391
  stopSeq = 0;
1367
1392
  /** #95: set when this round already injected a harness progress request */
1368
1393
  progressRequestedThisRound = false;
1394
+ /**
1395
+ * CONTROL PLANE: fired on every status change, immediately, independent of the
1396
+ * log.
1397
+ *
1398
+ * #126: `wait_children` wakeups used to ride `EventLog.onEvent`, so a status
1399
+ * change reached the parent only AFTER the write completed. After the
1400
+ * write-before-notify fix that meant a FAILED or slow write left a parked parent
1401
+ * waiting forever — persistence latency became control flow. Measured with every
1402
+ * write failing: `onEvent fired: 0`, so no wakeup at all.
1403
+ *
1404
+ * Control flow must not depend on durability, so this is separate from the
1405
+ * event pipeline. The log's `onEvent` now means "persisted, tell the world";
1406
+ * this means "my state changed, react now".
1407
+ */
1408
+ onControlStatusChange = null;
1369
1409
  setStatus(s, reason = "") {
1370
- // Update the field FIRST. log.append fires onEvent synchronously, and the
1371
- // master's child-event hook wakes parked wait_children callers — those
1410
+ // Update the field FIRST. The control-plane hook fires synchronously below
1411
+ // (see onControlStatusChange), and it is what wakes parked wait_children
1412
+ // callers — those
1372
1413
  // re-check agent.status at that instant. Updating after the append made
1373
1414
  // every waiter observe the OLD status ("running"), conclude "someone is
1374
1415
  // still working", and stay parked until timeout even though the child had
@@ -1377,6 +1418,15 @@ export class Agent {
1377
1418
  this.status = s;
1378
1419
  this.statusReason = reason;
1379
1420
  if (prev !== s) {
1421
+ // #126: control plane FIRST, and not via the log. A parent's wait_children
1422
+ // must wake on the status change itself, not on the durability of the write
1423
+ // that records it.
1424
+ try {
1425
+ this.onControlStatusChange?.(this.opts.id, s);
1426
+ }
1427
+ catch {
1428
+ /* a control-plane listener must never break a status change */
1429
+ }
1380
1430
  void this.log.append("state", this.currentSession, this.currentBranch, {
1381
1431
  from: prev,
1382
1432
  to: s,
@@ -1591,6 +1641,27 @@ export class Agent {
1591
1641
  text: "",
1592
1642
  reasoning: "",
1593
1643
  });
1644
+ // #37: `prompt-delivered` must mean "the outbound request CARRYING this
1645
+ // prompt has started", not "the prompt moved from the queue into
1646
+ // `this.messages`".
1647
+ //
1648
+ // It used to be logged in drainPendingPrompts, which runs BEFORE
1649
+ // refreshSkills, maybeRequestProgress and — critically — maybeCompact. So
1650
+ // the marker preceded the request, and `maybeCompact()` can itself make an
1651
+ // LLM call, which meant the timeline claimed a delivery order the API never
1652
+ // saw. Measured before the fix:
1653
+ //
1654
+ // LLM CALL #1
1655
+ // seq=5 prompt-delivered("first prompt")
1656
+ // seq=6 llm turn start
1657
+ //
1658
+ // The UI reorders by this marker (resequenceToDelivery), so the semantics
1659
+ // were the bug — not timeline-order.ts.
1660
+ //
1661
+ // Emitted ONCE per request, not per attempt: a retry sends the same payload,
1662
+ // so re-emitting would move the marker after a later event on each try.
1663
+ if (attempt === 1)
1664
+ await this.markDeliveredPrompts();
1594
1665
  try {
1595
1666
  return await this.callLlm(messages, tools, onDelta);
1596
1667
  }
@@ -1192,8 +1192,18 @@ export const TOOLS = [
1192
1192
  return stale;
1193
1193
  let oldText = str(args.old_text);
1194
1194
  const newText = str(args.new_text);
1195
+ // #126: the single most common edit_file failure in the real logs (22
1196
+ // occurrences) said only "old_text is required", while the tool's own
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),
1199
+ // which turned a confusing refusal into a re-addressable one.
1195
1200
  if (!oldText)
1196
- return { ok: false, result: "old_text is required" };
1201
+ return {
1202
+ ok: false,
1203
+ result: "old_text is required — pass the exact text to replace, or read with " +
1204
+ '`read_file({line_ids:"hash"})` and pass that block VERBATIM as old_text. ' +
1205
+ "Do NOT copy read_file's `N| ` line-number prefixes.",
1206
+ };
1197
1207
  const replaceAll = args.replace_all === true;
1198
1208
  // #7/#4: accept a block copied straight out of read_file's hashline
1199
1209
  // output. When the anchors do not verify we FAIL with the specific reason
@@ -1654,11 +1664,14 @@ export const TOOLS = [
1654
1664
  },
1655
1665
  {
1656
1666
  name: "message_agent",
1657
- description: "Send a message to a specific sub-agent (steer it mid-flight or answer its ask_user question).",
1667
+ description: "Send a message to another agent. Use it to steer a sub-agent mid-flight, to answer its " +
1668
+ "ask_user question, or to message your OWN PARENT (pass its id) — a sub-agent may ask its " +
1669
+ "parent a question or hand back a partial result, which is how you report progress without " +
1670
+ "ending your turn. You may NOT message a sibling or an unrelated agent.",
1658
1671
  parameters: {
1659
1672
  type: "object",
1660
1673
  properties: {
1661
- id: { type: "string", description: "sub-agent id" },
1674
+ id: { type: "string", description: "sub-agent id, or your parent's id" },
1662
1675
  text: { type: "string" },
1663
1676
  },
1664
1677
  required: ["id", "text"],
@@ -89,12 +89,25 @@ export class EventLog {
89
89
  data,
90
90
  };
91
91
  this.lastByBranch.set(branch, evt.id);
92
- try {
93
- this.onEvent?.(evt);
94
- }
95
- catch {
96
- /* observer must never break the log */
97
- }
92
+ // #54: WRITE FIRST, then notify.
93
+ //
94
+ // The observer used to fire BEFORE the write. The frontend receives a
95
+ // `kind:"event"` frame and — by design — treats it as "something changed"
96
+ // and re-reads `/events`. So the reload could read the log BEFORE this
97
+ // event reached disk, and the reload would simply not contain it.
98
+ //
99
+ // Measured against a real EventLog, reloading on every notification:
100
+ //
101
+ // appends: 30 WS-triggered reloads that MISSED the event: 30
102
+ //
103
+ // The 120ms debounce usually hides this, which is exactly why #54 felt
104
+ // random: a slow write misses, a fast one lands. Notifying after the write
105
+ // makes the event visible to any reader the observer triggers.
106
+ // `written` distinguishes a REAL write from the post-close drop path, which
107
+ // also resolves. #54: announcing a dropped event would tell the frontend to
108
+ // reload for something that will never appear — the row would flicker in and
109
+ // straight back out.
110
+ let written = false;
98
111
  const p = new Promise((resolve, reject) => {
99
112
  this.chain = this.chain.then(() => {
100
113
  if (!this.stream) {
@@ -103,10 +116,31 @@ export class EventLog {
103
116
  console.error(`[teapot] event dropped after log close (${this.agentId}): ${type}`);
104
117
  return resolve(evt);
105
118
  }
106
- this.stream.write(JSON.stringify(evt) + "\n", "utf8", (err) => err ? reject(err) : resolve(evt));
119
+ this.stream.write(JSON.stringify(evt) + "\n", "utf8", (err) => {
120
+ if (err)
121
+ return reject(err);
122
+ written = true;
123
+ resolve(evt);
124
+ });
107
125
  }, () => resolve(evt));
108
126
  });
109
127
  this.chain = this.chain.then(() => { }, () => { });
128
+ // notify once the write is handed to the OS, so a reload the observer
129
+ // triggers can actually see this event
130
+ void p
131
+ .then(() => {
132
+ if (!written)
133
+ return; // dropped after close: nothing on disk to announce
134
+ try {
135
+ this.onEvent?.(evt);
136
+ }
137
+ catch {
138
+ /* observer must never break the log */
139
+ }
140
+ })
141
+ .catch(() => {
142
+ /* a failed write must not notify — the event is not on disk */
143
+ });
110
144
  return p;
111
145
  }
112
146
  lastEventId(branch) {
package/dist/master.js CHANGED
@@ -16,6 +16,8 @@ import { bus } from "./bus.js";
16
16
  import { fetchModelList, contextLengthFor, isReasoningEffort, } from "./model-meta.js";
17
17
  /** how deep sub-agent spawning may nest (parent=0, its subs=1, …) */
18
18
  const MAX_SPAWN_DEPTH = 3;
19
+ /** statuses that mean a child has finished working, for #130 */
20
+ const SETTLED = new Set(["idle", "stopped", "waiting", "error"]);
19
21
  /**
20
22
  * Skills shipped with the package — resolved relative to this module so it
21
23
  * works from a global `npm install -g` install, an npx cache, or a repo
@@ -259,6 +261,14 @@ export class Master {
259
261
  replacementPid = null;
260
262
  tasks = [];
261
263
  childWaiters = new Map();
264
+ /**
265
+ * Children that have actually started, for #130.
266
+ *
267
+ * A child is `idle` between `addAgent` and `start()`, and that is its FIRST
268
+ * status transition — so without this the parent is told a brand-new sub-agent is
269
+ * idle before it has done anything.
270
+ */
271
+ reportedRunning = new Set();
262
272
  startedAt = Date.now();
263
273
  config;
264
274
  configPath;
@@ -524,6 +534,47 @@ export class Master {
524
534
  bus.emit("update", { kind: "event", agentId: e.agent, event: e });
525
535
  this.onChildEvent(ac, e);
526
536
  };
537
+ // #126: the CONTROL PLANE, separate from the event pipeline above.
538
+ //
539
+ // `onChildEvent` used to be what woke a parent's `wait_children`, and it runs
540
+ // after the event is persisted. So a failed or slow write left the parent
541
+ // parked forever — measured with every write failing, the wake never fired.
542
+ //
543
+ // A status change is CONTROL FLOW, not an observation, so it gets its own
544
+ // immediate path. `onChildEvent` keeps its other job (mirroring the child's
545
+ // activity into the parent's timeline, forwarding its final report), which
546
+ // legitimately belongs to the event stream.
547
+ agent.onControlStatusChange = (agentId, status) => {
548
+ if (!ac.parent)
549
+ return;
550
+ const parent = this.agents.get(ac.parent);
551
+ if (!parent)
552
+ return;
553
+ // the waiter re-checks whether ITS targets are all settled, so waking on
554
+ // every movement is correct and cheap
555
+ this.wakeParentWaiters(ac.parent);
556
+ // #130: only report a SETTLED child, never its birth.
557
+ //
558
+ // A freshly spawned child is `idle` for a moment before `start()` runs, and
559
+ // the FIRST status transition it ever makes is therefore idle->running via
560
+ // that idle. Reporting it told the parent "[harness] @kid is now idle" the
561
+ // instant the child existed — before it had done anything at all. Measured:
562
+ // transitions after a spawn are ["idle","running","idle"], so the first is
563
+ // always the spurious one.
564
+ //
565
+ // So the message waits for a child that has actually STARTED. A real
566
+ // completion still reports (it goes running -> idle), and the wake above is
567
+ // unconditional, so `wait_children` is unaffected either way.
568
+ // #130: a spawned child is `idle` until start() runs, and that lazy-load
569
+ // flip is its FIRST transition — so requiring a prior `running` is what stops
570
+ // the parent being told the child settled before it began.
571
+ 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");
573
+ if (status === "running")
574
+ this.reportedRunning.add(agentId);
575
+ else if (SETTLED.has(status))
576
+ this.reportedRunning.delete(agentId);
577
+ };
527
578
  // sub-agent management hooks — only when this agent can legally spawn
528
579
  if (myDepth < this.maxSpawnDepth()) {
529
580
  const self = agent;
@@ -788,8 +839,32 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
788
839
  const owned = this.sessionById(childId);
789
840
  if (owned)
790
841
  targetId = owned.agentId;
791
- const cfg = this.config.agents.find((a) => a.id === targetId);
792
- if (!cfg || cfg.parent !== parentId) {
842
+ // #132: a sub-agent must be able to message its OWN PARENT.
843
+ //
844
+ // The rule below only ever permitted parent -> child, so a child asking its
845
+ // parent a question was refused with the actively misleading
846
+ //
847
+ // not your sub-agent: p1. no such sub-agent exists.
848
+ //
849
+ // — naming the parent as nonexistent, when it is the caller's own parent and
850
+ // plainly listed in config.agents. The parent link is the trust boundary: a
851
+ // child may address its parent, a parent may address its children, and
852
+ // NOTHING may address a sibling or an unrelated agent.
853
+ const caller = this.config.agents.find((a) => a.id === parentId);
854
+ const target = this.config.agents.find((a) => a.id === targetId);
855
+ const childToParent = target?.parent === parentId;
856
+ if (target && caller?.parent === targetId) {
857
+ const p = this.agents.get(targetId);
858
+ if (!p)
859
+ throw new Error(`sub-agent not running: ${targetId}`);
860
+ // mirrored wording, so the recipient knows who is writing
861
+ p.enqueuePrompt(`[harness] Message from sub-agent @${parentId}:\n\n${text}`, "harness");
862
+ if (p.status !== "running")
863
+ p.start(`message from sub-agent ${parentId}`);
864
+ return;
865
+ }
866
+ const cfg = target;
867
+ if (!cfg || (!childToParent && cfg.parent !== parentId)) {
793
868
  // Scoping is correct — only the DIRECT parent may message a child, so a
794
869
  // grandchild is rightly refused. But "not your sub-agent: <id>" reads
795
870
  // like a bug when the agent simply guessed the wrong level of the tree.
@@ -842,9 +917,18 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
842
917
  // silently dropped exactly the details the operator spawned the sub
843
918
  // to gather. Context management is compaction's job, not the pipe's.
844
919
  `[harness] Sub-agent ${ac.id} finished. Final report:\n${report || "(no summary)"}`, "harness");
920
+ // #123: a parent's report is delivered INTO a live agent — queueing it is
921
+ // not enough. If the parent is idle (finished, or parked) the report sat in
922
+ // the queue until something else started the loop, so the operator saw a
923
+ // finished sub-agent and a parent that never reacted.
924
+ if (parent.status !== "running")
925
+ parent.start(`sub-agent ${ac.id} finished`);
845
926
  }
846
927
  else if (e.type === "error") {
847
928
  parent.enqueuePrompt(`[harness] Sub-agent ${ac.id} hit an error: ${String(e.data.message ?? "").slice(0, 500)}`, "harness");
929
+ // #123: same for an error report
930
+ if (parent.status !== "running")
931
+ parent.start(`sub-agent ${ac.id} error`);
848
932
  }
849
933
  // #63: a child's activity is NOT mirrored into the parent's log.
850
934
  //
@@ -525,14 +525,18 @@ export function buildApp(master) {
525
525
  app.get("/api/agents/:id/tree", async (c) => {
526
526
  const a = master.agents.get(c.req.param("id"));
527
527
  if (!a)
528
- return c.json({ error: "not found" }, 404);
528
+ return notFound(c, "agent", c.req.param("id"));
529
529
  const rel = c.req.query("path") || ".";
530
530
  let abs;
531
531
  try {
532
532
  abs = safeJoin(a.workspace, rel);
533
533
  }
534
534
  catch (err) {
535
- return c.json({ error: err.message }, 400);
535
+ // #126: safeJoin's message embeds the RESOLVED absolute path
536
+ // ("path escapes workspace: /home/user/secret/project/…"), so returning it
537
+ // verbatim hands the caller the server's directory layout. The rejection is
538
+ // correct; the disclosure was not. Echo back the path AS GIVEN.
539
+ return c.json({ error: `path is outside the workspace: ${rel}` }, 400);
536
540
  }
537
541
  let dirents;
538
542
  try {
@@ -580,7 +584,7 @@ export function buildApp(master) {
580
584
  app.get("/api/agents/:id/file", async (c) => {
581
585
  const a = master.agents.get(c.req.param("id"));
582
586
  if (!a)
583
- return c.json({ error: "not found" }, 404);
587
+ return notFound(c, "agent", c.req.param("id"));
584
588
  const rel = c.req.query("path") ?? "";
585
589
  if (!rel.trim())
586
590
  return c.json({ error: "path required" }, 400);
@@ -611,7 +615,7 @@ export function buildApp(master) {
611
615
  app.get("/api/agents/:id/raw", async (c) => {
612
616
  const a = master.agents.get(c.req.param("id"));
613
617
  if (!a)
614
- return c.json({ error: "not found" }, 404);
618
+ return notFound(c, "agent", c.req.param("id"));
615
619
  const rel = c.req.query("path") ?? "";
616
620
  if (!rel.trim())
617
621
  return c.json({ error: "path required" }, 400);
@@ -649,7 +653,7 @@ export function buildApp(master) {
649
653
  app.put("/api/agents/:id/file", async (c) => {
650
654
  const a = master.agents.get(c.req.param("id"));
651
655
  if (!a)
652
- return c.json({ error: "not found" }, 404);
656
+ return notFound(c, "agent", c.req.param("id"));
653
657
  const rel = c.req.query("path") ?? "";
654
658
  if (!rel.trim())
655
659
  return c.json({ error: "path required" }, 400);
@@ -755,20 +759,20 @@ export function buildApp(master) {
755
759
  });
756
760
  app.get("/api/agents/:id", (c) => {
757
761
  const a = master.agents.get(c.req.param("id"));
758
- return a ? c.json(a.snapshot()) : c.json({ error: "not found" }, 404);
762
+ return a ? c.json(a.snapshot()) : notFound(c, "agent", c.req.param("id"));
759
763
  });
760
764
  // lazy session load: stopped (not restored) → idle, history rebuilt from the log
761
765
  app.post("/api/agents/:id/load", async (c) => {
762
766
  const a = master.agents.get(c.req.param("id"));
763
767
  if (!a)
764
- return c.json({ error: "not found" }, 404);
768
+ return notFound(c, "agent", c.req.param("id"));
765
769
  await a.load();
766
770
  return c.json({ ok: true, agent: a.snapshot() });
767
771
  });
768
772
  app.post("/api/agents/:id/prompt", async (c) => {
769
773
  const a = master.agents.get(c.req.param("id"));
770
774
  if (!a)
771
- return c.json({ error: "not found" }, 404);
775
+ return notFound(c, "agent", c.req.param("id"));
772
776
  const body = await c.req
773
777
  .json()
774
778
  .catch(() => null); // #110
@@ -797,7 +801,7 @@ export function buildApp(master) {
797
801
  app.post("/api/agents/:id/prompt/cancel", async (c) => {
798
802
  const a = master.agents.get(c.req.param("id"));
799
803
  if (!a)
800
- return c.json({ error: "not found" }, 404);
804
+ return notFound(c, "agent", c.req.param("id"));
801
805
  const body = await c.req.json().catch(() => null); // #110
802
806
  if (!body)
803
807
  return c.json({ error: "invalid JSON" }, 400);
@@ -811,7 +815,7 @@ export function buildApp(master) {
811
815
  app.post("/api/agents/:id/start", (c) => {
812
816
  const a = master.agents.get(c.req.param("id"));
813
817
  if (!a)
814
- return c.json({ error: "not found" }, 404);
818
+ return notFound(c, "agent", c.req.param("id"));
815
819
  a.start("api start");
816
820
  return c.json({ ok: true });
817
821
  });
@@ -874,14 +878,14 @@ export function buildApp(master) {
874
878
  app.post("/api/agents/:id/stop", (c) => {
875
879
  const a = master.agents.get(c.req.param("id"));
876
880
  if (!a)
877
- return c.json({ error: "not found" }, 404);
881
+ return notFound(c, "agent", c.req.param("id"));
878
882
  a.stop("stopped via api");
879
883
  return c.json({ ok: true });
880
884
  });
881
885
  app.post("/api/agents/:id/goal", async (c) => {
882
886
  const a = master.agents.get(c.req.param("id"));
883
887
  if (!a)
884
- return c.json({ error: "not found" }, 404);
888
+ return notFound(c, "agent", c.req.param("id"));
885
889
  const body = await c.req.json()
886
890
  .catch(() => null); // #110
887
891
  if (!body)
@@ -911,7 +915,7 @@ export function buildApp(master) {
911
915
  app.get("/api/agents/:id/skills", async (c) => {
912
916
  const a = master.agents.get(c.req.param("id"));
913
917
  if (!a)
914
- return c.json({ error: "not found" }, 404);
918
+ return notFound(c, "agent", c.req.param("id"));
915
919
  try {
916
920
  const list = await currentSkills(a.toolCtx);
917
921
  return c.json({
@@ -926,7 +930,7 @@ export function buildApp(master) {
926
930
  app.post("/api/agents/:id/compact", async (c) => {
927
931
  const a = master.agents.get(c.req.param("id"));
928
932
  if (!a)
929
- return c.json({ error: "not found" }, 404);
933
+ return notFound(c, "agent", c.req.param("id"));
930
934
  try {
931
935
  return c.json({ ok: true, ...(await a.compactNow()) });
932
936
  }
@@ -954,7 +958,7 @@ export function buildApp(master) {
954
958
  app.post("/api/agents/:id/auto-continue", async (c) => {
955
959
  const a = master.agents.get(c.req.param("id"));
956
960
  if (!a)
957
- return c.json({ error: "not found" }, 404);
961
+ return notFound(c, "agent", c.req.param("id"));
958
962
  const body = await c.req.json().catch(() => null);
959
963
  if (!body || typeof body.value !== "boolean")
960
964
  return c.json({ error: "boolean value required" }, 400);
@@ -970,7 +974,7 @@ export function buildApp(master) {
970
974
  app.post("/api/agents/:id/auto-compact", async (c) => {
971
975
  const a = master.agents.get(c.req.param("id"));
972
976
  if (!a)
973
- return c.json({ error: "not found" }, 404);
977
+ return notFound(c, "agent", c.req.param("id"));
974
978
  const body = await c.req.json().catch(() => null);
975
979
  if (!body || typeof body.value !== "boolean")
976
980
  return c.json({ error: "boolean value required" }, 400);
@@ -990,7 +994,7 @@ export function buildApp(master) {
990
994
  app.post("/api/agents/:id/spawn", async (c) => {
991
995
  const a = master.agents.get(c.req.param("id"));
992
996
  if (!a)
993
- return c.json({ error: "not found" }, 404);
997
+ return notFound(c, "agent", c.req.param("id"));
994
998
  const body = await c.req
995
999
  .json()
996
1000
  .catch(() => null);
@@ -1013,7 +1017,7 @@ export function buildApp(master) {
1013
1017
  app.post("/api/agents/:id/stop-children", async (c) => {
1014
1018
  const a = master.agents.get(c.req.param("id"));
1015
1019
  if (!a)
1016
- return c.json({ error: "not found" }, 404);
1020
+ return notFound(c, "agent", c.req.param("id"));
1017
1021
  const body = await c.req.json().catch(() => ({ ids: undefined }));
1018
1022
  try {
1019
1023
  const r = await master.stopChildrenFor(c.req.param("id"), body.ids);
@@ -1027,20 +1031,26 @@ export function buildApp(master) {
1027
1031
  app.post("/api/agents/:id/todo", async (c) => {
1028
1032
  const a = master.agents.get(c.req.param("id"));
1029
1033
  if (!a)
1030
- return c.json({ error: "not found" }, 404);
1034
+ return notFound(c, "agent", c.req.param("id"));
1031
1035
  const body = await c.req.json().catch(() => null);
1032
1036
  if (!body)
1033
1037
  return c.json({ error: "invalid JSON" }, 400);
1034
1038
  await a.setTodo(body.text ?? "");
1035
1039
  if (body.notify !== false && body.text?.trim())
1036
1040
  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");
1037
1047
  return c.json({ ok: true });
1038
1048
  });
1039
1049
  // edit a previously-sent prompt: forks there, optionally summarizes the tail
1040
1050
  app.post("/api/agents/:id/edit-prompt", async (c) => {
1041
1051
  const a = master.agents.get(c.req.param("id"));
1042
1052
  if (!a)
1043
- return c.json({ error: "not found" }, 404);
1053
+ return notFound(c, "agent", c.req.param("id"));
1044
1054
  const body = await c.req
1045
1055
  .json()
1046
1056
  .catch(() => null);
@@ -1066,7 +1076,7 @@ export function buildApp(master) {
1066
1076
  app.post("/api/agents/:id/fork", async (c) => {
1067
1077
  const a = master.agents.get(c.req.param("id"));
1068
1078
  if (!a)
1069
- return c.json({ error: "not found" }, 404);
1079
+ return notFound(c, "agent", c.req.param("id"));
1070
1080
  const body = await c.req.json().catch(() => ({ fromEvent: null }));
1071
1081
  const r = await a.fork(body.fromEvent ?? null);
1072
1082
  return c.json({ ok: true, ...r });
@@ -1096,7 +1106,7 @@ export function buildApp(master) {
1096
1106
  app.get("/api/agents/:id/events", async (c) => {
1097
1107
  const a = master.agents.get(c.req.param("id"));
1098
1108
  if (!a)
1099
- return c.json({ error: "not found" }, 404);
1109
+ return notFound(c, "agent", c.req.param("id"));
1100
1110
  // #54: the frontend pairs a tool_call with its tool_result by walking the
1101
1111
  // events it holds, so a window that can contain a result WITHOUT its call
1102
1112
  // strands the bash row at "waiting for output…" forever — the call scrolled
@@ -1113,7 +1123,7 @@ export function buildApp(master) {
1113
1123
  if (sessionId) {
1114
1124
  const rec = master.sessionById(sessionId);
1115
1125
  if (!rec || rec.agentId !== c.req.param("id"))
1116
- return c.json({ error: "not found" }, 404);
1126
+ return notFound(c, "session (or it belongs to another agent)", sessionId);
1117
1127
  filePath = path.join(rec.dir, "chat.jsonl");
1118
1128
  }
1119
1129
  // #59: the plain "latest N" request is what the UI polls every ~120 ms for
@@ -1152,13 +1162,13 @@ export function buildApp(master) {
1152
1162
  const known = master.agents.has(id) || master.config.agents.some((a) => a.id === id);
1153
1163
  const owned = master.sessionsOf(id);
1154
1164
  if (!known && owned.length === 0)
1155
- return c.json({ error: "not found" }, 404);
1165
+ return notFound(c, "agent", id);
1156
1166
  return c.json({ sessions: owned });
1157
1167
  });
1158
1168
  app.get("/api/agents/:id/branches", async (c) => {
1159
1169
  const a = master.agents.get(c.req.param("id"));
1160
1170
  if (!a)
1161
- return c.json({ error: "not found" }, 404);
1171
+ return notFound(c, "agent", c.req.param("id"));
1162
1172
  // cached like /events — this used to bypass it and re-read the whole log
1163
1173
  const events = await readEventsCached(a.log.filePath);
1164
1174
  const branches = new Map();
@@ -1190,6 +1200,16 @@ export function buildApp(master) {
1190
1200
  // ---- metrics / SSE ----
1191
1201
  app.get("/api/metrics", (c) => c.json(master.metrics()));
1192
1202
  // current server version (for live-update polling)
1203
+ /**
1204
+ * A 404 that says WHICH thing was missing.
1205
+ *
1206
+ * #126: there were 24 hand-copied `c.json({ error: "not found" }, 404)` lines.
1207
+ * From a client they are indistinguishable — a typo in an agent id, a deleted
1208
+ * session, or a bad route all produce the same three words, and none carries
1209
+ * the id that was looked up. The id is the first thing anyone debugging this
1210
+ * needs, and it is already in hand.
1211
+ */
1212
+ const notFound = (c, what, id) => c.json({ error: `${what} not found: ${id || "(no id given)"}` }, 404);
1193
1213
  app.get("/api/version", (c) => c.json({ version: master.getVersion() }));
1194
1214
  // trigger a full server restart (agents stopped gracefully, new process spawned)
1195
1215
  app.post("/api/update/restart", async (c) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.26.14",
3
+ "version": "0.27.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",