teapot-coding-agent 0.24.2 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acp/adapter.js +197 -12
- package/dist/agent/agent.js +59 -0
- package/dist/index.js +87 -1
- package/dist/master.js +36 -8
- package/dist/server/api.js +29 -2
- package/package.json +1 -1
- package/public/assets/index-B7GFGiqt.js +30 -0
- package/public/assets/index-B7GFGiqt.js.map +1 -0
- package/public/assets/index-C20Fqw6W.css +1 -0
- package/public/assets/{shiki-DcpfEu1c.js → shiki-DyF3OiYm.js} +2 -2
- package/public/assets/{shiki-DcpfEu1c.js.map → shiki-DyF3OiYm.js.map} +1 -1
- package/public/index.html +2 -2
- package/public/assets/index-BKQtrd-0.css +0 -1
- package/public/assets/index-H2_fgmAt.js +0 -30
- package/public/assets/index-H2_fgmAt.js.map +0 -1
package/dist/acp/adapter.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { readEvents } from "../log/events.js";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
export const ACP_PROTOCOL_VERSION = 1;
|
|
4
|
+
/**
|
|
5
|
+
* How often a running turn is drained for new events.
|
|
6
|
+
*
|
|
7
|
+
* The web UI gets a WebSocket push; ACP has no such channel here, so the turn
|
|
8
|
+
* is streamed by polling the agent's own log. 150ms is well under the ~60ms
|
|
9
|
+
* render tick a human notices, and the read is a tail of a file this process
|
|
10
|
+
* is already appending to — cheap enough to be invisible next to an LLM call.
|
|
11
|
+
*/
|
|
12
|
+
const STREAM_POLL_MS = 150;
|
|
13
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
4
14
|
/** JSON-RPC error codes we use (the standard set plus JSON-RPC's own). */
|
|
5
15
|
const PARSE_ERROR = -32700;
|
|
6
16
|
const METHOD_NOT_FOUND = -32601;
|
|
@@ -13,6 +23,125 @@ const INTERNAL_ERROR = -32603;
|
|
|
13
23
|
* request bookkeeping have somewhere to live, and so it is directly testable
|
|
14
24
|
* with an in-memory stream pair.
|
|
15
25
|
*/
|
|
26
|
+
/* ---------- streaming: log events → session/update notifications ---------- */
|
|
27
|
+
/**
|
|
28
|
+
* ACP `ToolCall.kind` for a teapot tool name.
|
|
29
|
+
*
|
|
30
|
+
* The schema's own enum (verified against schema/v1/schema.json, not memory):
|
|
31
|
+
* read, edit, delete, move, search, execute, fetch, think, switch_mode, other,
|
|
32
|
+
* description. Editors use this to pick an icon and to decide whether the call
|
|
33
|
+
* mutates anything, so mapping our tools onto it is worth doing properly.
|
|
34
|
+
*/
|
|
35
|
+
const TOOL_KINDS = {
|
|
36
|
+
read_file: "read",
|
|
37
|
+
list_dir: "read",
|
|
38
|
+
glob: "search",
|
|
39
|
+
grep: "search",
|
|
40
|
+
bash: "execute",
|
|
41
|
+
bash_output: "execute",
|
|
42
|
+
read_url: "fetch",
|
|
43
|
+
web_fetch: "fetch",
|
|
44
|
+
edit_file: "edit",
|
|
45
|
+
write_file: "edit",
|
|
46
|
+
apply_patch: "edit",
|
|
47
|
+
file_edit: "edit",
|
|
48
|
+
memory: "edit",
|
|
49
|
+
get_memory: "read",
|
|
50
|
+
};
|
|
51
|
+
/** Filesystem tools whose `path` argument is worth surfacing as a location. */
|
|
52
|
+
const LOCATED_TOOLS = new Set([
|
|
53
|
+
"read_file",
|
|
54
|
+
"edit_file",
|
|
55
|
+
"write_file",
|
|
56
|
+
"apply_patch",
|
|
57
|
+
"file_edit",
|
|
58
|
+
"list_dir",
|
|
59
|
+
]);
|
|
60
|
+
/** a one-line title for a tool call, the way an editor shows it in a list */
|
|
61
|
+
function toolTitle(name, args) {
|
|
62
|
+
const first = (k) => {
|
|
63
|
+
const v = args[k];
|
|
64
|
+
return typeof v === "string" && v ? v : undefined;
|
|
65
|
+
};
|
|
66
|
+
if (name === "bash")
|
|
67
|
+
return first("command") ?? "bash";
|
|
68
|
+
if (name === "grep")
|
|
69
|
+
return first("pattern") ? `grep /${first("pattern")}/` : "grep";
|
|
70
|
+
return first("path") ?? name;
|
|
71
|
+
}
|
|
72
|
+
/** Absolute path for a tool's location, so an editor can open the file. */
|
|
73
|
+
function toolLocation(name, args, workspace) {
|
|
74
|
+
if (!LOCATED_TOOLS.has(name))
|
|
75
|
+
return [];
|
|
76
|
+
const p = args.path;
|
|
77
|
+
if (typeof p !== "string" || !p)
|
|
78
|
+
return [];
|
|
79
|
+
const abs = path.isAbsolute(p) ? p : path.resolve(workspace, p);
|
|
80
|
+
const line = Number(args.line ?? args.line_number ?? NaN);
|
|
81
|
+
return [{ path: abs, ...(Number.isFinite(line) && line > 0 ? { line } : {}) }];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Turn one logged event into the session/update payload for it, or null when
|
|
85
|
+
* the event has no ACP equivalent (and must therefore NOT be invented into
|
|
86
|
+
* one).
|
|
87
|
+
*
|
|
88
|
+
* Pure and exported so the mapping is testable without a transport, which is
|
|
89
|
+
* how the previous scaffold went unverified: `drainOnce` existed, was never
|
|
90
|
+
* called, and nothing noticed.
|
|
91
|
+
*/
|
|
92
|
+
export function updateForEvent(e, opts) {
|
|
93
|
+
const d = (e.data ?? {});
|
|
94
|
+
switch (e.type) {
|
|
95
|
+
case "message": {
|
|
96
|
+
// The completion AUDIT's verdict and the operator-facing summary are
|
|
97
|
+
// timeline content, not something the agent said to the model. They are
|
|
98
|
+
// marked precisely so the web feed can skip them; an editor must not be
|
|
99
|
+
// shown an agent speaking a harness message.
|
|
100
|
+
if (d.final === true || d.operatorFacing === true)
|
|
101
|
+
return null;
|
|
102
|
+
const text = String(d.content ?? "");
|
|
103
|
+
if (!text.trim())
|
|
104
|
+
return null;
|
|
105
|
+
return {
|
|
106
|
+
sessionUpdate: "agent_message_chunk",
|
|
107
|
+
content: { type: "text", text },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
case "tool_call": {
|
|
111
|
+
const name = String(d.name ?? "tool");
|
|
112
|
+
const args = (d.args ?? {});
|
|
113
|
+
return {
|
|
114
|
+
sessionUpdate: "tool_call",
|
|
115
|
+
toolCallId: String(d.callId ?? e.type),
|
|
116
|
+
title: toolTitle(name, args),
|
|
117
|
+
name,
|
|
118
|
+
kind: TOOL_KINDS[name] ?? "other",
|
|
119
|
+
status: "in_progress",
|
|
120
|
+
rawInput: args,
|
|
121
|
+
locations: toolLocation(name, args, opts.workspace),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
case "tool_result": {
|
|
125
|
+
const name = String(d.name ?? "tool");
|
|
126
|
+
return {
|
|
127
|
+
sessionUpdate: "tool_call_update",
|
|
128
|
+
toolCallId: String(d.callId ?? e.type),
|
|
129
|
+
status: d.ok === false ? "failed" : "completed",
|
|
130
|
+
content: [
|
|
131
|
+
{
|
|
132
|
+
type: "content",
|
|
133
|
+
content: { type: "text", text: String(d.result ?? "").slice(0, 20_000) },
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
default:
|
|
139
|
+
// state transitions, goals, todos, compactions, notes: all internal. The
|
|
140
|
+
// schema HAS update kinds for some of this (plan, usage_update), but
|
|
141
|
+
// emitting them with guessed payloads would be worse than silence.
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
16
145
|
export class AcpAdapter {
|
|
17
146
|
master;
|
|
18
147
|
input;
|
|
@@ -21,6 +150,8 @@ export class AcpAdapter {
|
|
|
21
150
|
sessions = new Map();
|
|
22
151
|
/** id -> notification params, so a prompt turn can stream updates */
|
|
23
152
|
turnUpdates = new Map();
|
|
153
|
+
/** sessions cancelled during the CURRENT turn, cleared when it starts (#15) */
|
|
154
|
+
cancelled = new Set();
|
|
24
155
|
buffer = "";
|
|
25
156
|
nextSession = 1;
|
|
26
157
|
initialized = false;
|
|
@@ -145,7 +276,13 @@ export class AcpAdapter {
|
|
|
145
276
|
cancel(params) {
|
|
146
277
|
const p = (params ?? {});
|
|
147
278
|
const s = p.sessionId ? this.sessions.get(String(p.sessionId)) : undefined;
|
|
148
|
-
s
|
|
279
|
+
if (!s)
|
|
280
|
+
return;
|
|
281
|
+
// Remember it HERE rather than asking the Agent whether it was stopped:
|
|
282
|
+
// the flag that says so is private, and the adapter already knows — it is
|
|
283
|
+
// the component that answered the cancel notification.
|
|
284
|
+
this.cancelled.add(s.id);
|
|
285
|
+
s.agent.stop("cancelled via ACP");
|
|
149
286
|
}
|
|
150
287
|
/* ---------- prompt turn ---------- */
|
|
151
288
|
async prompt(id, params) {
|
|
@@ -160,15 +297,30 @@ export class AcpAdapter {
|
|
|
160
297
|
if (!text)
|
|
161
298
|
return this.fail(id, INVALID_PARAMS, "prompt must contain text");
|
|
162
299
|
this.turnUpdates.set(id, { sessionId: s.id });
|
|
300
|
+
this.cancelled.delete(s.id); // a cancel is per-turn, not per-session
|
|
301
|
+
// Stream the turn as it happens. The editor shows the reply and each tool
|
|
302
|
+
// call appear as they happen; without this the client sees NOTHING until
|
|
303
|
+
// the whole turn finishes, which is the difference between a usable agent
|
|
304
|
+
// and a black box that eventually prints an answer.
|
|
305
|
+
const abort = new AbortController();
|
|
306
|
+
const stream = this.streamTurn(s.id, abort.signal).catch(() => { });
|
|
163
307
|
try {
|
|
164
308
|
s.agent.enqueuePrompt(text, "user");
|
|
165
309
|
if (s.agent.status !== "running")
|
|
166
310
|
s.agent.start("acp prompt");
|
|
167
311
|
await s.agent.settled();
|
|
168
|
-
|
|
169
|
-
|
|
312
|
+
// One last drain, or the final tool result and closing message can be
|
|
313
|
+
// missed: `settled()` resolves the moment the agent stops, and the last
|
|
314
|
+
// append may not have landed yet.
|
|
315
|
+
abort.abort();
|
|
316
|
+
await stream; // resolves after its own final drain
|
|
317
|
+
return this.ok(id, { stopReason: this.cancelled.has(s.id) ? "cancelled" : "end_turn" });
|
|
318
|
+
}
|
|
319
|
+
catch (err) {
|
|
320
|
+
return this.fail(id, INTERNAL_ERROR, err.message);
|
|
170
321
|
}
|
|
171
322
|
finally {
|
|
323
|
+
abort.abort();
|
|
172
324
|
this.turnUpdates.delete(id);
|
|
173
325
|
}
|
|
174
326
|
}
|
|
@@ -181,16 +333,49 @@ export class AcpAdapter {
|
|
|
181
333
|
for (const e of events) {
|
|
182
334
|
if (e.seq <= afterSeq)
|
|
183
335
|
continue;
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
},
|
|
190
|
-
});
|
|
336
|
+
const update = updateForEvent(e, { workspace: s.cwd });
|
|
337
|
+
// null means "no ACP equivalent" — state flips, goals, notes. Staying
|
|
338
|
+
// silent is correct; the old code invented a tool_call for every one.
|
|
339
|
+
if (update)
|
|
340
|
+
this.notify("session/update", { sessionId, update });
|
|
191
341
|
}
|
|
192
|
-
|
|
193
|
-
|
|
342
|
+
// Advance past EVERY event we read, including the ones we did not emit, so
|
|
343
|
+
// an un-emitted event is not reconsidered on the next poll.
|
|
344
|
+
return events.reduce((m, e) => Math.max(m, e.seq), afterSeq);
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Poll one session until it settles, streaming each batch as it appears.
|
|
348
|
+
*
|
|
349
|
+
* Polling the JSONL rather than subscribing to the bus is deliberate: the log
|
|
350
|
+
* is the agent's own record, so a listener attached at the wrong moment can
|
|
351
|
+
* miss the start of a reply, and a crash mid-turn leaves the file as the one
|
|
352
|
+
* complete account of what happened. It also means a resumed session replays
|
|
353
|
+
* exactly the same updates.
|
|
354
|
+
*/
|
|
355
|
+
async streamTurn(sessionId, signal) {
|
|
356
|
+
// Start from the CURRENT tail: everything before this prompt belongs to
|
|
357
|
+
// earlier turns, and a resumed session has already been replayed.
|
|
358
|
+
let cursor = await this.logTail(sessionId);
|
|
359
|
+
// NOTE the drain happens BEFORE the abort check, not after it. A short
|
|
360
|
+
// turn — a cached model, a tiny task, a failing endpoint that gives up
|
|
361
|
+
// quickly — can finish inside one poll interval, so a loop that slept and
|
|
362
|
+
// then bailed on `signal.aborted` would exit having drained NOTHING. That
|
|
363
|
+
// is exactly what happened: every turn raced the first tick and the
|
|
364
|
+
// editor received silence.
|
|
365
|
+
for (;;) {
|
|
366
|
+
cursor = await this.drainOnce(sessionId, cursor);
|
|
367
|
+
if (signal.aborted)
|
|
368
|
+
return;
|
|
369
|
+
await sleep(STREAM_POLL_MS);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/** highest seq currently in a session's log, or 0 */
|
|
373
|
+
async logTail(sessionId) {
|
|
374
|
+
const s = this.sessions.get(sessionId);
|
|
375
|
+
if (!s)
|
|
376
|
+
return 0;
|
|
377
|
+
const events = await readEvents(s.agent.log.filePath).catch(() => []);
|
|
378
|
+
return events.reduce((m, e) => Math.max(m, e.seq), 0);
|
|
194
379
|
}
|
|
195
380
|
get initialized_() {
|
|
196
381
|
return this.initialized;
|
package/dist/agent/agent.js
CHANGED
|
@@ -1760,6 +1760,39 @@ export class Agent {
|
|
|
1760
1760
|
const head = [{ role: "system", content: sys }];
|
|
1761
1761
|
return hasSystem ? [...head, ...this.messages.slice(1)] : [...head, ...this.messages];
|
|
1762
1762
|
}
|
|
1763
|
+
/**
|
|
1764
|
+
* Re-read AGENTS.md and adopt it if it changed. Returns the new text when it
|
|
1765
|
+
* did, or null when there is nothing to do.
|
|
1766
|
+
*
|
|
1767
|
+
* Called on compaction (#64 follow-up) and nowhere else. The one-shot capture
|
|
1768
|
+
* in systemPrompt() exists to keep the system prompt byte-identical for the
|
|
1769
|
+
* session so the provider's prefix cache survives; this is the single point
|
|
1770
|
+
* where breaking that identity is free, because compaction has just rewritten
|
|
1771
|
+
* the history underneath it.
|
|
1772
|
+
*
|
|
1773
|
+
* Failure is deliberately non-destructive: a read error, or a file that
|
|
1774
|
+
* vanished, leaves the previous content in place. Dropping the instructions
|
|
1775
|
+
* because of a transient error would be far worse than running one compaction
|
|
1776
|
+
* on slightly stale rules.
|
|
1777
|
+
*/
|
|
1778
|
+
reloadAgentsMd() {
|
|
1779
|
+
if (!this.agentsMdLoaded)
|
|
1780
|
+
return null; // nothing captured yet; nothing to update
|
|
1781
|
+
let fresh;
|
|
1782
|
+
try {
|
|
1783
|
+
const p = path.join(this.opts.workspace, "AGENTS.md");
|
|
1784
|
+
if (!existsSync(p))
|
|
1785
|
+
return null; // deleted → keep the last known rules
|
|
1786
|
+
fresh = readFileSync(p, "utf8").slice(0, 12_000);
|
|
1787
|
+
}
|
|
1788
|
+
catch {
|
|
1789
|
+
return null; // unreadable → keep what we have
|
|
1790
|
+
}
|
|
1791
|
+
if (fresh === this.agentsMd)
|
|
1792
|
+
return null;
|
|
1793
|
+
this.agentsMd = fresh;
|
|
1794
|
+
return fresh;
|
|
1795
|
+
}
|
|
1763
1796
|
/** AGENTS.md content cached for the session (empty string = none/read-failed) */
|
|
1764
1797
|
agentsMd = "";
|
|
1765
1798
|
/** captured once, with the rest of the session-frozen context */
|
|
@@ -2303,6 +2336,32 @@ export class Agent {
|
|
|
2303
2336
|
this.recentReads = this.recentReads.slice(0, restored.length); // keep order, drop misses
|
|
2304
2337
|
}
|
|
2305
2338
|
}
|
|
2339
|
+
// #64 follow-up: compaction is the ONE point in a session where re-reading
|
|
2340
|
+
// AGENTS.md is both safe and expected.
|
|
2341
|
+
//
|
|
2342
|
+
// It is expected because compaction exists precisely to summarise work
|
|
2343
|
+
// against the CURRENT instructions: an agent that spent an hour under one
|
|
2344
|
+
// set of project rules, then compacted, would otherwise carry the old rules
|
|
2345
|
+
// over the summary and never see the edit.
|
|
2346
|
+
//
|
|
2347
|
+
// It is safe because the prefix-cache concern that motivates freezing the
|
|
2348
|
+
// prompt does not apply here. That concern is about a prompt changing
|
|
2349
|
+
// mid-turn, re-pricing the whole prefix on every call. A compaction
|
|
2350
|
+
// ALREADY rewrote the history the prompt sits on top of, so the cache is
|
|
2351
|
+
// being re-priced regardless — one more changed block costs nothing extra.
|
|
2352
|
+
//
|
|
2353
|
+
// Only AGENTS.md is re-read, deliberately. The workspace listing and the
|
|
2354
|
+
// skills catalogue stay frozen: both are a snapshot of the workspace as it
|
|
2355
|
+
// was, and re-listing mid-session would contradict the instruction to
|
|
2356
|
+
// "not list again what is already here". AGENTS.md is different — it is
|
|
2357
|
+
// instructions, not a listing, and a stale copy is actively misleading.
|
|
2358
|
+
const refreshed = this.reloadAgentsMd();
|
|
2359
|
+
if (refreshed) {
|
|
2360
|
+
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
2361
|
+
event: "agents-md-reloaded",
|
|
2362
|
+
bytes: refreshed.length,
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2306
2365
|
// the operator can inspect WHAT was remembered — a dedicated typed event
|
|
2307
2366
|
// (not an agent message) keeps it out of the model's own voice
|
|
2308
2367
|
await this.log.append("compaction", this.currentSession, this.currentBranch, {
|
package/dist/index.js
CHANGED
|
@@ -35,6 +35,61 @@ async function waitForPortFree(port, host, timeoutMs) {
|
|
|
35
35
|
}
|
|
36
36
|
return false;
|
|
37
37
|
}
|
|
38
|
+
/* ---------- live-update handover: keeping the operator's terminal (#35) ---------- */
|
|
39
|
+
/**
|
|
40
|
+
* The pid of the process that replaced US, or null if we are the server.
|
|
41
|
+
*
|
|
42
|
+
* `Master.restartServer()` spawns the replacement and records its pid here, so
|
|
43
|
+
* the outgoing process can supervise its own successor instead of exiting and
|
|
44
|
+
* handing the shell a prompt. A field on the master rather than an env var: both
|
|
45
|
+
* processes are separate, but this value is written and read in the SAME
|
|
46
|
+
* process, so there is no environment for the two of them to disagree over.
|
|
47
|
+
*/
|
|
48
|
+
/** is `pid` still running? (EPERM means alive but not ours to signal) */
|
|
49
|
+
function pidAlive(pid) {
|
|
50
|
+
try {
|
|
51
|
+
process.kill(pid, 0);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
return e.code === "EPERM";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Watch the replacement on the operator's behalf.
|
|
60
|
+
*
|
|
61
|
+
* The terminal is still wired to THIS process, so their Ctrl-C arrives here
|
|
62
|
+
* first. Two things must happen, and the old one-shot shutdown() could do
|
|
63
|
+
* neither:
|
|
64
|
+
*
|
|
65
|
+
* 1. forward the signal, so the replacement actually stops; and
|
|
66
|
+
* 2. when the replacement does exit, leave too — otherwise the shell waits on
|
|
67
|
+
* a supervisor with nothing left to supervise and Ctrl-C looks broken.
|
|
68
|
+
*
|
|
69
|
+
* The original shutdown() cannot be reused for this: its `shuttingDown` latch
|
|
70
|
+
* is already set by the SIGTERM that started the handover, so a later Ctrl-C
|
|
71
|
+
* would be swallowed and this process would never exit.
|
|
72
|
+
*/
|
|
73
|
+
function supervise(pid) {
|
|
74
|
+
let signalled = false;
|
|
75
|
+
const forward = (sig) => {
|
|
76
|
+
// Once the replacement is on its way out, stop bouncing signals: a second
|
|
77
|
+
// SIGINT arriving during its shutdown would re-raise the same failure and
|
|
78
|
+
// the operator would have to press Ctrl-C several times to be heard.
|
|
79
|
+
if (signalled)
|
|
80
|
+
return;
|
|
81
|
+
signalled = true;
|
|
82
|
+
try {
|
|
83
|
+
process.kill(pid, sig);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* already gone — the poll loop below notices and exits */
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
90
|
+
process.on(sig, () => forward(sig));
|
|
91
|
+
}
|
|
92
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
38
93
|
async function main() {
|
|
39
94
|
// CLI: teapot [--port N] [-p N] [--config file] [-c file] [config.json]
|
|
40
95
|
let cfgArg;
|
|
@@ -114,7 +169,7 @@ async function main() {
|
|
|
114
169
|
return;
|
|
115
170
|
}
|
|
116
171
|
const app = buildApp(master);
|
|
117
|
-
serveApp(app, config.port, config.host);
|
|
172
|
+
const server = serveApp(app, config.port, config.host);
|
|
118
173
|
// agent crash isolation: an agent error never escapes its own loop; here we
|
|
119
174
|
// also make sure the process survives unexpected rejections.
|
|
120
175
|
process.on("uncaughtException", (err) => {
|
|
@@ -128,6 +183,37 @@ async function main() {
|
|
|
128
183
|
if (shuttingDown)
|
|
129
184
|
return;
|
|
130
185
|
shuttingDown = true;
|
|
186
|
+
// SUPERVISOR MODE. When a live update replaced us, we are no longer the
|
|
187
|
+
// server — but we are still the process the SHELL is waiting on, and it
|
|
188
|
+
// is still this process group that owns the terminal. Exiting here ended
|
|
189
|
+
// the shell's foreground job, so the operator was dumped back to the
|
|
190
|
+
// prompt mid-session while the new server kept running in the background
|
|
191
|
+
// ("teapot live restart returns to the shell", #35).
|
|
192
|
+
//
|
|
193
|
+
// So instead of exiting we hand over: keep the job slot, forward signals
|
|
194
|
+
// to the replacement, and only leave once it is gone. The operator's
|
|
195
|
+
// terminal — and their Ctrl-C — follow the server across the restart.
|
|
196
|
+
//
|
|
197
|
+
// The replacement is the child we spawned ourselves, so we hold it directly:
|
|
198
|
+
// no pid passed through the environment, and no way for the two processes
|
|
199
|
+
// to disagree about who supervises whom.
|
|
200
|
+
if (master.replacementPid !== null) {
|
|
201
|
+
const replacement = master.replacementPid;
|
|
202
|
+
console.log(`[teapot] handing the terminal to the new server (pid ${replacement})...`);
|
|
203
|
+
// Release the port FIRST. The replacement is blocked in waitForPortFree
|
|
204
|
+
// waiting for exactly this, and it will SIGTERM us again if we do not go
|
|
205
|
+
// — but the difference now is that we stop SERVING rather than exiting,
|
|
206
|
+
// so the shell's foreground job survives the handover.
|
|
207
|
+
await server.close();
|
|
208
|
+
console.log("[teapot] port released; supervising the new server");
|
|
209
|
+
supervise(replacement);
|
|
210
|
+
// Stay attached until the replacement exits. The shell is still waiting
|
|
211
|
+
// on US, so leaving early is exactly the bug.
|
|
212
|
+
while (pidAlive(replacement))
|
|
213
|
+
await sleep(500);
|
|
214
|
+
console.log("[teapot] new server exited; releasing the terminal");
|
|
215
|
+
process.exit(0);
|
|
216
|
+
}
|
|
131
217
|
console.log(`\n[teapot] shutting down${signal ? ` (${signal})` : ""}...`);
|
|
132
218
|
await Promise.allSettled([...master.agents.values()].map((a) => a.dispose()));
|
|
133
219
|
// During a live update the replacement SIGTERMs us to take the port. Linger
|
package/dist/master.js
CHANGED
|
@@ -231,6 +231,13 @@ function printAgentEvent(e) {
|
|
|
231
231
|
}
|
|
232
232
|
export class Master {
|
|
233
233
|
agents = new Map();
|
|
234
|
+
/**
|
|
235
|
+
* pid of the process that replaced us in a live update, or null while we are
|
|
236
|
+
* still the server. `restartServer()` sets it; `index.ts` reads it to keep
|
|
237
|
+
* supervising the replacement instead of exiting and dropping the operator
|
|
238
|
+
* back at the shell prompt (#35).
|
|
239
|
+
*/
|
|
240
|
+
replacementPid = null;
|
|
234
241
|
tasks = [];
|
|
235
242
|
childWaiters = new Map();
|
|
236
243
|
startedAt = Date.now();
|
|
@@ -783,14 +790,29 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
783
790
|
else if (e.type === "error") {
|
|
784
791
|
parent.enqueuePrompt(`[harness] Sub-agent ${ac.id} hit an error: ${String(e.data.message ?? "").slice(0, 500)}`, "harness");
|
|
785
792
|
}
|
|
786
|
-
//
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
793
|
+
// #63: a child's activity is NOT mirrored into the parent's log.
|
|
794
|
+
//
|
|
795
|
+
// This used to write a `sub` row per child prompt/message/tool_call/…, so
|
|
796
|
+
// the parent's timeline filled with the child's entire transcript. Two
|
|
797
|
+
// problems, in order of discovery:
|
|
798
|
+
//
|
|
799
|
+
// 1. The rows were invisible, because `"sub"` was never in the web UI's
|
|
800
|
+
// FEED_TYPES — so the filter dropped them and the feature did nothing
|
|
801
|
+
// for as long as it existed (#40).
|
|
802
|
+
// 2. Once it was made to work (#40), the parent's timeline was flooded:
|
|
803
|
+
// the operator reads a delegation as "what is this chat doing", not as
|
|
804
|
+
// a firehose of a child's work.
|
|
805
|
+
//
|
|
806
|
+
// The mirroring bought nothing. `sub` rows are display-only:
|
|
807
|
+
// `rebuildMessagesFrom` never reads them, so the model never saw them and
|
|
808
|
+
// the child's own history was never duplicated into the parent's context.
|
|
809
|
+
// What the parent actually needs — the child's report — already arrives as
|
|
810
|
+
// a harness prompt above, which is the channel the model reads.
|
|
811
|
+
//
|
|
812
|
+
// So the right fix is to stop writing them. A chat's timeline is its own.
|
|
813
|
+
// If per-child detail is wanted in the UI later, it belongs behind an
|
|
814
|
+
// explicit filter ("show sub-agent activity"), reading each child's own log
|
|
815
|
+
// rather than interleaving a copy into the parent's.
|
|
794
816
|
}
|
|
795
817
|
/** sessions root + helpers */
|
|
796
818
|
sessionsRoot() {
|
|
@@ -1246,6 +1268,11 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1246
1268
|
// process SIGTERMs us the instant it binds the port and the browser sees a
|
|
1247
1269
|
// dropped connection instead of {ok:true} (#35).
|
|
1248
1270
|
process.env.TEAPOT_RESTART_FROM = String(process.pid);
|
|
1271
|
+
// #35: remember the replacement so this process can hand the operator's
|
|
1272
|
+
// terminal over to it. Without that, the old process exited, the shell's
|
|
1273
|
+
// foreground job ended, and the operator was dumped back at the prompt while
|
|
1274
|
+
// the new server ran on in the background.
|
|
1275
|
+
this.replacementPid = null; // cleared below, once the pid is known
|
|
1249
1276
|
const child = spawn(execPath, args, {
|
|
1250
1277
|
// NOT detached, and NOT stdio:"ignore" (#35). Both made the live update
|
|
1251
1278
|
// silently daemonize: the replacement inherited nothing from this
|
|
@@ -1257,6 +1284,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1257
1284
|
stdio: "inherit",
|
|
1258
1285
|
env,
|
|
1259
1286
|
});
|
|
1287
|
+
this.replacementPid = child.pid ?? null;
|
|
1260
1288
|
await new Promise((r) => setTimeout(r, 1_500));
|
|
1261
1289
|
void child; // deliberately not unref()'d — see above
|
|
1262
1290
|
}
|
package/dist/server/api.js
CHANGED
|
@@ -1130,10 +1130,37 @@ export function buildApp(master) {
|
|
|
1130
1130
|
});
|
|
1131
1131
|
return app;
|
|
1132
1132
|
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Serve the app, returning a handle that can release the listening socket.
|
|
1135
|
+
*
|
|
1136
|
+
* #35: the live-update handover needs to give the port up WITHOUT exiting —
|
|
1137
|
+
* the outgoing process stays attached to the operator's terminal and supervises
|
|
1138
|
+
* its replacement, so it must not still be holding the port the replacement is
|
|
1139
|
+
* waiting to bind. Returning the server is what makes "stop serving, keep
|
|
1140
|
+
* running" expressible; previously the handle was dropped here and the only way
|
|
1141
|
+
* to release the port was process.exit().
|
|
1142
|
+
*/
|
|
1133
1143
|
export function serveApp(app, port, host) {
|
|
1134
1144
|
const wss = new WebSocketServer({ noServer: true });
|
|
1135
|
-
serve({ fetch: app.fetch, port, hostname: host, websocket: { server: wss } }, (info) => {
|
|
1136
|
-
const shown = host && host !== "0.0.0.0" && host !== "::"
|
|
1145
|
+
const server = serve({ fetch: app.fetch, port, hostname: host, websocket: { server: wss } }, (info) => {
|
|
1146
|
+
const shown = host && host !== "0.0.0.0" && host !== "::"
|
|
1147
|
+
? `http://${host}:${info.port}`
|
|
1148
|
+
: `http://localhost:${info.port} (all interfaces)`;
|
|
1137
1149
|
console.log(`[teapot] master listening on ${shown}`);
|
|
1138
1150
|
});
|
|
1151
|
+
return {
|
|
1152
|
+
close: async () => {
|
|
1153
|
+
// WebSocket clients hold the server open; without this the close would
|
|
1154
|
+
// hang on a live terminal and the handover would stall.
|
|
1155
|
+
for (const c of wss.clients)
|
|
1156
|
+
c.terminate();
|
|
1157
|
+
await new Promise((resolve) => {
|
|
1158
|
+
server.close(() => resolve());
|
|
1159
|
+
// A keep-alive connection can keep the socket open indefinitely; the
|
|
1160
|
+
// handover must not wait on that, so force it and report the port free
|
|
1161
|
+
// rather than blocking the replacement forever.
|
|
1162
|
+
setTimeout(resolve, 2_000);
|
|
1163
|
+
});
|
|
1164
|
+
},
|
|
1165
|
+
};
|
|
1139
1166
|
}
|