teapot-coding-agent 0.24.0 → 0.24.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.
@@ -346,10 +346,23 @@ export class Agent {
346
346
  return this.manualCompactBudget;
347
347
  }
348
348
  /**
349
- * Long-parking tools (wait_children) flip the visible status to idle so the
350
- * operator isn't staring at a running spinner for minutes. The run chain is
351
- * still parked inside the tool — but any prompt/stop wakes it immediately.
349
+ * Is this agent doing work that `stop()` would actually stop?
350
+ *
351
+ * `status` cannot answer this on its own. Three states count as live:
352
+ * - `running`: the obvious one;
353
+ * - a PARKED TOOL: `parkForTool()` flips the visible status to "idle" so a
354
+ * multi-minute `wait_children` doesn't look like a running spinner, but
355
+ * the loop is very much alive inside the tool;
356
+ * - QUEUED PROMPTS: prompts handed to a stopped agent are accepted and held,
357
+ * and a later `start()` will consume them.
358
+ *
359
+ * Anything keying "should this button stop or start?" off `status` alone
360
+ * therefore offers **start** on a working agent, and the user cannot stop it
361
+ * (#59) — the same trap #38 documented for editing history.
352
362
  */
363
+ isLive() {
364
+ return this.status === "running" || this.parkedByTool || this.pendingPrompts.length > 0;
365
+ }
353
366
  /**
354
367
  * A background shell (bash background=true) finished. Two channels:
355
368
  * 1. a system_note row in the timeline — the operator sees the outcome
@@ -614,6 +627,15 @@ export class Agent {
614
627
  if (msgs.length > 0) {
615
628
  this.messages = msgs;
616
629
  this.currentBranch = last.branch;
630
+ // #50: re-apply pruning to the restored history. `maybePrune` clips
631
+ // oversized tool output IN PLACE, so a live session keeps its clipped
632
+ // text while the log still holds the full result — a restart therefore
633
+ // handed the provider a strictly LARGER prompt than the one that had just
634
+ // succeeded, which is a size regression that only shows up after a
635
+ // reload. It is a size fix, not a semantic one: the model has already
636
+ // seen and acted on that output, and the pruned form keeps the head plus
637
+ // a marker, exactly as it did live.
638
+ this.maybePrune();
617
639
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
618
640
  event: "session-restored",
619
641
  branch: last.branch,
@@ -876,6 +898,17 @@ export class Agent {
876
898
  id: this.opts.id,
877
899
  status: this.status,
878
900
  statusReason: this.statusReason,
901
+ /**
902
+ * Is there work in flight that `stop` would actually stop?
903
+ *
904
+ * `status` alone is not enough to answer that, and the UI needs it: a
905
+ * parked tool (`wait_children`) rewrites the status to "idle" while the
906
+ * loop is very much alive, so a client keying "stop vs start" off
907
+ * `status === "running"` offered **start** on a running agent and the
908
+ * user could not stop it (#59). This is the same "running is not the
909
+ * only live state" fact #38 had to be taught about editing.
910
+ */
911
+ live: this.isLive(),
879
912
  workspace: this.workspace,
880
913
  workspaceMissing: this.workspaceMissing,
881
914
  session: this.currentSession,
