teapot-coding-agent 0.25.2 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -345,6 +345,46 @@ export class Agent {
345
345
  get compactBudgetIsManual() {
346
346
  return this.manualCompactBudget;
347
347
  }
348
+ /**
349
+ * A context size we are willing to act on.
350
+ *
351
+ * #73: `lastUsage.input` is the provider's own count, so it is normally the
352
+ * most accurate number available — but a provider that under-reports (a proxy
353
+ * that omits the field's real value, a broken gateway, a model served through
354
+ * a shim) makes BOTH safety gates believe the context is nearly empty:
355
+ *
356
+ * maybePrune() est < budget * 0.6 -> 0 prunes, forever
357
+ * maybeCompact() before < budget -> 0 compactions, forever
358
+ *
359
+ * Reproduced: ~40k characters of real conversation, provider reporting
360
+ * `prompt_tokens: 10` — zero prunes, zero compactions, and a gauge reading 10,
361
+ * so nothing looks wrong. The context then overflows for real, with the
362
+ * defence that exists to stop exactly that switched off.
363
+ *
364
+ * So the reported number is used, but never as the ONLY number: it is floored
365
+ * against the local estimate. The estimate is a heuristic; the point is that
366
+ * `??` was the wrong way to combine a measurement with a floor — it only falls
367
+ * back when the value is ABSENT, so a confidently wrong value was trusted more
368
+ * than the guess.
369
+ *
370
+ * The floor is a HALF of the local estimate. A quarter was tried first and
371
+ * was too weak to clear the 0.6-of-budget threshold for a realistic session
372
+ * (estimate 5108 → floored 1277 against a 3600 bar), so the guard still would
373
+ * not have fired. Half keeps the intent — catch "reported 10 when the real
374
+ * prompt is large" — without arguing with a provider whose tokenizer
375
+ * legitimately differs from ours, since a provider within 2x of the estimate
376
+ * is not the failure mode being defended against.
377
+ */
378
+ contextSizeForBudgeting() {
379
+ const estimated = this.estimateTokens();
380
+ const reported = this.lastUsage?.input;
381
+ if (reported === undefined)
382
+ return estimated;
383
+ // a provider reporting nothing at all is a different bug; treat it as unknown
384
+ if (!Number.isFinite(reported) || reported <= 0)
385
+ return estimated;
386
+ return Math.max(reported, Math.floor(estimated / 2));
387
+ }
348
388
  /**
349
389
  * Is this agent doing work that `stop()` would actually stop?
350
390
  *
@@ -722,10 +762,19 @@ export class Agent {
722
762
  droppedEvents: dropped.length,
723
763
  tailMode: tail,
724
764
  });
725
- // Seed the new branch's parent link to the fork event just written, so its
726
- // first event chains across instead of starting a disconnected parent:null
727
- // history (#38).
728
- this.log.seedBranch(newBranch, this.log.lastEventId(this.currentBranch));
765
+ // Seed the new branch's parent link so its first event chains across
766
+ // instead of starting a disconnected parent:null history (#38).
767
+ //
768
+ // #69: seed from the CUT POINT, not from this branch's tip. The fork event
769
+ // records where the edit was made (`fromEvent` = the last KEPT event), but
770
+ // `lastEventId(currentBranch)` is the newest event on the branch — which is
771
+ // one of the events this edit just DISCARDED. Seeding from the tip chained
772
+ // the new branch to the discarded tail, so lineageOf() walked straight back
773
+ // through it and a restart resurrected every dropped turn.
774
+ //
775
+ // The cut point is the right parent: it is the last event that survives.
776
+ const cutPoint = kept.at(-1)?.id ?? null;
777
+ this.log.seedBranch(newBranch, cutPoint);
729
778
  this.currentBranch = newBranch;
730
779
  this.messages = msgs;
731
780
  await this.log.append("prompt", this.currentSession, this.currentBranch, { source: "user", text });
@@ -940,6 +989,17 @@ export class Agent {
940
989
  ...(this.compactPhase ? { compacting: this.compactPhase } : {}),
941
990
  },
942
991
  pendingPrompts: this.pendingPrompts.length,
992
+ // #78: the IDS as well as the count. The web UI reconciles its local echo
993
+ // list against the live queue, and it was doing that by POSITION
994
+ // (slice(0, queued)) because only a count was published — so whenever the
995
+ // count dipped below the true queue length (a mid-queue cancellation, or a
996
+ // momentary gap), the NEWEST still-queued echoes were dropped and their
997
+ // undelivered log rows reappeared as settled "sent" rows. Identity needs
998
+ // the ids to travel; the count alone cannot express "these two".
999
+ //
1000
+ // Bounded like the rest of the snapshot, and ids are opaque, so this
1001
+ // cannot carry prompt text.
1002
+ pendingPromptIds: this.pendingPrompts.map((p) => p.id).filter((x) => !!x).slice(0, 64),
943
1003
  todo: this.todo.slice(0, 32_000), // match set_todo's cap — no silent truncation
944
1004
  parent: this.opts.parent,
945
1005
  awaiting: this.awaitingUser,
@@ -1114,9 +1174,24 @@ export class Agent {
1114
1174
  }
1115
1175
  /** Start (or resume) autonomous operation toward the goal. */
1116
1176
  start(reason = "start") {
1117
- if (this.status === "running")
1177
+ // #76: a stop() immediately followed by start() USED TO BE SILENTLY LOST.
1178
+ //
1179
+ // `stop()` only flips the status via an ENQUEUED task, so between the two
1180
+ // calls the status is still "running" and the guard below returned early —
1181
+ // before `stopRequested = false` could run. The stop then landed, the loop
1182
+ // exited, and the agent ended "stopped" with the restart discarded. The same
1183
+ // window made a stop+restart abort the new round's first tool.
1184
+ //
1185
+ // So a pending stop is not a reason to refuse a start. The loop's own
1186
+ // guards (`stopRequested` re-checked after every await) keep the old turn
1187
+ // from running on, and enqueue() serialises the two tasks, so the restart
1188
+ // is ordered after the stop rather than racing it.
1189
+ const pendingStop = this.stopRequested;
1190
+ if (this.status === "running" && !pendingStop)
1118
1191
  return;
1119
1192
  this.stopRequested = false;
1193
+ // claim the status against any stop write still queued behind us (#86)
1194
+ this.stopSeq++;
1120
1195
  this.awaitingUser = false; // a fresh start answers/resumes past any ask_user
1121
1196
  void this.enqueue(async () => {
1122
1197
  await this.ensureReady(); // lazy restore before the loop touches history
@@ -1133,11 +1208,32 @@ export class Agent {
1133
1208
  this.abort?.abort();
1134
1209
  this.toolAbort.abort();
1135
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;
1136
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();
1137
1231
  this.setStatus("stopped", reason);
1138
1232
  return Promise.resolve();
1139
1233
  });
1140
1234
  }
1235
+ /** monotonic counter: a start() bumps it, so a pending stop status write loses */
1236
+ stopSeq = 0;
1141
1237
  setStatus(s, reason = "") {
1142
1238
  // Update the field FIRST. log.append fires onEvent synchronously, and the
1143
1239
  // master's child-event hook wakes parked wait_children callers — those
@@ -1318,6 +1414,13 @@ export class Agent {
1318
1414
  const name = err.name;
1319
1415
  if (name === "StopRequested" || name === "AbortError" || this.stopRequested)
1320
1416
  throw err;
1417
+ // #76: a context-overflow error is the caller's to handle, not ours to
1418
+ // retry. Retrying the SAME oversized request four times with 5s/5s/30s
1419
+ // waits cannot succeed, and it delays the compact-and-retry that would
1420
+ // — so the overflow path never even ran. This is the same
1421
+ // "present, correct, and inert" shape as #54.
1422
+ if (isContextOverflow(err))
1423
+ throw err;
1321
1424
  if (attempt >= maxAttempts)
1322
1425
  throw err;
1323
1426
  const waitMs = waits[Math.min(attempt - 1, waits.length - 1)];
@@ -1505,8 +1608,15 @@ export class Agent {
1505
1608
  return finished;
1506
1609
  }
1507
1610
  for (const call of m.tool_calls) {
1508
- 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);
1509
1618
  return finished;
1619
+ }
1510
1620
  if (call.function.name === "finish") {
1511
1621
  this.pendingSubReport = ""; // the real summary supersedes it
1512
1622
  await this.handleFinish(call.function.arguments);
@@ -1585,6 +1695,13 @@ export class Agent {
1585
1695
  this.awaitingUser = true;
1586
1696
  this.setStatus("waiting", question.slice(0, 80));
1587
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);
1588
1705
  throw Object.assign(new Error("waiting for user"), { name: "WaitForUser" });
1589
1706
  }
1590
1707
  if (call.function.name === "get_todo") {
@@ -1716,8 +1833,23 @@ export class Agent {
1716
1833
  });
1717
1834
  const t0 = Date.now();
1718
1835
  // first filesystem touch creates a missing workspace (never at boot)
1719
- await this.ensureWorkspace();
1720
- 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
+ }
1721
1853
  this.stats.toolCalls++;
1722
1854
  await this.log.append("tool_result", this.currentSession, this.currentBranch, {
1723
1855
  callId: call.id,
@@ -1743,6 +1875,20 @@ export class Agent {
1743
1875
  `(last: ${call.function.name})`);
1744
1876
  }
1745
1877
  }
1878
+ // #76: every tool_call an assistant message issued must have a matching
1879
+ // tool result, or the provider REJECTS the next request — which is the
1880
+ // "`200 with no choices`" shape #50 traced to a malformed history.
1881
+ //
1882
+ // Two exits could leave some unanswered: `stopRequested` returns early
1883
+ // mid-batch, and a handler that throws (ask_user parking the loop is the
1884
+ // one that bites in practice) propagates out of the whole loop. Restore
1885
+ // has a hole-filler for exactly this; the LIVE array did not, so a session
1886
+ // that hit it produced an invalid next request and then failed with
1887
+ // something that looked like a provider fault.
1888
+ //
1889
+ // So the invariant is enforced here rather than at each exit: whatever
1890
+ // happened, answer what is still outstanding before moving on.
1891
+ await this.answerUnansweredToolCalls(m);
1746
1892
  if (finished)
1747
1893
  return true;
1748
1894
  }
@@ -1750,6 +1896,37 @@ export class Agent {
1750
1896
  // nudges the model; this round simply ends (no hard error)
1751
1897
  return finished;
1752
1898
  }
1899
+ /**
1900
+ * Append a tool result for every tool_call in `m` that has not been answered.
1901
+ *
1902
+ * Scans the tail of `messages` for `tool_call_id`s, so it is correct whichever
1903
+ * path left the batch unfinished — a stop, a throw, or a normal completion
1904
+ * where a handler returned early. Answered calls are left alone.
1905
+ */
1906
+ async answerUnansweredToolCalls(m) {
1907
+ const calls = m.tool_calls ?? [];
1908
+ if (!calls.length)
1909
+ return;
1910
+ const answered = new Set();
1911
+ for (const existing of this.messages) {
1912
+ if (existing.role === "tool" && existing.tool_call_id)
1913
+ answered.add(existing.tool_call_id);
1914
+ }
1915
+ for (const call of calls) {
1916
+ if (answered.has(call.id))
1917
+ continue;
1918
+ const content = "(not completed — the tool did not run)";
1919
+ this.messages.push({ role: "tool", tool_call_id: call.id, content });
1920
+ await this.log.append("tool_result", this.currentSession, this.currentBranch, {
1921
+ callId: call.id,
1922
+ name: call.function.name,
1923
+ ok: false,
1924
+ durationMs: 0,
1925
+ result: content,
1926
+ synthesized: true,
1927
+ });
1928
+ }
1929
+ }
1753
1930
  buildMessages() {
1754
1931
  // deliberately static: [system] + append-only history keeps prefix caches hot.
1755
1932
  // The workspace context block (AGENTS.md + ls snapshot) is appended to the
@@ -2139,7 +2316,7 @@ export class Agent {
2139
2316
  * protected so current work never loses its footing.
2140
2317
  */
2141
2318
  maybePrune() {
2142
- const est = this.lastUsage?.input ?? this.estimateTokens();
2319
+ const est = this.contextSizeForBudgeting(); // floored — see #73
2143
2320
  const budget = this.opts.contextTokenBudget;
2144
2321
  if (!budget || est < budget * 0.6)
2145
2322
  return 0; // prune only when it matters
@@ -2199,7 +2376,7 @@ export class Agent {
2199
2376
  // for providers that omit usage
2200
2377
  // stage 1: clip oversized old tool outputs before considering a summarize
2201
2378
  this.maybePrune();
2202
- const before = this.lastUsage?.input ?? this.estimateTokens();
2379
+ const before = this.contextSizeForBudgeting(); // floored — see #73
2203
2380
  if (!force && before < this.opts.contextTokenBudget)
2204
2381
  return;
2205
2382
  if (!force && this.opts.autoCompact === false)
@@ -2492,12 +2669,24 @@ export class Agent {
2492
2669
  async fork(fromEventId) {
2493
2670
  const newBranch = `br${this.branchCount()}${Date.now().toString(36).slice(-4)}`;
2494
2671
  const parentBranch = this.currentBranch;
2495
- await this.log.append("fork", this.currentSession, newBranch, {
2672
+ // Where the fork leaves the source branch. Recorded on the event AND used
2673
+ // to seed the new branch, so the two can never disagree.
2674
+ const fromEvent = fromEventId ?? this.log.lastEventId(parentBranch);
2675
+ // #69: the fork event must be appended under the SOURCE branch. Appending it
2676
+ // under the new one gave it parent:null (nothing had been written to that
2677
+ // branch yet), so lineageOf() walked an empty chain and a restart rebuilt
2678
+ // the fork with NO history at all — the same class of bug editPromptAt had,
2679
+ // fixed on one path only. editPromptAt already appends under the old
2680
+ // branch for exactly this reason.
2681
+ await this.log.append("fork", this.currentSession, parentBranch, {
2496
2682
  fromSession: this.currentSession,
2497
2683
  fromBranch: parentBranch,
2498
- fromEvent: fromEventId ?? this.log.lastEventId(parentBranch),
2684
+ fromEvent,
2499
2685
  newBranch,
2500
2686
  });
2687
+ // Seed the new branch's parent to the point it leaves the source, so its
2688
+ // first event chains across instead of starting at parent:null.
2689
+ this.log.seedBranch(newBranch, fromEvent);
2501
2690
  this.currentBranch = newBranch;
2502
2691
  this.messages = [...this.messages]; // independent history copy
2503
2692
  return { session: this.currentSession, branch: newBranch };
@@ -3,7 +3,7 @@
3
3
  * Tool specs are plain JSON-schema function definitions — provider-agnostic.
4
4
  */
5
5
  import { spawn } from "node:child_process";
6
- import { existsSync, promises as fs, realpathSync } from "node:fs";
6
+ import { existsSync, promises as fs, readlinkSync, realpathSync } from "node:fs";
7
7
  import path from "node:path";
8
8
  import { discoverSkills, isValidSkillName, readSkillFile, saveSkill, SKILL_FILE, } from "./skills.js";
9
9
  const str = (v, fallback = "") => (typeof v === "string" ? v : fallback);
@@ -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
@@ -60,14 +99,64 @@ export function safeJoin(cwd, p) {
60
99
  if (rel.startsWith("..") || path.isAbsolute(rel)) {
61
100
  throw new Error(`path escapes workspace: ${p}`);
62
101
  }
63
- // symlinks inside the workspace can point anywhere — resolve the real
64
- // target and confine THAT too (path.resolve alone doesn't follow links)
102
+ // symlinks inside the workspace can point anywhere — resolve the real target
103
+ // and confine THAT too (path.resolve alone doesn't follow links)
104
+ //
105
+ // #74: the old code resolved `abs` and swallowed a throw, treating failure as
106
+ // "fine". That is exactly backwards, and the failure is the NORMAL case:
107
+ // `realpathSync` throws ENOENT for a file that does not exist yet, which is
108
+ // every write_file creating something new. So `real` stayed as the LEXICAL
109
+ // path — which is inside the workspace — and the symlink guard never ran.
110
+ //
111
+ // Demonstrated: with `esc -> /outside` (a real directory), safeJoin allowed
112
+ // `esc/pwned.txt` and write_file wrote straight through it, landing outside
113
+ // the workspace. The dangling case happened to fail later with ENOTDIR, which
114
+ // is luck, not a control.
115
+ //
116
+ // So on ENOENT/ENOTDIR, resolve the nearest EXISTING ancestor instead of
117
+ // giving up. That still catches the escape — the ancestor IS the symlink —
118
+ // while allowing genuinely new files inside the workspace.
65
119
  let real = abs;
66
120
  try {
67
121
  real = realpathSync(abs);
68
122
  }
69
- catch {
70
- /* target may not exist yet (write_file) — lexical check above still holds */
123
+ catch (err) {
124
+ const code = err.code;
125
+ if (code === "ENOENT" || code === "ENOTDIR") {
126
+ // Walk up one component at a time. A component that is itself a SYMLINK
127
+ // must be resolved even when its target does not exist — realpathSync
128
+ // throws ENOENT for the dangling link itself, so naively walking to the
129
+ // first existing ancestor steps OVER the symlink and loses it. So read
130
+ // the link and check its target directly.
131
+ let probe = path.dirname(abs);
132
+ for (;;) {
133
+ try {
134
+ real = realpathSync(probe);
135
+ break;
136
+ }
137
+ catch (inner) {
138
+ if (inner.code !== "ENOENT")
139
+ break;
140
+ // this component is a dangling symlink: resolve where it POINTS
141
+ try {
142
+ const target = readlinkSync(probe);
143
+ real = path.isAbsolute(target) ? target : path.resolve(path.dirname(probe), target);
144
+ break;
145
+ }
146
+ catch {
147
+ /* not a symlink after all — keep walking */
148
+ }
149
+ const next = path.dirname(probe);
150
+ if (next === probe)
151
+ break; // reached the filesystem root
152
+ probe = next;
153
+ }
154
+ }
155
+ // keep the un-resolved tail so the comparison below sees the full path
156
+ const tail = path.relative(probe, abs);
157
+ if (tail && !tail.startsWith(".."))
158
+ real = path.join(real, tail);
159
+ }
71
160
  }
72
161
  const relReal = path.relative(cwd, real);
73
162
  if (relReal.startsWith("..") || path.isAbsolute(relReal)) {
@@ -920,7 +1009,11 @@ export const TOOLS = [
920
1009
  };
921
1010
  }
922
1011
  ctx.onFileRead?.(str(args.path));
923
- 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);
924
1017
  // #7/#4: opt-in hashline output. Default stays the historical `N| ` so
925
1018
  // nothing that depends on it changes until the mode is chosen.
926
1019
  const idMode = lineIdMode(args);
@@ -1006,8 +1099,25 @@ export const TOOLS = [
1006
1099
  if (stale)
1007
1100
  return stale;
1008
1101
  await fs.mkdir(path.dirname(p), { recursive: true });
1009
- await fs.writeFile(p, args.content, "utf8");
1010
- 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
+ };
1011
1121
  },
1012
1122
  },
1013
1123
  {
@@ -1086,12 +1196,15 @@ export const TOOLS = [
1086
1196
  const patLines = oldText.replace(/\r\n/g, "\n").split("\n");
1087
1197
  const hits = trailingWsMatches(srcLines, patLines);
1088
1198
  if (hits.length === 1 && patLines.length > 0) {
1089
- 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.
1090
1203
  const rebuilt = [
1091
1204
  ...srcLines.slice(0, hits[0]),
1092
- ...newText.replace(/\r\n/g, "\n").split("\n"),
1205
+ ...splitEolLines(newText),
1093
1206
  ...srcLines.slice(hits[0] + patLines.length),
1094
- ].join(eol);
1207
+ ].join(detectEol(text) ?? "\n");
1095
1208
  await fs.writeFile(p, rebuilt, "utf8");
1096
1209
  return {
1097
1210
  ok: true,
@@ -1147,12 +1260,10 @@ export const TOOLS = [
1147
1260
  // emit \r).
1148
1261
  const eol = text.includes("\r\n") ? "\r\n" : "\n";
1149
1262
  const out = hashSpan
1150
- ? text
1151
- .split("\n")
1263
+ ? splitEolLines(text)
1152
1264
  .slice(0, hashSpan.start)
1153
- .concat(newText.replace(/\r\n/g, "\n").split("\n"), text.split("\n").slice(hashSpan.end))
1154
- .join("\n")
1155
- .replace(/(?<!\r)\n/g, eol)
1265
+ .concat(splitEolLines(newText), splitEolLines(text).slice(hashSpan.end))
1266
+ .join(eol ?? "\n")
1156
1267
  : replaceAll
1157
1268
  ? text.split(oldText).join(newText)
1158
1269
  : text.replace(oldText, newText);
package/dist/master.js CHANGED
@@ -229,6 +229,22 @@ function printAgentEvent(e) {
229
229
  if (line)
230
230
  console.log(`${dim(ts)} ${who} ${line}`);
231
231
  }
232
+ /**
233
+ * Reduce an agent id to a safe single path segment.
234
+ *
235
+ * Agent ids become directory names, so anything path-like in one is an escape
236
+ * from the sessions root (#72). Kept in step with the per-call-site
237
+ * `.replace(/[^\w.-]/g, "-")` calls, and applied in `addAgent` too so the
238
+ * guarantee holds for every caller.
239
+ */
240
+ export function sanitizeAgentId(id) {
241
+ const cleaned = String(id ?? "")
242
+ .replace(/[^\w.-]/g, "-")
243
+ // a leading dot would create a hidden dir; ".." would climb out
244
+ .replace(/^\.+/, "")
245
+ .slice(0, 60);
246
+ return cleaned || "agent";
247
+ }
232
248
  export class Master {
233
249
  agents = new Map();
234
250
  /**
@@ -402,6 +418,21 @@ export class Master {
402
418
  * an older one's history.
403
419
  */
404
420
  async addAgent(ac, opts = {}) {
421
+ // #72: an agent id becomes a DIRECTORY name (sessions/<id>-<hash>), so it
422
+ // must be a safe path segment. Every caller sanitised its own id — the HTTP
423
+ // route, spawnChildFor's child name — except the ACP adapter, which passed
424
+ // the raw client string through, so `session/load {"sessionId":"../../x"}`
425
+ // created an agent logged outside <dataDir>/sessions/.
426
+ //
427
+ // Sanitised HERE as well as at the call sites: this is the one place that
428
+ // guarantees the property, so a future caller cannot reintroduce the escape
429
+ // by forgetting. A caller that already sanitised is unaffected — the
430
+ // transform is idempotent on a safe id.
431
+ const safeId = sanitizeAgentId(ac.id);
432
+ if (safeId !== ac.id) {
433
+ console.warn(`[teapot] agent id ${JSON.stringify(ac.id)} contained unsafe characters; using ${JSON.stringify(safeId)}`);
434
+ }
435
+ ac = { ...ac, id: safeId };
405
436
  if (this.agents.has(ac.id))
406
437
  throw new Error(`agent id already exists: ${ac.id}`);
407
438
  // provider resolution: inline overrides > named provider > legacy llm block
@@ -1246,9 +1277,30 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1246
1277
  // remove from map so a re-spawn after restart doesn't carry state
1247
1278
  this.agents.delete(id);
1248
1279
  });
1249
- const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("agent stop timeout")), timeoutMs));
1250
- await Promise.race([Promise.all(stopPromises), timeout]);
1251
- 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
+ }
1252
1304
  }
1253
1305
  /** Restart the server process (spawns a new master, keeps this one alive until ready). */
1254
1306
  async restartServer() {