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.
@@ -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?.agent.stop("cancelled via ACP");
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
- const stopReason = s.agent.goal.status === "done" ? "end_turn" : "end_turn";
169
- return this.ok(id, { stopReason });
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
- this.notify("session/update", {
185
- sessionId,
186
- update: {
187
- sessionUpdate: e.type === "message" ? "agent_message_chunk" : "tool_call",
188
- content: { type: "text", text: String(e.data?.content ?? "") },
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
- const max = events.reduce((m, e) => Math.max(m, e.seq), afterSeq);
193
- return max;
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;
@@ -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
- // mirror feed-worthy activity; nested subs keep their original actor id
787
- const MIRROR = new Set(["prompt", "message", "tool_call", "tool_result", "progress", "error", "question", "state"]);
788
- if (!MIRROR.has(e.type))
789
- return;
790
- const inner = e.type === "sub" ? e.data : null;
791
- const actor = inner?.sub ?? ac.id;
792
- const payload = inner ? { sub: actor, type: inner.type, data: inner.data } : { sub: ac.id, type: e.type, data: e.data };
793
- void parent.log.append("sub", parent.currentSession, parent.currentBranch, payload);
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
  }
@@ -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 !== "::" ? `http://${host}:${info.port}` : `http://localhost:${info.port} (all interfaces)`;
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.24.2",
3
+ "version": "0.25.0",
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",