teapot-coding-agent 0.26.0 → 0.26.2

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.
@@ -1190,6 +1190,8 @@ export class Agent {
1190
1190
  if (this.status === "running" && !pendingStop)
1191
1191
  return;
1192
1192
  this.stopRequested = false;
1193
+ // claim the status against any stop write still queued behind us (#86)
1194
+ this.stopSeq++;
1193
1195
  this.awaitingUser = false; // a fresh start answers/resumes past any ask_user
1194
1196
  void this.enqueue(async () => {
1195
1197
  await this.ensureReady(); // lazy restore before the loop touches history
@@ -1206,11 +1208,32 @@ export class Agent {
1206
1208
  this.abort?.abort();
1207
1209
  this.toolAbort.abort();
1208
1210
  this.wake?.();
1211
+ // #86: the status write is DEFERRED onto the run chain, so it can land
1212
+ // AFTER a start() that followed it. Observed: `idle -> stopped -> running ->
1213
+ // running -> idle`. The agent did resume — but it flashed "stopped" on the
1214
+ // way, and finished idle rather than running, so from the UI the operator's
1215
+ // ▶ start looked like it had done nothing.
1216
+ //
1217
+ // `start()` clears stopRequested synchronously, which is what makes this
1218
+ // detectable: by the time this task runs, a start has already claimed the
1219
+ // agent and the stop no longer owns the status.
1220
+ //
1221
+ // So record WHEN the stop was requested, and let a start that came after it
1222
+ // win. A stop issued while the agent is genuinely still running behaves
1223
+ // exactly as before.
1224
+ const stopSeq = ++this.stopSeq;
1209
1225
  void this.enqueue(() => {
1226
+ // Only a start() that arrived AFTER this stop may claim the status. The
1227
+ // seq check alone does that — no status test, because a genuinely running
1228
+ // agent must still be reported "stopped" (#86).
1229
+ if (this.stopSeq !== stopSeq)
1230
+ return Promise.resolve();
1210
1231
  this.setStatus("stopped", reason);
1211
1232
  return Promise.resolve();
1212
1233
  });
1213
1234
  }
1235
+ /** monotonic counter: a start() bumps it, so a pending stop status write loses */
1236
+ stopSeq = 0;
1214
1237
  setStatus(s, reason = "") {
1215
1238
  // Update the field FIRST. log.append fires onEvent synchronously, and the
1216
1239
  // master's child-event hook wakes parked wait_children callers — those
@@ -1585,8 +1608,15 @@ export class Agent {
1585
1608
  return finished;
1586
1609
  }
1587
1610
  for (const call of m.tool_calls) {
1588
- if (this.stopRequested)
1611
+ if (this.stopRequested) {
1612
+ // #54: this returned straight out of the turn, SKIPPING the
1613
+ // answerUnansweredToolCalls backstop below — so every remaining
1614
+ // tool_call in this assistant message stayed logged with NO
1615
+ // tool_result anywhere, and its row rendered as "waiting for
1616
+ // output…" for the rest of the session.
1617
+ await this.answerUnansweredToolCalls(m);
1589
1618
  return finished;
1619
+ }
1590
1620
  if (call.function.name === "finish") {
1591
1621
  this.pendingSubReport = ""; // the real summary supersedes it
1592
1622
  await this.handleFinish(call.function.arguments);
@@ -1665,6 +1695,13 @@ export class Agent {
1665
1695
  this.awaitingUser = true;
1666
1696
  this.setStatus("waiting", question.slice(0, 80));
1667
1697
  // control flow: park the loop; the operator's next prompt resumes it
1698
+ //
1699
+ // #54: answer the rest of this assistant batch BEFORE parking. The
1700
+ // throw leaves the whole tool loop, so any EARLIER call in the same
1701
+ // message that was logged but not yet run kept no tool_result — the
1702
+ // comment on the backstop below says as much, and it is exactly the
1703
+ // path that bites in practice because ask_user is common.
1704
+ await this.answerUnansweredToolCalls(m);
1668
1705
  throw Object.assign(new Error("waiting for user"), { name: "WaitForUser" });
1669
1706
  }
1670
1707
  if (call.function.name === "get_todo") {
@@ -1796,8 +1833,23 @@ export class Agent {
1796
1833
  });
1797
1834
  const t0 = Date.now();
1798
1835
  // first filesystem touch creates a missing workspace (never at boot)
1799
- await this.ensureWorkspace();
1800
- const result = await executeTool(call.function.name, call.function.arguments, this.toolCtx);
1836
+ // #54: this is awaited BETWEEN the tool_call append above and the
1837
+ // tool_result append below, and `fs.mkdir` has no catch — so a failure
1838
+ // propagated out of the whole loop, leaving the call logged with no
1839
+ // result ANYWHERE, and its row read "waiting for output…" for good.
1840
+ //
1841
+ // The loop's own backstop could not save it: it sits at the bottom of
1842
+ // the same loop, which a throw skips. So the tool_result is written in a
1843
+ // `finally` — the call is answered whatever happens.
1844
+ // the type is inferred from executeTool, so this cannot drift from it
1845
+ let result;
1846
+ try {
1847
+ await this.ensureWorkspace();
1848
+ result = await executeTool(call.function.name, call.function.arguments, this.toolCtx);
1849
+ }
1850
+ catch (err) {
1851
+ result = { ok: false, result: `tool could not run: ${err.message}` };
1852
+ }
1801
1853
  this.stats.toolCalls++;
1802
1854
  await this.log.append("tool_result", this.currentSession, this.currentBranch, {
1803
1855
  callId: call.id,
@@ -2029,6 +2081,40 @@ export class Agent {
2029
2081
  }
2030
2082
  return sys;
2031
2083
  }
2084
+ /**
2085
+ * The last few conversation turns, for a parent's `final` report.
2086
+ *
2087
+ * Captured BEFORE the final marker is written so the parent gets real
2088
+ * substance (findings, file paths, numbers), not just whatever the model
2089
+ * typed into the finish() summary argument.
2090
+ *
2091
+ * Extracted as its own method because a REJECTED completion audit also needs
2092
+ * it (#89) — the retry prompt should carry the same context an approved one
2093
+ * would — while emitting no `final: true`.
2094
+ */
2095
+ async captureRecentTurns() {
2096
+ const recent = [];
2097
+ for (let i = this.messages.length - 1; i >= 0 && recent.length < 6; i--) {
2098
+ const m = this.messages[i];
2099
+ if (m.role === "user")
2100
+ continue; // prompts/noise — keep agent output only
2101
+ if (m.role === "assistant" && !(m.content ?? "").trim())
2102
+ continue;
2103
+ const who = m.role === "tool" ? "tool" : m.role;
2104
+ let line = `[${who}] ${(m.content ?? "").trim()}`;
2105
+ if (m.role === "tool") {
2106
+ // tool results are prefixed "(failed)" by the logger when they fail —
2107
+ // the raw content is what matters here, and cap hard: these can be huge
2108
+ line = `[${who}] ${(m.content ?? "").trim().slice(0, 1200)}`;
2109
+ }
2110
+ // A generous cap, but not unbounded: one runaway turn must not blow up
2111
+ // the parent's context. The old 1500 char clip cut findings in half —
2112
+ // a review's conclusions routinely live past that mark (#42) — and this
2113
+ // text is bounded by 6 turns, so the worst case is ~120k chars.
2114
+ recent.unshift(line.slice(0, 20_000));
2115
+ }
2116
+ return recent;
2117
+ }
2032
2118
  async handleFinish(argsJson) {
2033
2119
  const args = safeParse(argsJson);
2034
2120
  if (args.goalComplete === true && this.goal.status !== "done") {
@@ -2053,8 +2139,39 @@ export class Agent {
2053
2139
  { role: "user", content: auditPrompt },
2054
2140
  ], []);
2055
2141
  const text = String(res.message.content ?? "");
2056
- const approved = /^APPROVED\b/i.test(text.trim());
2057
- const feedback = text.replace(/^(APPROVED|CHANGES-REQUIRED)\s*[:—-]?\s*/i, "").trim();
2142
+ // #90: a reply that is NOT a verdict must not be read as a rejection.
2143
+ //
2144
+ // `/^APPROVED\b/i.test("")` is false, so an auditor that returned
2145
+ // nothing — truncated, refused, or leaked provider tool-call markup —
2146
+ // was scored `changes-required` with no reason. Seven times in one
2147
+ // session: the worker was told to "address these gaps" while being
2148
+ // given none, and the fallback text was the contract it had already
2149
+ // read. A reason-less rejection carries no information and only
2150
+ // teaches everyone that the audit is noise.
2151
+ //
2152
+ // So an unusable reply is treated as NO VERDICT, exactly like an
2153
+ // auditor that threw — the `catch` below already fails open with the
2154
+ // reasoning "a flaky provider can't trap work in an unauditable
2155
+ // loop". The same situation reached by another route gets the same
2156
+ // answer.
2157
+ const verdictLine = text.trim();
2158
+ // provider fallbacks for a message with no usable text; treating one
2159
+ // of these as a "reason" is worse than treating it as nothing
2160
+ const PLACEHOLDER = /^\((tool call|no content|no output)\)$/i;
2161
+ const usable = verdictLine.length > 0 && !PLACEHOLDER.test(verdictLine);
2162
+ const decided = usable && /^(APPROVED|CHANGES-REQUIRED)\b/i.test(verdictLine);
2163
+ if (!decided) {
2164
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
2165
+ event: "audit-failed",
2166
+ detail: usable
2167
+ ? `auditor reply had no verdict: ${verdictLine.slice(0, 200)}`
2168
+ : "auditor returned no verdict",
2169
+ });
2170
+ await this.setGoalStatus("done");
2171
+ return;
2172
+ }
2173
+ const approved = /^APPROVED\b/i.test(verdictLine);
2174
+ const feedback = verdictLine.replace(/^(APPROVED|CHANGES-REQUIRED)\s*[:—-]?\s*/i, "").trim();
2058
2175
  // #51: storing the literal "(no detail)" made the UI draw a card
2059
2176
  // whose whole content was the absence of a reason, and that same
2060
2177
  // string landed in goal.md for the right panel. An empty string is
@@ -2087,6 +2204,19 @@ export class Agent {
2087
2204
  source: "harness",
2088
2205
  text: `[harness] The completion audit REJECTED this finish. Address these gaps, then finish again with goalComplete=true:\n\n${gaps}`,
2089
2206
  });
2207
+ // #89: RETURN before the final message is written.
2208
+ //
2209
+ // Falling through emitted `final: true` while the goal was still
2210
+ // active — and that is the event onChildEvent forwards to the
2211
+ // parent. So a REJECTED completion told the parent the work was
2212
+ // DONE, and the child then sat retrying against a parent that had
2213
+ // already moved on and stopped listening.
2214
+ //
2215
+ // The goal stays active and the retry prompt is queued above, so
2216
+ // the loop simply continues; the parent's notification is deferred
2217
+ // until a finish that actually survives the audit.
2218
+ await this.captureRecentTurns();
2219
+ return;
2090
2220
  }
2091
2221
  }
2092
2222
  catch (err) {
@@ -2103,29 +2233,7 @@ export class Agent {
2103
2233
  await this.setGoalStatus("done");
2104
2234
  }
2105
2235
  }
2106
- // Capture the last few conversation turns BEFORE the final marker so the
2107
- // parent gets real substance (findings, file paths, numbers), not just
2108
- // whatever the model typed into the finish() summary argument.
2109
- const recent = [];
2110
- for (let i = this.messages.length - 1; i >= 0 && recent.length < 6; i--) {
2111
- const m = this.messages[i];
2112
- if (m.role === "user")
2113
- continue; // prompts/noise — keep agent output only
2114
- if (m.role === "assistant" && !(m.content ?? "").trim())
2115
- continue;
2116
- const who = m.role === "tool" ? "tool" : m.role;
2117
- let line = `[${who}] ${(m.content ?? "").trim()}`;
2118
- if (m.role === "tool") {
2119
- // tool results are prefixed "(failed)" by the logger when they fail —
2120
- // the raw content is what matters here, and cap hard: these can be huge
2121
- line = `[${who}] ${(m.content ?? "").trim().slice(0, 1200)}`;
2122
- }
2123
- // A generous cap, but not unbounded: one runaway turn must not blow up
2124
- // the parent's context. The old 1500 char clip cut findings in half —
2125
- // a review's conclusions routinely live past that mark (#42) — and this
2126
- // text is bounded by 6 turns, so the worst case is ~120k chars.
2127
- recent.unshift(line.slice(0, 20_000));
2128
- }
2236
+ const recent = await this.captureRecentTurns();
2129
2237
  await this.log.append("message", this.currentSession, this.currentBranch, {
2130
2238
  role: "assistant",
2131
2239
  final: true,
@@ -16,6 +16,45 @@ function clip(s, max) {
16
16
  async function readText(p) {
17
17
  return fs.readFile(p, "utf8");
18
18
  }
19
+ /**
20
+ * The file's dominant line ending, or null when it is mixed or empty.
21
+ *
22
+ * `null` means "do not normalise" — see the note above.
23
+ */
24
+ export function detectEol(text) {
25
+ if (!text)
26
+ return null;
27
+ const crlf = (text.match(/\r\n/g) ?? []).length;
28
+ // a bare CR is a CR not followed by LF
29
+ const cr = (text.match(/\r(?!\n)/g) ?? []).length;
30
+ const lf = (text.match(/(?<!\r)\n/g) ?? []).length;
31
+ const kinds = [crlf, cr, lf].filter((n) => n > 0).length;
32
+ // one kind only -> unambiguous. Mixed -> null.
33
+ if (kinds !== 1)
34
+ return null;
35
+ if (crlf > 0)
36
+ return "\r\n";
37
+ if (cr > 0)
38
+ return "\r";
39
+ return "\n";
40
+ }
41
+ /** Split on ANY of the three line endings, dropping the terminators. */
42
+ export function splitEolLines(text) {
43
+ return text.split(/\r\n|\r|\n/);
44
+ }
45
+ /**
46
+ * Re-apply `eol` to content the model supplied.
47
+ *
48
+ * Models emit `\n` essentially always, so a CRLF or CR file rewritten with LF
49
+ * content would otherwise be silently converted. Untouched terminators already
50
+ * in the right form are left alone, so this is safe to run on merged content.
51
+ */
52
+ export function applyEol(content, eol) {
53
+ if (!eol || eol === "\n")
54
+ return content;
55
+ // normalise to \n first, then re-apply — avoids double-CR on "\r\n"
56
+ return content.replace(/\r\n|\r|\n/g, eol);
57
+ }
19
58
  /**
20
59
  * Optimistic-concurrency guard (#5). The model reads a file, thinks, then
21
60
  * writes — and in between, a sub-agent, a background job or the operator may
@@ -970,7 +1009,11 @@ export const TOOLS = [
970
1009
  };
971
1010
  }
972
1011
  ctx.onFileRead?.(str(args.path));
973
- const lines = text.split("\n");
1012
+ // #84: split on ALL THREE line endings. A CR-only (old-Mac) file used to
1013
+ // come back as a single line, so the model could not address lines 2..n at
1014
+ // all — and a CRLF file left a trailing "\r" on every line, which then
1015
+ // had to be stripped again by every consumer.
1016
+ const lines = splitEolLines(text);
974
1017
  // #7/#4: opt-in hashline output. Default stays the historical `N| ` so
975
1018
  // nothing that depends on it changes until the mode is chosen.
976
1019
  const idMode = lineIdMode(args);
@@ -1056,8 +1099,25 @@ export const TOOLS = [
1056
1099
  if (stale)
1057
1100
  return stale;
1058
1101
  await fs.mkdir(path.dirname(p), { recursive: true });
1059
- await fs.writeFile(p, args.content, "utf8");
1060
- return { ok: true, result: `wrote ${p} (${args.content.length} bytes)` };
1102
+ // #84: a file's line endings belong to the FILE. Writing the model's
1103
+ // content verbatim silently converted a CRLF (or CR) file to LF whenever
1104
+ // the model rewrote it — the model never emits \r — so one logical edit
1105
+ // produced a whole-file diff, and any tool that normalises on EOL (git,
1106
+ // prettier) would rewrite it afterwards.
1107
+ //
1108
+ // So an EXISTING file's dominant EOL is re-applied. detectEol returns null
1109
+ // for a mixed file, which is left untouched on purpose: choosing a winner
1110
+ // there would itself be the silent whole-file rewrite this prevents.
1111
+ const before = await currentOrNull(p);
1112
+ const eol = before === null ? null : detectEol(before);
1113
+ const out = applyEol(args.content, eol);
1114
+ await fs.writeFile(p, out, "utf8");
1115
+ const converted = eol !== null && eol !== "\n" && out !== args.content;
1116
+ return {
1117
+ ok: true,
1118
+ result: `wrote ${p} (${out.length} bytes)` +
1119
+ (converted ? ` (kept ${eol === "\r\n" ? "CRLF" : "CR"} line endings)` : ""),
1120
+ };
1061
1121
  },
1062
1122
  },
1063
1123
  {
@@ -1136,12 +1196,15 @@ export const TOOLS = [
1136
1196
  const patLines = oldText.replace(/\r\n/g, "\n").split("\n");
1137
1197
  const hits = trailingWsMatches(srcLines, patLines);
1138
1198
  if (hits.length === 1 && patLines.length > 0) {
1139
- const eol = text.includes("\r\n") ? "\r\n" : "\n";
1199
+ // #84: same detection as the hashline path — CR-only files are detected
1200
+ // too, and a MIXED file yields null and is left as-is rather than
1201
+ // homogenised. The old `includes("\r\n")` test called a CR-only file
1202
+ // "\n", so an edit to one silently converted its line endings.
1140
1203
  const rebuilt = [
1141
1204
  ...srcLines.slice(0, hits[0]),
1142
- ...newText.replace(/\r\n/g, "\n").split("\n"),
1205
+ ...splitEolLines(newText),
1143
1206
  ...srcLines.slice(hits[0] + patLines.length),
1144
- ].join(eol);
1207
+ ].join(detectEol(text) ?? "\n");
1145
1208
  await fs.writeFile(p, rebuilt, "utf8");
1146
1209
  return {
1147
1210
  ok: true,
@@ -1197,12 +1260,10 @@ export const TOOLS = [
1197
1260
  // emit \r).
1198
1261
  const eol = text.includes("\r\n") ? "\r\n" : "\n";
1199
1262
  const out = hashSpan
1200
- ? text
1201
- .split("\n")
1263
+ ? splitEolLines(text)
1202
1264
  .slice(0, hashSpan.start)
1203
- .concat(newText.replace(/\r\n/g, "\n").split("\n"), text.split("\n").slice(hashSpan.end))
1204
- .join("\n")
1205
- .replace(/(?<!\r)\n/g, eol)
1265
+ .concat(splitEolLines(newText), splitEolLines(text).slice(hashSpan.end))
1266
+ .join(eol ?? "\n")
1206
1267
  : replaceAll
1207
1268
  ? text.split(oldText).join(newText)
1208
1269
  : text.replace(oldText, newText);
package/dist/master.js CHANGED
@@ -1277,9 +1277,30 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1277
1277
  // remove from map so a re-spawn after restart doesn't carry state
1278
1278
  this.agents.delete(id);
1279
1279
  });
1280
- const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("agent stop timeout")), timeoutMs));
1281
- await Promise.race([Promise.all(stopPromises), timeout]);
1282
- console.log("[teapot] all agents stopped");
1280
+ // #79: two problems with racing a timeout against the real work.
1281
+ //
1282
+ // 1. The timer was never cleared, so it kept the event loop alive for the
1283
+ // FULL timeout after a fast, successful stop — a 30s handle on a stop
1284
+ // that took milliseconds.
1285
+ // 2. On timeout, the promises that HAD completed had already deleted their
1286
+ // agents from `this.agents`, while their peers were still mid-dispose.
1287
+ // `restartServer` then threw BEFORE spawning its replacement, so the
1288
+ // process was left serving with a half-empty agent map and no successor.
1289
+ //
1290
+ // Fixed by clearing the timer, and by reporting which agents did NOT stop
1291
+ // so the caller can decide rather than being left half-dead.
1292
+ let timer;
1293
+ const timeout = new Promise((_, reject) => {
1294
+ timer = setTimeout(() => reject(new Error("agent stop timeout")), timeoutMs);
1295
+ });
1296
+ try {
1297
+ await Promise.race([Promise.all(stopPromises), timeout]);
1298
+ console.log("[teapot] all agents stopped");
1299
+ }
1300
+ finally {
1301
+ if (timer)
1302
+ clearTimeout(timer);
1303
+ }
1283
1304
  }
1284
1305
  /** Restart the server process (spawns a new master, keeps this one alive until ready). */
1285
1306
  async restartServer() {
@@ -91,21 +91,58 @@ function mimeFor(rel, kind) {
91
91
  }
92
92
  export function buildApp(master) {
93
93
  const app = new Hono();
94
+ /**
95
+ * Cap on concurrent `/api/ws` event-stream clients (#79).
96
+ *
97
+ * Each one holds a listener on the global bus and receives every agent's
98
+ * events, so this bounds real fan-out. 64 is far above any plausible number of
99
+ * open tabs on a LAN and low enough that a runaway client cannot exhaust memory.
100
+ */
101
+ const MAX_WS_CLIENTS = 64;
94
102
  // Optional bearer auth for LAN exposure — TEAPOT_API_TOKEN env wins, else
95
103
  // the config's `password` field. Static files stay public; only /api/* is
96
- // gated. WebSocket handshakes can't send headers → they accept ?token=.
104
+ // gated.
97
105
  const apiToken = process.env.TEAPOT_API_TOKEN || master.config.password || "";
106
+ /**
107
+ * Routes where `?token=` is accepted (#79).
108
+ *
109
+ * A browser WebSocket handshake cannot send an Authorization header, so the
110
+ * query parameter is the ONLY way for the UI to authenticate those two
111
+ * sockets. It used to be accepted on EVERY `/api/*` route, which put the
112
+ * secret into proxy access logs, `Referer` headers and browser history for
113
+ * requests that never needed it — `DELETE /api/agents/:id`, `PUT
114
+ * /api/config`, `POST /api/update/restart`.
115
+ *
116
+ * So this narrows where the secret can LEAK rather than changing who is
117
+ * authenticated: every route still accepts the header, and only these two
118
+ * also accept the query form.
119
+ */
120
+ const tokenInQueryOk = (path) => path === "/api/ws" || /^\/api\/agents\/[^/]+\/term$/.test(path);
98
121
  if (apiToken)
99
122
  app.use("/api/*", async (c, next) => {
100
123
  const h = c.req.header("authorization");
101
124
  const provided = h?.startsWith("Bearer ") ? h.slice(7) : undefined;
102
- const q = c.req.query("token");
103
- if ((provided && provided === apiToken) || (q && q === apiToken))
125
+ if (provided && provided === apiToken)
104
126
  return next();
127
+ if (tokenInQueryOk(c.req.path)) {
128
+ const q = c.req.query("token");
129
+ if (q && q === apiToken)
130
+ return next();
131
+ }
105
132
  return c.json({ error: "unauthorized" }, 401);
106
133
  });
107
134
  // per-agent terminal spawn guard
108
135
  const termCounts = new Map();
136
+ /**
137
+ * Live `/api/ws` event-stream connections (#79).
138
+ *
139
+ * Every one of these receives EVERY agent's events, so the count is a direct
140
+ * multiplier on fan-out, and `bus.setMaxListeners(1000)` only silences a
141
+ * warning — it does not bound anything. Capped so a stuck or hostile client
142
+ * cannot pin unbounded memory, and paired with a liveness check below so a
143
+ * client that stops reading is reaped rather than buffered for ever.
144
+ */
145
+ let wsClients = 0;
109
146
  /**
110
147
  * Agent ids reserved by an in-flight create (#71).
111
148
  *
@@ -128,8 +165,30 @@ export function buildApp(master) {
128
165
  app.get("/api/ws", upgradeWebSocket(() => {
129
166
  let onUpdate = null;
130
167
  let ka = null;
168
+ // set by onOpen, cleared by a pong. Declared at the connection's scope
169
+ // because onMessage is a SIBLING handler and cannot see a local in onOpen.
170
+ let awaitingPong = false;
171
+ let counted = false;
131
172
  return {
132
173
  onOpen(_evt, ws) {
174
+ // #79: refuse beyond the cap rather than accepting and buffering.
175
+ // Each connection costs a listener on the global bus and receives
176
+ // every agent's events, so this bounds real memory, not just a count.
177
+ if (wsClients >= MAX_WS_CLIENTS) {
178
+ try {
179
+ ws.send(JSON.stringify({ kind: "error", error: `too many event-stream clients (max ${MAX_WS_CLIENTS})` }));
180
+ ws.close(1013, "too many clients");
181
+ }
182
+ catch {
183
+ /* already gone */
184
+ }
185
+ return;
186
+ }
187
+ wsClients++;
188
+ counted = true;
189
+ // a client that stops reading makes ws.send buffer without bound; the
190
+ // app-level ping doubles as a liveness probe — if a ping goes
191
+ // unanswered the socket is reaped instead of accumulating
133
192
  const send = (data) => {
134
193
  try {
135
194
  ws.send(JSON.stringify(data));
@@ -151,8 +210,23 @@ export function buildApp(master) {
151
210
  send(ev);
152
211
  };
153
212
  bus.on("update", onUpdate);
154
- // app-level liveness ping every 30s
155
- ka = setInterval(() => send({ kind: "ping" }), 30_000);
213
+ // app-level liveness ping every 30s, and a reaper for a client that
214
+ // has stopped answering (#79)
215
+ ka = setInterval(() => {
216
+ if (awaitingPong) {
217
+ // the previous ping went unanswered: the client is not reading,
218
+ // so its send buffer is growing. Reap it.
219
+ try {
220
+ ws.close(1011, "unresponsive");
221
+ }
222
+ catch {
223
+ /* already gone */
224
+ }
225
+ return;
226
+ }
227
+ awaitingPong = true;
228
+ send({ kind: "ping" });
229
+ }, 30_000);
156
230
  },
157
231
  onMessage(evt, ws) {
158
232
  // clients may send {"kind":"ping"} — nothing else to do today
@@ -160,6 +234,8 @@ export function buildApp(master) {
160
234
  const m = JSON.parse(String(evt.data));
161
235
  if (m?.kind === "ping")
162
236
  ws.send(JSON.stringify({ kind: "pong" }));
237
+ if (m?.kind === "pong")
238
+ awaitingPong = false;
163
239
  }
164
240
  catch {
165
241
  /* ignore junk */
@@ -170,6 +246,10 @@ export function buildApp(master) {
170
246
  clearInterval(ka);
171
247
  if (onUpdate)
172
248
  bus.off("update", onUpdate);
249
+ // only a connection that was actually admitted owns a slot — a
250
+ // refused one must not decrement one it never took (#70's lesson)
251
+ if (counted)
252
+ wsClients = Math.max(0, wsClients - 1);
173
253
  },
174
254
  };
175
255
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.26.0",
3
+ "version": "0.26.2",
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",