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 +1 -3
- package/dist/agent/agent.js +85 -14
- package/dist/agent/tools.js +16 -3
- package/dist/log/events.js +41 -7
- package/dist/master.js +86 -2
- package/dist/server/api.js +45 -25
- package/package.json +1 -1
- package/public/assets/index-B7AL8hNs.css +1 -0
- package/public/assets/{index-C1UUe1T-.js → index-amTKCrhX.js} +22 -22
- package/public/assets/index-amTKCrhX.js.map +1 -0
- package/public/assets/{shiki-BxZS8Zdw.js → shiki-rj6mePzJ.js} +2 -2
- package/public/assets/{shiki-BxZS8Zdw.js.map → shiki-rj6mePzJ.js.map} +1 -1
- package/public/index.html +2 -2
- package/README.ja.md +0 -243
- package/public/assets/index-C1UUe1T-.js.map +0 -1
- package/public/assets/index-DBx_UTjS.css +0 -1
package/README.md
CHANGED
|
@@ -6,8 +6,6 @@
|
|
|
6
6
|
|
|
7
7
|
[](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
|
package/dist/agent/agent.js
CHANGED
|
@@ -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
|
-
|
|
1231
|
-
//
|
|
1232
|
-
|
|
1233
|
-
|
|
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.
|
|
1371
|
-
//
|
|
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
|
}
|
package/dist/agent/tools.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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"],
|
package/dist/log/events.js
CHANGED
|
@@ -89,12 +89,25 @@ export class EventLog {
|
|
|
89
89
|
data,
|
|
90
90
|
};
|
|
91
91
|
this.lastByBranch.set(branch, evt.id);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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) =>
|
|
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
|
-
|
|
792
|
-
|
|
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
|
//
|
package/dist/server/api.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) => {
|