@@ -1864,18 +1897,37 @@ export class Agent {
1864
1897
  const text = String(res.message.content ?? "");
1865
1898
  const approved = /^APPROVED\b/i.test(text.trim());
1866
1899
  const feedback = text.replace(/^(APPROVED|CHANGES-REQUIRED)\s*[:—-]?\s*/i, "").trim();
1867
- await this.setGoalAudit(approved ? "approved" : "changes-required", feedback || "(no detail)");
1900
+ // #51: storing the literal "(no detail)" made the UI draw a card
1901
+ // whose whole content was the absence of a reason, and that same
1902
+ // string landed in goal.md for the right panel. An empty string is
1903
+ // the honest value: the auditor returned a verdict and no reason,
1904
+ // which every consumer can already render as "label only".
1905
+ await this.setGoalAudit(approved ? "approved" : "changes-required", feedback);
1906
+ // `operatorFacing: true` — the verdict is written for the TIMELINE
1907
+ // only. The audit ran in its own side conversation (a tools-less call
1908
+ // over buildMessages() + an audit prompt), so it was never in
1909
+ // `this.messages` and must not come back through a restore: doing so
1910
+ // injected an assistant turn that answers nothing into the model's
1911
+ // context, which providers answer with a 200 and no choices (#50).
1912
+ // The rejection feedback reaches the agent as a real prompt below.
1868
1913
  await this.log.append("message", this.currentSession, this.currentBranch, {
1869
1914
  role: "assistant",
1915
+ operatorFacing: true,
1870
1916
  content: approved
1871
1917
  ? `✅ completion audit: APPROVED — ${feedback}`
1872
1918
  : `🔍 completion audit: CHANGES REQUIRED — ${feedback}`,
1873
1919
  });
1874
1920
  if (!approved) {
1875
- // hand the gaps back to the worker as its next instruction
1921
+ // hand the gaps back to the worker as its next instruction.
1922
+ // #51: with the "(no detail)" placeholder gone, an empty verdict
1923
+ // would leave this prompt saying "address these gaps" and then
1924
+ // naming none — so restate the contract as the thing to re-check.
1925
+ const gaps = feedback.trim()
1926
+ ? feedback.slice(0, 2000)
1927
+ : `The auditor returned no specific gaps. Re-read the verification contract above and re-run it yourself, item by item, before finishing again:\n\n${this.goal.verify?.slice(0, 2000) || this.goal.text.slice(0, 2000)}`;
1876
1928
  this.pendingPrompts.push({
1877
1929
  source: "harness",
1878
- text: `[harness] The completion audit REJECTED this finish. Address these gaps, then finish again with goalComplete=true:\n\n${feedback.slice(0, 2000)}`,
1930
+ text: `[harness] The completion audit REJECTED this finish. Address these gaps, then finish again with goalComplete=true:\n\n${gaps}`,
1879
1931
  });
1880
1932
  }
1881
1933
  }
@@ -2152,6 +2204,20 @@ export class Agent {
2152
2204
  phase: "summarizing",
2153
2205
  summarized: oldCount,
2154
2206
  });
2207
+ // #56: the START belongs on the timeline too. Only the right panel showed
2208
+ // compaction, and it reports the *outcome*; a long summarize (hundreds of
2209
+ // thousands of tokens) looks exactly like a hung agent on the timeline,
2210
+ // because the first `context-compacted` event is only appended once the
2211
+ // whole pass is over. Log the announcement as a visible system_note so
2212
+ // the operator sees WHERE the conversation was rewritten, even if the
2213
+ // pass later fails — `before`/`oldCount` are known at this point, and the
2214
+ // completion divider still carries the authoritative after-figures.
2215
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
2216
+ event: "context-compaction-started",
2217
+ tokensBefore: before,
2218
+ messages: oldCount,
2219
+ reason: force ? "manual" : "auto",
2220
+ });
2155
2221
  this.compactPhase = "summarizing";
