teapot-coding-agent 0.28.2 → 0.29.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.
Files changed (171) hide show
  1. package/dist/agent/agent.js +414 -152
  2. package/dist/agent/skills.js +29 -15
  3. package/dist/agent/tools.js +22 -1
  4. package/dist/config-schema.js +2 -1
  5. package/dist/master.js +37 -7
  6. package/dist/scheduler/cron.js +5 -3
  7. package/dist/server/api.js +41 -20
  8. package/dist/server/term-size.js +13 -1
  9. package/package.json +14 -14
  10. package/public/assets/{astro-JHDYjn39.js → astro-BZ97kZFP.js} +2 -2
  11. package/public/assets/astro-BZ97kZFP.js.map +1 -0
  12. package/public/assets/bat-qdxAdBWQ.js.map +1 -1
  13. package/public/assets/c-Cz4nF6n_.js +1 -0
  14. package/public/assets/c-SSSlmE39.js.map +1 -1
  15. package/public/assets/clojure-DqKBuwfJ.js.map +1 -1
  16. package/public/assets/cmake-Bj61d0ZC.js.map +1 -1
  17. package/public/assets/cpp-B6pWdJV2.js +1 -0
  18. package/public/assets/cpp-ZGiJKGk8.js.map +1 -1
  19. package/public/assets/csharp-dmMxLVLW.js +2 -0
  20. package/public/assets/csharp-dmMxLVLW.js.map +1 -0
  21. package/public/assets/css-BGIxQoYs.js +2 -0
  22. package/public/assets/css-BGIxQoYs.js.map +1 -0
  23. package/public/assets/css-DgaLhXQH.js +1 -0
  24. package/public/assets/dart-CHZY9Xhz.js +2 -0
  25. package/public/assets/dart-CHZY9Xhz.js.map +1 -0
  26. package/public/assets/diff-woXpYk--.js.map +1 -1
  27. package/public/assets/dockerfile-IyjqRm3v.js.map +1 -1
  28. package/public/assets/{elixir-IXeIM30B.js → elixir-raGDl1oO.js} +2 -2
  29. package/public/assets/elixir-raGDl1oO.js.map +1 -0
  30. package/public/assets/erlang-D0CaQ5Pl.js.map +1 -1
  31. package/public/assets/fish-BJitypiv.js.map +1 -1
  32. package/public/assets/fsharp-PZeu-6rm.js.map +1 -1
  33. package/public/assets/github-dark-C-LZuMrd.js.map +1 -1
  34. package/public/assets/github-light-EUqPIrTm.js.map +1 -1
  35. package/public/assets/glsl-CaPTrB3a.js.map +1 -1
  36. package/public/assets/graphql-BZEhxNwM.js +1 -0
  37. package/public/assets/graphql-DEIChqxY.js.map +1 -1
  38. package/public/assets/groovy-CacY0gHj.js.map +1 -1
  39. package/public/assets/haskell-D8IpX4py.js.map +1 -1
  40. package/public/assets/hcl-Dh228itO.js.map +1 -1
  41. package/public/assets/html-BCy9P6D2.js +1 -0
  42. package/public/assets/{html-B2cKP6UN.js → html-DSw1lh5G.js} +2 -2
  43. package/public/assets/html-DSw1lh5G.js.map +1 -0
  44. package/public/assets/index-Bnx0qjHQ.js +30 -0
  45. package/public/assets/index-Bnx0qjHQ.js.map +1 -0
  46. package/public/assets/ini-B5eOa1yu.js.map +1 -1
  47. package/public/assets/java-BtIQNnN0.js.map +1 -1
  48. package/public/assets/java-_ATy9zCa.js +1 -0
  49. package/public/assets/javascript-BzbQS41l.js.map +1 -1
  50. package/public/assets/javascript-CpIGwZ_Y.js +1 -0
  51. package/public/assets/json-B88Oqtu5.js.map +1 -1
  52. package/public/assets/json-C3WlssX3.js +1 -0
  53. package/public/assets/json5-BR5RXkoi.js.map +1 -1
  54. package/public/assets/jsonc-CYpm1nAK.js.map +1 -1
  55. package/public/assets/jsx-C4yDKi0A.js +1 -0
  56. package/public/assets/jsx-D3KKoC5L.js.map +1 -1
  57. package/public/assets/{julia-BC1A_CJ4.js → julia-DCfl8sf8.js} +2 -2
  58. package/public/assets/julia-DCfl8sf8.js.map +1 -0
  59. package/public/assets/{justfile-Ca-8NFG9.js → justfile-CKP9W7x6.js} +2 -2
  60. package/public/assets/justfile-CKP9W7x6.js.map +1 -0
  61. package/public/assets/kotlin-DhhofPvG.js.map +1 -1
  62. package/public/assets/less-DVTAwKKz.js.map +1 -1
  63. package/public/assets/lua-DjuecEsk.js.map +1 -1
  64. package/public/assets/lua-fPRBWD-V.js +1 -0
  65. package/public/assets/makefile-Dixweg8N.js.map +1 -1
  66. package/public/assets/markdown-CboqBxpY.js.map +1 -1
  67. package/public/assets/markdown-CjQmuOi1.js +1 -0
  68. package/public/assets/mdx-DQZ5AkYe.js.map +1 -1
  69. package/public/assets/{nim-AzTgDUNM.js → nim-VAaFSKwL.js} +2 -2
  70. package/public/assets/nim-VAaFSKwL.js.map +1 -0
  71. package/public/assets/objective-c-D1A_Heim.js.map +1 -1
  72. package/public/assets/perl-BTdIRyOQ.js +1 -0
  73. package/public/assets/{perl-DYq_hxXC.js → perl-XQDPZEjP.js} +2 -2
  74. package/public/assets/perl-XQDPZEjP.js.map +1 -0
  75. package/public/assets/php-DPoYWQt_.js +2 -0
  76. package/public/assets/php-DPoYWQt_.js.map +1 -0
  77. package/public/assets/postcss-D_Wz94rJ.js.map +1 -1
  78. package/public/assets/postcss-DkKB82OS.js +1 -0
  79. package/public/assets/{powershell-CCSX75yM.js → powershell-QtSnmXcu.js} +2 -2
  80. package/public/assets/powershell-QtSnmXcu.js.map +1 -0
  81. package/public/assets/prisma-DMgNhnYS.js.map +1 -1
  82. package/public/assets/python-C5TF47hQ.js +1 -0
  83. package/public/assets/python-CzFU0dpx.js.map +1 -1
  84. package/public/assets/r-BXEss897.js +1 -0
  85. package/public/assets/r-DWzjseEo.js.map +1 -1
  86. package/public/assets/ruby-BdDtUACQ.js +1 -0
  87. package/public/assets/{ruby-BeaszOMm.js → ruby-DV2UBOs2.js} +2 -2
  88. package/public/assets/ruby-DV2UBOs2.js.map +1 -0
  89. package/public/assets/rust-DpiIf5JO.js +2 -0
  90. package/public/assets/rust-DpiIf5JO.js.map +1 -0
  91. package/public/assets/sass-DXrisJhu.js.map +1 -1
  92. package/public/assets/scala-BiFt_7mu.js.map +1 -1
  93. package/public/assets/{scss-DHtrkMkT.js → scss-8bKmll0B.js} +2 -2
  94. package/public/assets/scss-8bKmll0B.js.map +1 -0
  95. package/public/assets/shellscript-CVTB3cr6.js +1 -0
  96. package/public/assets/shellscript-CqHdAlCp.js.map +1 -1
  97. package/public/assets/shiki-B4EfnMLX.js +156 -0
  98. package/public/assets/shiki-B4EfnMLX.js.map +1 -0
  99. package/public/assets/solidity-Bj7E1mRn.js.map +1 -1
  100. package/public/assets/sql-CNu3Or2W.js +2 -0
  101. package/public/assets/sql-CNu3Or2W.js.map +1 -0
  102. package/public/assets/sql-CfM2IJm3.js +1 -0
  103. package/public/assets/stylus-B6D30XZt.js.map +1 -1
  104. package/public/assets/{svelte-tEuLYt3E.js → svelte-CdeT-Ich.js} +2 -2
  105. package/public/assets/svelte-CdeT-Ich.js.map +1 -0
  106. package/public/assets/swift-CyEgAFGc.js.map +1 -1
  107. package/public/assets/terraform-DswuEJGm.js.map +1 -1
  108. package/public/assets/toml-CcmNWLt0.js.map +1 -1
  109. package/public/assets/tsx-BOvsFOI6.js.map +1 -1
  110. package/public/assets/tsx-CGosfAUg.js +1 -0
  111. package/public/assets/typescript-Bb1J8qyy.js +1 -0
  112. package/public/assets/typescript-j_1H8WHN.js.map +1 -1
  113. package/public/assets/viml-DvXPmvsu.js.map +1 -1
  114. package/public/assets/{vue-Bg0byEaI.js → vue-BqdMUtqy.js} +2 -2
  115. package/public/assets/vue-BqdMUtqy.js.map +1 -0
  116. package/public/assets/xml-DuKfbPI6.js +1 -0
  117. package/public/assets/xml-WY0zc4PG.js.map +1 -1
  118. package/public/assets/yaml-DDA_XMNu.js +1 -0
  119. package/public/assets/yaml-DijMHRDA.js.map +1 -1
  120. package/public/assets/zig-CMLA9XwU.js.map +1 -1
  121. package/public/index.html +1 -1
  122. package/public/assets/astro-JHDYjn39.js.map +0 -1
  123. package/public/assets/c-DWGmR78Y.js +0 -1
  124. package/public/assets/cpp-D0jV2xBx.js +0 -1
  125. package/public/assets/csharp-oqKa8noW.js +0 -2
  126. package/public/assets/csharp-oqKa8noW.js.map +0 -1
  127. package/public/assets/css-CdtkT07m.js +0 -1
  128. package/public/assets/css-LbU1hoFO.js +0 -2
  129. package/public/assets/css-LbU1hoFO.js.map +0 -1
  130. package/public/assets/dart-CnvKMtbv.js +0 -2
  131. package/public/assets/dart-CnvKMtbv.js.map +0 -1
  132. package/public/assets/elixir-IXeIM30B.js.map +0 -1
  133. package/public/assets/graphql-DAzSVzvB.js +0 -1
  134. package/public/assets/html-B2cKP6UN.js.map +0 -1
  135. package/public/assets/html-DYtkB_T1.js +0 -1
  136. package/public/assets/index-j-byVujL.js +0 -30
  137. package/public/assets/index-j-byVujL.js.map +0 -1
  138. package/public/assets/java-DxlWrXwe.js +0 -1
  139. package/public/assets/javascript-Yelw-5ZN.js +0 -1
  140. package/public/assets/json-CaljABEy.js +0 -1
  141. package/public/assets/jsx-hEbJ82HF.js +0 -1
  142. package/public/assets/julia-BC1A_CJ4.js.map +0 -1
  143. package/public/assets/justfile-Ca-8NFG9.js.map +0 -1
  144. package/public/assets/lua-BJObVtAx.js +0 -1
  145. package/public/assets/markdown-Cla8Gbs6.js +0 -1
  146. package/public/assets/nim-AzTgDUNM.js.map +0 -1
  147. package/public/assets/perl-CCMzbIRV.js +0 -1
  148. package/public/assets/perl-DYq_hxXC.js.map +0 -1
  149. package/public/assets/php-Dl--GmOR.js +0 -2
  150. package/public/assets/php-Dl--GmOR.js.map +0 -1
  151. package/public/assets/postcss-e3XvZOFQ.js +0 -1
  152. package/public/assets/powershell-CCSX75yM.js.map +0 -1
  153. package/public/assets/python-ORI17Sev.js +0 -1
  154. package/public/assets/r-Dr1JUpHl.js +0 -1
  155. package/public/assets/ruby-BeaszOMm.js.map +0 -1
  156. package/public/assets/ruby-XbVsWxgo.js +0 -1
  157. package/public/assets/rust-Cfkwpbl8.js +0 -2
  158. package/public/assets/rust-Cfkwpbl8.js.map +0 -1
  159. package/public/assets/scss-DHtrkMkT.js.map +0 -1
  160. package/public/assets/shellscript-BiGZg9Om.js +0 -1
  161. package/public/assets/shiki-DCpmqeFE.js +0 -156
  162. package/public/assets/shiki-DCpmqeFE.js.map +0 -1
  163. package/public/assets/sql-0Qcw9ht3.js +0 -1
  164. package/public/assets/sql-CP0rg-5b.js +0 -2
  165. package/public/assets/sql-CP0rg-5b.js.map +0 -1
  166. package/public/assets/svelte-tEuLYt3E.js.map +0 -1
  167. package/public/assets/tsx-5f3ulCja.js +0 -1
  168. package/public/assets/typescript-Utq2Cl8c.js +0 -1
  169. package/public/assets/vue-Bg0byEaI.js.map +0 -1
  170. package/public/assets/xml-CoDIzWF0.js +0 -1
  171. package/public/assets/yaml-CxbubiML.js +0 -1
@@ -222,6 +222,43 @@ export class Agent {
222
222
  * round that produced neither a tool call nor a finish increments it.
223
223
  */
224
224
  consecutiveIdleRounds = 0;
225
+ /**
226
+ * Consecutive completion audits that REJECTED, #128.
227
+ *
228
+ * Measured before this existed: a model that finishes, is rejected, finishes
229
+ * again, is rejected again… ran 381 audit calls in one turn. Nothing bounded it:
230
+ *
231
+ * - `maxConsecutiveIdleRounds` counts IDLE rounds, and a rejected audit is not
232
+ * idle — the agent is actively working
233
+ * - `maxTurnsPerRound` only LOGS `round-turn-cap`; it does not break, and it is
234
+ * evaluated per round, while a reject never ends the round
235
+ *
236
+ * So the agent can spend unbounded provider spend re-finishing. When the cap is
237
+ * hit the goal is marked as needing attention rather than left looping: silently
238
+ * marking it done would be #128's fail-open problem all over again, and silently
239
+ * stopping would strand the operator with no signal.
240
+ */
241
+ consecutiveAuditRejects = 0;
242
+ /**
243
+ * #128: consecutive completion audits that produced NO verdict (empty /
244
+ * prose / provider throw). Fail-CLOSED — the goal stays active and the
245
+ * worker self-verifies again — but bounded: after 3 the retrying stops with
246
+ * a loud note (the same philosophy as the reject bound above), so a
247
+ * permanently broken auditor cannot trap the worker either. Reset by any
248
+ * real verdict and by setGoal.
249
+ */
250
+ auditUnavailableStreak = 0;
251
+ /**
252
+ * #91/#94: audit lineage for the FINAL event. `auditAttempts` counts every
253
+ * audit run (incremented at audit-started); `lastRejectedAudit` remembers
254
+ * the most recent CHANGES-REQUIRED verdict so the approving final can carry
255
+ * what the first attempt was told; `lastAuditOutcome` is the metadata the
256
+ * NEXT final event spreads in (then cleared — a later, unaudited finish
257
+ * must not inherit it).
258
+ */
259
+ auditAttempts = 0;
260
+ lastRejectedAudit = null;
261
+ lastAuditOutcome = null;
225
262
  constructor(opts) {
226
263
  this.opts = {
227
264
  progressIntervalMs: 10 * 60_000,
@@ -236,6 +273,7 @@ export class Agent {
236
273
  supportedParameters: [],
237
274
  maxConsecutiveToolErrors: 5,
238
275
  maxTurnsPerRound: 200,
276
+ maxAuditRejects: 3, // #128: bound the reject loop
239
277
  // generous: only a model that never acts and never finishes reaches it
240
278
  maxConsecutiveIdleRounds: 50,
241
279
  onError: "stop",
@@ -283,6 +321,8 @@ export class Agent {
283
321
  readOnly: this.opts.readOnlyTools,
284
322
  onIdlePark: (reason) => this.parkForTool(reason),
285
323
  onIdleUnpark: () => this.unparkFromTool(),
324
+ // #140: the park must not strand an already-queued prompt
325
+ hasPendingPrompts: () => this.pendingPrompts.length > 0,
286
326
  onBackgroundExit: (info) => void this.onBackgroundJobExit(info),
287
327
  onFileRead: (p) => this.trackFileRead(p),
288
328
  };
@@ -482,6 +522,28 @@ export class Agent {
482
522
  return (this.status === "running" ||
483
523
  this.parkedByTool ||
484
524
  this.pendingPrompts.length > 0 ||
525
+ this.compactPhase !== "" ||
526
+ // ask_user: the loop is parked on the operator's answer — stoppable work
527
+ // in flight, not idleness. Without this the UI offered "start", and
528
+ // pressing it cleared awaitingUser, silently discarding the open question
529
+ // while master.waitChildren's own active() kept counting the same child
530
+ // as working (three definitions of "alive" disagreeing about one state).
531
+ this.awaitingUser);
532
+ }
533
+ /**
534
+ * Is this agent doing in-flight work that a WAITER (wait_children) should
535
+ * hold for? Deliberately narrower than isLive(): queued prompts are DORMANT
536
+ * work (a stopped/idle child touches them only after it is started), so
537
+ * they must not keep a parent parked on a child that is visibly idle —
538
+ * that wait would hang. isLive() answers "stop vs start" (#59/#133); this
539
+ * answers "is work in flight".
540
+ */
541
+ isBusy() {
542
+ if (this.stopRequested)
543
+ return false;
544
+ return (this.status === "running" ||
545
+ this.status === "waiting" ||
546
+ this.parkedByTool ||
485
547
  this.compactPhase !== "");
486
548
  }
487
549
  /**
@@ -512,13 +574,21 @@ export class Agent {
512
574
  this.onBackgroundJobExitQueued(info);
513
575
  }
514
576
  parkForTool(reason) {
515
- if (this.parkedByTool || this.status !== "running")
516
- return;
577
+ if (this.parkedByTool)
578
+ return true; // already parked: the tool may proceed
579
+ if (this.status !== "running")
580
+ return false;
581
+ // #140: a prompt queued BEFORE the park would be stranded by it — the
582
+ // enqueue-time wake only handles prompts arriving DURING the park. Refuse,
583
+ // so the tool stands down and the next turn boundary consumes the queue.
584
+ if (this.pendingPrompts.length > 0)
585
+ return false;
517
586
  this.parkedByTool = true;
518
587
  this.preParkedStatus = { status: this.status, reason: this.statusReason };
519
588
  this.status = "idle";
520
589
  this.statusReason = `${reason} (idle — send a message to take over; resumes automatically when a sub-agent settles)`;
521
590
  bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
591
+ return true;
522
592
  }
523
593
  unparkFromTool() {
524
594
  if (!this.parkedByTool)
@@ -750,15 +820,10 @@ export class Agent {
750
820
  this.stats.inputTokens += u.inputTokens ?? 0;
751
821
  this.stats.outputTokens += u.outputTokens ?? 0;
752
822
  this.stats.cachedInputTokens += u.cachedInputTokens ?? 0;
753
- if (this.modelPricing) {
754
- const p = this.modelPricing;
755
- const inTok = u.inputTokens ?? 0;
756
- const cached = Math.min(u.cachedInputTokens ?? 0, inTok);
757
- // same blended-cache model as the live path (cached ≈ 10% of prompt)
758
- this.stats.costUsd =
759
- (this.stats.costUsd ?? 0) +
760
- ((inTok - cached) * p.prompt + (u.outputTokens ?? 0) * p.completion + cached * p.prompt * 0.1);
761
- }
823
+ // NO cost accumulation here: setModelPricing() rebuilds costUsd from
824
+ // this same log with a REPLACE — whichever runs first, adding on top
825
+ // of a rebuilt number counted every usage event twice (a restart plus
826
+ // the first interaction showed ~2x). The recompute is authoritative.
762
827
  }
763
828
  else if (e.type === "system_note") {
764
829
  const d = e.data;
@@ -989,6 +1054,14 @@ export class Agent {
989
1054
  }
990
1055
  async setGoal(text) {
991
1056
  this.goal = { text, status: "active", updatedAt: new Date().toISOString() };
1057
+ // a new goal is a new audit contract: the previous goal's rejection must
1058
+ // not ride along as the new goal's "previous" lineage, and the attempt
1059
+ // counters restart from zero (theirs counts rejects of THIS goal)
1060
+ this.consecutiveAuditRejects = 0;
1061
+ this.auditUnavailableStreak = 0;
1062
+ this.lastRejectedAudit = null;
1063
+ this.lastAuditOutcome = null;
1064
+ this.auditAttempts = 0;
992
1065
  await this.writeGoalFile();
993
1066
  await this.log.append("goal", this.currentSession, this.currentBranch, { event: "set", text });
994
1067
  }
@@ -1515,6 +1588,30 @@ export class Agent {
1515
1588
  if (this.stopRequested)
1516
1589
  break;
1517
1590
  // fresh user input arrived while we were finishing up — another round now
1591
+ // #128: bound consecutive audit rejections.
1592
+ //
1593
+ // A rejected audit queues a retry prompt, so the round CONTINUES — and
1594
+ // neither existing valve applies: maxConsecutiveIdleRounds counts IDLE
1595
+ // rounds (a rejection is not idle), and maxTurnsPerRound only logs. Measured
1596
+ // before this cap: 381 audit calls in a single turn.
1597
+ //
1598
+ // Deliberately NOT "mark the goal done": #128's whole complaint is that
1599
+ // "could not be audited" silently became "passed". The goal is left ACTIVE
1600
+ // and a loud note is logged, so the operator sees the loop and decides.
1601
+ // `>=` (not `>`): the JSDoc says REACHING 3 stops — `>` allowed a 4th
1602
+ // audit before breaking (off-by-one). The retry prompt is no longer
1603
+ // queued AT the cap (see handleFinish), so the break strands nothing.
1604
+ // The counter resets so ONE capped stretch doesn't brick every future
1605
+ // round-end until setGoal — a fixed model gets a fresh count.
1606
+ if (this.consecutiveAuditRejects >= (this.opts.maxAuditRejects ?? 3)) {
1607
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
1608
+ event: "audit-retry-cap",
1609
+ rejects: this.consecutiveAuditRejects,
1610
+ detail: "the completion audit rejected the same goal repeatedly ? stopping to avoid an unbounded retry loop (#128)",
1611
+ });
1612
+ this.consecutiveAuditRejects = 0;
1613
+ break;
1614
+ }
1518
1615
  if (this.pendingPrompts.length)
1519
1616
  continue;
1520
1617
  // A round that parked its work on a background bash (dev server,
@@ -1649,6 +1746,10 @@ export class Agent {
1649
1746
  await this.sleepInterruptible(wait);
1650
1747
  if (this.stopRequested)
1651
1748
  break;
1749
+ // the NEXT round is live work again — leaving "error" set made
1750
+ // isLive() false (error is not a live status) while the loop was
1751
+ // actually running, so the UI offered "start" on a working agent
1752
+ this.setStatus("running", "retrying");
1652
1753
  continue; // fresh round; the loop re-arms the tool signal itself
1653
1754
  }
1654
1755
  this.setStatus("error", msg.slice(0, 300));
@@ -1680,6 +1781,8 @@ export class Agent {
1680
1781
  bus.emit("update", {
1681
1782
  kind: "llm-delta",
1682
1783
  agentId: this.opts.id,
1784
+ // #146: the bubble is session-scoped, so the stream must say which session
1785
+ sessionId: this.currentSession,
1683
1786
  text: "",
1684
1787
  reasoning: "",
1685
1788
  });
@@ -1773,6 +1876,7 @@ export class Agent {
1773
1876
  bus.emit("update", {
1774
1877
  kind: "llm-delta",
1775
1878
  agentId: this.opts.id,
1879
+ sessionId: this.currentSession, // #146
1776
1880
  text: s.text,
1777
1881
  reasoning: s.reasoning,
1778
1882
  });
@@ -1922,6 +2026,24 @@ export class Agent {
1922
2026
  }
1923
2027
  if (call.function.name === "finish") {
1924
2028
  this.pendingSubReport = ""; // the real summary supersedes it
2029
+ // #128: answer the tool_call BEFORE the audit runs.
2030
+ //
2031
+ // handleFinish() calls the auditor with `buildMessages()`, and at that
2032
+ // moment the history ended at an assistant message carrying
2033
+ // `tool_calls: [finish]` with NO matching `tool` message. Measured, the
2034
+ // auditor received exactly that:
2035
+ //
2036
+ // assistant(tool_calls=[finish])
2037
+ // user(audit prompt) <-- no tool(tool_call_id=…)
2038
+ //
2039
+ // OpenAI-compatible providers require every tool_call to be answered,
2040
+ // and a strict one rejects the request outright — so a CHANGES-REQUIRED
2041
+ // audit would fail with a protocol error rather than a verdict, and
2042
+ // #128's fail-open would then mark the goal done.
2043
+ //
2044
+ // Answering first also makes the log honest: the call really was
2045
+ // completed, it simply did not end the round.
2046
+ await this.answerMeta(call, "(completion submitted for audit)");
1925
2047
  await this.handleFinish(call.function.arguments);
1926
2048
  // surface still-running children so the operator knows work may
1927
2049
  // continue after this agent goes idle
@@ -2475,6 +2597,8 @@ export class Agent {
2475
2597
  event: "audit-started",
2476
2598
  verify: this.goal.verify.slice(0, 2000),
2477
2599
  });
2600
+ // #94: number the attempts — the final carries the attempt that approved it
2601
+ this.auditAttempts++;
2478
2602
  try {
2479
2603
  const auditPrompt = `You are an independent completion AUDITOR (not the worker). Decide whether the goal is genuinely complete.\n\n` +
2480
2604
  `GOAL:\n${this.goal.text.slice(0, 4000)}\n\nVERIFICATION CONTRACT (must ALL hold):\n${this.goal.verify.slice(0, 2000)}\n\n` +
@@ -2509,13 +2633,33 @@ export class Agent {
2509
2633
  const usable = verdictLine.length > 0 && !PLACEHOLDER.test(verdictLine);
2510
2634
  const decided = usable && /^(APPROVED|CHANGES-REQUIRED)\b/i.test(verdictLine);
2511
2635
  if (!decided) {
2636
+ // #128: an unusable reply is NOT an approval. The old path failed
2637
+ // OPEN — recorded the failure and marked the goal done anyway —
2638
+ // so a verification contract was "satisfied" that nobody verified.
2639
+ // Fail CLOSED instead: the goal stays active and the worker is
2640
+ // asked to self-verify, with a bounded streak (see the field).
2641
+ this.auditUnavailableStreak++;
2512
2642
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
2513
2643
  event: "audit-failed",
2514
2644
  detail: usable
2515
2645
  ? `auditor reply had no verdict: ${verdictLine.slice(0, 200)}`
2516
2646
  : "auditor returned no verdict",
2647
+ attempt: this.auditUnavailableStreak,
2517
2648
  });
2518
- await this.setGoalStatus("done");
2649
+ if (this.auditUnavailableStreak < 3) {
2650
+ this.queueAuditUnavailableRetry();
2651
+ await this.captureRecentTurns();
2652
+ return;
2653
+ }
2654
+ // bounded fallback, same philosophy as the reject bound in the
2655
+ // loop: the goal is left ACTIVE and a loud note stops the loop —
2656
+ // the operator sees it and decides. Never "honored as done".
2657
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
2658
+ event: "audit-unavailable-cap",
2659
+ attempts: this.auditUnavailableStreak,
2660
+ detail: "the completion audit could not produce a verdict repeatedly — stopping so the operator decides (#128)",
2661
+ });
2662
+ await this.captureRecentTurns();
2519
2663
  return;
2520
2664
  }
2521
2665
  const approved = /^APPROVED\b/i.test(verdictLine);
@@ -2526,6 +2670,29 @@ export class Agent {
2526
2670
  // the honest value: the auditor returned a verdict and no reason,
2527
2671
  // which every consumer can already render as "label only".
2528
2672
  await this.setGoalAudit(approved ? "approved" : "changes-required", feedback);
2673
+ // a real verdict lands: the no-verdict streak restarts either way —
2674
+ // the streak's own JSDoc promises "reset by any real verdict", and
2675
+ // without this a flaky auditor (2 unusable, then a real verdict,
2676
+ // then 1 unusable) hit the cap far too early
2677
+ this.auditUnavailableStreak = 0;
2678
+ if (approved) {
2679
+ this.consecutiveAuditRejects = 0;
2680
+ // #91/#94: the final will carry WHAT was approved, on WHICH attempt,
2681
+ // and what the previous attempt was told (if it was rejected first)
2682
+ this.lastAuditOutcome = {
2683
+ audited: true,
2684
+ verdict: "approved",
2685
+ attempt: this.auditAttempts,
2686
+ ...(this.lastRejectedAudit
2687
+ ? { previous: { verdict: "changes-required", feedback: this.lastRejectedAudit.feedback } }
2688
+ : {}),
2689
+ };
2690
+ }
2691
+ else {
2692
+ // #94: the rejection survives in memory so the eventual approved
2693
+ // final can name what the first attempt was told
2694
+ this.lastRejectedAudit = { feedback, attempt: this.auditAttempts };
2695
+ }
2529
2696
  // `operatorFacing: true` — the verdict is written for the TIMELINE
2530
2697
  // only. The audit ran in its own side conversation (a tools-less call
2531
2698
  // over buildMessages() + an audit prompt), so it was never in
@@ -2541,6 +2708,10 @@ export class Agent {
2541
2708
  : `🔍 completion audit: CHANGES REQUIRED — ${feedback}`,
2542
2709
  });
2543
2710
  if (!approved) {
2711
+ this.consecutiveAuditRejects++;
2712
+ // #94: the rejection survives in memory so the eventual approved
2713
+ // final can name what the first attempt was told
2714
+ this.lastRejectedAudit = { feedback, attempt: this.auditAttempts };
2544
2715
  // hand the gaps back to the worker as its next instruction.
2545
2716
  // #51: with the "(no detail)" placeholder gone, an empty verdict
2546
2717
  // would leave this prompt saying "address these gaps" and then
@@ -2548,10 +2719,21 @@ export class Agent {
2548
2719
  const gaps = feedback.trim()
2549
2720
  ? feedback.slice(0, 2000)
2550
2721
  : `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)}`;
2551
- this.pendingPrompts.push({
2552
- source: "harness",
2553
- text: `[harness] The completion audit REJECTED this finish. Address these gaps, then finish again with goalComplete=true:\n\n${gaps}`,
2554
- });
2722
+ const retryText = `[harness] The completion audit REJECTED this finish. Address these gaps, then finish again with goalComplete=true:\n\n${gaps}`;
2723
+ // #128: counted above (consecutiveAuditRejects++ at the verdict) so
2724
+ // a model that cannot satisfy the auditor cannot loop forever — the
2725
+ // retry prompt is queued only while the bound is NOT yet reached,
2726
+ // so the loop's cap break strands nothing (a prompt queued AT the
2727
+ // cap would live on as an invisible no-id entry: no badge, no log
2728
+ // row, but isLive() true). Log the row too: a push-only prompt is
2729
+ // invisible on the timeline and lost to a restart otherwise.
2730
+ if (this.consecutiveAuditRejects < (this.opts.maxAuditRejects ?? 3)) {
2731
+ this.pendingPrompts.push({ source: "harness", text: retryText });
2732
+ await this.log.append("prompt", this.currentSession, this.currentBranch, {
2733
+ source: "harness",
2734
+ text: retryText,
2735
+ });
2736
+ }
2555
2737
  // #89: RETURN before the final message is written.
2556
2738
  //
2557
2739
  // Falling through emitted `final: true` while the goal was still
@@ -2568,25 +2750,78 @@ export class Agent {
2568
2750
  }
2569
2751
  }
2570
2752
  catch (err) {
2571
- // auditor unavailable → fail open (record it, honor the finish) so a
2572
- // flaky provider can't trap work in an unauditable loop
2753
+ // #128: an auditor that THROWS is not an approval either. Same
2754
+ // fail-closed treatment as a no-verdict reply: active goal, bounded
2755
+ // self-verify retries, then a loud stop for the operator.
2756
+ this.auditUnavailableStreak++;
2573
2757
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
2574
2758
  event: "audit-failed",
2575
2759
  detail: String(err.message).slice(0, 300),
2760
+ attempt: this.auditUnavailableStreak,
2576
2761
  });
2577
- await this.setGoalStatus("done");
2762
+ if (this.auditUnavailableStreak < 3) {
2763
+ this.queueAuditUnavailableRetry();
2764
+ await this.captureRecentTurns();
2765
+ return;
2766
+ }
2767
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
2768
+ event: "audit-unavailable-cap",
2769
+ attempts: this.auditUnavailableStreak,
2770
+ detail: "the completion audit could not produce a verdict repeatedly — stopping so the operator decides (#128)",
2771
+ });
2772
+ await this.captureRecentTurns();
2773
+ return;
2578
2774
  }
2579
2775
  }
2580
2776
  else {
2581
2777
  await this.setGoalStatus("done");
2582
2778
  }
2583
2779
  }
2780
+ await this.appendFinalMessage(args);
2781
+ }
2782
+ /**
2783
+ * #128: the retry prompt for an audit that could not produce a verdict.
2784
+ * Carries a real instruction (unlike a reason-less rejection — #90), so the
2785
+ * worker re-checks the contract itself instead of being told to fix nothing.
2786
+ */
2787
+ queueAuditUnavailableRetry() {
2788
+ const text = `[harness] The completion audit could not run (the auditor returned no verdict). ` +
2789
+ `Do NOT treat the work as verified: re-check the verification contract yourself, item by item, ` +
2790
+ `then finish with goalComplete=true again.\n\n` +
2791
+ `${this.goal.verify?.slice(0, 2000) || this.goal.text.slice(0, 2000)}`;
2792
+ this.pendingPrompts.push({ source: "harness", text });
2793
+ // log the row: a push-only prompt is invisible on the timeline and lost to
2794
+ // a restart (rebuildMessagesFrom replays prompt rows) — same as enqueuePrompt
2795
+ void this.log
2796
+ .append("prompt", this.currentSession, this.currentBranch, { source: "harness", text })
2797
+ .catch(() => { });
2798
+ }
2799
+ /** The `final: true` event — the parent's notification (#89: never before the goal settles). */
2800
+ async appendFinalMessage(args) {
2584
2801
  const recent = await this.captureRecentTurns();
2802
+ // #91/#94: the final says how it relates to the audit — approved on which
2803
+ // attempt, what the previous attempt was told. Cleared on emit: a later
2804
+ // unaudited finish must not inherit stale lineage.
2805
+ const outcome = this.lastAuditOutcome;
2806
+ this.lastAuditOutcome = null;
2585
2807
  await this.log.append("message", this.currentSession, this.currentBranch, {
2586
2808
  role: "assistant",
2587
2809
  final: true,
2588
2810
  content: String(args.summary ?? ""),
2589
2811
  ...(recent.length ? { recentContext: recent } : {}),
2812
+ ...(outcome
2813
+ ? {
2814
+ audited: outcome.audited,
2815
+ auditVerdict: outcome.verdict,
2816
+ auditAttempt: outcome.attempt,
2817
+ ...(outcome.previous
2818
+ ? {
2819
+ previousAuditVerdict: outcome.previous.verdict,
2820
+ previousAuditFeedback: outcome.previous.feedback.slice(0, 2000),
2821
+ }
2822
+ : {}),
2823
+ }
2824
+ : {}),
2590
2825
  });
2591
2826
  }
2592
2827
  async maybeRequestProgress() {
@@ -2698,6 +2933,12 @@ export class Agent {
2698
2933
  * warm across restarts.
2699
2934
  */
2700
2935
  async answerMeta(call, content) {
2936
+ // one tool_call gets ONE answer: the finish path answers before the audit
2937
+ // (#128) AND at round end — a second answerMeta for the same id would push
2938
+ // a duplicate `tool` message AND a duplicate log row, and strict
2939
+ // OpenAI-compatible providers reject two responses for one call with a 400.
2940
+ if (this.messages.some((m) => m.role === "tool" && m.tool_call_id === call.id))
2941
+ return;
2701
2942
  const raw = call.function.arguments ?? "{}";
2702
2943
  this.messages.push({ role: "tool", tool_call_id: call.id, content });
2703
2944
  // log the call AND its answer so restores replay byte-exact sequences
@@ -2815,157 +3056,178 @@ export class Agent {
2815
3056
  // progress is visible: announce the phase up front, then stream the
2816
3057
  // summarizer's output as it generates (a long summarize used to look like
2817
3058
  // the agent silently hung — hundreds of thousands of tokens take a while)
3059
+ // #127: the STATE must be established BEFORE the first notification — the
3060
+ // emit used to precede `compactPhase` by an async log write, so the first
3061
+ // progress event observed a snapshot with `compactPhase` still unset:
3062
+ // "idle, not compacting, but a summarizing banner" from three paths at
3063
+ // once. Sync set → emit → log keeps every path consistent.
3064
+ this.compactPhase = "summarizing";
2818
3065
  bus.emit("update", {
2819
3066
  kind: "compaction-progress",
2820
3067
  agentId: this.opts.id,
2821
3068
  phase: "summarizing",
2822
3069
  summarized: oldCount,
2823
3070
  });
2824
- // #56: the START belongs on the timeline too. Only the right panel showed
2825
- // compaction, and it reports the *outcome*; a long summarize (hundreds of
2826
- // thousands of tokens) looks exactly like a hung agent on the timeline,
2827
- // because the first `context-compacted` event is only appended once the
2828
- // whole pass is over. Log the announcement as a visible system_note so
2829
- // the operator sees WHERE the conversation was rewritten, even if the
2830
- // pass later fails — `before`/`oldCount` are known at this point, and the
2831
- // completion divider still carries the authoritative after-figures.
2832
- await this.log.append("system_note", this.currentSession, this.currentBranch, {
2833
- event: "context-compaction-started",
2834
- tokensBefore: before,
2835
- messages: oldCount,
2836
- reason: force ? "manual" : "auto",
2837
- });
2838
- this.compactPhase = "summarizing";
3071
+ // Everything below may throw — EventLog appends reject on stream errors,
3072
+ // and disk-full is exactly when they will. The phase MUST not survive a
3073
+ // failed compaction: isLive() keys off it, so a leaked "summarizing"
3074
+ // pinned live=true forever (stop offered, never worked).
2839
3075
  try {
2840
- summary = await this.summarize(old, (text) => {
2841
- bus.emit("update", {
2842
- kind: "llm-delta",
2843
- agentId: this.opts.id,
2844
- text: `[compact] ${text}`,
2845
- reasoning: "",
2846
- });
2847
- });
2848
- this.compactPhase = "harvesting";
2849
- bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "harvesting" });
2850
- if (summary)
2851
- await this.harvestLessons(summary); // durable knowledge → memory.md
2852
- }
2853
- catch (err) {
2854
- await this.log.append("error", this.currentSession, this.currentBranch, {
2855
- message: `compaction summarize failed, falling back to truncation: ${err.message}`,
3076
+ // #56: the START belongs on the timeline too. Only the right panel showed
3077
+ // compaction, and it reports the *outcome*; a long summarize (hundreds of
3078
+ // thousands of tokens) looks exactly like a hung agent on the timeline,
3079
+ // because the first `context-compacted` event is only appended once the
3080
+ // whole pass is over. Log the announcement as a visible system_note so
3081
+ // the operator sees WHERE the conversation was rewritten, even if the
3082
+ // pass later fails — `before`/`oldCount` are known at this point, and the
3083
+ // completion divider still carries the authoritative after-figures.
3084
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
3085
+ event: "context-compaction-started",
3086
+ tokensBefore: before,
3087
+ messages: oldCount,
3088
+ reason: force ? "manual" : "auto",
2856
3089
  });
2857
- }
2858
- if (!summary) {
2859
- // fallback: drop the oldest half without LLM help so work can continue
2860
- mode = "truncate";
2861
- summarizedCount = 0;
2862
- // Cut at a safe boundary, but never at index 0 — that would drop the
2863
- // ENTIRE history (including the prompt just delivered) and leave the agent
2864
- // with nothing. On a short history floor(oldCount/2) can round to 0, and
2865
- // the old code then bailed out silently: compaction "ran", reported
2866
- // nothing, and the context stayed over budget, so the very next provider
2867
- // call overflowed — the "unstable after compact" report (#8). Always drop
2868
- // at least one message, and say so.
2869
- const halfCut = this.safeCut(Math.max(1, Math.floor(oldCount / 2)));
2870
- if (halfCut <= 0) {
2871
- // nothing in the prefix is safe to drop (e.g. it is all tool results).
2872
- // Report it instead of pretending compaction happened.
2873
- await this.log.append("system_note", this.currentSession, this.currentBranch, {
2874
- event: "context-compaction-skipped",
2875
- reason: "summarize failed and no safe prefix to truncate",
3090
+ try {
3091
+ summary = await this.summarize(old, (text) => {
3092
+ bus.emit("update", {
3093
+ kind: "llm-delta",
3094
+ agentId: this.opts.id,
3095
+ // #146: the bubble is session-scoped, so the stream must say which session
3096
+ sessionId: this.currentSession,
3097
+ text: `[compact] ${text}`,
3098
+ reasoning: "",
3099
+ });
2876
3100
  });
2877
- this.compactPhase = "";
2878
- bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "done" });
2879
- return;
3101
+ this.compactPhase = "harvesting";
3102
+ bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "harvesting" });
3103
+ if (summary)
3104
+ await this.harvestLessons(summary); // durable knowledge → memory.md
2880
3105
  }
2881
- droppedCount = halfCut;
2882
- this.messages = this.messages.slice(halfCut);
2883
- }
2884
- else {
2885
- this.messages = [
2886
- {
2887
- role: "user",
2888
- content: `[harness] Context was compacted: ${oldCount} earlier messages were summarized. ` +
2889
- "Goal and notes are managed by the harness (AGENTS.md / memory.md are injected into your prompt when present).\n\n" +
2890
- `## Summary of earlier conversation\n${summary}`,
2891
- },
2892
- ...this.messages.slice(cut),
2893
- ];
2894
- }
2895
- const after = this.estimateTokens();
2896
- this.stats.compactions++;
2897
- this.compactedAtLen = this.messages.length;
2898
- // Post-compact file restore (Claude Code style): re-attach the most
2899
- // recently read files so the model doesn't burn its first turns re-reading
2900
- // exactly what it had in context a moment ago. Budget: 3 files × 400 lines.
2901
- if (this.recentReads.length && mode === "summarize") {
2902
- const restored = [];
2903
- for (const rel of this.recentReads.slice(0, 3)) {
2904
- try {
2905
- const abs = safeJoin(this.opts.workspace, rel);
2906
- const text = await fs.readFile(abs, "utf8");
2907
- const head = text.split("\n").slice(0, 400).join("\n");
2908
- restored.push(`--- ${rel}${text.length > head.length ? " (first 400 lines)" : ""} ---\n${head}`);
3106
+ catch (err) {
3107
+ await this.log.append("error", this.currentSession, this.currentBranch, {
3108
+ message: `compaction summarize failed, falling back to truncation: ${err.message}`,
3109
+ });
3110
+ }
3111
+ if (!summary) {
3112
+ // fallback: drop the oldest half without LLM help so work can continue
3113
+ mode = "truncate";
3114
+ summarizedCount = 0;
3115
+ // Cut at a safe boundary, but never at index 0 — that would drop the
3116
+ // ENTIRE history (including the prompt just delivered) and leave the agent
3117
+ // with nothing. On a short history floor(oldCount/2) can round to 0, and
3118
+ // the old code then bailed out silently: compaction "ran", reported
3119
+ // nothing, and the context stayed over budget, so the very next provider
3120
+ // call overflowed — the "unstable after compact" report (#8). Always drop
3121
+ // at least one message, and say so.
3122
+ const halfCut = this.safeCut(Math.max(1, Math.floor(oldCount / 2)));
3123
+ if (halfCut <= 0) {
3124
+ // nothing in the prefix is safe to drop (e.g. it is all tool results).
3125
+ // Report it instead of pretending compaction happened.
3126
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
3127
+ event: "context-compaction-skipped",
3128
+ reason: "summarize failed and no safe prefix to truncate",
3129
+ });
3130
+ return; // the finally clears the phase and tells the UI it ended
2909
3131
  }
2910
- catch {
2911
- /* file vanished since the read — skip */
3132
+ droppedCount = halfCut;
3133
+ this.messages = this.messages.slice(halfCut);
3134
+ }
3135
+ else {
3136
+ this.messages = [
3137
+ {
3138
+ role: "user",
3139
+ content: `[harness] Context was compacted: ${oldCount} earlier messages were summarized. ` +
3140
+ "Goal and notes are managed by the harness (AGENTS.md / memory.md are injected into your prompt when present).\n\n" +
3141
+ `## Summary of earlier conversation\n${summary}`,
3142
+ },
3143
+ ...this.messages.slice(cut),
3144
+ ];
3145
+ }
3146
+ const after = this.estimateTokens();
3147
+ this.stats.compactions++;
3148
+ this.compactedAtLen = this.messages.length;
3149
+ // Post-compact file restore (Claude Code style): re-attach the most
3150
+ // recently read files so the model doesn't burn its first turns re-reading
3151
+ // exactly what it had in context a moment ago. Budget: 3 files × 400 lines.
3152
+ if (this.recentReads.length && mode === "summarize") {
3153
+ const restored = [];
3154
+ const restoredPaths = new Set();
3155
+ for (const rel of this.recentReads.slice(0, 3)) {
3156
+ try {
3157
+ const abs = safeJoin(this.opts.workspace, rel);
3158
+ const text = await fs.readFile(abs, "utf8");
3159
+ const head = text.split("\n").slice(0, 400).join("\n");
3160
+ restored.push(`--- ${rel}${text.length > head.length ? " (first 400 lines)" : ""} ---\n${head}`);
3161
+ restoredPaths.add(rel);
3162
+ }
3163
+ catch {
3164
+ /* file vanished since the read — skip */
3165
+ }
3166
+ }
3167
+ if (restored.length) {
3168
+ this.messages.splice(1, 0, {
3169
+ role: "user",
3170
+ content: `[harness] These files were recently read and are re-attached for continuity ` +
3171
+ `(they may have changed — re-read if precision matters):\n\n${restored.join("\n\n")}`,
3172
+ });
3173
+ // keep exactly what restored, in order — slicing by COUNT kept a
3174
+ // vanished middle entry while dropping the surviving tail
3175
+ this.recentReads = this.recentReads.filter((rel) => restoredPaths.has(rel));
2912
3176
  }
2913
3177
  }
2914
- if (restored.length) {
2915
- this.messages.splice(1, 0, {
2916
- role: "user",
2917
- content: `[harness] These files were recently read and are re-attached for continuity ` +
2918
- `(they may have changed — re-read if precision matters):\n\n${restored.join("\n\n")}`,
3178
+ // #64 follow-up: compaction is the ONE point in a session where re-reading
3179
+ // AGENTS.md is both safe and expected.
3180
+ //
3181
+ // It is expected because compaction exists precisely to summarise work
3182
+ // against the CURRENT instructions: an agent that spent an hour under one
3183
+ // set of project rules, then compacted, would otherwise carry the old rules
3184
+ // over the summary and never see the edit.
3185
+ //
3186
+ // It is safe because the prefix-cache concern that motivates freezing the
3187
+ // prompt does not apply here. That concern is about a prompt changing
3188
+ // mid-turn, re-pricing the whole prefix on every call. A compaction
3189
+ // ALREADY rewrote the history the prompt sits on top of, so the cache is
3190
+ // being re-priced regardless — one more changed block costs nothing extra.
3191
+ //
3192
+ // Only AGENTS.md is re-read, deliberately. The workspace listing and the
3193
+ // skills catalogue stay frozen: both are a snapshot of the workspace as it
3194
+ // was, and re-listing mid-session would contradict the instruction to
3195
+ // "not list again what is already here". AGENTS.md is different — it is
3196
+ // instructions, not a listing, and a stale copy is actively misleading.
3197
+ const refreshed = this.reloadAgentsMd();
3198
+ if (refreshed) {
3199
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
3200
+ event: "agents-md-reloaded",
3201
+ bytes: refreshed.length,
2919
3202
  });
2920
- this.recentReads = this.recentReads.slice(0, restored.length); // keep order, drop misses
2921
3203
  }
2922
- }
2923
- // #64 follow-up: compaction is the ONE point in a session where re-reading
2924
- // AGENTS.md is both safe and expected.
2925
- //
2926
- // It is expected because compaction exists precisely to summarise work
2927
- // against the CURRENT instructions: an agent that spent an hour under one
2928
- // set of project rules, then compacted, would otherwise carry the old rules
2929
- // over the summary and never see the edit.
2930
- //
2931
- // It is safe because the prefix-cache concern that motivates freezing the
2932
- // prompt does not apply here. That concern is about a prompt changing
2933
- // mid-turn, re-pricing the whole prefix on every call. A compaction
2934
- // ALREADY rewrote the history the prompt sits on top of, so the cache is
2935
- // being re-priced regardless — one more changed block costs nothing extra.
2936
- //
2937
- // Only AGENTS.md is re-read, deliberately. The workspace listing and the
2938
- // skills catalogue stay frozen: both are a snapshot of the workspace as it
2939
- // was, and re-listing mid-session would contradict the instruction to
2940
- // "not list again what is already here". AGENTS.md is different — it is
2941
- // instructions, not a listing, and a stale copy is actively misleading.
2942
- const refreshed = this.reloadAgentsMd();
2943
- if (refreshed) {
3204
+ // the operator can inspect WHAT was remembered — a dedicated typed event
3205
+ // (not an agent message) keeps it out of the model's own voice
3206
+ await this.log.append("compaction", this.currentSession, this.currentBranch, {
3207
+ tokensBefore: before,
3208
+ tokensAfter: after,
3209
+ summarized: summarizedCount,
3210
+ dropped: droppedCount,
3211
+ mode,
3212
+ ...(summary ? { summary: String(summary).slice(0, 20_000) } : {}),
3213
+ });
2944
3214
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
2945
- event: "agents-md-reloaded",
2946
- bytes: refreshed.length,
3215
+ event: "context-compacted",
3216
+ tokensBefore: before,
3217
+ tokensAfter: after,
3218
+ summarized: summarizedCount,
3219
+ dropped: droppedCount,
3220
+ mode,
2947
3221
  });
2948
3222
  }
2949
- // the operator can inspect WHAT was remembered — a dedicated typed event
2950
- // (not an agent message) keeps it out of the model's own voice
2951
- await this.log.append("compaction", this.currentSession, this.currentBranch, {
2952
- tokensBefore: before,
2953
- tokensAfter: after,
2954
- summarized: summarizedCount,
2955
- dropped: droppedCount,
2956
- mode,
2957
- ...(summary ? { summary: String(summary).slice(0, 20_000) } : {}),
2958
- });
2959
- await this.log.append("system_note", this.currentSession, this.currentBranch, {
2960
- event: "context-compacted",
2961
- tokensBefore: before,
2962
- tokensAfter: after,
2963
- summarized: summarizedCount,
2964
- dropped: droppedCount,
2965
- mode,
2966
- });
2967
- this.compactPhase = "";
2968
- bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "done" });
3223
+ finally {
3224
+ this.compactPhase = "";
3225
+ // the end signal lives in the FINALLY: a compaction that dies mid-flight
3226
+ // (a log write rejecting on disk-full) used to leave the phase cleared
3227
+ // server-side while the UI banner stayed "summarizing" forever — the
3228
+ // client never got the done event. Emitting here covers every exit.
3229
+ bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "done" });
3230
+ }
2969
3231
  }
2970
3232
  /** Ask the model for dense continuation notes over the compacted range. */
2971
3233
  async summarize(old, onDelta) {