2156
2222
  try {
2157
2223
  summary = await this.summarize(old, (text) => {
@@ -2686,13 +2752,28 @@ function rebuildMessagesFrom(list) {
2686
2752
  msgs.push(mk());
2687
2753
  }
2688
2754
  else if (e.type === "message") {
2689
- // final summaries are operator-facing only — the live loop never puts
2690
- // them in model history, so restores must skip them too
2691
- if (d.final === true)
2755
+ // Operator-facing rows are TIMELINE content, not conversation: the live
2756
+ // loop never put them in `messages`, so a restore must not either.
2757
+ // `final` is the finish() summary; `operatorFacing` covers harness notes
2758
+ // such as the completion audit verdict. Replaying either produced a
2759
+ // history shape no live run emits, and a provider given that answers
2760
+ // 200 with zero choices (#50).
2761
+ if (d.final === true || d.operatorFacing === true)
2692
2762
  continue;
2693
2763
  const role = d.role === "assistant" ? "assistant" : "user";
2694
- const m = { role, content: typeof d.content === "string" ? d.content : "" };
2695
- if (Array.isArray(d.toolCalls) && d.toolCalls.length > 0) {
2764
+ const content = typeof d.content === "string" ? d.content : "";
2765
+ const hasCalls = Array.isArray(d.toolCalls) && d.toolCalls.length > 0;
2766
+ // A blank assistant turn that issued NO tool calls is an artefact of a
2767
+ // kill mid-stream: the model streamed nothing, so the live run never had
2768
+ // this turn, yet the restore replays it as a real utterance. sanitize()
2769
+ // then rewrites blank assistant content to the literal "(no content)",
2770
+ // so the agent is told it previously said a sentence it never said — a
2771
+ // history shape no live run produces (#50). A blank turn that DID call
2772
+ // tools is legitimate and must stay, or its results are orphaned.
2773
+ if (role === "assistant" && !content && !hasCalls)
2774
+ continue;
2775
+ const m = { role, content };
2776
+ if (hasCalls) {
2696
2777
  m.tool_calls = d.toolCalls.map((c) => ({
2697
2778
  id: c.id,
2698
2779
  type: "function",
package/dist/agent/llm.js CHANGED
@@ -128,6 +128,75 @@ function sanitize(messages) {
128
128
  return { ...m };
129
129
  });
130
130
  }
131
+ /**
132
+ * Why did the response carry no usable message?
133
+ *
134
+ * #50 — this used to be a bare "LLM API returned no choices", which is what
135
+ * made the report undiagnosable: an empty `choices` array, a 200 carrying an
136
+ * `error` object, a `delta`-shaped body, and an SSE stream that the SDK
137
+ * handed back as a plain String all produce the identical line, and none of
138
+ * them is a useful thing to read in a log.
139
+ *
140
+ * The leading text is kept stable at "LLM API returned no choices" so any
141
+ * existing matching (and the retry notes operators have learned to read)
142
+ * still works; the diagnosis follows after a colon.
143
+ */
144
+ function noChoicesDetail(res) {
145
+ const base = "LLM API returned no choices";
146
+ // a gateway that answers 200 with an error object instead of failing: the
147
+ // actionable message is right there and used to be thrown away
148
+ const err = res?.error;
149
+ if (err && typeof err === "object") {
150
+ const msg = typeof err.message === "string" ? err.message : JSON.stringify(err);
151
+ const code = err.code ?? err.type;
152
+ return `${base}: provider returned an error in a 200 response${code ? ` (${String(code)})` : ""} — ${String(msg).slice(0, 300)}`;
153
+ }
154
+ // the SDK returned raw text — e.g. an SSE body served to a non-streaming
155
+ // request. JSON.parse succeeds for a real object, so a failure here means we
156
+ // were handed something that is not a completion at all.
157
+ if (typeof res === "string") {
158
+ const head = res.slice(0, 160).replace(/\s+/g, " ").trim();
159
+ return `${base}: response was not a completion object (${res.length} bytes of ${res.trimStart().startsWith("data:") || res.trimStart().startsWith("event:") ? "SSE" : "text"})${head ? ` — ${head}` : ""}`;
160
+ }
161
+ const choices = res?.choices;
162
+ if (Array.isArray(choices)) {
163
+ const first = choices[0];
164
+ if (!first)
165
+ return `${base}: the provider returned an empty choices array (0 candidates)`;
166
+ if (first.delta && !first.message) {
167
+ return `${base}: the provider returned a streaming delta on a non-streaming request (finish_reason=${String(first.finish_reason)})`;
168
+ }
169
+ if (first.message === null) {
170
+ return `${base}: the provider returned a choice with a null message (finish_reason=${String(first.finish_reason)})`;
171
+ }
172
+ return `${base}: the provider returned a choice with no message field (finish_reason=${String(first.finish_reason)})`;
173
+ }
174
+ return `${base}: the response had no choices array (${safeShape(res)})`;
175
+ }
176
+ /** a short type summary of an unexpected response, for the log line */
177
+ function safeShape(res) {
178
+ try {
179
+ const o = res;
180
+ if (!o || typeof o !== "object")
181
+ return `got ${typeof res}`;
182
+ return `keys: ${Object.keys(o).slice(0, 8).join(", ") || "(none)"}`;
183
+ }
184
+ catch {
185
+ return "unreadable response";
186
+ }
187
+ }
188
+ /**
189
+ * HTTP statuses that mean "this request shape is not supported here", so
190
+ * re-issuing it without `stream: true` is a genuinely different request.
191
+ *
192
+ * Deliberately an allow-list rather than the whole 4xx range. 408/425/429 are
193
+ * TRANSIENT: replaying them immediately against an already-saturated provider
194
+ * just doubles the request count without changing anything. 401/403 mean the
195
+ * credentials are wrong, which is not fixed by dropping `stream` either — they
196
+ * are excluded on purpose, since a fallback there would replace one honest
197
+ * auth error with the same one and a wasted round trip.
198
+ */
199
+ const STREAM_REJECTED_STATUS = new Set([400, 404, 405, 406, 415, 422, 501]);
131
200
  export async function chat(cfg, messages, tools, signal, onDelta) {
132
201
  try {
133
202
  const res = await client(cfg).chat.completions.create({
@@ -136,10 +205,14 @@ export async function chat(cfg, messages, tools, signal, onDelta) {
136
205
  ...(tools.length ? { tools } : {}),
137
206
  ...effortForRequest(cfg),
138
207
  }, { signal });
139
- const raw = res.choices?.[0];
208
+ // #50: a gateway may answer with `null` (or any non-object) on some proxy
209
+ // error paths, so the guard is on `rm`, not on `res` being truthy —
210
+ // reading `res.choices` unguarded threw a bare TypeError that named neither
211
+ // the provider nor the shape.
212
+ const raw = res?.choices?.[0];
140
213
  const rm = raw?.message;
141
214
  if (!rm)
142
- throw new Error("LLM API returned no choices");
215
+ throw new Error(noChoicesDetail(res));
143
216
  // normalize: providers attach extra fields (reasoning, refusal, ...) and
144
217
  // nullable content — keep only what our protocol understands
145
218
  const message = {
@@ -198,6 +271,13 @@ export async function chat(cfg, messages, tools, signal, onDelta) {
198
271
  }
199
272
  catch (err) {
200
273
  const e = err;
274
+ // #50: our own shape-diagnosis already reads as a full sentence and names
275
+ // the response it saw. It carries no HTTP status (the request SUCCEEDED —
276
+ // that is the whole problem), so the generic wrapper below would have
277
+ // relabelled it "LLM API error ?: …", hiding both the diagnosis and the
278
+ // fact that no status was ever involved.
279
+ if (String(err.message ?? "").startsWith("LLM API returned no choices"))
280
+ throw err;
201
281
  if (e.status === undefined && !String(err.message).includes("provider returned"))
202
282
  throw err; // not an API error (abort, bug, ...)
203
283
  const detail = e.error?.message ?? e.message ?? "unknown provider error";
@@ -284,14 +364,58 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
284
364
  function: { name: c.name, arguments: c.args || "{}" },
285
365
  }];
286
366
  });
287
- if (!message.content && !message.tool_calls)
288
- throw new Error("LLM API error: empty completion");
367
+ if (!message.content && !message.tool_calls) {
368
+ // #50: a stream that opened, produced NOTHING, and closed cleanly. The
369
+ // throw carries a marker so the handler below can tell this apart from a
370
+ // request the provider actively rejected: this one is a broken/flaky
371
+ // proxy, and re-issuing it without `stream` is a genuinely different
372
+ // request that usually succeeds. Throwing a bare Error would make the two
373
+ // indistinguishable and cost us that safety net.
374
+ const e = new Error("LLM API error: empty completion");
375
+ e.emptyStream = true;
376
+ throw e;
377
+ }
289
378
  return { message, reasoning: reasoning || undefined, usage };
290
379
  }
291
380
  catch (err) {
292
- // provider may not support streaming at all — one clean fallback
293
- if (!gotChunk && !signal?.aborted)
381
+ // #50 — a provider that doesn't support streaming at all still gets ONE
382
+ // clean fallback, but ONLY when the streaming attempt produced nothing for
383
+ // a reason that could plausibly be the stream itself.
384
+ //
385
+ // The old condition was `!gotChunk && !signal?.aborted`, which also matched
386
+ // a request that FAILED — a gateway answering 200 with an SSE `error`
387
+ // frame, or a context-length rejection. In those cases the fallback
388
+ // re-issued the same oversized request in a different shape, and the
389
+ // resulting "LLM API returned no choices" replaced the real diagnosis.
390
+ // That is why a restored 1500-message session reported "no choices" three
391
+ // times and then errored: the genuine "context length exceeded" was
392
+ // discarded on the first attempt and could never be recognised by
393
+ // isContextOverflow(), so the compact-and-retry recovery never ran.
394
+ //
395
+ // Two exclusions, and the second one matters as much as the first:
396
+ //
397
+ // 1. A status that means the REQUEST ITSELF was rejected. Only then is
398
+ // re-issuing it without `stream` a different request rather than the
399
+ // same failing one. `400`/`404`/`405`/`422` are the shapes a provider
400
+ // uses to say "no streaming here" — deliberately NOT the whole 4xx
401
+ // range, because 408/425/429 are transient: replaying those against an
402
+ // already-saturated provider just doubles the request count.
403
+ // 2. A THROWN error, as opposed to a request that simply produced no
404
+ // chunks. A 200 that streams zero chunks and closes cleanly is a
405
+ // broken/flaky proxy, and that is precisely the case the non-streaming
406
+ // retry rescues. Requiring a 4xx here would have deleted that safety
407
+ // net — a regression this file's own tests caught.
408
+ //
409
+ // So the fallback runs when the request was rejected outright, OR when it
410
+ // completed without error and without a single chunk. Everything else —
411
+ // 5xx, an error frame, a mid-stream failure — is the ANSWER, rethrown
412
+ // untouched so overflow recovery can see it.
413
+ const status = err?.status;
414
+ const rejectedByProvider = typeof status === "number" && STREAM_REJECTED_STATUS.has(status);
415
+ const emptyStream = err?.emptyStream === true;
416
+ if (!gotChunk && !signal?.aborted && (rejectedByProvider || emptyStream)) {
294
417
  return chat(cfg, messages, tools, signal, onDelta);
418
+ }
295
419
  // user interrupt: hand back whatever streamed so far so the harness can
296
420
  // keep the partial output visible instead of losing it
297
421
  if (signal?.aborted && (text || reasoning)) {
@@ -182,7 +182,13 @@ function startBackgroundShell(cmd, ctx) {
182
182
  * Wording varies by provider (OpenRouter/OpenAI/Anthropic/local servers). */
183
183
  export function isContextOverflow(err) {
184
184
  const msg = String(err?.message ?? "").toLowerCase();
185
- return (/context (length|window|limit)|too many tokens|prompt is too long|max.{0,12}tokens.{0,20}(exceed|surpass)|request too large|payload too large|exceeds.{0,20}limit/.test(msg) || /\b(400|413)\b.*token/.test(msg));
185
+ return (/context (length|window|limit)|too many tokens|prompt is too long|max.{0,12}tokens.{0,20}(exceed|surpass)|request too large|payload too large|exceeds.{0,20}limit/.test(msg) ||
186
+ /\b(400|413)\b.*token/.test(msg) ||
187
+ // #50: some gateways answer an over-long request with HTTP 200 and put the
188
+ // real diagnosis in the body, which llm.ts now surfaces after the "no
189
+ // choices" prefix. Without this the compact-and-retry recovery never
190
+ // recognised it and the turn simply failed.
191
+ /no choices:[\s\S]{0,300}context (length|window|limit)/.test(msg));
186
192
  }
187
193
  /** Any background shell still running for this workspace? The auto-continue
188
194
  * loop consults this before nudging a finished round: an agent that parked
@@ -709,10 +715,43 @@ export function hashLine(content) {
709
715
  }
710
716
  /** `LINE:HASH| content` for each line, 1-indexed */
711
717
  export function renderHashlines(lines, startLine = 1) {
712
- return lines
718
+ // same trailing-newline rule as renderGutter (#7): a file ending in "\n"
719
+ // splits into a final "" that is not a line, and an anchor for it is a lie
720
+ // the model can copy into an edit that then never verifies
721
+ const body = lines.length && lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines;
722
+ return body
713
723
  .map((l, i) => `${startLine + i}:${hashLine(l)}| ${l}`)
714
724
  .join("\n");
715
725
  }
726
+ /**
727
+ * Width of the `N| ` gutter for a page, so every line in it lines up.
728
+ *
729
+ * #7: the prefix had no padding, so the gutter silently widened at line 1000
730
+ * and the whole block jumped sideways mid-file — the exact "unfriendly line
731
+ * numbers" complaint. Padding to the widest number IN THE PAGE is enough: a
732
+ * page is a bounded window, and aligning to the file's total length would
733
+ * push every line of a 20-line read of a 500k-line file 6 characters right.
734
+ */
735
+ export function gutterWidth(startLine, count) {
736
+ return String(Math.max(0, startLine - 1) + count).length;
737
+ }
738
+ /**
739
+ * The default `N| ` display, with a stable gutter width and no phantom line.
740
+ *
741
+ * #7, second half: the prefix was added AFTER `text.split("\n")`, so a file
742
+ * ending in a newline produced a final numbered row for a line that does not
743
+ * exist. A model that copies the tail verbatim then hands `edit_file` an
744
+ * old_text with a phantom line in it, and the edit fails for no visible reason.
745
+ * A trailing empty element is dropped, which is what the reader means.
746
+ */
747
+ export function renderGutter(lines, startLine = 1) {
748
+ // a trailing newline splits into a final "" — not a line of the file
749
+ const body = lines.length && lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines;
750
+ if (!body.length)
751
+ return "";
752
+ const w = gutterWidth(startLine, body.length);
753
+ return body.map((l, i) => `${String(startLine + i).padStart(w)}| ${l}`).join("\n");
754
+ }
716
755
  /** parse a `LINE:HASH` anchor; returns null when the text is not an anchor */
717
756
  export function parseAnchor(anchor) {
718
757
  const m = /^\s*(\d+):([0-9a-z]{4,8}| {4})\s*$/.exec(anchor);
@@ -913,7 +952,7 @@ export const TOOLS = [
913
952
  }
914
953
  const parts = regions.map(([s, e]) => idMode === "hash"
915
954
  ? renderHashlines(lines.slice(s, e + 1), s + 1)
916
- : lines.slice(s, e + 1).map((l, k) => `${s + k + 1}| ${l}`).join("\n"));
955
+ : renderGutter(lines.slice(s, e + 1), s + 1));
917
956
  let result = parts.join("\n--\n");
918
957
  if (idxs.length > page.length || off > 0)
919
958
  result += `\n(${off + 1}–${off + page.length} of ${idxs.length} matches)`;
@@ -924,8 +963,11 @@ export const TOOLS = [
924
963
  off = off < 0 ? Math.max(0, lines.length + off) : Math.max(0, off - 1);
925
964
  const lim = num(args.limit, 2000);
926
965
  const pageLines = lines.slice(off, off + lim);
927
- const slice = idMode === "hash" ? renderHashlines(pageLines, off + 1) : pageLines.map((l, i) => `${off + i + 1}| ${l}`).join("\n");
928
- const more = off + lim < lines.length ? `\n... (${lines.length - off - lim} more lines)` : "";
966
+ const slice = idMode === "hash" ? renderHashlines(pageLines, off + 1) : renderGutter(pageLines, off + 1);
967
+ // the phantom trailing line is dropped for the count too, or a file that
968
+ // ends in a newline always reports one line more than it has
969
+ const total = lines.length && lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
970
+ const more = off + lim < total ? `\n... (${total - off - lim} more lines)` : "";
929
971
  return { ok: true, result: slice + more };
930
972
  },
931
973
  },
@@ -1090,12 +1132,27 @@ export const TOOLS = [
1090
1132
  // already proved which lines it means, so a block whose content occurs
1091
1133
  // twice must still edit the anchored occurrence rather than fail the
1092
1134
  // uniqueness check (#4).
1135
+ // The hashline path splits on "\n" to index by resolved line number, which
1136
+ // leaves a trailing "\r" on every line of a CRLF file. Rejoining with
1137
+ // "\n" then DROPS all of them, so a single edit silently rewrote a
1138
+ // Windows file's line endings: the replaced line arrived with the model's
1139
+ // "\n" while every untouched line kept its "\r\n". The file still parsed
1140
+ // and the edit "succeeded", so nothing reported it — but the next
1141
+ // checkout diff shows the whole file changed, and tools that normalise on
1142
+ // EOL (git, prettier, formatters) now rewrite it.
1143
+ //
1144
+ // So the EOL is detected ONCE from the untouched text and re-applied to
1145
+ // the replacement, which also keeps a CRLF file CRLF when the model sends
1146
+ // LF-separated new_text (the overwhelmingly common case — models never
1147
+ // emit \r).
1148
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
1093
1149
  const out = hashSpan
1094
1150
  ? text
1095
1151
  .split("\n")
1096
1152
  .slice(0, hashSpan.start)
1097
- .concat(newText.split("\n"), text.split("\n").slice(hashSpan.end))
1153
+ .concat(newText.replace(/\r\n/g, "\n").split("\n"), text.split("\n").slice(hashSpan.end))
1098
1154
  .join("\n")
1155
+ .replace(/(?<!\r)\n/g, eol)
1099
1156
  : replaceAll
1100
1157
  ? text.split(oldText).join(newText)
1101
1158
  : text.replace(oldText, newText);
@@ -156,3 +156,132 @@ export async function readEvents(filePath) {
156
156
  return [];
157
157
  }
158
158
  }
159
+ /** Default floor for a tail read, so a small `limit` cannot cause a tiny read. */
160
+ const TAIL_MIN_BYTES = 64 * 1024;
161
+ /**
162
+ * Read only the LAST `limit` events from an append-only JSONL log.
163
+ *
164
+ * #59: the web UI polls this endpoint every ~120 ms for the selected agent, and
165
+ * the full re-parse cost 348 ms on a 57 MB / 200 000-event log. The mtime cache
166
+ * could not help — a RUNNING agent appends constantly, so `size` and `mtimeMs`
167
+ * change on every single request and the cache only ever helped stopped agents.
168
+ *
169
+ * The log is strictly append-only: `EventLog.append` is the only writer and
170
+ * always writes a whole `JSON.stringify(evt) + "\n"` under a serialized chain,
171
+ * so an append never rewrites earlier bytes. That makes a tail read safe, and
172
+ * makes byte-offset caching legitimate.
173
+ *
174
+ * Anchored to the tail deliberately. The read starts at a byte offset chosen for
175
+ * the file's current size, so the FIRST line it sees is usually a fragment —
176
+ * dropped. `bytesScanned` is reported so callers (and tests) can assert the read
177
+ * really was partial rather than trusting a wall-clock number.
178
+ */
179
+ export async function readEventsTail(filePath, limit) {
180
+ const empty = { events: [], total: 0, bytesScanned: 0, truncated: false };
181
+ if (limit <= 0)
182
+ return empty;
183
+ let fh = null;
184
+ try {
185
+ const fsp = await import("node:fs/promises");
186
+ fh = await fsp.open(filePath, "r");
187
+ const st = await fh.stat();
188
+ if (st.size === 0)
189
+ return empty;
190
+ // grow the window until it holds `limit` whole lines. Start at a floor so a
191
+ // small limit still reads a sensible block, then double — bounded by the
192
+ // file size, because a pathological file must not make us read forever.
193
+ let want = Math.min(st.size, Math.max(TAIL_MIN_BYTES, limit * 512));
194
+ for (;;) {
195
+ const r = await readWindow(fh, st.size, want);
196
+ if (r.events.length >= limit || want >= st.size) {
197
+ return {
198
+ events: r.events.slice(-limit),
199
+ // `total` is an APPROXIMATE count: only the window was parsed, so this
200
+ // is "at least this many". /events uses it for the "load older" hint,
201
+ // where approximate is fine — an exact count would mean reading the
202
+ // entire file, which is the cost this function exists to avoid.
203
+ total: r.events.length,
204
+ bytesScanned: r.bytes,
205
+ truncated: r.bytes < st.size,
206
+ };
207
+ }
208
+ want = Math.min(st.size, want * 4);
209
+ }
210
+ }
211
+ catch {
212
+ return empty;
213
+ }
214
+ finally {
215
+ await fh?.close().catch(() => { });
216
+ }
217
+ }
218
+ /** read the last `bytes` of the file, dropping the leading partial line */
219
+ async function readWindow(fh, size, bytes) {
220
+ const start = Math.max(0, size - bytes);
221
+ const buf = Buffer.alloc(size - start);
222
+ if (buf.length)
223
+ await fh.read(buf, 0, buf.length, start);
224
+ const text = buf.toString("utf8");
225
+ const nl = text.indexOf("\n");
226
+ // the first line is a fragment unless we started at 0
227
+ const body = start > 0 ? (nl === -1 ? "" : text.slice(nl + 1)) : text;
228
+ const events = [];
229
+ for (const line of body.split("\n")) {
230
+ if (!line.trim())
231
+ continue;
232
+ try {
233
+ events.push(JSON.parse(line));
234
+ }
235
+ catch {
236
+ /* torn or corrupt line: skip it, never return a fragment */
237
+ }
238
+ }
239
+ return { events, bytes: buf.length };
240
+ }
241
+ /**
242
+ * Keep one branch's rows out of a timeline, optionally including the history it
243
+ * inherited before it forked.
244
+ *
245
+ * #38. A fork is a new timeline, but the agent's CONTEXT on it still includes
246
+ * everything before the fork point — `rebuildMessagesFrom` walks the parent
247
+ * chain across branch boundaries precisely so a restarted fork keeps its
248
+ * history. Filtering the view by strict equality therefore showed *less* than
249
+ * the model actually reasons over, which is why a filtered fork looked like
250
+ * data loss even though nothing was missing from the log.
251
+ *
252
+ * `includeInherited` walks the parent links out of the branch's own events and
253
+ * keeps everything they reach, so the view matches the model's context. Strict
254
+ * remains the default: a caller that genuinely wants only a branch's own rows
255
+ * (counting, auditing) should not be handed inherited history silently.
256
+ *
257
+ * Pure and exported so the rule can be tested without an HTTP server.
258
+ */
259
+ export function filterByBranch(events, branch, includeInherited = false) {
260
+ if (!includeInherited)
261
+ return events.filter((e) => e.branch === branch);
262
+ const byId = new Map(events.map((e) => [e.id, e]));
263
+ // every event belonging to the branch, plus everything reachable by `parent`
264
+ const wanted = new Set();
265
+ // A WORKLIST, not a loop over the growing set: iterating `wanted` while
266
+ // adding to it inside the same pass re-visits nodes and, on a parent cycle,
267
+ // never terminates.
268
+ const queue = [];
269
+ for (const e of events) {
270
+ if (e.branch !== branch)
271
+ continue;
272
+ if (wanted.has(e.id))
273
+ continue;
274
+ wanted.add(e.id);
275
+ queue.push(e.id);
276
+ }
277
+ while (queue.length) {
278
+ const cur = byId.get(queue.pop());
279
+ // walk up through the fork boundary — a fork's parent is the last event of
280
+ // the SOURCE branch, and that branch's history is inherited too
281
+ if (cur?.parent && !wanted.has(cur.parent)) {
282
+ wanted.add(cur.parent);
283
+ queue.push(cur.parent);
284
+ }
285
+ }
286
+ return events.filter((e) => wanted.has(e.id));
287
+ }
@@ -51,7 +51,7 @@ async function listModels(baseUrl, apiKey) {
51
51
  }
52
52
  import path from "node:path";
53
53
  import { bus } from "../bus.js";
54
- import { readEvents } from "../log/events.js";
54
+ import { readEvents, readEventsTail, filterByBranch } from "../log/events.js";
55
55
  /** media categories the file tree can preview inline */
56
56
  const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"]);
57
57
  const VIDEO_EXT = new Set(["mp4", "webm", "mov", "mkv", "avi", "m4v"]);
@@ -943,7 +943,14 @@ export function buildApp(master) {
943
943
  const a = master.agents.get(c.req.param("id"));
944
944
  if (!a)
945
945
  return c.json({ error: "not found" }, 404);
946
- const limit = Math.min(Number(c.req.query("limit") ?? 200), 5000);
946
+ // #54: the frontend pairs a tool_call with its tool_result by walking the
947
+ // events it holds, so a window that can contain a result WITHOUT its call
948
+ // strands the bash row at "waiting for output…" forever — the call scrolled
949
+ // out while the command was still running. 200 was small enough to hit this
950
+ // on any moderately long command. The default is now sized to hold a whole
951
+ // pair even when thousands of events land in between (a long stream logs one
952
+ // per delta); the server clamps hard, so a caller asking for more is cheap.
953
+ const limit = Math.min(Number(c.req.query("limit") ?? 2000), 20000);
947
954
  let filePath = a.log.filePath;
948
955
  // ?session=<internalId> reads ANY session owned by this agent (past
949
956
  // incarnations too) — the internal id, not the agent id, is the stable
@@ -955,12 +962,22 @@ export function buildApp(master) {
955
962
  return c.json({ error: "not found" }, 404);
956
963
  filePath = path.join(rec.dir, "chat.jsonl");
957
964
  }
958
- let events = await readEventsCached(filePath);
965
+ // #59: the plain "latest N" request is what the UI polls every ~120 ms for
966
+ // a RUNNING agent, and it is the one that does NOT need history — it serves
967
+ // it from a tail read that scans ~2% of the file. `?before` (older pages)
968
+ // and `?branch` genuinely need the whole log, so they keep the full parse.
969
+ // `total` is a lower bound on the tail path (see readEventsTail) and is
970
+ // only used for the "load older" affordance, where approximate is fine.
959
971
  const branch = c.req.query("branch");
972
+ const before = c.req.query("before");
973
+ if (!before && !branch) {
974
+ const tail = await readEventsTail(filePath, limit);
975
+ return c.json({ events: tail.events, total: tail.total, partial: tail.truncated });
976
+ }
977
+ let events = await readEventsCached(filePath);
960
978
  if (branch)
961
- events = events.filter((e) => e.branch === branch);
979
+ events = filterByBranch(events, branch, c.req.query("lineage") === "true");
962
980
  // cursor pagination for older pages: everything strictly BEFORE this id
963
- const before = c.req.query("before");
964
981
  if (before) {
965
982
  const idx = events.findIndex((e) => e.id === before);
966
983
  events = idx === -1 ? [] : events.slice(0, idx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.24.0",
3
+ "version": "0.24.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",