teapot-coding-agent 0.37.59 → 0.37.61

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 (42) hide show
  1. package/dist/acp/adapter.js +4 -72
  2. package/dist/agent/agent.js +61 -2247
  3. package/dist/agent/compact-rules.js +0 -53
  4. package/dist/agent/editorconfig.js +4 -56
  5. package/dist/agent/goal-audit.js +0 -34
  6. package/dist/agent/key-pool.js +0 -52
  7. package/dist/agent/kill.js +1 -13
  8. package/dist/agent/llm.js +9 -310
  9. package/dist/agent/mcp-oauth-manager.js +2 -255
  10. package/dist/agent/mcp-registry.js +1 -188
  11. package/dist/agent/mcp.js +1 -85
  12. package/dist/agent/meta-tools.js +0 -26
  13. package/dist/agent/plugins.js +1 -81
  14. package/dist/agent/provider-headers.js +1 -28
  15. package/dist/agent/skills.js +8 -94
  16. package/dist/agent/tools.js +16 -809
  17. package/dist/bus.js +1 -2
  18. package/dist/config-schema.js +0 -40
  19. package/dist/home.js +1 -19
  20. package/dist/index.js +0 -136
  21. package/dist/log/events.js +3 -195
  22. package/dist/master.js +31 -1180
  23. package/dist/migrations/index.js +0 -55
  24. package/dist/migrations/providers.js +0 -13
  25. package/dist/migrations/registry.js +0 -9
  26. package/dist/migrations/session-titles.js +3 -43
  27. package/dist/migrations/sessions.js +2 -20
  28. package/dist/model-meta.js +166 -142
  29. package/dist/scheduler/cron.js +2 -17
  30. package/dist/server/api.js +12 -778
  31. package/dist/server/image-resize.js +1 -64
  32. package/dist/server/term-size.js +1 -120
  33. package/dist/server/terminals.js +0 -159
  34. package/dist/session-title.js +2 -35
  35. package/package.json +1 -1
  36. package/public/assets/{index-KzHw4Sqo.js → index-C1eqcSbW.js} +4 -4
  37. package/public/assets/index-C1eqcSbW.js.map +1 -0
  38. package/public/assets/{shiki-BwDjoZdu.js → shiki-DaLn_bCe.js} +2 -2
  39. package/public/assets/shiki-DaLn_bCe.js.map +1 -0
  40. package/public/index.html +1 -1
  41. package/public/assets/index-KzHw4Sqo.js.map +0 -1
  42. package/public/assets/shiki-BwDjoZdu.js.map +0 -1
@@ -1,11 +1,3 @@
1
- /**
2
- * Agent: an event-driven loop over one workspace.
3
- *
4
- * CPU discipline: the agent only consumes CPU while waiting on LLM/tool I/O
5
- * promises; when idle it holds zero timers and zero polling loops. All
6
- * periodic behaviour (progress reports, scheduled tasks) is driven by the
7
- * master's single low-frequency scheduler tick or by turn boundaries.
8
- */
9
1
  import { promises as fs, existsSync, readFileSync, readdirSync, statSync } from "node:fs";
10
2
  import path from "node:path";
11
3
  import { EventLog, readEvents } from "../log/events.js";
@@ -20,158 +12,31 @@ import { metaToolSpecs } from "./meta-tools.js";
20
12
  import { skillRootsFingerprint, foreignSkillRoots } from "./skills.js";
21
13
  import { extractRules, enforceRules, RULES_HEADING } from "./compact-rules.js";
22
14
  import { bus } from "../bus.js";
23
- /**
24
- * Harness nudges, named and exported so they can be asserted on directly.
25
- *
26
- * Both were fixed for a reason invisible in the prose:
27
- *
28
- * - AUTO_CONTINUE_NUDGE used to say only "continue working". The model
29
- * was never told HOW TO STOP, so it worked forever: it replied in prose, the
30
- * loop saw an active goal and no finish(), and nudged again. The sub-agent
31
- * variant already named finish() — the ROOT one did not.
32
- * - PROGRESS_REQUEST used to DESCRIBE the report instead of asking for
33
- * the tool, so the model answered in prose, then — not knowing that had
34
- * satisfied the request — sent a voluntary report_progress right after, and
35
- * every requested report appeared TWICE on the timeline.
36
- */
37
- export const AUTO_CONTINUE_NUDGE =
38
- // the prefix marks this as harness text, not something the operator typed.
39
- // Unprefixed, the model read it as a request to reply — and prose ends the round
40
- // without finishing, which starts the next one.
41
- "[harness] " +
15
+ export const AUTO_CONTINUE_NUDGE = "[harness] " +
42
16
  "Continue working toward the current goal. If you are blocked, explain why briefly. " +
43
- // name the tool explicitly, the way the sub-agent nudge already does
44
17
  "When the goal is genuinely met — or truly blocked — call finish() " +
45
18
  "(finish(goalComplete=true) when met, finish(goalComplete=false) when blocked) " +
46
19
  "instead of replying in prose; prose here does not stop the loop.";
47
- /**
48
- * The auto-continue nudge with the CURRENT GOAL restated.
49
- *
50
- * the nudge said "the current goal" and never said what it was. The sub-agent
51
- * variant has always included its task text for exactly this reason — a forked
52
- * child otherwise drifts back into the inherited conversation and keeps going
53
- * forever — but the ROOT nudge had no such reminder, so a root agent that
54
- * had lost the thread had nothing to re-anchor on and simply continued until the
55
- * `maxConsecutiveIdleRounds` valve tripped.
56
- *
57
- * The goal is clipped like the sub-agent's: a 20k-character goal would otherwise
58
- * re-enter the context on EVERY round.
59
- */
60
20
  export function autoContinueNudge(goalText) {
61
21
  const goal = goalText.trim();
62
22
  if (!goal)
63
23
  return AUTO_CONTINUE_NUDGE;
64
24
  return `${AUTO_CONTINUE_NUDGE}\nCurrent goal:\n=====\n${goal.slice(0, 1500)}\n=====`;
65
25
  }
66
- export const PROGRESS_REQUEST =
67
- // The leading sentence is kept VERBATIM: it is what existing harness prompts,
68
- // tests and any operator muscle-memory key off. #45 changed the INSTRUCTION
69
- // (call the tool, don't answer in prose), not the identification of the request.
70
- "[harness] Please give a brief progress report now: what you are doing, goal progress, " +
26
+ export const PROGRESS_REQUEST = "[harness] Please give a brief progress report now: what you are doing, goal progress, " +
71
27
  "what you recently tried, any problems, and your next step. Keep it under 10 lines. " +
72
- // ask for the TOOL, not prose — a prose answer left the model unsure whether it
73
- // had satisfied the request, so it sent a voluntary report_progress right after
74
- // and every requested report appeared TWICE on the timeline
75
28
  "Record it with the report_progress TOOL (doing, goalStatus, recent, problems, next) — " +
76
29
  "call that tool; do NOT answer in prose, which would just duplicate this request.";
77
- /**
78
- * Recent context kept VERBATIM through a compaction, in tokens.
79
- *
80
- * Pi defaults `keepRecentTokens` to 20000 and opencode to `keep: 15000` for the
81
- * same reason: the newest exchanges are what the model needs to continue
82
- * accurately, and a verbatim copy beats a summary of them.
83
- */
84
30
  const KEEP_RECENT_TOKENS = 15_000;
85
- /**
86
- * When pruning old tool output becomes worthwhile, as a share of the budget.
87
- *
88
- * Just under 1: pruning is DESTRUCTIVE (a clipped tool result leaves the model's
89
- * context entirely — the log keeps it, but the model cannot see it without re-issuing
90
- * the call), so it must only spend that information when the next request would
91
- * otherwise not fit. The old value was 0.6 of a budget that is itself 75% of the
92
- * window, i.e. history was being clipped at 45% of the real window with a quarter of
93
- * the window still free — which is what the operator noticed ("25%余ってる").
94
- *
95
- * A share rather than an absolute count, so it tracks whatever window the session is
96
- * configured for.
97
- */
98
31
  const PRUNE_AT_SHARE = 0.95;
99
- /**
100
- * Short-context mode: the same knobs, retuned for a window where the default
101
- * tail is a large share of everything.
102
- *
103
- * 262k window (fine) but 47% of a 32k one — so on a small model the verbatim tail
104
- * alone nearly fills the budget and the first prune does almost nothing useful.
105
- * The mode therefore (a) prunes at 0.6 of the budget instead of 0.95, (b) keeps a
106
- * QUARTER of the budget verbatim instead of a fixed 15k, and (c) clips tool results
107
- * down to 200 chars instead of 400, because on a small window the head of an output
108
- * is most of what fits.
109
- *
110
- * Opt-in by design: on a large window this throws away real information early.
111
- */
112
32
  const SHORT_PRUNE_AT_SHARE = 0.6;
113
33
  const SHORT_PRUNE_KEEP_CHARS = 200;
114
- /**
115
- * The summarize call is skipped when the summarizable prefix is a small share of
116
- * the budget, because the call cannot reuse any prefix cache (its system prompt is
117
- * its instructions) and so re-bills the whole history uncached. Below this share,
118
- * dropping the prefix outright reclaims the budget for free and the model can
119
- * recover the details by re-reading the goal or the files.
120
- *
121
- * A SHARE rather than an absolute token count: what counts as "a lot to lose"
122
- * scales with the window a session is configured for. An absolute constant
123
- * calibrated for the 96k default would disable summarization entirely for a
124
- * session with a small budget (measured: a 200-token budget with a 654-token
125
- * history is over every bar, yet its prefix is a few hundred tokens).
126
- *
127
- * Manual compaction and overflow recovery bypass this — those are not cost
128
- * decisions.
129
- */
130
34
  const SUMMARIZE_MIN_SHARE = 0.02;
131
- /** the absolute floor for the bar above (see the note where it is applied) */
132
35
  const SUMMARIZE_MIN_TOKENS = 2_000;
133
- /**
134
- * Bounded retries when the summarizer's own request is over the window, and how much
135
- * of the shadowed region to shed each time. ZCode handles the same case with a bounded
136
- * `prompt-too-long` retry ("compact 请求自身超窗时丢旧 API round 重试"): unbounded would
137
- * burn tokens against a provider that refuses every size.
138
- */
139
36
  const SUMMARIZE_OVERFLOW_RETRIES = 3;
140
37
  const SUMMARIZE_SHRINK = 0.6;
141
- /**
142
- * Consecutive AUTO compaction failures before auto-compaction stops trying (ZCode:
143
- * `max auto failures: 3`). The failure is usually environmental — an unavailable
144
- * model, a provider outage — so retrying it every turn re-burns the provider for a
145
- * pass that cannot succeed. A success resets the count, so one bad stretch does not
146
- * disable compaction for the session.
147
- */
148
38
  const MAX_AUTO_COMPACT_FAILURES = 3;
149
- /**
150
- * How much of the budget to KEEP on each successive overflow recovery.
151
- *
152
- * An overflow means the estimate was too low, so a single fixed cut can still
153
- * leave the request too large — and the old code turned that second failure into a
154
- * hard error. opencode uses the same shape (`SHRINK_STEPS [0.7, 0.5, 0.35]`): each
155
- * retry keeps less, so the request shrinks even when the first attempt did not
156
- * shrink it enough. The last entry repeats for any further attempt.
157
- */
158
39
  const OVERFLOW_SHRINK_STEPS = [0.5, 0.35, 0.2];
159
- /**
160
- * The compaction directive, delivered as the FINAL user message after the replayed
161
- * conversation rather than as a distinct summarizer `system` prompt.
162
- *
163
- * This position is the whole point: the request is then a genuine prefix-EXTENSION
164
- * of the last routed request, so the provider's KV cache is reused instead of being
165
- * discarded at the first token. Measured before the change — a bespoke summarizer
166
- * system prompt shares nothing with the conversation, and the entire history is
167
- * re-prefilled at full price exactly when the conversation is largest. DeepSeek
168
- * Harness reaches the same conclusion in its `compaction-summary-prefix-cache-reuse`
169
- * note.
170
- *
171
- * It opens with the sentence tests use to RECOGNISE a summarizer call; keeping that
172
- * stable matters (an earlier version reworded it and silently made every update pass
173
- * look like an ordinary turn).
174
- */
175
40
  const COMPACT_INSTRUCTION = [
176
41
  "You compress a coding agent's conversation into dense notes for it to continue working.",
177
42
  "Condense the conversation ABOVE into a structured checkpoint so another model can",
@@ -185,23 +50,11 @@ const COMPACT_INSTRUCTION = [
185
50
  "## Files and code sections",
186
51
  "## Errors and fixes",
187
52
  "## Problem solving",
188
- "## All user messages", // verbatim-ish: intent survives repeated compaction
53
+ "## All user messages",
189
54
  "## Pending tasks",
190
55
  "## Current work",
191
56
  "## Next step",
192
57
  "## Durable lessons",
193
- /**
194
- * Hard constraints, as their own section with a fixed shape.
195
- *
196
- * "must/must-not" rules are the one thing a summary cannot recover from losing: everything else
197
- * can be re-derived by reading the code again, but a constraint the operator stated once and
198
- * never repeated is simply gone. Reported as "compact時にルール(must/must notが中心だけど、should
199
- * レベルのものも)が確実に引き継げる機構があると良いねって思った".
200
- *
201
- * So they get a dedicated heading with a MACHINE-CHECKABLE shape (one `MUST`/`MUST NOT`/`SHOULD`
202
- * keyword per line, see `RULES_SECTION` + `extractRules`): the summarizer cannot merge them into
203
- * prose, and `enforceRules` re-inserts any that went missing instead of trusting it did not.
204
- */
205
58
  "## Hard constraints",
206
59
  "",
207
60
  "Rules: be terse bullet points, no prose flourishes. Quote file paths and key numbers exactly.",
@@ -211,7 +64,6 @@ const COMPACT_INSTRUCTION = [
211
64
  "Output only the checkpoint text: do not call any tool, and do not mention this request or that the context was compacted.",
212
65
  "The <analysis> block is discarded before your notes are stored, so think freely there.",
213
66
  ].join("\n");
214
- /** Appended to COMPACT_INSTRUCTION when notes from an earlier pass are present. */
215
67
  const UPDATE_INSTRUCTION = [
216
68
  "",
217
69
  "The notes you produced last time are above, framed as a checkpoint. This is an",
@@ -220,21 +72,9 @@ const UPDATE_INSTRUCTION = [
220
72
  "request just because the new messages do not repeat it: an unanswered request",
221
73
  "stays, an answered one goes. Return the FULL updated notes under the same headings.",
222
74
  ].join("\n");
223
- /** framing around the prior notes when an update pass carries them forward */
224
75
  const SUMMARY_OPEN = "<conversation_checkpoint>\n";
225
76
  const SUMMARY_CLOSE = "\n</conversation_checkpoint>";
226
- /**
227
- * What the completion auditor may use. Read-only by construction — it inspects artifacts to check a
228
- * claim, and must not be able to change what it is judging.
229
- */
230
77
  const AUDIT_READ_ONLY_TOOLS = new Set(["read_file", "list_dir", "grep", "bash"]);
231
- /**
232
- * SYSTEM_TEMPLATE must stay byte-identical across every request of a session:
233
- * provider prefix caches key on it, so changing it (or injecting per-turn
234
- * state) re-prices the whole context. That is why session state lives behind
235
- * meta tools instead. The cache rationale itself stays HERE in a comment —
236
- * the model does not need our cost-engineering notes every turn.
237
- */
238
78
  const SYSTEM_TEMPLATE = `You are a coding agent working autonomously inside a workspace.
239
79
 
240
80
  Most session state is not injected into prompts — fetch it with tools instead:
@@ -323,34 +163,7 @@ Most session state is not injected into prompts — fetch it with tools instead:
323
163
  read_memory(query), open at most one or two of the notes that match, and stop.
324
164
  If nothing is relevant, carry on — do not keep searching, and do not read the
325
165
  whole file "just in case". An unread note is an unwritten note.`;
326
- /**
327
- * A message whose ENTIRE text is parenthesised is an acknowledgement, not work.
328
- *
329
- * the first version of this listed three literals — `(tool call)`,
330
- * `(no content)`, `(no output)` — and caught 2 of the 10 forms actually reaching
331
- * a parent. Measured across every session log, the rest are harness
332
- * acknowledgements written by `answerMeta` and the meta-tool handlers:
333
- *
334
- * (goal complete) (round ended) (tool try) (no sub-agents)
335
- * (tool_call) (nothing running to stop) (no result recorded)
336
- * (todo.md is empty …) (decisions.md is empty …)
337
- *
338
- * So it matches the SHAPE — a whole line wrapped in parentheses — rather than an
339
- * enumeration that is stale the moment another ack is added.
340
- *
341
- * The trade: a genuine message that happens to be entirely parenthesised would
342
- * also be dropped. That is vanishingly rare in this feed, and the alternative —
343
- * an enumeration — was measured to be wrong 80% of the time. If it ever matters,
344
- * the ack should be marked structurally rather than by its punctuation.
345
- */ export const PLACEHOLDER_TEXT = /^\(.*\)$/;
346
- /**
347
- * Harness acknowledgements — meta-lines about the run itself rather than the
348
- * work ("decision recorded to decisions.md", "progress recorded", …).
349
- *
350
- * these are addressed to the OPERATOR, not to whoever reads the next
351
- * agent's report, and they consumed slots in a six-deep window that the parent
352
- * reads as its only account of the work.
353
- */
166
+ export const PLACEHOLDER_TEXT = /^\(.*\)$/;
354
167
  export const HARNESS_ACK = /^(decision recorded|progress recorded|goal saved|feedback saved|memory saved|todo saved|no skills yet|skills created|read_memory|memory|\(tool call\)|\(no content\)|\(no output\)|true|false|null|none)\b/i;
355
168
  export class Agent {
356
169
  log;
@@ -362,9 +175,7 @@ export class Agent {
362
175
  currentBranch = "br0";
363
176
  goal = { text: "", status: "active", updatedAt: new Date().toISOString() };
364
177
  latestProgress = null;
365
- /** operator-maintained task list (todo.md) — humans edit, agent reads */
366
178
  todo = "";
367
- /** set once the conversation has been restored (lazy: on first interaction) */
368
179
  readyPromise = null;
369
180
  stats = {
370
181
  turns: 0,
@@ -374,172 +185,50 @@ export class Agent {
374
185
  outputTokens: 0,
375
186
  compactions: 0,
376
187
  startedAt: null,
377
- /** running cost estimate in USD (pricing known only) */
378
188
  costUsd: 0,
379
189
  };
380
- /**
381
- * Everything has a default EXCEPT `supportedParameters`, which must stay optional:
382
- * `undefined` means "the endpoint published no capability list", and that is a
383
- * different answer from an empty list ("published, and excludes it") — #150 turns
384
- * on that distinction.
385
- */
386
190
  opts;
387
191
  messages = [];
388
- /**
389
- * The conversation this agent holds, for INSPECTION.
390
- *
391
- * Tests need to assert what the agent will actually send (compaction, tool pairing,
392
- * restoration), and reaching into a private field meant every assertion had to launder
393
- * the type through `as unknown as { messages: … }` — thirty of them, each of which would
394
- * keep compiling after the shape changed. A read-only view makes the intent explicit and
395
- * lets the type checker do its job. There is no setter: mutation belongs to the class.
396
- */
397
192
  get conversation() {
398
193
  return this.messages;
399
194
  }
400
- /**
401
- * The live array, for SEEDING a scenario in a test.
402
- *
403
- * Separate from `conversation` on purpose: reading and appending are different
404
- * intents, and a test that plants 4000-character turns to reach a compaction threshold
405
- * should look like what it is. Production code never calls this — history is appended
406
- * by the turn loop.
407
- */
408
195
  get conversationForTest() {
409
196
  return this.messages;
410
197
  }
411
- /**
412
- * The endpoint this agent resolved, for INSPECTION.
413
- *
414
- * Tests assert that a per-agent override, a provider preset and the default all land on
415
- * the same baseUrl/key/model — reaching into `opts` to do it meant the assertions had to
416
- * defeat `private` and would have kept compiling after the shape changed.
417
- */
418
198
  get llmConfig() {
419
199
  return this.opts.llm;
420
200
  }
421
- /** the provider this agent resolved, for inspection (which preset won, and why) */
422
201
  get providerName() {
423
202
  return this.opts.provider;
424
203
  }
425
- /**
426
- * User prompts waiting for the next turn boundary. Deliberately NOT queued
427
- * on runChain: that chain holds the long-running loop, so queueing behind
428
- * it would delay both the log entry (UI) and delivery until the round —
429
- * sometimes the whole goal — finished.
430
- */
431
204
  pendingPrompts = [];
432
205
  stopRequested = false;
433
- /**
434
- * "Finish the round you are in, then stop" — a LIVE UPDATE stop.
435
- *
436
- * `stop()` aborts the in-flight LLM call and the running tool, which is right for an operator
437
- * pressing ■ (they want it to stop NOW). It is wrong for a live update: aborting mid-tool leaves
438
- * a half-written file and a call that never got its result, and the session then resumes from a
439
- * state that never existed.
440
- *
441
- * Asked for as "動作中のエージェントループを適切なタイミング(ツール実行完了や生成終了などで1ターンが
442
- * 終わる)で停止する". The loop already checks `stopRequested` at the TOP of each round, so this flag
443
- * only has to make the loop notice at that boundary instead of being yanked out of it.
444
- */
445
206
  windDownRequested = false;
446
207
  wake = null;
447
- /** set while a long-parking tool (wait_children) holds the run chain */
448
208
  parkedByTool = false;
449
- /** status before parking, restored on unpark */
450
209
  preParkedStatus = null;
451
210
  abort = null;
452
- /** aborted only by dispose(): kills in-flight subprocess groups instantly */
453
211
  toolAbort = new AbortController();
454
212
  runChain = Promise.resolve();
455
- /** whether contextTokenBudget came from config (manual) or was derived */
456
213
  manualCompactBudget = false;
457
- /** workspace dir absent at boot — session runs "as ghost" until created */
458
214
  workspaceMissing = false;
459
215
  lastProgressAt = Date.now();
460
- /** the provider's own prompt_tokens from the last completed turn */
461
216
  lastUsage;
462
- /** USD per token for the CURRENT model (from provider /models metadata);
463
- undefined = pricing unknown → cost estimate stays 0 and hidden */
464
217
  modelPricing;
465
- /** messages.length right after the last successful compaction */
466
218
  compactedAtLen = 0;
467
- /**
468
- * A sub-agent's latest bare text turn, held back until the round ends.
469
- * Models usually end a sub-agent task with a plain message instead of calling
470
- * finish(); only finish() forwards a report to the parent, so without this the
471
- * parent got nothing. Held (not sent immediately) so a mid-task
472
- * narration never pre-empts a real finish() summary.
473
- */
474
219
  pendingSubReport = "";
475
- /** live compaction phase for UI progress ("summarizing"/"harvesting") */
476
220
  compactPhase = "";
477
- /** set by ask_user: the loop is parked until the operator replies */
478
221
  awaitingUser = false;
479
- /** real assistant output since the last progress report (chars / turns) */
480
222
  activityChars = 0;
481
223
  turnsSinceProgress = 0;
482
224
  consecutiveToolErrors = 0;
483
- /** consecutive round-fatal API errors (drives the retry backoff) */
484
225
  consecutiveApiErrors = 0;
485
- /**
486
- * True while the round-level retry backoff is sleeping.
487
- *
488
- * Separate from `status`, which must keep reading "error" so the operator sees WHAT
489
- * failed while the retry is pending — but an agent waiting to retry is stoppable
490
- * work in flight, so `isLive()` has to say so or the UI offers "start" and no stop.
491
- */
492
226
  retrySleeping = false;
493
- /**
494
- * Consecutive auto-continued rounds that did no work.
495
- *
496
- * Reset by any tool call, any finish, a stop, or fresh operator input — only a
497
- * round that produced neither a tool call nor a finish increments it.
498
- */
499
227
  consecutiveIdleRounds = 0;
500
- /**
501
- * Consecutive completion audits that REJECTED, #128.
502
- *
503
- * again, is rejected again… ran 381 audit calls in one turn. Nothing bounded it:
504
- *
505
- * - `maxConsecutiveIdleRounds` counts IDLE rounds, and a rejected audit is not
506
- * idle — the agent is actively working
507
- * - `maxTurnsPerRound` only LOGS `round-turn-cap`; it does not break, and it is
508
- * evaluated per round, while a reject never ends the round
509
- *
510
- * So the agent can spend unbounded provider spend re-finishing. When the cap is
511
- * hit the goal is marked as needing attention rather than left looping: silently
512
- * marking it done would be #128's fail-open problem all over again, and silently
513
- * stopping would strand the operator with no signal.
514
- */
515
228
  consecutiveAuditRejects = 0;
516
- /**
517
- * consecutive completion audits that produced NO verdict (empty /
518
- * prose / provider throw). Fail-CLOSED — the goal stays active and the
519
- * worker self-verifies again — but bounded: after 3 the retrying stops with
520
- * a loud note (the same philosophy as the reject bound above), so a
521
- * permanently broken auditor cannot trap the worker either. Reset by any
522
- * real verdict and by setGoal.
523
- */
524
229
  auditUnavailableStreak = 0;
525
- /**
526
- * #91/#94: audit lineage for the FINAL event. `auditAttempts` counts every
527
- * audit run (incremented at audit-started); `lastRejectedAudit` remembers
528
- * the most recent CHANGES-REQUIRED verdict so the approving final can carry
529
- * what the first attempt was told; `lastAuditOutcome` is the metadata the
530
- * NEXT final event spreads in (then cleared — a later, unaudited finish
531
- * must not inherit it).
532
- */
533
230
  auditAttempts = 0;
534
231
  lastRejectedAudit = null;
535
- /**
536
- * What the agent had done BEFORE an audit rejected its first finish.
537
- *
538
- * Captured at rejection time because that is the last moment the pre-rejection work is still
539
- * the bulk of the history. The eventual approved final would otherwise report only the retry,
540
- * which is a narrow fix-up — the parent would be judging hours of work from its last few
541
- * minutes.
542
- */
543
232
  preRejectionTurns = [];
544
233
  lastAuditOutcome = null;
545
234
  constructor(opts) {
@@ -550,20 +239,11 @@ export class Agent {
550
239
  autoContinue: true,
551
240
  continueDelayMs: 15_000,
552
241
  autoCompact: true,
553
- // no effort and no catalogue entry means "provider default" — the
554
- // effort field is then never sent, which is the safe default
555
242
  reasoningEffort: "",
556
- // undefined, NOT []: "the endpoint published no capability list" and "the
557
- // endpoint published a list that excludes this parameter" are different
558
- // answers, and only the second is a definite no. An empty array here
559
- // would make every silent endpoint look like an explicit refusal — measured:
560
- // commandcode and google publish no list at all, so `[]` hid the very control
561
- // #150 was about.
562
243
  supportedParameters: undefined,
563
244
  maxConsecutiveToolErrors: 5,
564
245
  maxTurnsPerRound: 200,
565
- maxAuditRejects: 3, // #128: bound the reject loop
566
- // generous: only a model that never acts and never finishes reaches it
246
+ maxAuditRejects: 3,
567
247
  maxConsecutiveIdleRounds: 50,
568
248
  onError: "stop",
569
249
  retryDelayMs: 60_000,
@@ -581,12 +261,7 @@ export class Agent {
581
261
  provider: "",
582
262
  ...opts,
583
263
  };
584
- // remember WHERE the budget came from: config = manual, anything else is
585
- // derived from a known context window (or stays at the default)
586
264
  this.manualCompactBudget = opts.contextTokenBudget !== undefined;
587
- // a declared window implies its own budget: compact at ~75% of the
588
- // model's real context unless the config set an explicit one (the 96k
589
- // default only makes sense for unknown-window models)
590
265
  if (this.opts.contextWindowTokens && !opts.contextTokenBudget) {
591
266
  this.opts.contextTokenBudget = Math.round(this.opts.contextWindowTokens * 0.75);
592
267
  }
@@ -595,22 +270,12 @@ export class Agent {
595
270
  { dir: path.join(opts.workspace, "skills"), source: "workspace" },
596
271
  ...(opts.globalSkillsDir ? [{ dir: opts.globalSkillsDir, source: "global" }] : []),
597
272
  ...(opts.pluginSkillRoots ?? []),
598
- // OTHER HARNESSES' skill directories. The SKILL.md format is shared,
599
- // so these are readable as-is — verified against real third-party skills
600
- // (terminalskills/skills, trailofbits/skills, …). They sit below the
601
- // workspace and teapot's own global dir so a project always wins, and
602
- // missing directories are skipped silently. Paths are the documented
603
- // native locations plus the cross-tool ones several tools also read.
604
273
  ...foreignSkillRoots(opts.workspace),
605
- // shipped-with-package skills: lowest priority, always discoverable
606
274
  ...(opts.bundledSkillsDir ? [{ dir: opts.bundledSkillsDir, source: "bundled" }] : []),
607
275
  ];
608
276
  this.toolCtx = {
609
277
  cwd: opts.workspace,
610
- // so a background job records WHICH agent started it: sibling agents share a workspace,
611
- // and `isLive()` must not count their jobs as this one's work in flight
612
278
  agentId: opts.id,
613
- // the job RECORD lives beside the session, not in a global dir: it is this agent's history
614
279
  stateDir: opts.sessionDir,
615
280
  defaultTimeoutMs: 120_000,
616
281
  maxOutputBytes: 60_000,
@@ -619,85 +284,31 @@ export class Agent {
619
284
  readOnly: this.opts.readOnlyTools,
620
285
  onIdlePark: (reason) => this.parkForTool(reason),
621
286
  onIdleUnpark: () => this.unparkFromTool(),
622
- // the park must not strand an already-queued prompt
623
287
  hasPendingPrompts: () => this.pendingPrompts.length > 0,
624
288
  onBackgroundExit: (info) => void this.onBackgroundJobExit(info),
625
289
  onFileRead: (p) => this.trackFileRead(p),
626
290
  };
627
- // the session id IS the directory name — one directory per incarnation
628
291
  this.mainSession = path.basename(opts.sessionDir);
629
292
  this.currentSession = this.mainSession;
630
293
  }
631
294
  skillRoots;
632
295
  skillsCache = [];
633
- /** fingerprint of the roots that produced skillsCache ("" = not scanned yet) */
634
296
  skillsStamp = "";
635
- /**
636
- * The notes the LAST compaction produced, if any.
637
- *
638
- * Compaction UPDATES these rather than re-deriving notes from scratch: a fresh
639
- * summary each pass loses whatever the previous one distilled (every
640
- * implementation surveyed — opencode, Kilo, Pi — updates for this reason).
641
- * Recovered from the log so the continuity survives a restart.
642
- */
643
297
  lastCompactSummary;
644
- /**
645
- * The accumulated hard rules, kept INDEPENDENTLY of the summary.
646
- *
647
- * Codex's `retained_instructions.rs` states the property that matters: retained instructions are
648
- * selected "independently of compaction", with the host owning storage and lifecycle, so
649
- * "restrictions are never truncated into partial permissions".
650
- *
651
- * The first version of this re-derived the rules from `lastCompactSummary` on each pass, which
652
- * fails in exactly the case the mechanism exists for: a pass that drops every rule leaves
653
- * nothing to re-insert. Keeping the set here (and re-seeding it from the log on restart) makes
654
- * it survive any single pass, and lets a pass add to it without ever removing from it.
655
- */
656
298
  compactRules = [];
657
- /**
658
- * A short human title for this session, derived from the operator's first
659
- * prompt. Undefined until the log is read (or when there is no usable prompt).
660
- *
661
- * A MANUAL rename sets it too, and that must survive: the derivation above only fills it when it
662
- * is still undefined, so an operator's name is never overwritten by the next restore. Without this
663
- * path the header kept showing the old label after a rename — the value lives in `config.json`
664
- * (`AgentConfig.sessionTitle`), and nothing wrote it into the live agent.
665
- */
666
299
  setSessionTitle(title) {
667
300
  this.sessionTitle = title?.trim() || undefined;
668
301
  }
669
302
  sessionTitle;
670
- /** overflow recoveries used in the current turn; reset when a turn succeeds */
671
303
  overflowRecoveries = 0;
672
- /**
673
- * Consecutive auto-compaction failures; at MAX_AUTO_COMPACT_FAILURES auto passes are
674
- * skipped until one succeeds (ZCode's circuit breaker). Manual and overflow recovery
675
- * are exempt — those are not "keep trying", they are "the operator/provider asked".
676
- */
677
304
  consecutiveCompactFailures = 0;
678
- /**
679
- * A bounded DIGEST of memory.md: every heading, plus its first line.
680
- *
681
- * Memory notes are worth this much because they are what a later session reads instead of
682
- * re-learning the workspace: they are genuinely long — 2.6KB is the median, and the largest hold
683
- * full project briefs. A model that never sees them cannot decide to consult them: that is the
684
- * whole finding of #48 (182 writes, 13 reads after a write). A model that sees the WHOLE file
685
- * pays for it on every request and breaks the prefix cache.
686
- *
687
- * Codex solves this with a `MEMORY_SUMMARY` in the prompt plus a searchable registry on disk.
688
- * This is the same shape: the digest says what memory HOLDS, `read_memory(query)` fetches the
689
- * part that matters.
690
- *
691
- * The budget is deliberately small. This is an index, and an index that costs as much as the
692
- * content has no reason to be an index.
693
- */
694
305
  memoryDigestText() {
695
306
  let text;
696
307
  try {
697
308
  text = readFileSync(this.memoryFile, "utf8");
698
309
  }
699
310
  catch {
700
- return ""; // no notes yet
311
+ return "";
701
312
  }
702
313
  if (!text.trim())
703
314
  return "";
@@ -709,18 +320,11 @@ export class Agent {
709
320
  const line = lines[i].trim();
710
321
  if (!line)
711
322
  continue;
712
- // provenance markers are bookkeeping, not content
713
- // bookkeeping, not content: an old-style provenance comment, or a `_from:_` line — the
714
- // latter is reachable through read_memory(query), where it is useful, but in a DIGEST it
715
- // would be noise under every heading
716
323
  if (/^<!--/.test(line) || /^_from:/.test(line))
717
324
  continue;
718
325
  const isHeading = /^#{1,6}\s/.test(line);
719
326
  const isBullet = /^[-*]\s/.test(line);
720
- // headings and the FIRST line under one: enough to recognise a topic without
721
- // reproducing the note. A bullet is included only as a one-line sample.
722
327
  if (!isHeading && !isBullet) {
723
- // a continuation of the previous line — skip, the heading already names the topic
724
328
  if (!out.length)
725
329
  out.push(line.slice(0, 100));
726
330
  continue;
@@ -748,29 +352,18 @@ export class Agent {
748
352
  try {
749
353
  const stamp = await skillRootsFingerprint(this.skillRoots);
750
354
  if (stamp === this.skillsStamp)
751
- return; // unchanged → reuse the cache
355
+ return;
752
356
  this.skillsCache = await currentSkills(this.toolCtx);
753
357
  this.skillsStamp = stamp;
754
358
  }
755
359
  catch {
756
- /* keep previous cache */
757
360
  }
758
361
  }
759
- /**
760
- * Request config for a call made in THIS conversation: the session id rides
761
- * along so OpenCode's endpoints can route and prompt-cache the turn (without
762
- * it they answer 400 — see providerHeaders()). Read from `currentSession`
763
- * rather than copied onto opts.llm, because a fork/incarnation switch moves
764
- * the agent to a new conversation while the endpoint settings stay put.
765
- */
766
362
  requestConfig(extra = {}) {
767
363
  return { ...this.opts.llm, sessionId: this.currentSession, ...extra };
768
364
  }
769
365
  callLlm(messages, tools, onDelta) {
770
366
  const fn = this.opts.chatFn ?? chatStream;
771
- // carry the effort setting + the model's advertised capabilities into
772
- // the request config. chat() applies effortForRequest(), which sends the
773
- // field ONLY for OpenRouter endpoints whose catalogue lists support.
774
367
  return fn(this.requestConfig({
775
368
  ...(this.opts.reasoningEffort ? { reasoningEffort: this.opts.reasoningEffort } : {}),
776
369
  ...(this.opts.supportedParameters
@@ -778,31 +371,21 @@ export class Agent {
778
371
  : {}),
779
372
  }), messages, tools, this.abort?.signal, onDelta);
780
373
  }
781
- /** expose id for metrics */
782
374
  opts_id() {
783
375
  return this.opts.id;
784
376
  }
785
- /** snapshot of the current conversation for fork-by-reference spawning */
786
377
  exportMessages() {
787
378
  return [...this.messages];
788
379
  }
789
- /** seed a conversation (fork-by-reference sub-agents) — replaces history */
790
380
  importMessages(msgs) {
791
381
  this.messages = msgs;
792
382
  this.compactedAtLen = 0;
793
383
  }
794
- /**
795
- * Attach USD-per-token pricing for the CURRENT model (provider /models
796
- * metadata). Late resolution is normal: the metadata lookup races boot.
797
- * Recomputes the session cost from the log so a restart with pricing now
798
- * known still shows the full number instead of starting from zero.
799
- */
800
384
  async setModelPricing(p) {
801
385
  const wasUndefined = !this.modelPricing;
802
386
  this.modelPricing = p;
803
387
  if (!p || !wasUndefined)
804
- return; // already priced, or nothing to price
805
- // recompute from the usage events (cheap: file is mtime-cached)
388
+ return;
806
389
  try {
807
390
  const events = await readEvents(this.log.filePath);
808
391
  let cost = 0;
@@ -817,30 +400,17 @@ export class Agent {
817
400
  this.stats.costUsd = cost;
818
401
  }
819
402
  catch {
820
- /* no log yet — live accumulation takes over */
821
403
  }
822
404
  }
823
405
  get workspace() {
824
406
  return this.opts.workspace;
825
407
  }
826
- /**
827
- * True while the workspace directory does not exist on disk.
828
- *
829
- * Re-checked here rather than trusting the flag set at `init()`: the directory can come BACK
830
- * (the operator recreates it, another agent in the same project makes it, a new session is
831
- * created in it), and a boot-time snapshot then leaves every older session flagged 👻 forever.
832
- * That is the reported symptom: a recreated directory shows its session count, but the sidebar
833
- * lists only the session that was just created, because all the older ones still say "ghost".
834
- */
835
408
  get isGhost() {
836
409
  const exists = existsSync(this.workspace);
837
- // only ever CLEAR from here: a sync getter must not claim a directory exists when it does
838
- // not, but it can safely notice that it does again
839
410
  if (exists)
840
411
  this.workspaceMissing = false;
841
412
  return this.workspaceMissing;
842
413
  }
843
- /** Create the workspace on demand — before any tool touches the fs. */
844
414
  async ensureWorkspace() {
845
415
  if (!this.workspaceMissing)
846
416
  return;
@@ -848,66 +418,19 @@ export class Agent {
848
418
  this.workspaceMissing = false;
849
419
  bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
850
420
  }
851
- /** true when the operator pinned contextTokenBudget in the config */
852
421
  get compactBudgetIsManual() {
853
422
  return this.manualCompactBudget;
854
423
  }
855
- /**
856
- * The context size the prune/compact gates act on.
857
- *
858
- * `lastUsage.input` is the provider's own count, but a proxy or shim that under-reports it would
859
- * make BOTH gates believe the context is nearly empty, switching off the defence entirely. So it
860
- * is floored at HALF the local estimate: a provider within 2x of our tokenizer is legitimate, a
861
- * provider reporting ~10 tokens for a large prompt is not.
862
- */
863
424
  contextSizeForBudgeting() {
864
425
  const estimated = this.estimateTokens();
865
426
  const reported = this.lastUsage?.input;
866
427
  if (reported === undefined)
867
428
  return estimated;
868
- // a provider reporting nothing at all is a different bug; treat it as unknown
869
429
  if (!Number.isFinite(reported) || reported <= 0)
870
430
  return estimated;
871
431
  return Math.max(reported, Math.floor(estimated / 2));
872
432
  }
873
- /**
874
- * Is this agent doing work that `stop()` would actually stop?
875
- *
876
- * `status` cannot answer this on its own. Three states count as live:
877
- * - `running`: the obvious one;
878
- * - a PARKED TOOL: `parkForTool()` flips the visible status to "idle" so a
879
- * multi-minute `wait_children` doesn't look like a running spinner, but
880
- * the loop is very much alive inside the tool;
881
- * - QUEUED PROMPTS: prompts handed to a stopped agent are accepted and held,
882
- * and a later `start()` will consume them.
883
- *
884
- * Anything keying "should this button stop or start?" off `status` alone
885
- * therefore offers **start** on a working agent, and the user cannot stop it
886
- * — the same trap #38 documented for editing history.
887
- */
888
433
  isLive() {
889
- // a MANUAL compaction on an idle agent was rewriting `messages` — a
890
- // large, non-cancellable operation — while `isLive()` reported false, so the
891
- // UI offered "start".
892
- //
893
- // and the reverse — after a STOP with prompts still queued, `live` stayed
894
- // true forever, so the controls kept offering "stop" for an agent that was
895
- // already stopped, and no amount of reloading helped. Measured:
896
- //
897
- // after stop : status = stopped live = true pendingPrompts = 1
898
- //
899
- // A queued prompt is not work in progress: it is waiting for the operator to
900
- // press start. #9 settled that a stop must neither deliver nor silently
901
- // DISCARD those prompts, so they are kept — but keeping them must not make the
902
- // agent read as running. A stop outranks them.
903
- //
904
- // and one more, from the same family. The round-level retry backoff sets
905
- // status "error" while it sleeps ("… — retrying in 60s"), and `error` is not a
906
- // live status — so the UI offered "▶ start" for an agent that was waiting to
907
- // retry, and there was NO stop control at all. The operator pressed what was
908
- // there and nothing stopped, because nothing could. A scheduled retry is work
909
- // in flight, so it counts as live; the dedicated flag keeps that independent of
910
- // the display status, which still has to read "error".
911
434
  if (this.stopRequested)
912
435
  return false;
913
436
  return (this.status === "running" ||
@@ -915,52 +438,13 @@ export class Agent {
915
438
  this.pendingPrompts.length > 0 ||
916
439
  this.compactPhase !== "" ||
917
440
  this.retrySleeping ||
918
- // a BACKGROUND job is work in flight, and `stop()` now ends it. The
919
- // round that started it deliberately ended (the loop breaks rather than
920
- // nagging — see hasRunningBgShells in loop()), so the status reads "idle"
921
- // while a dev server / watcher / long build is still running. Without this
922
- // the UI offered "▶ start" and there was no stop control for the one thing
923
- // the operator most needs to stop. Note this is checked AFTER stopRequested:
924
- // once stopped, the jobs are already dead, so `live` must go false or the
925
- // button would offer stop forever.
926
- // OWNER-SCOPED, not directory-scoped. Sub-agents inherit their parent's workspace, so
927
- // asking "does this DIRECTORY have background shells?" made every stopped sibling of a
928
- // busy agent report `live: true`. MEASURED: four STOPPED sub-agents in one workspace
929
- // showed `· ▶4` in the sidebar because the parent had background jobs, and their stop
930
- // buttons offered to stop work that was already gone.
931
- //
932
- // `hasRunningBgShells(cwd)` is still the right question where the caller wants the
933
- // DIRECTORY (parking a round, killing a workspace's jobs) — just not for `isLive()`.
934
441
  hasRunningBgShellsFor(this.toolCtx.cwd, this.opts.id) ||
935
- // ask_user: the loop is parked on the operator's answer — stoppable work
936
- // in flight, not idleness. Without this the UI offered "start", and
937
- // pressing it cleared awaitingUser, silently discarding the open question
938
- // while master.waitChildren's own active() kept counting the same child
939
- // as working (three definitions of "alive" disagreeing about one state).
940
442
  this.awaitingUser ||
941
- // `needs_operator` IS that state now, named. Leaving the check on the flag alone would let
942
- // the status and `live` disagree — #59's exact class of bug — and it is why a
943
- // `needs_operator` snapshot reported `live: false` until this was added.
944
443
  this.status === "needs_operator");
945
444
  }
946
- /**
947
- * The text of every prompt still waiting for a turn boundary.
948
- *
949
- * Read-only view for callers that need to know WHAT is queued, not just how
950
- * many — a delivery assertion has to see the text, or "the message arrived"
951
- * degrades into "something is queued".
952
- */
953
445
  pendingPromptTexts() {
954
446
  return this.pendingPrompts.map((p) => p.text);
955
447
  }
956
- /**
957
- * Is this agent doing in-flight work that a WAITER (wait_children) should
958
- * hold for? Deliberately narrower than isLive(): queued prompts are DORMANT
959
- * work (a stopped/idle child touches them only after it is started), so
960
- * they must not keep a parent parked on a child that is visibly idle —
961
- * that wait would hang. isLive() answers "stop vs start" (#59/#133); this
962
- * answers "is work in flight".
963
- */
964
448
  isBusy() {
965
449
  if (this.stopRequested)
966
450
  return false;
@@ -970,19 +454,10 @@ export class Agent {
970
454
  this.parkedByTool ||
971
455
  this.compactPhase !== "");
972
456
  }
973
- /** recently read workspace files (most recent first) — post-compact restore */
974
457
  recentReads = [];
975
458
  trackFileRead(p) {
976
459
  this.recentReads = [p, ...this.recentReads.filter((x) => x !== p)].slice(0, 5);
977
460
  }
978
- /**
979
- * A background shell (bash background=true) finished. Two channels:
980
- * 1. a system_note row in the timeline — the operator sees the outcome
981
- * without opening anything;
982
- * 2. a queued harness prompt, delivered at the next turn boundary — the
983
- * agent learns exit code + output tail WITHOUT having to poll
984
- * bash_output blindly. Failed jobs say so loudly so the model reacts.
985
- */
986
461
  onBackgroundJobExit(info) {
987
462
  const ok = info.code === 0;
988
463
  const one = info.cmd.replace(/\s+/g, " ").slice(0, 80);
@@ -994,33 +469,17 @@ export class Agent {
994
469
  cmd: one,
995
470
  failed: !ok,
996
471
  });
997
- // queue the report for the next turn boundary (agent-visible channel)
998
472
  this.onBackgroundJobExitQueued(info);
999
473
  }
1000
474
  parkForTool(reason) {
1001
475
  if (this.parkedByTool)
1002
- return true; // already parked: the tool may proceed
476
+ return true;
1003
477
  if (this.status !== "running")
1004
478
  return false;
1005
- // a prompt queued BEFORE the park would be stranded by it — the
1006
- // enqueue-time wake only handles prompts arriving DURING the park. Refuse,
1007
- // so the tool stands down and the next turn boundary consumes the queue.
1008
479
  if (this.pendingPrompts.length > 0)
1009
480
  return false;
1010
481
  this.parkedByTool = true;
1011
482
  this.preParkedStatus = { status: this.status, reason: this.statusReason };
1012
- /**
1013
- * `waiting`, NOT `idle`.
1014
- *
1015
- * A parked loop is alive and will resume by itself, so showing "idle" contradicted the
1016
- * stop button beside it: the badge read idle while the header offered ■ stop, and the
1017
- * operator reasonably concluded the UI was broken. `live` was already true for exactly this
1018
- * reason (a parked tool counts as work in flight), so only the DISPLAYED status was wrong.
1019
- *
1020
- * The two park reasons are named separately because they mean different things to the
1021
- * operator: a `wait_children` park resumes when a sub-agent settles, and a background-shell
1022
- * park resumes when a `wait_children`/round-end wakes it.
1023
- */
1024
483
  this.status = "waiting";
1025
484
  this.statusReason = `${reason} — send a message to take over; resumes by itself`;
1026
485
  bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
@@ -1033,69 +492,33 @@ export class Agent {
1033
492
  const prev = this.preParkedStatus;
1034
493
  this.preParkedStatus = null;
1035
494
  if (!this.stopRequested) {
1036
- // restore the pre-park display state (running again)
1037
495
  this.status = prev?.status ?? "running";
1038
496
  bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
1039
497
  }
1040
498
  }
1041
- /** current LLM settings (read-only view) */
1042
499
  get llm() {
1043
500
  return this.opts.llm;
1044
501
  }
1045
- /** Swap LLM settings mid-flight; the next turn picks them up. */
1046
502
  setLlmConfig(llm) {
1047
503
  this.opts.llm = llm;
1048
504
  }
1049
505
  async init() {
1050
506
  await this.log.load();
1051
507
  await this.migrateMemoryToAgentScope();
1052
- // NOTE: the workspace is NOT created here. Boot-time mkdir resurrected
1053
- // deleted project directories on every restart, which operators hated.
1054
- // It is created lazily in ensureWorkspace() when the agent first needs
1055
- // to touch the filesystem. Until then a missing workspace marks the
1056
- // session as "ghost" (visible-but-flagged in the UI).
1057
508
  this.workspaceMissing = !(await fs
1058
509
  .stat(this.workspace)
1059
510
  .then(() => true)
1060
511
  .catch(() => false));
1061
- // goal lives next to the session log (dataDir), NOT in the workspace —
1062
- // migrate a legacy workspace GOAL.md once, then never touch the workspace
1063
512
  await this.migrateGoalFromWorkspace();
1064
513
  const stored = await this.readGoalStore();
1065
514
  this.goal = stored ?? { text: "", status: "active", updatedAt: new Date().toISOString() };
1066
- // operator-maintained task list lives beside goal.md
1067
515
  this.todo = await fs.readFile(this.todoFile, "utf8").catch(() => "");
1068
- // NO skill scan here. It was the single largest boot cost — 493 agents
1069
- // each walking their roots (readdir + stat, recursively) measured 2120 ms of
1070
- // a 4.7 s boot. Every one of those walks was redundant:
1071
- //
1072
- // - the turn loop calls `refreshSkills()` at the TOP of every turn, before
1073
- // the system prompt (which embeds the catalogue) is built, so the scan is
1074
- // always current before any LLM request
1075
- // - the `list_skills` tool calls it itself before reading the cache
1076
- // - `/api/agents/:id/skills` (what the panel uses) does its own fresh
1077
- // `currentSkills()` walk and never reads this cache
1078
- //
1079
- // Caching the walk was the other option and was WORSE: the fingerprint exists
1080
- // to notice a NESTED skill's add/edit, which does not change its root's mtime,
1081
- // so any TTL/mtime key reintroduces the staleness the walk was written to fix
1082
- // (the skills-fingerprint tests pin exactly that). Skipping boot's copy costs
1083
- // no freshness at all.
1084
- //
1085
- // The conversation is likewise not restored here: boot cost stays O(agents),
1086
- // not O(history). Both are rebuilt lazily by ensureReady()/the turn loop.
1087
516
  if (this.opts.restoreSession) {
1088
517
  this.status = "stopped";
1089
518
  this.statusReason = "session not loaded — select it or send a prompt";
1090
519
  bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
1091
520
  }
1092
521
  }
1093
- /**
1094
- * Resolve the session/workspace this agent operates on.
1095
- *
1096
- * Public because `load()` is a thin wrapper over it and a test that reproduces a
1097
- * restart needs the same step WITHOUT the reload side effects.
1098
- */
1099
522
  ensureReady() {
1100
523
  if (!this.readyPromise) {
1101
524
  this.readyPromise = (async () => {
@@ -1108,18 +531,9 @@ export class Agent {
1108
531
  }
1109
532
  return this.readyPromise;
1110
533
  }
1111
- /** Explicit load (e.g. the user clicked the agent in the UI): stopped → idle. */
1112
534
  async load() {
1113
535
  await this.ensureReady();
1114
536
  }
1115
- /**
1116
- * Answer tool_calls in a RESTORED history that have no tool_result.
1117
- *
1118
- * The ordinary backstop (`answerUnansweredToolCalls`) works on an assistant
1119
- * turn inside a live loop. After a restart there is no loop: the call is in
1120
- * the log, the process that was running it is gone, and nothing else will ever
1121
- * write its result.
1122
- */
1123
537
  async answerUnansweredRestoredCalls() {
1124
538
  const seen = new Set();
1125
539
  for (const ev of await readEvents(this.log.filePath)) {
@@ -1149,42 +563,14 @@ export class Agent {
1149
563
  detail: "a restart left these tool calls without a result; they are marked not completed (#54)",
1150
564
  });
1151
565
  }
1152
- /** harness-managed files inside the session directory */
1153
566
  get goalFile() {
1154
567
  return path.join(this.opts.sessionDir, "goal.md");
1155
568
  }
1156
- /**
1157
- * Durable notes — AGENT-scoped, not session-scoped.
1158
- *
1159
- * silently orphaned everything it had learned. Across this machine, 66 agents had more
1160
- * than one session (median 6 each) and only 5 of them had notes in any session at all —
1161
- * the notes were written, then abandoned when the next session was minted. A "durable
1162
- * note" that dies with the timeline is not durable.
1163
- *
1164
- * The file lives next to the agent's session DIRECTORY (`sessions/<agent>/memory.md`),
1165
- * a level above the per-session dirs, so every session of that agent sees the same
1166
- * notes. A session's own `memory.md` from an older build is migrated on first read.
1167
- */
1168
569
  get memoryFile() {
1169
570
  const perSession = path.join(this.opts.sessionDir, "memory.md");
1170
571
  const parent = path.dirname(this.opts.sessionDir);
1171
- /**
1172
- * Agent-scoped when the session dir sits inside a `sessions/` ROOT, session-scoped otherwise.
1173
- *
1174
- * The previous test was `/-[0-9a-f]{6,}$/` against the basename — "looks like
1175
- * `<agent>-<hex>`". MEASURED: `mkdtemp` suffixes are 6 alphanumerics, so ~0.2% of them are
1176
- * all-hex and match. That made the memory file's LOCATION depend on a random temp name, which
1177
- * is why a test asserting `read_memory(query)` failed in CI and passed locally: the fixture
1178
- * `mems-AbCdEf` resolved to the agent-wide file, and a sibling test's notes were then in
1179
- * scope.
1180
- *
1181
- * The structural fact is reliable: `Master.resolveSessionDir` always mints session dirs
1182
- * inside its `sessions/` root, so a parent named `sessions` means "one level down from the
1183
- * agent". A bare temp dir has no such parent and stays session-scoped.
1184
- */
1185
572
  return path.basename(parent) === "sessions" ? path.join(parent, "memory.md") : perSession;
1186
573
  }
1187
- /** the pre-migration location, read once so notes are not lost on upgrade */
1188
574
  get legacyMemoryFile() {
1189
575
  return path.join(this.opts.sessionDir, "memory.md");
1190
576
  }
@@ -1204,7 +590,6 @@ export class Agent {
1204
590
  const raw = await this.readGoalStoreRaw();
1205
591
  return raw === null ? null : this.parseGoalFile(raw);
1206
592
  }
1207
- /** One-time import of a pre-0.6.0 workspace GOAL.md; content is preserved. */
1208
593
  async migrateGoalFromWorkspace() {
1209
594
  const legacy = path.join(this.workspace, "GOAL.md");
1210
595
  let wsText;
@@ -1212,7 +597,7 @@ export class Agent {
1212
597
  wsText = await fs.readFile(legacy, "utf8");
1213
598
  }
1214
599
  catch {
1215
- return; // nothing to migrate
600
+ return;
1216
601
  }
1217
602
  const existing = await this.readGoalStoreRaw();
1218
603
  if (existing === null)
@@ -1224,14 +609,6 @@ export class Agent {
1224
609
  to: this.goalFile,
1225
610
  });
1226
611
  }
1227
- /**
1228
- * Move notes from the per-session file into the agent-wide one — once.
1229
- *
1230
- * Older builds wrote memory.md inside the session dir. Those notes are real knowledge and
1231
- * must not be abandoned by the upgrade that makes them durable, so the session's file is
1232
- * APPENDED to the agent-wide one (never replacing it: the agent-wide file may already hold
1233
- * notes from other sessions) and then removed so a later session does not re-import it.
1234
- */
1235
612
  async migrateMemoryToAgentScope() {
1236
613
  const from = this.legacyMemoryFile;
1237
614
  const to = this.memoryFile;
@@ -1242,7 +619,7 @@ export class Agent {
1242
619
  legacy = (await fs.readFile(from, "utf8")).trim();
1243
620
  }
1244
621
  catch {
1245
- return; // nothing to migrate
622
+ return;
1246
623
  }
1247
624
  if (!legacy) {
1248
625
  await fs.rm(from, { force: true }).catch(() => { });
@@ -1257,15 +634,8 @@ export class Agent {
1257
634
  console.log(`[teapot] memory: migrated ${legacy.length} bytes to ${path.basename(to)} (agent scope)`);
1258
635
  }
1259
636
  catch {
1260
- /* keep the session copy if the agent-wide write failed — losing notes is worse */
1261
637
  }
1262
638
  }
1263
- /**
1264
- * Resolve a leading sub_fork header: load the parent session's file (and
1265
- * recurse if THAT one is itself a sub_fork), take its lineage up to the
1266
- * recorded event, and return it prepended to our own events. Never copies
1267
- * bytes into our log — the prefix lives in the parent's file. Cycle-safe.
1268
- */
1269
639
  async spliceSubForkPrefix(own, baseDir = path.dirname(this.log.filePath)) {
1270
640
  const header = own.find((e) => e.type === "sub_fork");
1271
641
  if (!header)
@@ -1275,7 +645,6 @@ export class Agent {
1275
645
  return own;
1276
646
  const parentDir = path.join(baseDir, d.parentSession);
1277
647
  let parentEvents = await readEvents(path.join(parentDir, "chat.jsonl")).catch(() => []);
1278
- // grandchild chains: the parent file may itself open with a sub_fork
1279
648
  if (parentEvents.some((e) => e.type === "sub_fork")) {
1280
649
  parentEvents = await this.spliceSubForkPrefix(parentEvents, parentDir);
1281
650
  }
@@ -1283,7 +652,6 @@ export class Agent {
1283
652
  console.warn(`[teapot] sub_fork: parent session ${d.parentSession} unreadable — starting without inherited context`);
1284
653
  return own;
1285
654
  }
1286
- // lineage of the parent up to (and including) the recorded fork point
1287
655
  const byId = new Map(parentEvents.map((e) => [e.id, e]));
1288
656
  const tip = byId.get(d.upToEvent);
1289
657
  if (!tip) {
@@ -1293,7 +661,7 @@ export class Agent {
1293
661
  const prefix = [];
1294
662
  for (let cur = tip; cur; cur = cur.parent ? byId.get(cur.parent) : undefined) {
1295
663
  if (prefix.some((p) => p.id === cur.id))
1296
- break; // cycle guard
664
+ break;
1297
665
  prefix.push(cur);
1298
666
  }
1299
667
  prefix.reverse();
@@ -1301,39 +669,17 @@ export class Agent {
1301
669
  prefix.shift();
1302
670
  return [...prefix, ...own.filter((e) => e !== header)];
1303
671
  }
1304
- /**
1305
- * Restore the conversation from the JSONL log exactly once, on demand.
1306
- * Everything that touches history (prompts, start, fork, UI selection)
1307
- * funnels through here; boot stays cheap no matter how many sessions exist.
1308
- *
1309
- * Strategy: find the most recent event overall (its branch is where the
1310
- * agent was), walk the `parent` chain backwards (crossing fork points),
1311
- * then replay events forward into ChatMessages. Meta-tool calls
1312
- * (finish / report_progress) never produced logged tool results, so we
1313
- * synthesize their responses to keep the message sequence valid.
1314
- */
1315
672
  async restoreFromLog() {
1316
673
  const own = await readEvents(this.log.filePath);
1317
674
  if (own.length === 0)
1318
675
  return;
1319
- // a sub_fork header points at the session this agent branched from —
1320
- // splice that prefix in from the parent's file (recursively, cycle-safe)
1321
- // instead of ever copying parent history into our own log
1322
676
  const events = await this.spliceSubForkPrefix(own);
1323
677
  const lineage = lineageOf(events);
1324
678
  if (!lineage.length)
1325
679
  return;
1326
680
  const last = lineage[lineage.length - 1];
1327
681
  const msgs = rebuildMessagesFrom(lineage);
1328
- // Rebuild lifetime stats from the log: turns/tools/tokens/compactions are
1329
- // SESSION totals, so a reload must not reset them to zero. Counted over
1330
- // the whole file (not just the current branch) to match what the runtime
1331
- // panel showed before the restart.
1332
682
  for (const e of own) {
1333
- // the title comes from the FIRST user prompt, which never changes, so
1334
- // deriving it here is free — this loop already walks the whole log at
1335
- // restore. Only the first one is taken; later prompts are a different
1336
- // subject and would make the sidebar label churn under the reader.
1337
683
  if (this.sessionTitle === undefined && e.type === "prompt" && e.data?.source === "user") {
1338
684
  this.sessionTitle = sessionTitleFrom(e.data?.text) ?? undefined;
1339
685
  }
@@ -1350,23 +696,11 @@ export class Agent {
1350
696
  this.stats.inputTokens += u.inputTokens ?? 0;
1351
697
  this.stats.outputTokens += u.outputTokens ?? 0;
1352
698
  this.stats.cachedInputTokens += u.cachedInputTokens ?? 0;
1353
- // NO cost accumulation here: setModelPricing() rebuilds costUsd from
1354
- // this same log with a REPLACE — whichever runs first, adding on top
1355
- // of a rebuilt number counted every usage event twice (a restart plus
1356
- // the first interaction showed ~2x). The recompute is authoritative.
1357
699
  }
1358
700
  else if (e.type === "system_note") {
1359
701
  const d = e.data;
1360
702
  if (d.event === "context-compacted")
1361
703
  this.stats.compactions++;
1362
- /**
1363
- * Re-seed the durable rule set so it survives a restart.
1364
- *
1365
- * The newest `compaction-rules` note is the accumulated set; without this a reload starts
1366
- * empty and the first compaction after it has nothing to enforce — the failure the whole
1367
- * mechanism exists to prevent. Seeded through `extractRules` rather than trusting the
1368
- * stored shape, so the note is the same one a human can read in the log.
1369
- */
1370
704
  if (d.event === "compaction-rules" && Array.isArray(d.rules)) {
1371
705
  const seeded = d.rules
1372
706
  .map((line) => extractRules(`${RULES_HEADING}\n- ${String(line)}`)[0])
@@ -1376,16 +710,11 @@ export class Agent {
1376
710
  }
1377
711
  }
1378
712
  else if (e.type === "compaction") {
1379
- // keep the NEWEST notes: they are what the next compaction updates, and
1380
- // without this a restart would silently fall back to re-summarizing
1381
713
  const d = e.data;
1382
714
  if (typeof d.summary === "string" && d.summary)
1383
715
  this.lastCompactSummary = d.summary;
1384
716
  }
1385
717
  }
1386
- // newest usage event seeds the context gauge right after reload —
1387
- // inputTokens from the LAST turn is the live context size, and cached
1388
- // must ride along or the "⚡️ N% cached" pill disappears after a restart
1389
718
  for (let i = own.length - 1; i >= 0; i--) {
1390
719
  if (own[i].type === "usage") {
1391
720
  const u = own[i].data;
@@ -1402,35 +731,7 @@ export class Agent {
1402
731
  if (msgs.length > 0) {
1403
732
  this.messages = msgs;
1404
733
  this.currentBranch = last.branch;
1405
- // re-apply pruning to the restored history. `maybePrune` clips
1406
- // oversized tool output IN PLACE, so a live session keeps its clipped
1407
- // text while the log still holds the full result — a restart therefore
1408
- // handed the provider a strictly LARGER prompt than the one that had just
1409
- // succeeded, which is a size regression that only shows up after a
1410
- // reload. It is a size fix, not a semantic one: the model has already
1411
- // seen and acted on that output, and the pruned form keeps the head plus
1412
- // a marker, exactly as it did live.
1413
- //
1414
- // FORCED: this reproduces what the live session had already done, so it must
1415
- // not consult the "is there room" bar — a restored context that happens to sit
1416
- // below it still has to match the prompt the provider last accepted.
1417
734
  await this.maybePrune(true);
1418
- // answer tool_calls the log says are still open.
1419
- //
1420
- // The restore path rebuilds `msgs` straight from the log, so a call whose
1421
- // process died mid-command comes back as an assistant turn with
1422
- // `tool_calls` and NO matching `tool_result`. Nothing ever answers it —
1423
- // not `stop()` (there is no turn to stop) and not `dispose()` (the process
1424
- // is already gone). The row then renders as still running for good.
1425
- //
1426
- // every one a `bash`, each followed immediately by
1427
- // `session-restored` / `idle->stopped disposed`. The shape is a restart
1428
- // (or a crash) landing while a long command was in flight, which is
1429
- // precisely when a human is most likely to notice "it never finished".
1430
- //
1431
- // So close them ON RESTORE, before the restored history is used. This is
1432
- // the same backstop as the mid-turn paths, reached from the one place
1433
- // those could not: after the process is gone.
1434
735
  await this.answerUnansweredRestoredCalls();
1435
736
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
1436
737
  event: "session-restored",
@@ -1439,11 +740,6 @@ export class Agent {
1439
740
  });
1440
741
  }
1441
742
  }
1442
- /**
1443
- * Force a compaction pass (manual /compact). Serialized on the run chain so
1444
- * it lands at a safe point relative to a running loop; reports whether
1445
- * anything was actually compacted.
1446
- */
1447
743
  async compactNow() {
1448
744
  const ran = await this.enqueue(async () => {
1449
745
  await this.ensureReady();
@@ -1453,19 +749,7 @@ export class Agent {
1453
749
  });
1454
750
  return { ran };
1455
751
  }
1456
- /**
1457
- * Edit a previously-sent prompt: fork the conversation at that point,
1458
- * replace its text, and optionally fold everything that happened after it
1459
- * into a summary note on the new branch (ChatGPT-edit style). The agent
1460
- * must not be running — editing under a live loop would race its history.
1461
- */
1462
752
  async editPromptAt(eventId, text, tail) {
1463
- // "running" is not the only live state: parkForTool() rewrites the status to
1464
- // "idle" while the loop is still very much alive inside a parked tool
1465
- // (wait_children does this), and queued prompts have not been consumed yet.
1466
- // Editing underneath either of those replaces the very `messages` array the
1467
- // agent is about to resume into, so its old-branch work kept running
1468
- // against the new branch.
1469
753
  if (this.status === "running" || this.parkedByTool || this.pendingPrompts.length)
1470
754
  throw new Error("agent is running — stop it before editing history");
1471
755
  const all = await readEvents(this.log.filePath);
@@ -1477,7 +761,7 @@ export class Agent {
1477
761
  if (tIdx === -1)
1478
762
  throw new Error("prompt is not on this agent's current lineage");
1479
763
  const kept = lineage.slice(0, tIdx);
1480
- const dropped = lineage.slice(tIdx); // includes the original prompt itself
764
+ const dropped = lineage.slice(tIdx);
1481
765
  const msgs = rebuildMessagesFrom(kept);
1482
766
  if (tail === "summarize" && dropped.length > 0) {
1483
767
  try {
@@ -1494,21 +778,11 @@ export class Agent {
1494
778
  }
1495
779
  }
1496
780
  catch {
1497
- // summarization is best-effort; the fork proceeds without notes
1498
781
  }
1499
782
  }
1500
783
  msgs.push({ role: "user", content: text });
1501
784
  const newBranch = `br${this.branchCount()}${Date.now().toString(36).slice(-4)}`;
1502
- // Quiesce BEFORE rewriting history: an in-flight LLM call, a parked tool or
1503
- // a queued prompt would otherwise resume into the NEW messages array and
1504
- // keep running the old branch's work here. Mirrors stop(), which is
1505
- // the only other place that cancels this state.
1506
785
  await this.quiesceForHistoryEdit();
1507
- // The fork event must be appended while currentBranch is still the OLD
1508
- // one. EventLog links parents PER BRANCH (lastByBranch), so appending it
1509
- // under the new branch gave it parent:null — severing the chain, and
1510
- // lineageOf() then returned only the post-fork events, so a restart rebuilt
1511
- // the history with none of the pre-edit context.
1512
786
  await this.log.append("fork", this.currentSession, this.currentBranch, {
1513
787
  fromSession: this.currentSession,
1514
788
  fromBranch: this.currentBranch,
@@ -1518,17 +792,6 @@ export class Agent {
1518
792
  droppedEvents: dropped.length,
1519
793
  tailMode: tail,
1520
794
  });
1521
- // Seed the new branch's parent link so its first event chains across
1522
- // instead of starting a disconnected parent:null history.
1523
- //
1524
- // seed from the CUT POINT, not from this branch's tip. The fork event
1525
- // records where the edit was made (`fromEvent` = the last KEPT event), but
1526
- // `lastEventId(currentBranch)` is the newest event on the branch — which is
1527
- // one of the events this edit just DISCARDED. Seeding from the tip chained
1528
- // the new branch to the discarded tail, so lineageOf() walked straight back
1529
- // through it and a restart resurrected every dropped turn.
1530
- //
1531
- // The cut point is the right parent: it is the last event that survives.
1532
795
  const cutPoint = kept.at(-1)?.id ?? null;
1533
796
  this.log.seedBranch(newBranch, cutPoint);
1534
797
  this.currentBranch = newBranch;
@@ -1537,45 +800,31 @@ export class Agent {
1537
800
  bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
1538
801
  return { droppedEvents: dropped.length, branch: newBranch };
1539
802
  }
1540
- /**
1541
- * Cancel everything that could resume against the history we are about to
1542
- * replace, then wait for the loop to actually stop.
1543
- *
1544
- * An edit rewinds the conversation, so any work still in flight belongs to
1545
- * the branch being discarded: an LLM call whose result would append to the
1546
- * new messages, a parked tool, and queued prompts that were never delivered
1547
- *. stopRequested is set and cleared around the wait so the agent is
1548
- * usable again once the edit lands.
1549
- */
1550
803
  async quiesceForHistoryEdit() {
1551
804
  if (!this.stopRequested) {
1552
805
  this.stopRequested = true;
1553
- this.abort?.abort(); // interrupt an in-flight LLM call
1554
- this.toolAbort.abort(); // kill subprocesses / interrupt a parked tool
1555
- this.wake?.(); // release wait_children
806
+ this.abort?.abort();
807
+ this.toolAbort.abort();
808
+ this.wake?.();
1556
809
  try {
1557
810
  await this.settled();
1558
811
  }
1559
812
  catch {
1560
- /* the loop is being abandoned; its errors are not ours to report */
1561
813
  }
1562
814
  this.parkedByTool = false;
1563
- this.pendingPrompts = []; // undelivered prompts belong to the old branch
815
+ this.pendingPrompts = [];
1564
816
  this.stopRequested = false;
1565
- // a fresh signal: toolCtx holds a copy of the reference, so republish
1566
817
  if (this.toolAbort.signal.aborted) {
1567
818
  this.toolAbort = new AbortController();
1568
819
  this.toolCtx.signal = this.toolAbort.signal;
1569
820
  }
1570
- this.bgExits = []; // old-branch job exits must not be folded in
821
+ this.bgExits = [];
1571
822
  }
1572
823
  }
1573
824
  parseGoalFile(text) {
1574
- // humans and agents may append their own status lines — latest wins
1575
825
  const all = [...text.matchAll(/status:\s*(\w+)/gi)];
1576
826
  const last = all[all.length - 1]?.[1];
1577
827
  const status = last === "done" ? "done" : last === "paused" ? "paused" : "active";
1578
- // keep bookkeeping lines out of the injected goal text
1579
828
  let body = text.trim();
1580
829
  for (let i = 0; i < 4; i++) {
1581
830
  const stripped = body.replace(/\n+(?:status|updated):[^\n]*$/i, "").trimEnd();
@@ -1583,7 +832,6 @@ export class Agent {
1583
832
  break;
1584
833
  body = stripped;
1585
834
  }
1586
- // verification contract (pi-goal-x style): a `verify:` block at the tail
1587
835
  let verify = "";
1588
836
  const vm = body.match(/\nverify:\s*\n([\s\S]+)$/i) ?? body.match(/^verify:\s*(.+)$/im);
1589
837
  if (vm) {
@@ -1596,7 +844,6 @@ export class Agent {
1596
844
  return out;
1597
845
  }
1598
846
  async writeGoalFile() {
1599
- // don't let previously-appended bookkeeping lines accumulate in the body
1600
847
  let body = this.goal.text.trimEnd();
1601
848
  for (let i = 0; i < 4; i++) {
1602
849
  const stripped = body.replace(/\n+(?:status|updated):[^\n]*$/i, "").trimEnd();
@@ -1609,9 +856,6 @@ export class Agent {
1609
856
  }
1610
857
  async setGoal(text) {
1611
858
  this.goal = { text, status: "active", updatedAt: new Date().toISOString() };
1612
- // a new goal is a new audit contract: the previous goal's rejection must
1613
- // not ride along as the new goal's "previous" lineage, and the attempt
1614
- // counters restart from zero (theirs counts rejects of THIS goal)
1615
859
  this.consecutiveAuditRejects = 0;
1616
860
  this.auditUnavailableStreak = 0;
1617
861
  this.lastRejectedAudit = null;
@@ -1620,29 +864,11 @@ export class Agent {
1620
864
  await this.writeGoalFile();
1621
865
  await this.log.append("goal", this.currentSession, this.currentBranch, { event: "set", text });
1622
866
  }
1623
- /** Persist the operator-maintained task list (todo.md). */
1624
867
  async setTodo(text, by = "human") {
1625
868
  this.todo = text;
1626
869
  await fs.writeFile(this.todoFile, text, "utf8");
1627
870
  await this.log.append("todo", this.currentSession, this.currentBranch, { event: "set", by });
1628
871
  }
1629
- /**
1630
- * Apply surgical changes to todo.md.
1631
- *
1632
- * Each entry names an item by its exact (normalised) text and says what to do with it:
1633
- * - `done` / `reopen` / `remove` act on an EXISTING checkbox line;
1634
- * - `add` appends a new unchecked line, even when nothing matches.
1635
- *
1636
- * `action` is optional and defaults to `done`, so every existing caller and every model
1637
- * that only sends `{item, done}` keeps working.
1638
- *
1639
- * Why not whole-list replacement, which Codex (`update_plan`) and DeepSeek Harness
1640
- * (`todo/write`) both use: `todo.md` is a file the OPERATOR also edits, and it holds
1641
- * headings, notes and prose — not only checkboxes. A model re-emitting "the list" would
1642
- * silently drop anything it did not repeat, which is exactly the data loss `set_memory`
1643
- * caused before `#48` (measured: 1028 → 83 bytes). The `content` argument still exists
1644
- * for a deliberate restructure.
1645
- */
1646
872
  async updateTodoItems(updates, by = "agent") {
1647
873
  const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
1648
874
  const lines = this.todo.split("\n");
@@ -1653,7 +879,6 @@ export class Agent {
1653
879
  for (const u of updates) {
1654
880
  const action = u.action ?? (u.done === false ? "reopen" : "done");
1655
881
  const target = norm(u.item);
1656
- // find the LAST matching checkbox line (later duplicates win)
1657
882
  let idx = -1;
1658
883
  for (let i = lines.length - 1; i >= 0; i--) {
1659
884
  const m = lines[i].match(/^(\s*[-*+] \[)([ xX])(\].*)$/);
@@ -1665,9 +890,6 @@ export class Agent {
1665
890
  }
1666
891
  }
1667
892
  if (action === "add") {
1668
- // An ADD repeats: the caller may add several items with the same wording, and
1669
- // refusing because one already exists would make "add" unpredictable. But adding
1670
- // the SAME text twice in one call is a mistake, so collapse it.
1671
893
  if (idx !== -1) {
1672
894
  unmatched.push(`${u.item} (already present — not added twice)`);
1673
895
  continue;
@@ -1690,7 +912,6 @@ export class Agent {
1690
912
  matched++;
1691
913
  }
1692
914
  if (matched > 0 || added > 0 || removed > 0) {
1693
- // trailing blank lines from a removal are tidied so the file does not grow a gap
1694
915
  while (lines.length > 1 && lines[lines.length - 1].trim() === "" && lines[lines.length - 2].trim() === "")
1695
916
  lines.pop();
1696
917
  this.todo = lines.join("\n");
@@ -1717,14 +938,6 @@ export class Agent {
1717
938
  parts.push(`NOT FOUND in todo.md: ${unmatched.map((s) => `"${s}"`).join(", ")} — check get_todo and retry with the exact wording, or use action:"add" to create it`);
1718
939
  return parts.join(". ");
1719
940
  }
1720
- /**
1721
- * `by` records WHY the status changed, because "active" alone is ambiguous.
1722
- *
1723
- * Two very different things produce it: the operator resuming a paused goal, and the auditor
1724
- * rejecting a `finish(goalComplete=true)`. The timeline rendered the second wording for both, so
1725
- * pressing ▶ working on a paused goal said "the audit rejected the finish" — reported as
1726
- * "pausedからの移動はrejectじゃないのでは?".
1727
- */
1728
941
  async setGoalStatus(status, by = "operator") {
1729
942
  this.goal = { ...this.goal, status, updatedAt: new Date().toISOString() };
1730
943
  await this.writeGoalFile();
@@ -1734,7 +947,6 @@ export class Agent {
1734
947
  by,
1735
948
  });
1736
949
  }
1737
- /** Set the goal's verification contract (completion requirements). */
1738
950
  async setGoalVerify(verify) {
1739
951
  this.goal = { ...this.goal, verify: verify.slice(0, 4000), updatedAt: new Date().toISOString() };
1740
952
  await this.writeGoalFile();
@@ -1743,7 +955,6 @@ export class Agent {
1743
955
  verify: verify.slice(0, 2000),
1744
956
  });
1745
957
  }
1746
- /** Record a verification-contract audit outcome (pi-goal-x style review). */
1747
958
  async setGoalAudit(verdict, feedback) {
1748
959
  this.goal = {
1749
960
  ...this.goal,
@@ -1752,14 +963,6 @@ export class Agent {
1752
963
  };
1753
964
  if (verdict === "approved")
1754
965
  this.goal.status = "done";
1755
- /**
1756
- * A rejection REOPENS the goal, and that is logged as its own status event.
1757
- *
1758
- * It used to flip `status` to "active" with no event at all, so the timeline only ever saw a
1759
- * bare "active" and could not tell this apart from an operator resuming a paused goal — which
1760
- * is why the `by` field exists. Logged here rather than through `setGoalStatus` because the
1761
- * audit entry below must be written in the same order the state changed.
1762
- */
1763
966
  const reopened = verdict !== "approved" && this.goal.status === "done";
1764
967
  if (reopened) {
1765
968
  this.goal.status = "active";
@@ -1779,54 +982,16 @@ export class Agent {
1779
982
  snapshot() {
1780
983
  return {
1781
984
  id: this.opts.id,
1782
- /**
1783
- * The DISPLAYED status, which must never contradict `live`.
1784
- *
1785
- * idle なのにコントロールは stop ボタンが表示されてて微妙" — and the same for
1786
- * `wait_children`. `live` was already true for both (a parked tool and a running
1787
- * background shell are work in flight, and stop() ends them), so only the badge was
1788
- * wrong: it said idle while the header offered ■ stop.
1789
- *
1790
- * Rather than patch each site, the contradiction is fixed HERE: if there is work in
1791
- * flight that stop would stop, the status says "waiting", never "idle". Callers that
1792
- * need the raw state for logic use `live`, which is unchanged.
1793
- */
1794
985
  status: this.status === "idle" && this.isLive() ? "waiting" : this.status,
1795
986
  statusReason: this.statusReason,
1796
- /**
1797
- * Is there work in flight that `stop` would actually stop?
1798
- *
1799
- * `status` alone is not enough to answer that, and the UI needs it: a
1800
- * parked tool (`wait_children`) rewrites the status to "idle" while the
1801
- * loop is very much alive, so a client keying "stop vs start" off
1802
- * `status === "running"` offered **start** on a running agent and the
1803
- * user could not stop it. This is the same "running is not the
1804
- * only live state" fact #38 had to be taught about editing.
1805
- */
1806
987
  live: this.isLive(),
1807
988
  workspace: this.workspace,
1808
- // through the getter, so a workspace that came back stops being reported as 👻
1809
989
  workspaceMissing: this.isGhost,
1810
990
  session: this.currentSession,
1811
- // a human label for this session, so the sidebar does not ask the
1812
- // operator to read `teapot-10-16854917`. Absent when the first prompt had
1813
- // no usable line — the UI then falls back to the id.
1814
991
  ...(this.sessionTitle ? { sessionTitle: this.sessionTitle } : {}),
1815
992
  branch: this.currentBranch,
1816
993
  goal: {
1817
994
  status: this.goal.status,
1818
- /**
1819
- * The goal is sent WHOLE.
1820
- *
1821
- * This used to be `slice(0, 400)`, which reads as a sane payload cap and behaves as a
1822
- * silent data loss: the right panel's goal card is the only place the operator sees the
1823
- * text they wrote, so a long goal stopped mid-sentence. Reported as "goalに設定されている
1824
- * 文字列が長いとフロントエンドのgoal欄で後半が切られる? 全部表示されてほしい".
1825
- *
1826
- * payload argument does not survive the numbers either — this rides one `/api/agents`
1827
- * response that is already carrying every agent's stats, and a goal is a few KB at worst.
1828
- * `verify` keeps its own cap below, where the auditor writes it.
1829
- */
1830
995
  text: this.goal.text,
1831
996
  ...(this.goal.verify ? { verify: this.goal.verify.slice(0, 1000) } : {}),
1832
997
  ...(this.goal.audit ? { audit: this.goal.audit } : {}),
@@ -1835,51 +1000,27 @@ export class Agent {
1835
1000
  stats: { ...this.stats, costUsd: this.modelPricing ? this.stats.costUsd : undefined },
1836
1001
  model: this.opts.llm.model,
1837
1002
  provider: this.opts.provider,
1838
- // #47/#150: the effort in force, and whether this model can take one at all
1839
1003
  ...(this.opts.reasoningEffort ? { reasoningEffort: this.opts.reasoningEffort } : {}),
1840
- effortSupported: effortChoosable(this.opts.supportedParameters),
1841
- // the levels to OFFER, when the endpoint publishes them: a model with
1842
- // ["max","high","low"] must not be shown minimal/medium, which would either be
1843
- // refused or silently clamped
1004
+ /**
1005
+ * Offer the control unless the catalog SAYS the model cannot reason.
1006
+ *
1007
+ * `effortChoosable(undefined)` is `true` on purpose: a model missing from models.dev is
1008
+ * UNKNOWN, not unsupported, and the wire recovers from a rejected `reasoning_effort`
1009
+ * (`isEffortUnsupported`). The previous rule gated this on OpenRouter alone, which hid the
1010
+ * control for every other provider whose model did support it.
1011
+ */
1012
+ effortSupported: this.opts.supportedEfforts?.length ? true : effortChoosable(undefined),
1844
1013
  ...(this.opts.supportedEfforts?.length ? { effortOptions: this.opts.supportedEfforts } : {}),
1845
1014
  sessionDir: this.opts.sessionDir,
1846
1015
  ctx: {
1847
1016
  usedTokens: this.lastUsage?.input ?? this.estimateTokens(),
1848
1017
  compactAt: this.opts.contextTokenBudget,
1849
- // false when the budget was derived (75% of a known window) instead of
1850
- // being pinned in config — lets the UI say "75% of window" rather than
1851
- // mislabeling every inferred-window model "manual override"
1852
1018
  compactAtIsManual: this.compactBudgetIsManual,
1853
1019
  window: this.opts.contextWindowTokens || 0,
1854
- // live compaction phase ("summarizing"/"harvesting"/"done") — lets any
1855
- // client show progress even if it missed the bus event
1856
1020
  ...(this.compactPhase ? { compacting: this.compactPhase } : {}),
1857
1021
  },
1858
1022
  pendingPrompts: this.pendingPrompts.length,
1859
- // the IDS as well as the count. The web UI reconciles its local echo
1860
- // list against the live queue, and it was doing that by POSITION
1861
- // (slice(0, queued)) because only a count was published — so whenever the
1862
- // count dipped below the true queue length (a mid-queue cancellation, or a
1863
- // momentary gap), the NEWEST still-queued echoes were dropped and their
1864
- // undelivered log rows reappeared as settled "sent" rows. Identity needs
1865
- // the ids to travel; the count alone cannot express "these two".
1866
- //
1867
- // Bounded like the rest of the snapshot, and ids are opaque, so this
1868
- // cannot carry prompt text.
1869
1023
  pendingPromptIds: this.pendingPrompts.map((p) => p.id).filter((x) => !!x).slice(0, 64),
1870
- // the QUEUE itself, not just its ids. The UI's echo rows are
1871
- // memory-only (like liveByAgent and timelineCache), so after a reload the
1872
- // queue survives on the server while the UI has nothing to render — the
1873
- // queued message then appears only as its log row, i.e. already sent.
1874
- //
1875
- // Ids alone cannot fix that: they identify a prompt, they do not carry its
1876
- // text, so the UI cannot rebuild an echo from them. This is the queue the
1877
- // operator is looking at, already on screen in the ⏳ badge, so shipping it
1878
- // is not new exposure — it is the same data in a shape the timeline can
1879
- // use.
1880
- //
1881
- // `at` is the enqueue time so the echo keeps a stable position and id
1882
- // across rebuilds; `sent` mirrors the echo flag the UI already tracks.
1883
1024
  pendingPromptQueue: this.pendingPrompts
1884
1025
  .filter((p) => !!p.id)
1885
1026
  .slice(0, 64)
@@ -1890,59 +1031,27 @@ export class Agent {
1890
1031
  at: Date.now() - (this.pendingPrompts.length - i),
1891
1032
  ...(p.images?.length ? { images: p.images.slice(0, 8) } : {}),
1892
1033
  })),
1893
- todo: this.todo.slice(0, 32_000), // match set_todo's cap — no silent truncation
1034
+ todo: this.todo.slice(0, 32_000),
1894
1035
  parent: this.opts.parent,
1895
1036
  awaiting: this.awaitingUser,
1896
1037
  autoContinue: this.opts.autoContinue,
1897
1038
  autoCompact: this.opts.autoCompact,
1898
1039
  };
1899
1040
  }
1900
- /**
1901
- * Queue a user prompt. Returns immediately: the event is logged right away
1902
- * (so every connected UI sees it instantly) and the text is handed to the
1903
- * model at the next turn boundary — never mid-turn, and never blocked by
1904
- * the running loop. The very first prompt on a fresh boot also triggers the
1905
- * lazy session restore (before the mailbox is filled, so no duplicates).
1906
- */
1907
1041
  enqueuePrompt(text, source = "user", images) {
1908
- // a prompt during a tool park (wait_children) takes over instantly:
1909
- // unpark fixes the DISPLAY, aborting the park signal actually ends the
1910
- // wait — the run chain resumes with this prompt drained at the boundary
1911
1042
  if (this.parkedByTool) {
1912
1043
  this.toolAbort.abort();
1913
- // the abort signal is one-shot — rearm it AND republish on toolCtx
1914
- // (toolCtx holds a COPY of the signal reference, so replacing only
1915
- // this.toolAbort left the park listening to the dead controller)
1916
1044
  this.toolAbort = new AbortController();
1917
1045
  this.toolCtx.signal = this.toolAbort.signal;
1918
1046
  }
1919
1047
  this.unparkFromTool();
1920
1048
  this.wake?.();
1921
- // a user message means the operator JUST checked in — they obviously know
1922
- // the state, so don't nag with a progress report right after
1923
1049
  if (source === "user") {
1924
1050
  this.lastProgressAt = Date.now();
1925
1051
  this.activityChars = 0;
1926
1052
  this.turnsSinceProgress = 0;
1927
1053
  }
1928
- // stable id shared by the log event and the UI's pending echo — the UI
1929
- // flips its echo to "sent" when prompt-delivered carries this id back
1930
1054
  const promptId = source === "user" ? `u${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` : "";
1931
- // the queue entry is registered SYNCHRONOUSLY, before the async work.
1932
- //
1933
- // It used to be pushed inside the `.then()`, so it only existed once
1934
- // `ensureReady()` resolved — while `cancelPrompt` reads the array
1935
- // synchronously. A cancel issued in that window found nothing, returned null,
1936
- // got a 409, and the UI discarded the draft: the operator saw a withdrawn
1937
- // prompt that the model then received and acted on.
1938
- //
1939
- // The wide case is worse than the same-tick one: an agent still restoring a
1940
- // 4,000-event session log holds that promise for real wall-clock time, so the
1941
- // cancel essentially always loses.
1942
- //
1943
- // Registering first is safe because the queue is drained only at a turn
1944
- // boundary (`drainPendingPrompts`), which cannot run inside this
1945
- // synchronous stretch. The log append still happens in order, after.
1946
1055
  const entry = { source, text, images, ...(promptId ? { id: promptId } : {}) };
1947
1056
  this.pendingPrompts.push(entry);
1948
1057
  void this.ensureReady()
@@ -1953,7 +1062,6 @@ export class Agent {
1953
1062
  ...(promptId ? { promptId } : {}),
1954
1063
  }))
1955
1064
  .then(() => {
1956
- // a session's label comes from its FIRST user prompt; the host stores it once
1957
1065
  if (source === "user")
1958
1066
  this.opts.onFirstUserPrompt?.(this.currentSession);
1959
1067
  })
@@ -1961,14 +1069,10 @@ export class Agent {
1961
1069
  .catch(() => { });
1962
1070
  return promptId;
1963
1071
  }
1964
- /**
1965
- * Withdraw a still-pending user prompt (before it reaches an LLM call).
1966
- * Returns the text so the UI can put it back into the composer draft.
1967
- */
1968
1072
  cancelPrompt(promptId) {
1969
1073
  const i = this.pendingPrompts.findIndex((p) => p.id === promptId);
1970
1074
  if (i === -1)
1971
- return null; // already delivered or unknown
1075
+ return null;
1972
1076
  const [p] = this.pendingPrompts.splice(i, 1);
1973
1077
  void this.log
1974
1078
  .append("system_note", this.currentSession, this.currentBranch, {
@@ -1980,17 +1084,9 @@ export class Agent {
1980
1084
  .catch(() => { });
1981
1085
  return p.text;
1982
1086
  }
1983
- /**
1984
- * Hand queued user prompts to the model at a turn boundary. Each consumed
1985
- * prompt is logged as a system_note (prompt-delivered) so the UI can flip
1986
- * its pending echo to "sent" at exactly the moment the text enters an LLM
1987
- * call payload — not merely when it was logged.
1988
- */
1989
1087
  drainPendingPrompts() {
1990
1088
  this.drainBackgroundExits();
1991
1089
  for (const p of this.pendingPrompts.splice(0)) {
1992
- // multimodal: images ride as OpenAI content parts (text first, then
1993
- // images); plain prompts keep the string form for cache friendliness
1994
1090
  if (p.images?.length) {
1995
1091
  this.messages.push({
1996
1092
  role: "user",
@@ -2006,30 +1102,12 @@ export class Agent {
2006
1102
  }
2007
1103
  if (p.source === "user") {
2008
1104
  const id = p.id ?? `u${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
2009
- // queued here, MARKED at the request boundary instead — see
2010
- // markDeliveredPrompts(). Text is kept so the marker can carry the same
2011
- // preview the UI shows.
2012
1105
  this.undeliveredPrompts.push({ id, preview: p.text.slice(0, 80) });
2013
1106
  }
2014
1107
  }
2015
1108
  }
2016
- /** ids of user prompts that have entered an LLM call payload (recent first-capped) */
2017
1109
  deliveredPrompts = [];
2018
- /**
2019
- * User prompts consumed from the queue but not yet MARKED as delivered.
2020
- *
2021
- * They are drained at a turn boundary, but the `prompt-delivered` marker must
2022
- * wait until the request that carries them actually starts — which is later,
2023
- * and after `maybeCompact()`, which can itself call the LLM.
2024
- */
2025
1110
  undeliveredPrompts = [];
2026
- /**
2027
- * Log `prompt-delivered` for every prompt whose outbound request is starting.
2028
- *
2029
- * the marker's meaning is "the request carrying this prompt has begun".
2030
- * It used to fire in drainPendingPrompts, i.e. before the request, so the
2031
- * timeline reordered by a moment the API never saw.
2032
- */
2033
1111
  async markDeliveredPrompts() {
2034
1112
  if (this.undeliveredPrompts.length === 0)
2035
1113
  return;
@@ -2037,7 +1115,6 @@ export class Agent {
2037
1115
  this.undeliveredPrompts = [];
2038
1116
  for (const { id, preview } of pending) {
2039
1117
  this.deliveredPrompts.push(id);
2040
- // cap: the UI only needs recent confirmations
2041
1118
  if (this.deliveredPrompts.length > 64)
2042
1119
  this.deliveredPrompts.shift();
2043
1120
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
@@ -2048,40 +1125,22 @@ export class Agent {
2048
1125
  }
2049
1126
  bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
2050
1127
  }
2051
- /**
2052
- * Background jobs that exited since the last LLM call. Their outcomes are
2053
- * folded into ONE harness message at the turn boundary — the agent learns
2054
- * exit codes and output tails without polling bash_output.
2055
- */
2056
1128
  bgExits = [];
2057
1129
  onBackgroundJobExitQueued(info) {
2058
1130
  this.bgExits.push(info);
2059
1131
  if (this.bgExits.length > 32)
2060
- this.bgExits.shift(); // cap runaway spam
1132
+ this.bgExits.shift();
2061
1133
  bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
2062
- // the round ended BECAUSE this job was running (auto-continue is
2063
- // suppressed while bg jobs are alive) — its exit IS the wake-up call.
2064
- // Without this the report would sit unread until the operator poked
2065
- // the agent. Stopped agents stay stopped (a deliberate human stop).
2066
- //
2067
- // Checking status once is NOT enough: the exit can land in the window
2068
- // between the loop's hasRunningBgShells() check (which breaks the round
2069
- // out) and the loop actually flipping to idle. The callback then sees
2070
- // "running", skips start(), and the agent goes idle with the report
2071
- // unread — wedged until a human pokes it. So remember that the loop was
2072
- // still winding down, and re-check once it has settled.
2073
1134
  const windingDown = this.status === "running";
2074
1135
  if (!this.stopRequested && (this.status === "idle" || windingDown)) {
2075
- // park the decision until the in-flight round has actually settled
2076
1136
  void this.enqueue(async () => {
2077
1137
  if (this.stopRequested)
2078
- return; // a stop raced us — respect it
1138
+ return;
2079
1139
  if (this.status === "idle")
2080
1140
  this.start(`background job ${info.id} finished`);
2081
1141
  });
2082
1142
  }
2083
1143
  }
2084
- /** fold queued background-exit reports into the next LLM call (once each) */
2085
1144
  drainBackgroundExits() {
2086
1145
  if (!this.bgExits.length)
2087
1146
  return;
@@ -2097,7 +1156,6 @@ export class Agent {
2097
1156
  this.messages.push({ role: "user", content: text });
2098
1157
  void this.log.append("prompt", this.currentSession, this.currentBranch, { source: "harness", text }).catch(() => { });
2099
1158
  }
2100
- /** Resolves when all queued work (including a running loop) has settled. */
2101
1159
  settled() {
2102
1160
  return this.runChain.catch(() => { });
2103
1161
  }
@@ -2106,29 +1164,15 @@ export class Agent {
2106
1164
  this.runChain = p.then(() => { }, () => { });
2107
1165
  return p;
2108
1166
  }
2109
- /** Start (or resume) autonomous operation toward the goal. */
2110
1167
  start(reason = "start") {
2111
- // a stop() immediately followed by start() USED TO BE SILENTLY LOST.
2112
- //
2113
- // `stop()` only flips the status via an ENQUEUED task, so between the two
2114
- // calls the status is still "running" and the guard below returned early —
2115
- // before `stopRequested = false` could run. The stop then landed, the loop
2116
- // exited, and the agent ended "stopped" with the restart discarded. The same
2117
- // window made a stop+restart abort the new round's first tool.
2118
- //
2119
- // So a pending stop is not a reason to refuse a start. The loop's own
2120
- // guards (`stopRequested` re-checked after every await) keep the old turn
2121
- // from running on, and enqueue() serialises the two tasks, so the restart
2122
- // is ordered after the stop rather than racing it.
2123
1168
  const pendingStop = this.stopRequested;
2124
1169
  if (this.status === "running" && !pendingStop)
2125
1170
  return;
2126
1171
  this.stopRequested = false;
2127
- // claim the status against any stop write still queued behind us
2128
1172
  this.stopSeq++;
2129
- this.awaitingUser = false; // a fresh start answers/resumes past any ask_user
1173
+ this.awaitingUser = false;
2130
1174
  void this.enqueue(async () => {
2131
- await this.ensureReady(); // lazy restore before the loop touches history
1175
+ await this.ensureReady();
2132
1176
  this.setStatus("running", reason);
2133
1177
  this.stats.startedAt ??= new Date().toISOString();
2134
1178
  });
@@ -2136,96 +1180,32 @@ export class Agent {
2136
1180
  }
2137
1181
  stop(reason = "stopped by user") {
2138
1182
  this.stopRequested = true;
2139
- this.parkedByTool = false; // the park display must not outlive a stop
2140
- // interrupt an in-flight LLM call AND any parked tool (wait_children):
2141
- // without aborting toolCtx.signal the park ran to its full timeout
1183
+ this.parkedByTool = false;
2142
1184
  this.abort?.abort();
2143
1185
  this.toolAbort.abort();
2144
- // and the BACKGROUND shells, which neither signal above reaches. A job
2145
- // spawned with `background: true` is `detached` and its spawning tool returned
2146
- // long ago, so `toolAbort` has nothing on the stack to abort: the agent parked
2147
- // in its "wait for the job" idle state, the job kept running, and `stop()` —
2148
- // the control the operator pressed for exactly this — ended nothing. MEASURED:
2149
- // `sleep 300` outlived both stop() and dispose().
2150
1186
  killBgShells(this.toolCtx.cwd);
2151
1187
  this.wake?.();
2152
- // the status write is DEFERRED onto the run chain, so it can land
2153
- // AFTER a start() that followed it. Observed: `idle -> stopped -> running ->
2154
- // running -> idle`. The agent did resume — but it flashed "stopped" on the
2155
- // way, and finished idle rather than running, so from the UI the operator's
2156
- // ▶ start looked like it had done nothing.
2157
- //
2158
- // `start()` clears stopRequested synchronously, which is what makes this
2159
- // detectable: by the time this task runs, a start has already claimed the
2160
- // agent and the stop no longer owns the status.
2161
- //
2162
- // So record WHEN the stop was requested, and let a start that came after it
2163
- // win. A stop issued while the agent is genuinely still running behaves
2164
- // exactly as before.
2165
1188
  const stopSeq = ++this.stopSeq;
2166
1189
  void this.enqueue(() => {
2167
- // Only a start() that arrived AFTER this stop may claim the status. The
2168
- // seq check alone does that — no status test, because a genuinely running
2169
- // agent must still be reported "stopped".
2170
1190
  if (this.stopSeq !== stopSeq)
2171
1191
  return Promise.resolve();
2172
1192
  this.setStatus("stopped", reason);
2173
1193
  return Promise.resolve();
2174
1194
  });
2175
1195
  }
2176
- /** monotonic counter: a start() bumps it, so a pending stop status write loses */
2177
1196
  stopSeq = 0;
2178
- /** #95: set when this round already injected a harness progress request */
2179
1197
  progressRequestedThisRound = false;
2180
- /**
2181
- * The turn number at which an auto-continue nudge was injected.
2182
- *
2183
- * `progressRequestedThisRound` covers ONE direction (progress asked first, nudge stands down). The
2184
- * reverse was unguarded: the nudge is appended at the END of a round, and the NEXT round's first
2185
- * turn calls `maybeRequestProgress()` — so one boundary carried "Continue working toward the
2186
- * current goal" and then "Please give a brief progress report now", 2ms apart. MEASURED: 21
2187
- * occurrences after the #95 fix, e.g. teapot-6-cca1d159 at 14:00:50 — the #182 report.
2188
- *
2189
- * A TURN NUMBER, not a boolean, because the nudge recurs every round in an auto-continue loop: a
2190
- * flag set at the end of one round would suppress the report at every later boundary and progress
2191
- * reports would stop for the whole session (MEASURED as three broken progress tests).
2192
- */
2193
1198
  nudgedAtTurn = -1;
2194
- /**
2195
- * CONTROL PLANE: fired on every status change, immediately, independent of the
2196
- * log.
2197
- *
2198
- * `wait_children` wakeups used to ride `EventLog.onEvent`, so a status
2199
- * change reached the parent only AFTER the write completed. After the
2200
- * write-before-notify fix that meant a FAILED or slow write left a parked parent
2201
- * waiting forever — persistence latency became control flow. Measured with every
2202
- * write failing: `onEvent fired: 0`, so no wakeup at all.
2203
- *
2204
- * Control flow must not depend on durability, so this is separate from the
2205
- * event pipeline. The log's `onEvent` now means "persisted, tell the world";
2206
- * this means "my state changed, react now".
2207
- */
2208
1199
  onControlStatusChange = null;
2209
1200
  setStatus(s, reason = "") {
2210
- // Update the field FIRST. The control-plane hook fires synchronously below
2211
- // (see onControlStatusChange), and it is what wakes parked wait_children
2212
- // callers — those
2213
- // re-check agent.status at that instant. Updating after the append made
2214
- // every waiter observe the OLD status ("running"), conclude "someone is
2215
- // still working", and stay parked until timeout even though the child had
2216
- // just settled.
2217
1201
  const prev = this.status;
2218
1202
  this.status = s;
2219
1203
  this.statusReason = reason;
2220
1204
  if (prev !== s) {
2221
- // control plane FIRST, and not via the log. A parent's wait_children
2222
- // must wake on the status change itself, not on the durability of the write
2223
- // that records it.
2224
1205
  try {
2225
1206
  this.onControlStatusChange?.(this.opts.id, s);
2226
1207
  }
2227
1208
  catch {
2228
- /* a control-plane listener must never break a status change */
2229
1209
  }
2230
1210
  void this.log.append("state", this.currentSession, this.currentBranch, {
2231
1211
  from: prev,
@@ -2235,41 +1215,17 @@ export class Agent {
2235
1215
  }
2236
1216
  bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
2237
1217
  }
2238
- /** Main auto-run loop: turn -> tools -> turn ... -> done/idle/stop. */
2239
1218
  async loop() {
2240
- /**
2241
- * The ROUND BOUNDARY is where a live update stops this loop.
2242
- *
2243
- * `windDownRequested` is set by `windDownForUpdate()`; reaching here means the previous round
2244
- * ran to completion (its tool returned, its generation finished), which is exactly the "1ターン
2245
- * が終わる" moment the operator asked for. Nothing is aborted, so the log ends on a complete
2246
- * round and the next process resumes from a real state.
2247
- */
2248
1219
  while (!this.stopRequested && !this.windDownRequested) {
2249
1220
  try {
2250
- // stop() aborted the tool signal and nothing re-armed it (only the
2251
- // parkedByTool path ever did), so EVERY tool after a stop returned
2252
- // "aborted (harness shutdown)" until process restart. A new round is
2253
- // new work: give it a live signal (re-arm AND republish — toolCtx
2254
- // holds a copy of the reference).
2255
1221
  if (this.toolAbort.signal.aborted) {
2256
1222
  this.toolAbort = new AbortController();
2257
1223
  this.toolCtx.signal = this.toolAbort.signal;
2258
1224
  }
2259
- // failures from the previous round must not poison this one — the
2260
- // counter only ever reset on success, so a tripped limit re-tripped
2261
- // on the first failure after restart ("6 consecutive" right after start)
2262
1225
  this.consecutiveToolErrors = 0;
2263
- // a round that got as far as an LLM call means the API is reachable
2264
- // again — reset the error-retry backoff to its base delay
2265
1226
  this.consecutiveApiErrors = 0;
2266
1227
  const turnsAtStart = this.stats.turns;
2267
1228
  let finished = await this.runTurnsUntilIdle();
2268
- // Round ended on the per-round turn cap. NO model-facing nudge: telling
2269
- // the model "the round ended, consider finish()" invited premature
2270
- // finishes that conflicted with the goal. The cap is harness-side only
2271
- // (logged for observability); continuation goes through the normal
2272
- // auto-continue path below.
2273
1229
  const cap = Math.max(0, this.opts.maxTurnsPerRound ?? 200);
2274
1230
  if (!finished && cap > 0 && this.stats.turns - turnsAtStart >= cap && !this.stopRequested) {
2275
1231
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
@@ -2279,22 +1235,6 @@ export class Agent {
2279
1235
  }
2280
1236
  if (this.stopRequested)
2281
1237
  break;
2282
- // fresh user input arrived while we were finishing up — another round now
2283
- // bound consecutive audit rejections.
2284
- //
2285
- // A rejected audit queues a retry prompt, so the round CONTINUES — and
2286
- // neither existing valve applies: maxConsecutiveIdleRounds counts IDLE
2287
- // rounds (a rejection is not idle), and maxTurnsPerRound only logs. Measured
2288
- // before this cap: 381 audit calls in a single turn.
2289
- //
2290
- // Deliberately NOT "mark the goal done": #128's whole complaint is that
2291
- // "could not be audited" silently became "passed". The goal is left ACTIVE
2292
- // and a loud note is logged, so the operator sees the loop and decides.
2293
- // `>=` (not `>`): the JSDoc says REACHING 3 stops — `>` allowed a 4th
2294
- // audit before breaking (off-by-one). The retry prompt is no longer
2295
- // queued AT the cap (see handleFinish), so the break strands nothing.
2296
- // The counter resets so ONE capped stretch doesn't brick every future
2297
- // round-end until setGoal — a fixed model gets a fresh count.
2298
1238
  if (this.consecutiveAuditRejects >= (this.opts.maxAuditRejects ?? 3)) {
2299
1239
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
2300
1240
  event: "audit-retry-cap",
@@ -2306,12 +1246,6 @@ export class Agent {
2306
1246
  }
2307
1247
  if (this.pendingPrompts.length)
2308
1248
  continue;
2309
- // A round that parked its work on a background bash (dev server,
2310
- // watcher, long build) ENDED ON PURPOSE — the job's exit notification
2311
- // is the wake-up. Nagging with "continue working" here just made the
2312
- // agent spin up duplicate work while it meant to wait. Checked BEFORE
2313
- // the finished/goal gates: a finish(goalComplete=false) round that
2314
- // leaves a bg job running is exactly the "wait for it" pattern.
2315
1249
  if (hasRunningBgShells(this.toolCtx.cwd)) {
2316
1250
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
2317
1251
  event: "auto-continue-suppressed",
@@ -2319,37 +1253,21 @@ export class Agent {
2319
1253
  });
2320
1254
  break;
2321
1255
  }
2322
- // A SUB-AGENT that ended its round on a bare message (no finish call)
2323
- // still owes the parent a report — nothing else will carry it.
2324
- // Emitted HERE, after the loop, so a real finish() on a later turn
2325
- // always wins and this never pre-empts a better summary.
2326
1256
  if (this.pendingSubReport) {
2327
1257
  const report = this.pendingSubReport;
2328
1258
  this.pendingSubReport = "";
2329
1259
  if (!finished) {
2330
1260
  await this.handleFinish(JSON.stringify({ goalComplete: true, summary: report }));
2331
- finished = true; // it IS finished now — don't auto-nudge a reporter
1261
+ finished = true;
2332
1262
  }
2333
1263
  }
2334
- // a round that did real work resets the idle counter. `finished`
2335
- // covers the finish() paths; a round that ran tool calls is caught by the
2336
- // turn counter below, which is checked before this point.
2337
1264
  if (finished)
2338
1265
  this.consecutiveIdleRounds = 0;
2339
- // auto-continue only makes sense with an active goal to continue toward
2340
1266
  if (finished ||
2341
1267
  !this.opts.autoContinue ||
2342
1268
  this.goal.status !== "active" ||
2343
1269
  !this.goal.text.trim())
2344
1270
  break;
2345
- // auto-continue: wait quietly, then nudge with a fresh round. Sub-agents get a
2346
- // SCOPE-ANCHORED nudge — a generic "continue toward the goal" made a forked sub-agent drift
2347
- // into one of the parent's older tasks.
2348
- //
2349
- // Bound CONSECUTIVE rounds that produced neither a finish() nor a tool call. Any tool call
2350
- // resets it (that is real work, not stalling), as does a stop or a fresh prompt. This is a
2351
- // safety valve, not a policy: a talk-only root agent ends every round after one turn, so no
2352
- // per-round cap can ever see it, and `finished` stays false forever.
2353
1271
  if (++this.consecutiveIdleRounds > this.opts.maxConsecutiveIdleRounds) {
2354
1272
  this.consecutiveIdleRounds = 0;
2355
1273
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
@@ -2363,36 +1281,15 @@ export class Agent {
2363
1281
  await this.sleepInterruptible(this.opts.continueDelayMs);
2364
1282
  if (this.stopRequested)
2365
1283
  break;
2366
- // do NOT pair this with a progress request.
2367
- //
2368
- // Both fire at the turn boundary and both inject a user message, so the model received two
2369
- // harness instructions back to back — "Continue working toward the current goal"
2370
- // immediately followed by "Please give a brief progress report now", which reads as the
2371
- // harness talking to itself.
2372
- //
2373
- // `maybeRequestProgress` runs its OWN `llmCall` at the top of the turn, so the assistant
2374
- // message it produces is a progress REPORT, not a continuation. The round then ended,
2375
- // this branch saw the flag, logged `auto-continue-suppressed`, and BROKE — leaving the
2376
- // agent idle with the work unfinished. The operator asked "何故 auto-continue が効いてない?"
2377
- // and the log shows exactly one such note, at the very end.
2378
- //
2379
- // The suppression is still right (one instruction per boundary); breaking is not. What is
2380
- // already in flight IS the continuation, so there is nothing left to add — but the loop
2381
- // must keep going, or a progress request silently ends the session's work.
2382
1284
  if (this.progressRequestedThisRound) {
2383
1285
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
2384
1286
  event: "auto-continue-suppressed",
2385
1287
  detail: "a progress report was already requested this round (#95) — the round that carried it already continues the work, so no nudge is added and the loop goes on",
2386
1288
  });
2387
- // consume the flag: it authorised skipping THIS boundary's nudge, not every later one
2388
1289
  this.progressRequestedThisRound = false;
2389
1290
  continue;
2390
1291
  }
2391
1292
  const isSub = !!this.opts.parent;
2392
- // the ROOT nudge now restates the goal too, exactly as the sub-agent
2393
- // one always has. Both branches need it — a child drifts back into the
2394
- // inherited conversation, a root agent loses the thread, and in both cases
2395
- // "continue toward the current goal" is unactionable without the goal text.
2396
1293
  const nudge = isSub
2397
1294
  ? `[harness] Auto-nudge. Re-anchor on YOUR task — and only that:\n\n${this.goal.text.slice(0, 1500)}\n\nThe inherited conversation is reference only; other agents own any older tasks in it. If your task is complete, call finish() now instead of continuing. If blocked, explain why briefly and finish().`
2398
1295
  : autoContinueNudge(this.goal.text);
@@ -2401,21 +1298,14 @@ export class Agent {
2401
1298
  text: nudge,
2402
1299
  });
2403
1300
  this.messages.push({ role: "user", content: nudge });
2404
- // the nudge is consumed by the NEXT turn, which is the one that must not also ask for a report
2405
1301
  this.nudgedAtTurn = this.stats.turns + 1;
2406
1302
  }
2407
1303
  catch (err) {
2408
1304
  const name = err.name;
2409
- // a stop (user abort or pre-call guard) is control flow, not a failure
2410
1305
  if (this.stopRequested || name === "AbortError" || name === "StopRequested" || name === "WaitForUser")
2411
1306
  break;
2412
1307
  const msg = err.message ?? String(err);
2413
1308
  await this.log.append("error", this.currentSession, this.currentBranch, { message: msg });
2414
- // onError:"retry" keeps an unattended agent alive across failures —
2415
- // BOTH provider outages (API errors) and runaway trips (a tool that
2416
- // keeps failing). Back off, then start a fresh round; the delay
2417
- // doubles per consecutive failure so a hard failure doesn't spin,
2418
- // and any stop/prompt still interrupts the sleep instantly.
2419
1309
  const isRunaway = /runaway detection/.test(msg);
2420
1310
  if (this.opts.onError === "retry" && !this.awaitingUser && !this.pendingPrompts.length) {
2421
1311
  this.consecutiveApiErrors++;
@@ -2427,10 +1317,6 @@ export class Agent {
2427
1317
  kind: isRunaway ? "runaway" : "api",
2428
1318
  });
2429
1319
  this.setStatus("error", `${msg.slice(0, 220)} — retrying in ${Math.round(wait / 1000)}s`);
2430
- // while this sleeps the agent is NOT idle — it is going to try again,
2431
- // and the operator must be able to stop it. Without the flag `isLive()` read
2432
- // false (status is "error"), so the UI showed "▶ start" and offered no stop
2433
- // at all while a 5s/5s/30s…30min staircase ran.
2434
1320
  this.retrySleeping = true;
2435
1321
  try {
2436
1322
  await this.sleepInterruptible(wait);
@@ -2440,32 +1326,18 @@ export class Agent {
2440
1326
  }
2441
1327
  if (this.stopRequested)
2442
1328
  break;
2443
- // the NEXT round is live work again — leaving "error" set made
2444
- // isLive() false (error is not a live status) while the loop was
2445
- // actually running, so the UI offered "start" on a working agent
2446
1329
  this.setStatus("running", "retrying");
2447
- continue; // fresh round; the loop re-arms the tool signal itself
1330
+ continue;
2448
1331
  }
2449
1332
  this.setStatus("error", msg.slice(0, 300));
2450
1333
  return;
2451
1334
  }
2452
1335
  }
2453
- // waiting on an ask_user answer is a status of its own — not idle (which
2454
- // would let auto-continue nag) and not stopped
2455
1336
  if (!this.stopRequested && !this.awaitingUser)
2456
1337
  this.setStatus("idle", "round complete");
2457
1338
  }
2458
- /**
2459
- * One LLM call with a fresh abort controller so stop() can interrupt it
2460
- * immediately, plus loop-level retries for provider flakiness (the SDK
2461
- * already backsoff 429/5xx; this covers exhausted rate limits and 400s).
2462
- * When onDelta is given, cumulative stream snapshots are forwarded to the
2463
- * UI via the bus (reset to empty at the start of every attempt).
2464
- */
2465
1339
  async llmCall(messages, tools, onDelta) {
2466
1340
  const maxAttempts = 4;
2467
- // fail fast, escalate late: most provider hiccups recover in seconds;
2468
- // only sustained failure earns a long cooldown (operator request)
2469
1341
  const waits = this.opts.llmRetryWaitsMs ?? [5_000, 5_000, 30_000];
2470
1342
  for (let attempt = 1;; attempt++) {
2471
1343
  if (this.stopRequested)
@@ -2475,19 +1347,10 @@ export class Agent {
2475
1347
  bus.emit("update", {
2476
1348
  kind: "llm-delta",
2477
1349
  agentId: this.opts.id,
2478
- // the bubble is session-scoped, so the stream must say which session
2479
1350
  sessionId: this.currentSession,
2480
1351
  text: "",
2481
1352
  reasoning: "",
2482
1353
  });
2483
- // `prompt-delivered` means "the outbound request CARRYING this prompt
2484
- // has started" — which is why it is emitted here and not in
2485
- // drainPendingPrompts. That runs BEFORE maybeCompact, which can itself make
2486
- // an LLM call, so the marker preceded the request and the timeline claimed a
2487
- // delivery order the API never saw.
2488
- //
2489
- // Once per request, not per attempt: a retry sends the same payload, so
2490
- // re-emitting would move the marker past later events on each try.
2491
1354
  if (attempt === 1)
2492
1355
  await this.markDeliveredPrompts();
2493
1356
  try {
@@ -2498,11 +1361,6 @@ export class Agent {
2498
1361
  const name = err.name;
2499
1362
  if (name === "StopRequested" || name === "AbortError" || this.stopRequested)
2500
1363
  throw err;
2501
- // a context-overflow error is the caller's to handle, not ours to
2502
- // retry. Retrying the SAME oversized request four times with 5s/5s/30s
2503
- // waits cannot succeed, and it delays the compact-and-retry that would
2504
- // — so the overflow path never even ran. This is the same
2505
- // "present, correct, and inert" shape as #54.
2506
1364
  if (isContextOverflow(err))
2507
1365
  throw err;
2508
1366
  if (attempt >= maxAttempts)
@@ -2518,51 +1376,11 @@ export class Agent {
2518
1376
  }
2519
1377
  }
2520
1378
  }
2521
- /**
2522
- * One "round": alternate LLM turns and tool executions until the model
2523
- * produces a final answer without tool calls. Returns true if the agent
2524
- * called finish().
2525
- */
2526
- /**
2527
- * The meta-tool handlers, keyed by the table's own name union.
2528
- *
2529
- * `Record<MetaToolName, …>` is the whole point: because `MetaToolName` is derived from
2530
- * `META_TOOLS`, advertising a tool without handling it here is a TYPE ERROR rather than a
2531
- * runtime "unknown tool". That failure used to be silent — the call fell through to
2532
- * `executeTool`, which answered "unknown tool" for a tool the request had just described.
2533
- *
2534
- * Each handler mutates session state and answers its own `tool_call`. The return value is
2535
- * the only control flow the old if-chain had:
2536
- * - `"next"` — the `continue` it replaced: this call is done, move to the next one in
2537
- * the assistant batch;
2538
- * - `"end"` — the `return true` it replaced (`finish` only): end the whole batch, so a
2539
- * model that emits finish() plus another call cannot keep mutating the workspace
2540
- * after reporting completion.
2541
- */
2542
1379
  metaHandlers = {
2543
1380
  finish: async (call, m) => {
2544
- this.pendingSubReport = ""; // the real summary supersedes it
2545
- // answer the tool_call BEFORE the audit runs.
2546
- //
2547
- // handleFinish() calls the auditor with `buildMessages()`, and at that
2548
- // moment the history ended at an assistant message carrying
2549
- // `tool_calls: [finish]` with NO matching `tool` message. Measured, the
2550
- // auditor received exactly that:
2551
- //
2552
- // assistant(tool_calls=[finish])
2553
- // user(audit prompt) <-- no tool(tool_call_id=…)
2554
- //
2555
- // OpenAI-compatible providers require every tool_call to be answered,
2556
- // and a strict one rejects the request outright — so a CHANGES-REQUIRED
2557
- // audit would fail with a protocol error rather than a verdict, and
2558
- // #128's fail-open would then mark the goal done.
2559
- //
2560
- // Answering first also makes the log honest: the call really was
2561
- // completed, it simply did not end the round.
1381
+ this.pendingSubReport = "";
2562
1382
  await this.answerMeta(call, "(completion submitted for audit)");
2563
1383
  await this.handleFinish(call.function.arguments);
2564
- // surface still-running children so the operator knows work may
2565
- // continue after this agent goes idle
2566
1384
  try {
2567
1385
  const running = (this.toolCtx.subAgents?.list?.() ?? []).filter((k) => k.status === "running");
2568
1386
  if (running.length)
@@ -2571,20 +1389,8 @@ export class Agent {
2571
1389
  detail: running.map((k) => `${k.id} (${k.status})`).join(", "),
2572
1390
  });
2573
1391
  }
2574
- catch { /* best-effort notice */ }
2575
- // answer the tool_call so a follow-up round stays API-valid
1392
+ catch { }
2576
1393
  await this.answerMeta(call, this.goal.status === "done" ? "(goal complete)" : "(round ended)");
2577
- // the caller marks the round finished when this returns "end"
2578
- // `continue` advanced the TOOL-CALL loop, so every later call in
2579
- // this same assistant message still executed — a model emitting
2580
- // finish() plus one more call got real filesystem writes AFTER it had
2581
- // reported `final: true` and the goal was already done. The parent was
2582
- // told the work finished while the workspace was still being mutated.
2583
- //
2584
- // So finish ends the BATCH: answer anything still outstanding (so no
2585
- // tool_call is left unpaired — the provider rejects the next request
2586
- // otherwise, #76) and leave the tool loop. The outer loop then returns
2587
- // on `finished`.
2588
1394
  await this.answerUnansweredToolCalls(m);
2589
1395
  return "end";
2590
1396
  },
@@ -2598,8 +1404,6 @@ export class Agent {
2598
1404
  const text = String(a.text ?? "").trim();
2599
1405
  if (text)
2600
1406
  await this.setGoal(text);
2601
- // pi-goal-x-style verification contract: plain-text completion
2602
- // requirements audited before finish(goalComplete=true) is honored
2603
1407
  if (typeof a.verify === "string" && a.verify.trim())
2604
1408
  await this.setGoalVerify(a.verify.trim().slice(0, 4000));
2605
1409
  await this.answerMeta(call, text ? "goal updated" : "empty goal rejected");
@@ -2617,8 +1421,6 @@ export class Agent {
2617
1421
  ask_user: async (call, m) => {
2618
1422
  const a = safeParse(call.function.arguments);
2619
1423
  const question = String(a.question ?? "").slice(0, 2000);
2620
- // models sometimes pass objects ({label}, {option}, {value}) —
2621
- // extract the label instead of rendering "[object Object]" buttons
2622
1424
  const options = Array.isArray(a.options)
2623
1425
  ? a.options
2624
1426
  .slice(0, 6)
@@ -2635,8 +1437,6 @@ export class Agent {
2635
1437
  })
2636
1438
  .filter((o) => o.trim())
2637
1439
  : [];
2638
- // the callId lets the UI tell "still open" from "already answered"
2639
- // (a settled tool_result for this id exists) and disable re-answering
2640
1440
  await this.log.append("question", this.currentSession, this.currentBranch, {
2641
1441
  question,
2642
1442
  options,
@@ -2646,13 +1446,6 @@ export class Agent {
2646
1446
  "Their reply (possibly free text, not one of the options) arrives as your next user message.");
2647
1447
  this.awaitingUser = true;
2648
1448
  this.setStatus("needs_operator", question.slice(0, 80));
2649
- // control flow: park the loop; the operator's next prompt resumes it
2650
- //
2651
- // answer the rest of this assistant batch BEFORE parking. The
2652
- // throw leaves the whole tool loop, so any EARLIER call in the same
2653
- // message that was logged but not yet run kept no tool_result — the
2654
- // comment on the backstop below says as much, and it is exactly the
2655
- // path that bites in practice because ask_user is common.
2656
1449
  await this.answerUnansweredToolCalls(m);
2657
1450
  throw Object.assign(new Error("waiting for user"), { name: "WaitForUser" });
2658
1451
  },
@@ -2662,9 +1455,6 @@ export class Agent {
2662
1455
  },
2663
1456
  set_todo: async (call, m) => {
2664
1457
  const a = safeParse(call.function.arguments);
2665
- // Surgical item changes — no full rewrite, so the operator's own headings and
2666
- // notes in todo.md survive. `action` decides what to do; `done` is kept as
2667
- // shorthand so a model that only sends {item, done} still works.
2668
1458
  const ACTIONS = new Set(["done", "reopen", "add", "remove"]);
2669
1459
  const updates = Array.isArray(a.updates)
2670
1460
  ? a.updates
@@ -2713,7 +1503,6 @@ export class Agent {
2713
1503
  this.messages.push({ role: "tool", tool_call_id: call.id, content: "rule required" });
2714
1504
  return "next";
2715
1505
  }
2716
- // repeated corrections gain weight: [xN] tag counts occurrences
2717
1506
  const existing = await fs.readFile(this.feedbackFile, "utf8").catch(() => "");
2718
1507
  const lines = existing.split("\n");
2719
1508
  const idx = lines.findIndex((l) => l.includes(rule.slice(0, 60)));
@@ -2776,19 +1565,6 @@ export class Agent {
2776
1565
  await this.answerMeta(call, "(memory.md is empty — nothing noted yet)");
2777
1566
  return "next";
2778
1567
  }
2779
- // `query` searches instead of dumping the whole file.
2780
- //
2781
- // This is the one idea worth taking from the ecosystem. MCP's reference memory
2782
- // server (the de-facto standard, used by Claude and offered by DSH as a
2783
- // reference) exposes `search` alongside `read`, and its search is deliberately
2784
- // a case-insensitive substring match — no embeddings, no model, no provider
2785
- // dependency. DSH's guidance to the model is the same shape: "when historical
2786
- // information may be relevant, search memory and use relevant results".
2787
- //
2788
- // `read_memory` only 97 times across every session, and only 13 of those reads
2789
- // came after a write. Dumping a growing file gives the model no way to ask a
2790
- // QUESTION, so a big file is all-or-nothing — either burn the whole thing into
2791
- // the context or do not look at all.
2792
1568
  if (!query) {
2793
1569
  await this.answerMeta(call, mem.trim());
2794
1570
  return "next";
@@ -2796,15 +1572,6 @@ export class Agent {
2796
1572
  const needle = query.toLowerCase();
2797
1573
  const lines = mem.split(/\r?\n/);
2798
1574
  const hits = lines.filter((line) => line.toLowerCase().includes(needle));
2799
- /**
2800
- * Give each hit its CONTEXT, not just the bare line.
2801
- *
2802
- * A matching line on its own is often meaningless — "the cache lies" says nothing
2803
- * without the heading it sits under. And a note needs to be dateable to be usable: a
2804
- * lesson harvested from a compaction three weeks ago should not read with the same
2805
- * authority as one from today. So each hit is reported with its nearest preceding
2806
- * heading and any `_from:_` provenance line.
2807
- */
2808
1575
  const headingAt = (idx) => {
2809
1576
  for (let i = idx; i >= 0; i--) {
2810
1577
  const l = lines[i].trim();
@@ -2814,7 +1581,6 @@ export class Agent {
2814
1581
  return "";
2815
1582
  };
2816
1583
  const provenanceAt = (idx) => {
2817
- // the `_from:_` line lives within a few lines above its lesson block
2818
1584
  for (let i = idx; i >= Math.max(0, idx - 6); i--) {
2819
1585
  const l = lines[i].trim();
2820
1586
  if (/^_from:/.test(l))
@@ -2847,9 +1613,6 @@ export class Agent {
2847
1613
  const a = safeParse(call.function.arguments);
2848
1614
  const content = String(a.content ?? "").slice(0, 32_000);
2849
1615
  await fs.writeFile(this.memoryFile, content, "utf8");
2850
- // this used to say "injected into future prompts", which was false —
2851
- // memory.md is never injected. The model then behaved as if it already held
2852
- // its notes. Say what actually happened, and what the model must do instead.
2853
1616
  await this.answerMeta(call, `memory.md REPLACED (${content.length} bytes). It is not in your prompt — call read_memory when you need it.`);
2854
1617
  return "next";
2855
1618
  },
@@ -2860,14 +1623,6 @@ export class Agent {
2860
1623
  await this.answerMeta(call, "forget_memory needs a `query` — nothing removed");
2861
1624
  return "next";
2862
1625
  }
2863
- // notes must be REMOVABLE, not only appendable.
2864
- //
2865
- // MCP's reference memory makes observations "added or removed
2866
- // independently" for exactly this reason: an append-only store accumulates
2867
- // stale facts ("the build takes 4 minutes" stays long after it takes 40
2868
- // seconds), and a memory that is confidently wrong is worse than one that is
2869
- // silent. Without a delete, the only way to correct a file was `set_memory`
2870
- // with the whole content re-typed — which is how 1028 bytes became 83.
2871
1626
  const prev = await fs.readFile(this.memoryFile, "utf8").catch(() => "");
2872
1627
  if (!prev.trim()) {
2873
1628
  await this.answerMeta(call, "(memory.md is empty — nothing to forget)");
@@ -2886,8 +1641,6 @@ export class Agent {
2886
1641
  await this.answerMeta(call, `(no line in memory.md matches ${JSON.stringify(query)} — nothing removed)`);
2887
1642
  return "next";
2888
1643
  }
2889
- // collapse the blank runs a deletion leaves behind, so the file does not
2890
- // grow holes
2891
1644
  const next = kept.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
2892
1645
  await fs.writeFile(this.memoryFile, next, "utf8");
2893
1646
  await this.answerMeta(call, `removed ${removed.length} line(s) from memory.md:\n${removed.join("\n").slice(0, 1_500)}\n` +
@@ -2901,11 +1654,6 @@ export class Agent {
2901
1654
  await this.answerMeta(call, "add_memory needs non-empty `text` — nothing appended");
2902
1655
  return "next";
2903
1656
  }
2904
- // APPEND rather than replace. `set_memory` overwrites the whole file,
2905
- // so a model that had not just read it silently ERASED everything it did not
2906
- // re-send — MEASURED on a real session: 21 writes, with the content dropping
2907
- // from 1028 to 83 bytes. Appending is the operation "remember this" actually
2908
- // wants, and it cannot lose what is already there.
2909
1657
  const prev = await fs.readFile(this.memoryFile, "utf8").catch(() => "");
2910
1658
  const sep = prev && !prev.endsWith("\n") ? "\n" : "";
2911
1659
  const next = `${prev}${sep}${text}\n`;
@@ -2916,26 +1664,14 @@ export class Agent {
2916
1664
  };
2917
1665
  async runTurnsUntilIdle() {
2918
1666
  let finished = false;
2919
- // maxTurnsPerRound is a SOFT cap (0 disables): reaching it nudges the
2920
- // model to wrap up instead of hard-failing the round — long autonomous
2921
- // stretches are normal here, and a throw used to leave status=error with
2922
- // no auto-recovery even though autoContinue would happily keep going.
2923
1667
  const cap = Math.max(0, this.opts.maxTurnsPerRound ?? 200);
2924
1668
  for (let guard = 0; cap === 0 || guard < cap; guard++) {
2925
1669
  if (this.stopRequested)
2926
1670
  return finished;
2927
- // deliver prompts queued while the previous turn was running
2928
1671
  this.drainPendingPrompts();
2929
- // skills may have been created last turn — refresh the prompt listing
2930
1672
  await this.refreshSkills();
2931
- // periodic progress report at turn boundary (no mid-turn interruption)
2932
- // the flag is per-TURN, not per-round. `maybeRequestProgress` sits
2933
- // inside the turn loop and auto-continue is checked after the round
2934
- // ends, so the flag has to mean "this boundary already carried an
2935
- // instruction" — reset here, set below if the request fires.
2936
1673
  this.progressRequestedThisRound = false;
2937
1674
  await this.maybeRequestProgress();
2938
- // keep the context window bounded before spending tokens on a turn
2939
1675
  await this.maybeCompact();
2940
1676
  await this.log.append("state", this.currentSession, this.currentBranch, {
2941
1677
  from: this.status,
@@ -2943,22 +1679,19 @@ export class Agent {
2943
1679
  detail: "llm turn start",
2944
1680
  turn: ++this.stats.turns,
2945
1681
  });
2946
- // stream the assistant reply live to connected clients
2947
1682
  let res;
2948
1683
  try {
2949
1684
  res = await this.llmCall(this.buildMessages(), allToolSpecs(), (s) => {
2950
1685
  bus.emit("update", {
2951
1686
  kind: "llm-delta",
2952
1687
  agentId: this.opts.id,
2953
- sessionId: this.currentSession, // #146
1688
+ sessionId: this.currentSession,
2954
1689
  text: s.text,
2955
1690
  reasoning: s.reasoning,
2956
1691
  });
2957
1692
  });
2958
1693
  }
2959
1694
  catch (err) {
2960
- // user stop mid-stream: persist the partial output so the timeline
2961
- // keeps what was already visible (otherwise it silently vanishes)
2962
1695
  const partial = err.partial;
2963
1696
  if (this.stopRequested && partial && (partial.text || partial.reasoning)) {
2964
1697
  await this.log.append("message", this.currentSession, this.currentBranch, {
@@ -2970,19 +1703,11 @@ export class Agent {
2970
1703
  this.messages.push({ role: "assistant", content: partial.text ?? "" });
2971
1704
  }
2972
1705
  else if (this.stopRequested) {
2973
- // nothing had streamed — leave an explicit marker so the log shows
2974
- // why this prompt has no reply
2975
1706
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
2976
1707
  event: "turn-interrupted",
2977
1708
  detail: "stopped before any output arrived",
2978
1709
  });
2979
1710
  }
2980
- // Provider context overflow → compact and retry (OpenCode-style recovery).
2981
- // Even with autoCompact off, an actual overflow is proof the estimate
2982
- // missed. Staged rather than single-shot (opencode: SHRINK_STEPS
2983
- // [0.7, 0.5, 0.35]): one fixed cut can still be too shallow, and the old
2984
- // code returned the second overflow as a hard error even though cutting
2985
- // deeper would have worked.
2986
1711
  if (isContextOverflow(err) && !this.stopRequested) {
2987
1712
  const attempt = (this.overflowRecoveries += 1);
2988
1713
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
@@ -2991,7 +1716,6 @@ export class Agent {
2991
1716
  detail: String(err.message).slice(0, 200),
2992
1717
  });
2993
1718
  const before = this.stats.compactions;
2994
- // each retry cuts deeper: 1x, then the staged fractions of the budget
2995
1719
  const shrink = OVERFLOW_SHRINK_STEPS[Math.min(attempt - 1, OVERFLOW_SHRINK_STEPS.length - 1)];
2996
1720
  await this.maybeCompact(true, shrink);
2997
1721
  if (this.stats.compactions > before) {
@@ -2999,23 +1723,10 @@ export class Agent {
2999
1723
  event: "overflow-recovered",
3000
1724
  attempt,
3001
1725
  });
3002
- guard--; // the retry does not count against this round's cap
1726
+ guard--;
3003
1727
  continue;
3004
1728
  }
3005
1729
  }
3006
- /**
3007
- * A STRUCTURAL cap is not an overflow, and no retry or compaction can clear it.
3008
- *
3009
- * Command Code's gateway refuses a body whose JSON node count exceeds its limit while the
3010
- * session is nowhere near its token window — measured upstream at 22.6% of a 1M-token model
3011
- * with 1,436 messages and 61 image parts. `isContextOverflow` correctly declines to claim
3012
- * it, so without this branch the loop would exhaust its retries and surface the raw 400 with
3013
- * no advice.
3014
- *
3015
- * Compacting anyway would be worse than useless: it costs a full-price summarisation pass
3016
- * and the same structure is refused again. The honest answer is fewer messages, fewer tool
3017
- * calls, fewer images — or a new session.
3018
- */
3019
1730
  const nodeCap = isRequestNodeLimit(err);
3020
1731
  if (nodeCap.hit && !this.stopRequested) {
3021
1732
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
@@ -3033,8 +1744,6 @@ export class Agent {
3033
1744
  }
3034
1745
  throw err;
3035
1746
  }
3036
- // a request the provider accepted: the current depth is enough, so the next
3037
- // overflow starts its staircase over rather than cutting from the deepest step
3038
1747
  this.overflowRecoveries = 0;
3039
1748
  if (res.usage) {
3040
1749
  const inTok = res.usage.inputTokens ?? 0;
@@ -3042,10 +1751,6 @@ export class Agent {
3042
1751
  this.stats.inputTokens += inTok;
3043
1752
  this.stats.cachedInputTokens += cached;
3044
1753
  this.stats.outputTokens += res.usage.outputTokens ?? 0;
3045
- // cost estimate: uncached input at prompt rate, cache hits usually
3046
- // bill at a fraction (OpenRouter reports the blended prompt price, so
3047
- // cached tokens are charged separately at ~10% — a common provider
3048
- // convention; without per-provider detail this is the honest guess)
3049
1754
  if (this.modelPricing) {
3050
1755
  const p = this.modelPricing;
3051
1756
  const uncached = Math.max(0, inTok - cached);
@@ -3061,9 +1766,6 @@ export class Agent {
3061
1766
  await this.log.append("usage", this.currentSession, this.currentBranch, res.usage);
3062
1767
  }
3063
1768
  const m = res.message;
3064
- // sanitize() fills empty content with "(tool call)" for providers that
3065
- // reject blank content — that placeholder is request-only and must NOT
3066
- // reach the timeline as if the agent had said something
3067
1769
  const isPlaceholder = m.content === "(tool call)" || m.content === "(no content)";
3068
1770
  await this.log.append("message", this.currentSession, this.currentBranch, {
3069
1771
  role: "assistant",
@@ -3074,42 +1776,9 @@ export class Agent {
3074
1776
  this.messages.push(m);
3075
1777
  this.turnsSinceProgress++;
3076
1778
  this.activityChars += m.content?.length ?? 0;
3077
- // A round that ends with a plain assistant message and NO tool calls is
3078
- // the end of the agent's work. For a ROOT agent that is just "done" — but
3079
- // for a SUB-AGENT it means the work is over and the parent is waiting.
3080
- //
3081
- // Models overwhelmingly prefer writing their report as the final message
3082
- // over calling finish(), and only handleFinish() emits the `final: true`
3083
- // event that onChildEvent() forwards to the parent. So that habit silently
3084
- // cost the parent the ENTIRE result — the report existed only in the
3085
- // child's own log, which the parent's model context never replays.
3086
- // Harvest it here so both paths share one code path.
3087
1779
  if (!m.tool_calls?.length) {
3088
- // A turn with no tool calls and real text is how models overwhelmingly
3089
- // signal "I'm done" — far more often than by calling finish(), which
3090
- // the spawn directive asks for but they routinely ignore.
3091
- //
3092
- // Two things were wrong:
3093
- // 1. only handleFinish() emits the `final: true` event that
3094
- // onChildEvent() forwards to the parent, so a sub-agent that ended
3095
- // with a plain message sent the parent NOTHING — the report lived
3096
- // only in the child's log, which the parent's context never
3097
- // replays;
3098
- // 2. the round then returned "not finished" with an active goal, so
3099
- // auto-continue re-nudged and the child looped forever (measured:
3100
- // 257 consecutive turns) re-reporting the same text.
3101
- //
3102
- // A bare message might be a mid-task narration though — sub-agents
3103
- // often say "here's what I found so far…" and call finish() later with
3104
- // a better summary. So do NOT report yet: remember it and give the child
3105
- // ONE more turn to call finish() properly. The harness nudge makes that
3106
- // turn explicit, so a child that just wanted to narrate ends here (and
3107
- // its held report is forwarded by the loop's end-of-round path), while
3108
- // a child that has a real summary delivers it instead. A real finish()
3109
- // clears the held note, so it is forwarded exactly once.
3110
1780
  if (this.opts.parent) {
3111
1781
  if (this.pendingSubReport) {
3112
- // already offered a turn after a bare message — take the held report
3113
1782
  this.messages.push({
3114
1783
  role: "user",
3115
1784
  content: "[harness] Report delivered. If this IS your final answer, call finish() now with a summary for your parent; otherwise continue the task.",
@@ -3122,26 +1791,15 @@ export class Agent {
3122
1791
  content: "[harness] If you are done, call finish() with a summary for your parent so it receives your report. Otherwise continue the task.",
3123
1792
  });
3124
1793
  }
3125
- continue; // one more turn before we treat the message as final
1794
+ continue;
3126
1795
  }
3127
1796
  return finished;
3128
1797
  }
3129
1798
  for (const call of m.tool_calls) {
3130
1799
  if (this.stopRequested) {
3131
- // this returned straight out of the turn, SKIPPING the
3132
- // answerUnansweredToolCalls backstop below — so every remaining
3133
- // tool_call in this assistant message stayed logged with NO
3134
- // tool_result anywhere, and its row rendered as "waiting for
3135
- // output…" for the rest of the session.
3136
1800
  await this.answerUnansweredToolCalls(m);
3137
1801
  return finished;
3138
1802
  }
3139
- // Meta tools dispatch through ONE table (src/agent/meta-tools.ts + metaHandlers
3140
- // above), so a tool the prompt advertises but the loop does not handle is a
3141
- // COMPILE error rather than a runtime "unknown tool". The two return kinds are the
3142
- // two the old if-chain had: "next" is the `continue` it replaced, "end" is
3143
- // finish() ending the whole batch. Anything not in the table is a real tool
3144
- // and falls through to executeTool below.
3145
1803
  const meta = this.metaHandlers[call.function.name];
3146
1804
  if (meta) {
3147
1805
  if ((await meta(call, m)) === "end") {
@@ -3154,19 +1812,9 @@ export class Agent {
3154
1812
  callId: call.id,
3155
1813
  name: call.function.name,
3156
1814
  args: safeParse(call.function.arguments),
3157
- argsRaw: call.function.arguments, // byte-exact for cache-safe restore
1815
+ argsRaw: call.function.arguments,
3158
1816
  });
3159
1817
  const t0 = Date.now();
3160
- // first filesystem touch creates a missing workspace (never at boot)
3161
- // this is awaited BETWEEN the tool_call append above and the
3162
- // tool_result append below, and `fs.mkdir` has no catch — so a failure
3163
- // propagated out of the whole loop, leaving the call logged with no
3164
- // result ANYWHERE, and its row read "waiting for output…" for good.
3165
- //
3166
- // The loop's own backstop could not save it: it sits at the bottom of
3167
- // the same loop, which a throw skips. So the tool_result is written in a
3168
- // `finally` — the call is answered whatever happens.
3169
- // the type is inferred from executeTool, so this cannot drift from it
3170
1818
  let result;
3171
1819
  try {
3172
1820
  await this.ensureWorkspace();
@@ -3176,20 +1824,7 @@ export class Agent {
3176
1824
  result = { ok: false, result: `tool could not run: ${err.message}` };
3177
1825
  }
3178
1826
  this.stats.toolCalls++;
3179
- // a tool call is real work, so it clears the idle-round counter.
3180
1827
  this.consecutiveIdleRounds = 0;
3181
- // ONE clip, applied to BOTH the log and the live message.
3182
- //
3183
- // The log stored `result.slice(0, 8000)` while `messages` kept the full
3184
- // text, and a restore rebuilds `messages` from the log — so a model
3185
- // resuming work saw a different (and much smaller) tool result than it
3186
- // had just seen live. Measured: 8000 bytes logged, 436 after restart.
3187
- //
3188
- // The 8000-byte log clip is deliberate — it bounds the log file — so the
3189
- // fix is not to log everything. It is to clip the live message the SAME
3190
- // way, so the two views agree and a restart is not a silent downgrade.
3191
- // Restore is byte-exact everywhere else (`answerMeta` preserves its exact
3192
- // content); this was the one place the replay diverged.
3193
1828
  const capped = result.result.slice(0, 8000);
3194
1829
  await this.log.append("tool_result", this.currentSession, this.currentBranch, {
3195
1830
  callId: call.id,
@@ -3201,9 +1836,6 @@ export class Agent {
3201
1836
  this.messages.push({ role: "tool", tool_call_id: call.id, content: capped });
3202
1837
  this.consecutiveToolErrors = result.ok ? 0 : this.consecutiveToolErrors + 1;
3203
1838
  if (this.consecutiveToolErrors >= this.opts.maxConsecutiveToolErrors) {
3204
- // structured, greppable AND visible: the note carries what failed so
3205
- // the operator (and the UI) can show "WHICH tool, with what error"
3206
- // instead of a bare counter
3207
1839
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
3208
1840
  event: "runaway-detected",
3209
1841
  failures: this.consecutiveToolErrors,
@@ -3215,38 +1847,12 @@ export class Agent {
3215
1847
  `(last: ${call.function.name})`);
3216
1848
  }
3217
1849
  }
3218
- // every tool_call an assistant message issued must have a matching
3219
- // tool result, or the provider REJECTS the next request — which is the
3220
- // "`200 with no choices`" shape #50 traced to a malformed history.
3221
- //
3222
- // Two exits could leave some unanswered: `stopRequested` returns early
3223
- // mid-batch, and a handler that throws (ask_user parking the loop is the
3224
- // one that bites in practice) propagates out of the whole loop. Restore
3225
- // has a hole-filler for exactly this; the LIVE array did not, so a session
3226
- // that hit it produced an invalid next request and then failed with
3227
- // something that looked like a provider fault.
3228
- //
3229
- // So the invariant is enforced here rather than at each exit: whatever
3230
- // happened, answer what is still outstanding before moving on.
3231
1850
  await this.answerUnansweredToolCalls(m);
3232
1851
  if (finished)
3233
1852
  return true;
3234
1853
  }
3235
- // cap reached — loop() logs the note, asks for a progress report and
3236
- // nudges the model; this round simply ends (no hard error)
3237
1854
  return finished;
3238
1855
  }
3239
- /**
3240
- * Append a tool result for every tool_call in `m` that has not been answered.
3241
- *
3242
- * Scans the tail of `messages` for `tool_call_id`s, so it is correct whichever
3243
- * path left the batch unfinished — a stop, a throw, or a normal completion
3244
- * where a handler returned early. Answered calls are left alone.
3245
- *
3246
- * PUBLIC so a test can reproduce "the batch was left half-answered" directly. That
3247
- * state is what the provider rejects the next request over, and reaching it through a
3248
- * whole turn would make the assertion depend on everything else in the loop.
3249
- */
3250
1856
  async answerUnansweredToolCalls(m) {
3251
1857
  const calls = m.tool_calls ?? [];
3252
1858
  if (!calls.length)
@@ -3272,86 +1878,46 @@ export class Agent {
3272
1878
  }
3273
1879
  }
3274
1880
  buildMessages() {
3275
- // deliberately static: [system] + append-only history keeps prefix caches hot.
3276
- // The workspace context block (AGENTS.md + ls snapshot) is appended to the
3277
- // system message ONCE per session — changing it mid-session would re-price
3278
- // the whole prefix cache, so it is captured at first use and frozen.
3279
1881
  const sys = this.systemPrompt();
3280
1882
  const hasSystem = this.messages[0]?.role === "system";
3281
1883
  const head = [{ role: "system", content: sys }];
3282
- // NOTE: tool-call pairing is guaranteed one layer down, in `sanitize()`,
3283
- // because EVERY request path passes through there (turns, the summarizer, the ACP
3284
- // adapter) while this builds only an Agent's own turn. A violated pairing is refused
3285
- // with a bare "invalid request error", which is not recognisable as an overflow and
3286
- // so burns the whole retry staircase before failing with nothing to act on.
3287
1884
  return hasSystem ? [...head, ...this.messages.slice(1)] : [...head, ...this.messages];
3288
1885
  }
3289
- /**
3290
- * Re-read AGENTS.md and adopt it if it changed. Returns the new text when it
3291
- * did, or null when there is nothing to do.
3292
- *
3293
- * Called on compaction (#64 follow-up) and nowhere else. The one-shot capture
3294
- * in systemPrompt() exists to keep the system prompt byte-identical for the
3295
- * session so the provider's prefix cache survives; this is the single point
3296
- * where breaking that identity is free, because compaction has just rewritten
3297
- * the history underneath it.
3298
- *
3299
- * Failure is deliberately non-destructive: a read error, or a file that
3300
- * vanished, leaves the previous content in place. Dropping the instructions
3301
- * because of a transient error would be far worse than running one compaction
3302
- * on slightly stale rules.
3303
- */
3304
1886
  reloadAgentsMd() {
3305
1887
  if (!this.agentsMdLoaded)
3306
- return null; // nothing captured yet; nothing to update
1888
+ return null;
3307
1889
  let fresh;
3308
1890
  try {
3309
1891
  const p = path.join(this.opts.workspace, "AGENTS.md");
3310
1892
  if (!existsSync(p))
3311
- return null; // deleted → keep the last known rules
1893
+ return null;
3312
1894
  fresh = readFileSync(p, "utf8").slice(0, 12_000);
3313
1895
  }
3314
1896
  catch {
3315
- return null; // unreadable → keep what we have
1897
+ return null;
3316
1898
  }
3317
1899
  if (fresh === this.agentsMd)
3318
1900
  return null;
3319
1901
  this.agentsMd = fresh;
3320
1902
  return fresh;
3321
1903
  }
3322
- /** AGENTS.md content cached for the session (empty string = none/read-failed) */
3323
1904
  agentsMd = "";
3324
- /** captured once, with the rest of the session-frozen context */
3325
1905
  agentsMdLoaded = false;
3326
- /** one-time workspace listing injected with the system prompt */
3327
1906
  wsSnapshot = "";
3328
- /**
3329
- * one-time catalogue of available skills, injected with the system prompt
3330
- *. Built once alongside wsSnapshot so the prompt stays byte-identical
3331
- * for the session and the provider prefix cache survives.
3332
- */
3333
1907
  skillCatalogue = "";
3334
- /** the compact digest of memory.md put into the frozen prompt (Codex's MEMORY_SUMMARY) */
3335
1908
  memoryDigest = "";
3336
1909
  systemPrompt() {
3337
1910
  if (!this.agentsMdLoaded) {
3338
1911
  this.agentsMdLoaded = true;
3339
- // AGENTS.md + workspace listing are captured ONCE and PERSISTED to the
3340
- // session dir, so a restart re-issues the identical system prompt (the
3341
- // prefix cache survives; the snapshot is a session artifact, not live
3342
- // truth — list_dir shows current state).
3343
1912
  const snapPath = path.join(this.opts.sessionDir, "workspace-snapshot.txt");
3344
1913
  try {
3345
- // a previous incarnation already froze a snapshot → reuse it verbatim.
3346
- // The sentinel covers EMPTY workspaces too ("" would look like "no
3347
- // file" and make the restart re-list, breaking prefix-cache identity).
3348
1914
  this.wsSnapshot = readFileSync(snapPath, "utf8");
3349
1915
  }
3350
1916
  catch {
3351
1917
  this.wsSnapshot = "";
3352
1918
  }
3353
1919
  if (this.wsSnapshot === "" && existsSync(snapPath)) {
3354
- this.wsSnapshot = "(empty workspace)"; // the file exists but was empty
1920
+ this.wsSnapshot = "(empty workspace)";
3355
1921
  }
3356
1922
  if (!this.wsSnapshot) {
3357
1923
  try {
@@ -3374,7 +1940,6 @@ export class Agent {
3374
1940
  size = `${(statSync(path.join(this.opts.workspace, e.name)).size / 1024).toFixed(1)}K`;
3375
1941
  }
3376
1942
  catch {
3377
- /* vanished */
3378
1943
  }
3379
1944
  }
3380
1945
  lines.push(e.isDirectory() ? `${e.name}/` : `${e.name} (${size})`);
@@ -3386,7 +1951,6 @@ export class Agent {
3386
1951
  void fs.writeFile(snapPath, this.wsSnapshot, "utf8");
3387
1952
  }
3388
1953
  catch {
3389
- /* best-effort persistence */
3390
1954
  }
3391
1955
  }
3392
1956
  catch {
@@ -3403,8 +1967,6 @@ export class Agent {
3403
1967
  this.agentsMd = "";
3404
1968
  }
3405
1969
  }
3406
- // capture the skill catalogue in the SAME one-shot block, so the
3407
- // system prompt stays byte-identical for the session (prefix cache).
3408
1970
  this.skillCatalogue = this.skillCatalogueText();
3409
1971
  this.memoryDigest = this.memoryDigestText();
3410
1972
  }
@@ -3415,15 +1977,6 @@ export class Agent {
3415
1977
  if (this.wsSnapshot) {
3416
1978
  sys += `\n\n## Workspace snapshot (top level)\n${this.wsSnapshot}\nUse list_dir/read_file for anything deeper — do NOT list again what is already here.`;
3417
1979
  }
3418
- // the catalogue of AVAILABLE SKILLS. The prompt previously named the
3419
- // skill TOOLS but never the skills themselves, so the only way to discover
3420
- // one was to spend a tool call on list_skills() — and a model that does not
3421
- // know a skill exists will never think to look.
3422
- //
3423
- // Appended ONCE, in the same one-shot block as AGENTS.md and the workspace
3424
- // snapshot, so SYSTEM_TEMPLATE stays byte-identical for the session and the
3425
- // provider prefix cache survives (#14's constraint). list_skills() remains
3426
- // for the full detail (a skill saved mid-session shows up there).
3427
1980
  if (this.skillCatalogue) {
3428
1981
  sys +=
3429
1982
  `\n\n## Skills available in this workspace\n${this.skillCatalogue}\n` +
@@ -3431,19 +1984,6 @@ export class Agent {
3431
1984
  `pass what you want done so the instructions arrive with the playbook. ` +
3432
1985
  `list_skills() shows the full detail.`;
3433
1986
  }
3434
- /**
3435
- * A DIGEST of the durable notes — Codex's `MEMORY_SUMMARY`, not the notes themselves.
3436
- *
3437
- * The full file cannot go here (it is unbounded, and re-injecting it every turn is the
3438
- * prefix-cache break that made takos' design a bad fit). But leaving the notes entirely
3439
- * behind a tool call was measured to fail the other way: the model never learned they
3440
- * existed, so it never asked. Codex resolves exactly this by injecting a compact summary
3441
- * and telling the model to search the rest — the summary names WHAT is in memory, the tool
3442
- * fetches the detail.
3443
- *
3444
- * Captured in the same one-shot block as AGENTS.md and the skills catalogue, so the prompt
3445
- * stays byte-identical for the session.
3446
- */
3447
1987
  if (this.memoryDigest) {
3448
1988
  sys +=
3449
1989
  `\n\n## Your durable notes (memory.md — a DIGEST, not the whole file)\n${this.memoryDigest}\n` +
@@ -3454,24 +1994,6 @@ export class Agent {
3454
1994
  }
3455
1995
  return sys;
3456
1996
  }
3457
- /**
3458
- * The last few conversation turns, for a parent's `final` report.
3459
- *
3460
- * Captured BEFORE the final marker is written so the parent gets real
3461
- * substance (findings, file paths, numbers), not just whatever the model
3462
- * typed into the finish() summary argument.
3463
- *
3464
- * Extracted as its own method because a REJECTED completion audit also needs
3465
- * it — the retry prompt should carry the same context an approved one
3466
- * would — while emitting no `final: true`.
3467
- */
3468
- /**
3469
- * A COMPACT account of what the worker actually did, for the auditor.
3470
- *
3471
- * Not the conversation: see the note at the audit call. This is the last few assistant turns plus
3472
- * the tools they ran, which is what a reviewer needs to see whether the claimed work happened —
3473
- * and it stays small enough that the auditor can still answer the question it was asked.
3474
- */
3475
1997
  auditEvidence(summary) {
3476
1998
  const lines = [];
3477
1999
  for (let i = this.messages.length - 1; i >= 0 && lines.length < 24; i--) {
@@ -3480,7 +2002,7 @@ export class Agent {
3480
2002
  if (!text)
3481
2003
  continue;
3482
2004
  if (m.role === "user")
3483
- break; // stop at the request that started this round
2005
+ break;
3484
2006
  const who = m.role === "assistant" ? "worker" : m.role;
3485
2007
  lines.push(`${who}: ${text.replace(/\s+/g, " ").slice(0, 400)}`);
3486
2008
  }
@@ -3494,58 +2016,21 @@ export class Agent {
3494
2016
  for (let i = this.messages.length - 1; i >= 0 && recent.length < 6; i--) {
3495
2017
  const m = this.messages[i];
3496
2018
  if (m.role === "user")
3497
- continue; // prompts/noise — keep agent output only
2019
+ continue;
3498
2020
  const text = String(m.content ?? "").trim();
3499
- // a message whose text is a PLACEHOLDER carries no information.
3500
- //
3501
- // `llm.ts` rewrites any assistant/tool message that has no usable text to
3502
- // the literal "(tool call)" or "(no content)". Those are display
3503
- // artifacts, and the parent's report was landing them verbatim — in the
3504
- // real log, TWO of the six entries in an accepted report were literally
3505
- // "[assistant] (tool call)", so half of what the parent was told about the
3506
- // work was a placeholder.
3507
- //
3508
- // A tool CALL is real work, but it is represented in the tool RESULT that
3509
- // follows, so dropping the placeholder loses nothing.
3510
2021
  if (!text || PLACEHOLDER_TEXT.test(text))
3511
2022
  continue;
3512
- // a window this small must not be spent on BOOKKEEPING. The real log
3513
- // showed a 6-slot window where two 23-char "(tool call)" placeholders and a
3514
- // 40-char "decision recorded to decisions.md" took half the slots, pushing
3515
- // out the reasoning a parent would need to judge the work. Harness
3516
- // acknowledgements carry no findings, so they cost a slot for nothing.
3517
2023
  if (HARNESS_ACK.test(text))
3518
2024
  continue;
3519
2025
  const who = m.role === "tool" ? "tool" : m.role;
3520
2026
  let line = `[${who}] ${text}`;
3521
2027
  if (m.role === "tool") {
3522
- // tool results are prefixed "(failed)" by the logger when they fail —
3523
- // the raw content is what matters here, and cap hard: these can be huge
3524
2028
  line = `[${who}] ${(m.content ?? "").trim().slice(0, 1200)}`;
3525
2029
  }
3526
- // A generous cap, but not unbounded: one runaway turn must not blow up
3527
- // the parent's context. The old 1500 char clip cut findings in half —
3528
- // a review's conclusions routinely live past that mark — and this
3529
- // text is bounded by 6 turns, so the worst case is ~120k chars.
3530
2030
  recent.unshift(line.slice(0, 20_000));
3531
2031
  }
3532
2032
  return recent;
3533
2033
  }
3534
- /**
3535
- * Run the completion auditor, streaming its steps into the timeline.
3536
- *
3537
- * The auditor is a real review, not a single question: pi-goal-x gives it read-only tools so it
3538
- * can inspect the artifacts instead of trusting the worker's summary, and it reports its progress
3539
- * as it goes. Both matter here:
3540
- *
3541
- * - WITHOUT the tools the auditor could only re-read the claim it was asked to check, which is
3542
- * how three audits in github-bug-research-1f48715f replied in prose and produced no verdict;
3543
- * - WITHOUT the progress rows the timeline showed `audit-started` and then nothing until the
3544
- * verdict, so an audit that produced no verdict was indistinguishable from one that never ran.
3545
- *
3546
- * Bounded by `AUDIT_MAX_STEPS`: an auditor that keeps reading is doing work the operator cannot
3547
- * see the end of, and the worker is blocked while it runs.
3548
- */
3549
2034
  async runAuditor(first) {
3550
2035
  const AUDIT_MAX_STEPS = 12;
3551
2036
  const step = (data) => this.log.append("audit_step", this.currentSession, this.currentBranch, data).catch(() => { });
@@ -3588,7 +2073,6 @@ export class Agent {
3588
2073
  });
3589
2074
  }
3590
2075
  }
3591
- // out of steps: ask once for the verdict alone, with no tools to reach for
3592
2076
  const final = await this.callLlm([...messages, { role: "user", content: "Stop investigating. Reply with the verdict line only." }], []);
3593
2077
  return String(final.message.content ?? "");
3594
2078
  }
@@ -3596,73 +2080,30 @@ export class Agent {
3596
2080
  const args = safeParse(argsJson);
3597
2081
  if (args.goalComplete === true && this.goal.status !== "done") {
3598
2082
  if (this.goal.verify?.trim()) {
3599
- // pi-goal-x-style INDEPENDENT COMPLETION REVIEW: a separate LLM call
3600
- // checks the verification contract against the conversation + summary
3601
- // before "done" is honored. changes-required reopens the goal and
3602
- // queues the auditor's feedback as the next user prompt.
3603
2083
  await this.log.append("goal", this.currentSession, this.currentBranch, {
3604
2084
  event: "audit-started",
3605
2085
  verify: this.goal.verify.slice(0, 2000),
3606
2086
  });
3607
- // number the attempts — the final carries the attempt that approved it
3608
2087
  this.auditAttempts++;
3609
2088
  try {
3610
- /**
3611
- * The auditor runs in its OWN conversation.
3612
- *
3613
- * It used to be `buildMessages()` + one more user turn — the worker's entire conversation,
3614
- * handed to a model that was then told "you are an auditor, not the worker". MEASURED in
3615
- * github-bug-research-1f48715f: with ~220k tokens of work log in front of it, the auditor
3616
- * replied by CONTINUING the work (prose, no verdict) on all three attempts, and the goal
3617
- * was abandoned with the audit never actually running.
3618
- *
3619
- * A review is not a continuation. The auditor gets the objective, the contract and the
3620
- * worker's claim — everything it needs to judge — and nothing to continue.
3621
- */
3622
2089
  const evidence = this.auditEvidence(args.summary);
3623
2090
  const auditMessages = [
3624
2091
  { role: "user", content: auditPrompt({ goal: this.goal.text, verify: this.goal.verify, summary: String(args.summary ?? ""), evidence }) },
3625
2092
  ];
3626
2093
  const text = await this.runAuditor(auditMessages);
3627
- /**
3628
- * The verdict is the reply's LAST line — see `parseAuditVerdict`. Anything else is NO
3629
- * VERDICT, never a rejection: a reason-less "fix these gaps" carries no information and
3630
- * only teaches everyone that the audit is noise.
3631
- */
3632
2094
  const verdict = parseAuditVerdict(text);
3633
2095
  if (verdict.kind === "none") {
3634
- // an unusable reply is NOT an approval. The old path failed
3635
- // OPEN — recorded the failure and marked the goal done anyway —
3636
- // so a verification contract was "satisfied" that nobody verified.
3637
- // Fail CLOSED instead: the goal stays active and the worker is
3638
- // asked to self-verify, with a bounded streak (see the field).
3639
2096
  this.auditUnavailableStreak++;
3640
2097
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
3641
2098
  event: "audit-failed",
3642
2099
  detail: verdict.detail,
3643
2100
  attempt: this.auditUnavailableStreak,
3644
2101
  });
3645
- /**
3646
- * A no-verdict reply gets a bounded number of retries, then the operator decides.
3647
- *
3648
- * MEASURED in github-bug-research-1f48715f: three audits in six minutes all failed the
3649
- * same way, and the retry prompt told the worker to "re-check the contract yourself, then
3650
- * finish again" — which cannot help, because the worker's re-verification was never what
3651
- * the auditor was missing. It re-ran the SAME call against the SAME model with the SAME
3652
- * reply shape, so the loop cost three rounds and ended at the cap anyway.
3653
- *
3654
- * The retry is worth keeping for the transient case (a provider hiccup, a truncated
3655
- * reply), and the prompt now asks for the thing that actually helps — a concise statement
3656
- * of what was verified. Three attempts, then a loud note.
3657
- */
3658
2102
  if (this.auditUnavailableStreak < 3) {
3659
2103
  this.queueAuditUnavailableRetry();
3660
2104
  await this.captureRecentTurns();
3661
2105
  return;
3662
2106
  }
3663
- // bounded fallback, same philosophy as the reject bound in the
3664
- // loop: the goal is left ACTIVE and a loud note stops the loop —
3665
- // the operator sees it and decides. Never "honored as done".
3666
2107
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
3667
2108
  event: "audit-unavailable-cap",
3668
2109
  attempts: this.auditUnavailableStreak,
@@ -3672,23 +2113,11 @@ export class Agent {
3672
2113
  return;
3673
2114
  }
3674
2115
  const approved = verdict.kind === "approved";
3675
- // the parser already separated marker from reason
3676
2116
  const feedback = verdict.reason;
3677
- // storing the literal "(no detail)" made the UI draw a card
3678
- // whose whole content was the absence of a reason, and that same
3679
- // string landed in goal.md for the right panel. An empty string is
3680
- // the honest value: the auditor returned a verdict and no reason,
3681
- // which every consumer can already render as "label only".
3682
2117
  await this.setGoalAudit(approved ? "approved" : "changes-required", feedback);
3683
- // a real verdict lands: the no-verdict streak restarts either way —
3684
- // the streak's own JSDoc promises "reset by any real verdict", and
3685
- // without this a flaky auditor (2 unusable, then a real verdict,
3686
- // then 1 unusable) hit the cap far too early
3687
2118
  this.auditUnavailableStreak = 0;
3688
2119
  if (approved) {
3689
2120
  this.consecutiveAuditRejects = 0;
3690
- // #91/#94: the final will carry WHAT was approved, on WHICH attempt,
3691
- // and what the previous attempt was told (if it was rejected first)
3692
2121
  this.lastAuditOutcome = {
3693
2122
  audited: true,
3694
2123
  verdict: "approved",
@@ -3699,25 +2128,11 @@ export class Agent {
3699
2128
  };
3700
2129
  }
3701
2130
  else {
3702
- // the rejection survives in memory so the eventual approved
3703
- // final can name what the first attempt was told
3704
2131
  this.lastRejectedAudit = { feedback, attempt: this.auditAttempts };
3705
- /**
3706
- * …AND capture the work that was just rejected, once. This runs before the retry
3707
- * prompt is queued, so the history still holds the original task; capturing later
3708
- * (at the approved finish) would read only the fix-up.
3709
- */
3710
2132
  if (!this.preRejectionTurns.length) {
3711
2133
  this.preRejectionTurns = await this.captureRecentTurns();
3712
2134
  }
3713
2135
  }
3714
- // `operatorFacing: true` — the verdict is written for the TIMELINE
3715
- // only. The audit ran in its own side conversation (a tools-less call
3716
- // over buildMessages() + an audit prompt), so it was never in
3717
- // `this.messages` and must not come back through a restore: doing so
3718
- // injected an assistant turn that answers nothing into the model's
3719
- // context, which providers answer with a 200 and no choices.
3720
- // The rejection feedback reaches the agent as a real prompt below.
3721
2136
  await this.log.append("message", this.currentSession, this.currentBranch, {
3722
2137
  role: "assistant",
3723
2138
  operatorFacing: true,
@@ -3727,24 +2142,11 @@ export class Agent {
3727
2142
  });
3728
2143
  if (!approved) {
3729
2144
  this.consecutiveAuditRejects++;
3730
- // the rejection survives in memory so the eventual approved
3731
- // final can name what the first attempt was told
3732
2145
  this.lastRejectedAudit = { feedback, attempt: this.auditAttempts };
3733
- // hand the gaps back to the worker as its next instruction.
3734
- // with the "(no detail)" placeholder gone, an empty verdict
3735
- // would leave this prompt saying "address these gaps" and then
3736
- // naming none — so restate the contract as the thing to re-check.
3737
2146
  const gaps = feedback.trim()
3738
2147
  ? feedback.slice(0, 2000)
3739
2148
  : `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)}`;
3740
2149
  const retryText = `[harness] The completion audit REJECTED this finish. Address these gaps, then finish again with goalComplete=true:\n\n${gaps}`;
3741
- // counted above (consecutiveAuditRejects++ at the verdict) so
3742
- // a model that cannot satisfy the auditor cannot loop forever — the
3743
- // retry prompt is queued only while the bound is NOT yet reached,
3744
- // so the loop's cap break strands nothing (a prompt queued AT the
3745
- // cap would live on as an invisible no-id entry: no badge, no log
3746
- // row, but isLive() true). Log the row too: a push-only prompt is
3747
- // invisible on the timeline and lost to a restart otherwise.
3748
2150
  if (this.consecutiveAuditRejects < (this.opts.maxAuditRejects ?? 3)) {
3749
2151
  this.pendingPrompts.push({ source: "harness", text: retryText });
3750
2152
  await this.log.append("prompt", this.currentSession, this.currentBranch, {
@@ -3752,25 +2154,11 @@ export class Agent {
3752
2154
  text: retryText,
3753
2155
  });
3754
2156
  }
3755
- // RETURN before the final message is written.
3756
- //
3757
- // Falling through emitted `final: true` while the goal was still
3758
- // active — and that is the event onChildEvent forwards to the
3759
- // parent. So a REJECTED completion told the parent the work was
3760
- // DONE, and the child then sat retrying against a parent that had
3761
- // already moved on and stopped listening.
3762
- //
3763
- // The goal stays active and the retry prompt is queued above, so
3764
- // the loop simply continues; the parent's notification is deferred
3765
- // until a finish that actually survives the audit.
3766
2157
  await this.captureRecentTurns();
3767
2158
  return;
3768
2159
  }
3769
2160
  }
3770
2161
  catch (err) {
3771
- // an auditor that THROWS is not an approval either. Same
3772
- // fail-closed treatment as a no-verdict reply: active goal, bounded
3773
- // self-verify retries, then a loud stop for the operator.
3774
2162
  this.auditUnavailableStreak++;
3775
2163
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
3776
2164
  event: "audit-failed",
@@ -3797,43 +2185,18 @@ export class Agent {
3797
2185
  }
3798
2186
  await this.appendFinalMessage(args);
3799
2187
  }
3800
- /**
3801
- * The retry prompt for an audit that could not produce a verdict.
3802
- *
3803
- * It asks the worker to make the NEXT audit easier to answer, which is the only thing within its
3804
- * power: a no-verdict reply means the auditor could not judge from what it was given, so a
3805
- * concise statement of what was verified is the actual fix. It does NOT claim the work is
3806
- * unverified-and-must-be-redone; the work may be fine and the auditor simply failed, and telling
3807
- * the worker to redo it produced the repeat loop measured in github-bug-research-1f48715f.
3808
- */
3809
2188
  queueAuditUnavailableRetry() {
3810
2189
  const text = `[harness] The completion audit could not produce a verdict — that is a problem with the ` +
3811
2190
  `AUDIT, not a finding about your work. Before finishing again, state plainly and briefly what ` +
3812
2191
  `you verified for each item of the contract, so the auditor has something to judge.\n\n` +
3813
2192
  `${this.goal.verify?.slice(0, 2000) || this.goal.text.slice(0, 2000)}`;
3814
2193
  this.pendingPrompts.push({ source: "harness", text });
3815
- // log the row: a push-only prompt is invisible on the timeline and lost to
3816
- // a restart (rebuildMessagesFrom replays prompt rows) — same as enqueuePrompt
3817
2194
  void this.log
3818
2195
  .append("prompt", this.currentSession, this.currentBranch, { source: "harness", text })
3819
2196
  .catch(() => { });
3820
2197
  }
3821
- /** The `final: true` event — the parent's notification (#89: never before the goal settles). */
3822
2198
  async appendFinalMessage(args) {
3823
2199
  const recent = await this.captureRecentTurns();
3824
- /**
3825
- * an APPROVED finish after a REJECTION must describe the WHOLE task.
3826
- *
3827
- * `captureRecentTurns()` reads the last six meaningful messages, which after a
3828
- * changes-required verdict is exactly the RETRY — so the parent was told about the last few
3829
- * minutes of work and nothing about the hours that came before it. The rejection branch
3830
- * already queues the auditor's feedback as the retry prompt, so the retry is a narrow fix-up;
3831
- * reporting only that makes the work look trivial and the parent has no basis to judge it.
3832
- *
3833
- * The pre-rejection activity is captured when the reject arrives (`preRejectionTurns`) and is
3834
- * prepended here, clearly labelled, so the parent reads "this is what was done, and this is
3835
- * what the audit then required". Bounded to the same six entries so the report stays a report.
3836
- */
3837
2200
  const before = this.preRejectionTurns;
3838
2201
  this.preRejectionTurns = [];
3839
2202
  const combined = before.length
@@ -3844,9 +2207,6 @@ export class Agent {
3844
2207
  ...recent,
3845
2208
  ]
3846
2209
  : recent;
3847
- // #91/#94: the final says how it relates to the audit — approved on which
3848
- // attempt, what the previous attempt was told. Cleared on emit: a later
3849
- // unaudited finish must not inherit stale lineage.
3850
2210
  const outcome = this.lastAuditOutcome;
3851
2211
  this.lastAuditOutcome = null;
3852
2212
  await this.log.append("message", this.currentSession, this.currentBranch, {
@@ -3874,37 +2234,21 @@ export class Agent {
3874
2234
  const activityOk = this.activityChars >= this.opts.progressMinChars ||
3875
2235
  this.turnsSinceProgress >= this.opts.progressMaxQuietTurns;
3876
2236
  if (!elapsedOk || !activityOk)
3877
- return; // stalling provider → don't waste a turn asking
3878
- /**
3879
- * ONE harness instruction per boundary, in EITHER order.
3880
- *
3881
- * This is the mirror of the `progressRequestedThisRound` check in the auto-continue branch. A
3882
- * nudge injected at the end of a round is consumed by the NEXT round's first turn, which is
3883
- * where this runs — so without this the model receives both, back to back.
3884
- */
2237
+ return;
3885
2238
  if (this.nudgedAtTurn === this.stats.turns)
3886
2239
  return;
3887
- // remember that this boundary already carries an instruction, so the
3888
- // auto-continue nudge can stand down instead of pairing with it.
3889
2240
  this.progressRequestedThisRound = true;
3890
2241
  this.lastProgressAt = Date.now();
3891
2242
  this.activityChars = 0;
3892
2243
  this.turnsSinceProgress = 0;
3893
2244
  const request = PROGRESS_REQUEST;
3894
- // log both sides so a session restore replays this exchange faithfully
3895
2245
  await this.log.append("prompt", this.currentSession, this.currentBranch, {
3896
2246
  source: "harness",
3897
2247
  text: request,
3898
2248
  });
3899
2249
  this.messages.push({ role: "user", content: request });
3900
- // Give the model the report_progress TOOL here. The old no-tools call
3901
- // made the model answer in plain text while its own mental "did I report?"
3902
- // ledger stayed unsolved — it then re-sent a voluntary report_progress
3903
- // right after, and the timeline showed every requested report TWICE.
3904
2250
  const res = await this.llmCall(this.buildMessages(), allToolSpecs());
3905
2251
  const calls = res.message.tool_calls ?? [];
3906
- // the assistant turn MUST enter history before its tool answers, or the
3907
- // sequence [user, tool] is invalid and every later request 400s
3908
2252
  this.messages.push(res.message);
3909
2253
  await this.log.append("message", this.currentSession, this.currentBranch, {
3910
2254
  role: "assistant",
@@ -3918,27 +2262,9 @@ export class Agent {
3918
2262
  if (call.function.name === "report_progress") {
3919
2263
  await this.recordProgress(call.function.arguments);
3920
2264
  }
3921
- /**
3922
- * EVERY tool call needs a `tool` message, but only `report_progress` was a report.
3923
- *
3924
- * This loop used to answer `"progress recorded"` to whatever the model called, so a `bash`
3925
- * invocation came back with the text "progress recorded" and `durationMs: 0` — it never ran.
3926
- * call, `bash <npm view polling loop>`, and its result was `"progress recorded"`, while the
3927
- * very next `progress` row read "(no report produced)". The command's real output appeared
3928
- * only on the NEXT turn, which is what made it look like a stray duplicate.
3929
- *
3930
- * The model did nothing wrong: it was asked for a progress report and chose to run its work
3931
- * instead. Answering that call with the report text both lies to the model (the tool result
3932
- * is its only view of what happened) and drops the command's output.
3933
- *
3934
- * A non-report call is answered with a plain acknowledgement — it is NOT executed here,
3935
- * because this path has no tool context and must not run side effects a second time. The
3936
- * round then continues and the model sees that it still owes a report.
3937
- */
3938
2265
  const isReport = call.function.name === "report_progress";
3939
2266
  const answer = isReport ? "progress recorded" : "not run — this turn was a progress request";
3940
2267
  this.messages.push({ role: "tool", tool_call_id: call.id, content: answer });
3941
- // log both sides so restores replay the exact exchange (meta-style)
3942
2268
  await this.log.append("tool_call", this.currentSession, this.currentBranch, {
3943
2269
  callId: call.id,
3944
2270
  name: call.function.name,
@@ -3948,25 +2274,15 @@ export class Agent {
3948
2274
  await this.log.append("tool_result", this.currentSession, this.currentBranch, {
3949
2275
  callId: call.id,
3950
2276
  name: call.function.name,
3951
- // a not-run answer is not a FAILURE, so `ok` stays true — the row must not read as an error
3952
2277
  ok: true,
3953
2278
  durationMs: 0,
3954
2279
  result: answer,
3955
2280
  });
3956
2281
  }
3957
2282
  if (!reported && !(res.message.content ?? "").trim()) {
3958
- // degenerate: neither a tool call nor prose — record a stub so the
3959
- // operator sees SOMETHING at the requested moment
3960
2283
  await this.recordProgress(JSON.stringify({ freeform: "(no report produced)" }));
3961
2284
  }
3962
2285
  }
3963
- /* ---------- context compaction ---------- */
3964
- /**
3965
- * Rough token estimate good enough to trigger compaction before overflow.
3966
- * ASCII runs ≈ 4 chars/token; CJK (kana/kanji/hanja and friends) ≈ 1
3967
- * token/char — the old flat /4 underestimated Japanese sessions ~4x.
3968
- * Adds a small per-message overhead for role/framing tokens.
3969
- */
3970
2286
  estimateTokens(msgs = this.messages) {
3971
2287
  let ascii = 0;
3972
2288
  let wide = 0;
@@ -3987,12 +2303,6 @@ export class Agent {
3987
2303
  }
3988
2304
  return Math.ceil(ascii / 4 + wide * 1.2 + msgs.length * 8);
3989
2305
  }
3990
- /**
3991
- * Latest index whose message may START the kept tail of a compacted
3992
- * history. Anything except a dangling tool result is safe: a kept
3993
- * assistant-with-tool_calls always has its tool responses after it (they
3994
- * are never cut apart — we only remove a prefix).
3995
- */
3996
2306
  safeCut(maxCut) {
3997
2307
  for (let i = Math.min(maxCut, this.messages.length - 1); i >= 1; i--) {
3998
2308
  if (this.messages[i].role !== "tool")
@@ -4000,23 +2310,11 @@ export class Agent {
4000
2310
  }
4001
2311
  return -1;
4002
2312
  }
4003
- /**
4004
- * Answer a meta tool call (finish / get_goal / ask_user / …): into the
4005
- * in-memory history AND the log as a regular tool_result, so session
4006
- * restores replay the exact same bytes and provider prefix caches stay
4007
- * warm across restarts.
4008
- */
4009
2313
  async answerMeta(call, content) {
4010
- // one tool_call gets ONE answer: the finish path answers before the audit
4011
- // AND at round end — a second answerMeta for the same id would push
4012
- // a duplicate `tool` message AND a duplicate log row, and strict
4013
- // OpenAI-compatible providers reject two responses for one call with a 400.
4014
2314
  if (this.messages.some((m) => m.role === "tool" && m.tool_call_id === call.id))
4015
2315
  return;
4016
2316
  const raw = call.function.arguments ?? "{}";
4017
2317
  this.messages.push({ role: "tool", tool_call_id: call.id, content });
4018
- // log the call AND its answer so restores replay byte-exact sequences
4019
- // (meta calls previously went unlogged and restored as bare "{}" args)
4020
2318
  await this.log.append("tool_call", this.currentSession, this.currentBranch, {
4021
2319
  callId: call.id,
4022
2320
  name: call.function.name,
@@ -4031,29 +2329,14 @@ export class Agent {
4031
2329
  result: content,
4032
2330
  });
4033
2331
  }
4034
- /**
4035
- * Stage 1 of context management: clip OLD oversized tool outputs before paying for a full
4036
- * summarise. They are the usual bulk and their details rarely matter once executed; the recent
4037
- * window is protected so current work keeps its footing.
4038
- *
4039
- * It runs as a PRE-STEP of `maybeCompact()`, only once that pass has decided the context is
4040
- * actually over budget. Clipping is more destructive than summarising — the model cannot see a
4041
- * clipped result again without re-issuing the call — so doing it while room is still free trades
4042
- * real information for nothing.
4043
- */
4044
2332
  async maybePrune(force = false) {
4045
- const est = this.contextSizeForBudgeting(); // floored — see #73
2333
+ const est = this.contextSizeForBudgeting();
4046
2334
  const budget = this.opts.contextTokenBudget;
4047
2335
  if (!budget)
4048
2336
  return 0;
4049
- // Below the bar there is enough room for the next request, so clipping now would
4050
- // spend information to buy space that is already free.
4051
2337
  const pruneShare = this.opts.shortContext ? SHORT_PRUNE_AT_SHARE : PRUNE_AT_SHARE;
4052
2338
  if (!force && est < budget * pruneShare)
4053
2339
  return 0;
4054
- // protect the recent tail (~half the budget in chars) from any pruning. Short mode
4055
- // protects a THIRD of it: a 2x-budget tail on a small window is most of the window,
4056
- // so pruning would find nothing to do exactly when it is needed.
4057
2340
  const protectChars = this.opts.shortContext ? Math.floor(budget * 1.2) : budget * 2;
4058
2341
  let seen = 0;
4059
2342
  let boundary = this.messages.length;
@@ -4064,19 +2347,12 @@ export class Agent {
4064
2347
  break;
4065
2348
  }
4066
2349
  const PRUNE_MIN = 3_000;
4067
- // Tool-kind aware pruning (Claude Code MicroCompact style): high-volume,
4068
- // REPRODUCIBLE results (file reads, shell, grep) are safe to clip because
4069
- // the model can re-issue them; one-shot results (spawned agent reports,
4070
- // web fetches) are kept — re-fetching is impossible or expensive.
4071
2350
  const PRUNABLE_TOOLS = new Set([
4072
2351
  "bash", "read_file", "list_dir", "grep", "glob", "write_file", "edit_file", "apply_patch",
4073
2352
  "bash_output",
4074
2353
  ]);
4075
2354
  let saved = 0;
4076
2355
  let count = 0;
4077
- // callId → tool name, from the assistant turn that issued each call
4078
- // (a tool result's PREVIOUS message is not reliably its caller when
4079
- // parallel calls interleave)
4080
2356
  const callerTool = new Map();
4081
2357
  for (const m of this.messages) {
4082
2358
  for (const t of m.tool_calls ?? [])
@@ -4097,10 +2373,6 @@ export class Agent {
4097
2373
  }
4098
2374
  }
4099
2375
  if (count > 0)
4100
- // AWAITED, not fire-and-forget: the caller decides what to do next based on the
4101
- // count, and a compaction pass that reports its outcome must have its reasons on
4102
- // disk first — a `void` append could land after the pass returned, so the log
4103
- // read the order "compacted" → "pruned".
4104
2376
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
4105
2377
  event: "context-pruned",
4106
2378
  outputs: count,
@@ -4108,37 +2380,15 @@ export class Agent {
4108
2380
  });
4109
2381
  return count;
4110
2382
  }
4111
- /**
4112
- * Compact history when the real prompt size exceeds the budget.
4113
- *
4114
- * @param keepFraction share of the budget to keep verbatim; the overflow path
4115
- * lowers it on each retry so a too-shallow cut is not fatal
4116
- */
4117
2383
  async maybeCompact(force = false, keepFraction) {
4118
- // prefer the provider's own count from the last response — it is exactly
4119
- // what would overflow the window; the char heuristic is only a fallback
4120
- // for providers that omit usage
4121
- const before = this.contextSizeForBudgeting(); // floored — see #73
4122
- // SHORT-CONTEXT MODE prunes INSIDE the budget, so it must run before the
4123
- // "are we over budget" return below — otherwise its lower threshold is unreachable
4124
- // and the mode does nothing at all. This is the one path where pruning deliberately
4125
- // spends information early, and it only exists because the operator asked for it.
2384
+ const before = this.contextSizeForBudgeting();
4126
2385
  if (this.opts.shortContext)
4127
2386
  await this.maybePrune();
4128
2387
  if (!force && before < this.opts.contextTokenBudget)
4129
2388
  return;
4130
2389
  if (!force && this.opts.autoCompact === false)
4131
2390
  return;
4132
- // stage 1: clip oversized old tool outputs before considering a summarize.
4133
- // ONLY reached once this pass is actually over budget — pruning is destructive,
4134
- // so it must not run on a context that still has room (see maybePrune()).
4135
2391
  const pruned = await this.maybePrune(true);
4136
- // If clipping alone brought the request back under budget, STOP: that is the
4137
- // cheapest possible compaction (no LLM call, no history rewritten, the tool
4138
- // results are re-issuable) and everything below would discard context the model
4139
- // can still use. This is the ordering the operator asked for — "どうしても足りなかったら
4140
- // 過去履歴落とす程度でいい" — and it is what Anthropic calls the lightest form of
4141
- // compaction. A manual `/compact` still means "summarize", so it is exempt.
4142
2392
  if (pruned > 0 && !force && this.contextSizeForBudgeting() < this.opts.contextTokenBudget) {
4143
2393
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
4144
2394
  event: "context-pruned-enough",
@@ -4149,56 +2399,25 @@ export class Agent {
4149
2399
  this.compactedAtLen = this.messages.length;
4150
2400
  return;
4151
2401
  }
4152
- // CIRCUIT BREAKER (ZCode: `max auto failures: 3`). A compaction that keeps
4153
- // failing would otherwise retry on EVERY turn, re-burning the provider for a pass
4154
- // that cannot succeed — and the failure is usually environmental (an unavailable
4155
- // model, a provider outage), so retrying it eagerly achieves nothing. Auto
4156
- // attempts only: a manual `/compact` is the operator asking, and an overflow
4157
- // recovery is the provider having just refused, so both must still try.
4158
2402
  if (!force && this.consecutiveCompactFailures >= MAX_AUTO_COMPACT_FAILURES)
4159
2403
  return;
4160
- // a forced pass on an already-tiny history would just summarize the
4161
- // summary — report ran=false instead
4162
2404
  if (force && this.messages.length <= this.compactedAtLen)
4163
2405
  return;
4164
- this.lastUsage = undefined; // stale after compaction — re-armed next turn
4165
- // keep roughly the most recent quarter of the budget as live context
4166
- // Keep the most recent stretch VERBATIM, as every implementation surveyed
4167
- // does (Pi: keepRecentTokens 20000, opencode: keep 15000). Recency is what the
4168
- // model needs to continue accurately, and verbatim beats a summary of it.
4169
- //
4170
- // Size: a quarter of the budget, but never less than KEEP_RECENT_TOKENS — a
4171
- // small budget would otherwise keep so little that the model loses the turn it
4172
- // is in the middle of. Measured in TOKENS rather than characters, so the choice
4173
- // matches the published defaults instead of depending on the char heuristic.
2406
+ this.lastUsage = undefined;
4174
2407
  const keepTokens = keepFraction !== undefined
4175
- // an explicit retry (overflow recovery): shrink toward a share of the budget
4176
2408
  ? Math.max(1, Math.floor(this.opts.contextTokenBudget * keepFraction))
4177
- // otherwise keep the newest stretch verbatim, but never more than a quarter
4178
- // of the budget — a fixed floor GREATER than the budget would keep everything
4179
- // and compaction would never fire at all (measured: a 500-token budget kept
4180
- // a 7.5k-token history intact)
4181
- // already scaled: on a small budget a quarter is below the 15k default, so a
4182
- // 32k-window session keeps ~6k rather than 15k. Short mode does NOT need to
4183
- // change this — checked while implementing #161 rather than assumed.
4184
2409
  : Math.min(KEEP_RECENT_TOKENS, Math.max(1, Math.floor(this.opts.contextTokenBudget / 4)));
4185
2410
  let cut = -1;
4186
2411
  for (let i = this.messages.length - 1; i >= 1; i--) {
4187
- // token-aware boundary, using the SAME estimate the budget uses — a raw char
4188
- // ratio would disagree with it for wide (CJK) text and per-message overhead
4189
2412
  if (this.estimateTokens(this.messages.slice(i)) > keepTokens)
4190
2413
  break;
4191
2414
  cut = i;
4192
2415
  }
4193
2416
  cut = this.safeCut(cut);
4194
2417
  if (cut <= 0)
4195
- return; // nothing safely compactable (phase never armed yet)
2418
+ return;
4196
2419
  let old = this.messages.slice(0, cut);
4197
2420
  const oldCount = old.length;
4198
- // The prefix's share of the size the budget check ACTED on (which floors our
4199
- // estimate against the provider's reported prompt_tokens). Scaling by character
4200
- // share rather than message count: messages vary hugely in size, and the point
4201
- // is to avoid treating an unmeasurably large context as small.
4202
2421
  const totalChars = this.messages.reduce((n, m) => n + (m.content?.length ?? 0), 0);
4203
2422
  const oldChars = old.reduce((n, m) => n + (m.content?.length ?? 0), 0);
4204
2423
  const oldShareOfBefore = totalChars > 0 ? Math.round((before * oldChars) / totalChars) : 0;
@@ -4206,56 +2425,10 @@ export class Agent {
4206
2425
  let mode = "summarize";
4207
2426
  let summarizedCount = oldCount;
4208
2427
  let droppedCount = 0;
4209
- // COST GATE. The criterion is the ABSOLUTE amount of context the cut would
4210
- // discard, not how far over the budget we are: a ratio test gets it backwards,
4211
- // since a small window overflowing slightly is exactly the case where a
4212
- // summary buys almost nothing (there is little knowledge to lose), while a large
4213
- // window overflowing slightly still has a lot to lose.
4214
- //
4215
- // Since the summarizer now REPLAYS the conversation prefix, the call reuses the
4216
- // provider's KV cache, so it is far cheaper than it used to be — but it is not
4217
- // free (the shadowed region is re-read, and the summary itself is generated), so
4218
- // a shallow cut is still better served by the free drop.
4219
- //
4220
- // MANUAL is exempt: an explicit /compact is a user command, not a cost decision,
4221
- // and opencode draws the line in the same place ("only the user compacts when
4222
- // automatic compaction is off"). An overflow recovery is exempt too — the
4223
- // provider just told us the prompt was too big, so the question is not whether
4224
- // the saving is worth a call.
4225
- //
4226
- // Use BOTH sizes. `oldTokens` is our estimate of the prefix being summarized;
4227
- // `before` is the size the budget check acted on, which already floors that
4228
- // estimate against the provider's REPORTED prompt_tokens. Gating on the local
4229
- // estimate alone would silently truncate a context the provider says is large
4230
- // but we cannot measure (caught by a test: provider reports 5000 tokens for a
4231
- // 300-token local estimate). The provider tokenizes for real; our count is a
4232
- // guess, so the larger of the two is the honest figure.
4233
2428
  const oldTokens = Math.max(this.estimateTokens(old), oldShareOfBefore);
4234
- // The bar is a SHARE of the budget with an absolute floor, so it scales with a
4235
- // realistic window while never sitting above a small session's entire history
4236
- // (measured: a budget of 200 made a pure-share bar 10 tokens, while the whole
4237
- // summarizable prefix in that fixture was an 8-token prompt — summarization
4238
- // would have been silently disabled).
4239
- //
4240
- // Below the bar the prefix is a turn or two, and dropping it costs the model
4241
- // nothing it cannot recover by re-reading the goal or the files; a full-history
4242
- // LLM call to preserve that is not worth its price.
4243
2429
  const summarizeBar = Math.max(SUMMARIZE_MIN_TOKENS, this.opts.contextTokenBudget * SUMMARIZE_MIN_SHARE);
4244
2430
  const worthSummarizing = force || oldTokens >= summarizeBar;
4245
- // UPDATE mode: carry the previous notes forward instead of re-deriving them.
4246
- // Every implementation surveyed (opencode, Kilo, Pi) updates rather than
4247
- // re-summarizes, because a fresh summary each time loses whatever the previous
4248
- // pass had already distilled. Recovered from the LAST compaction event, so it
4249
- // survives a restart.
4250
2431
  const previousSummary = this.lastCompactSummary;
4251
- // progress is visible: announce the phase up front, then stream the
4252
- // summarizer's output as it generates (a long summarize used to look like
4253
- // the agent silently hung — hundreds of thousands of tokens take a while)
4254
- // the STATE must be established BEFORE the first notification — the
4255
- // emit used to precede `compactPhase` by an async log write, so the first
4256
- // progress event observed a snapshot with `compactPhase` still unset:
4257
- // "idle, not compacting, but a summarizing banner" from three paths at
4258
- // once. Sync set → emit → log keeps every path consistent.
4259
2432
  this.compactPhase = "summarizing";
4260
2433
  bus.emit("update", {
4261
2434
  kind: "compaction-progress",
@@ -4263,84 +2436,34 @@ export class Agent {
4263
2436
  phase: "summarizing",
4264
2437
  summarized: oldCount,
4265
2438
  });
4266
- // Everything below may throw — EventLog appends reject on stream errors,
4267
- // and disk-full is exactly when they will. The phase MUST not survive a
4268
- // failed compaction: isLive() keys off it, so a leaked "summarizing"
4269
- // pinned live=true forever (stop offered, never worked).
4270
2439
  try {
4271
- // the START belongs on the timeline too. Only the right panel showed
4272
- // compaction, and it reports the *outcome*; a long summarize (hundreds of
4273
- // thousands of tokens) looks exactly like a hung agent on the timeline,
4274
- // because the first `context-compacted` event is only appended once the
4275
- // whole pass is over. Log the announcement as a visible system_note so
4276
- // the operator sees WHERE the conversation was rewritten, even if the
4277
- // pass later fails — `before`/`oldCount` are known at this point, and the
4278
- // completion divider still carries the authoritative after-figures.
4279
2440
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
4280
2441
  event: "context-compaction-started",
4281
2442
  tokensBefore: before,
4282
2443
  messages: oldCount,
4283
2444
  reason: force ? "manual" : "auto",
4284
- worthSummarizing, // makes the cost decision auditable
2445
+ worthSummarizing,
4285
2446
  updating: Boolean(previousSummary),
4286
2447
  });
4287
2448
  try {
4288
2449
  if (worthSummarizing) {
4289
- // OVERFLOW RETRY on the summarizer itself. The replayed prefix is the head
4290
- // of a request that has already succeeded, so it normally fits — but the
4291
- // cut can land such that the shadowed region is nearly the whole history
4292
- // (measured: with a recent-keep target smaller than one message, cut lands
4293
- // at the far end and `old` is everything). When the conversation was over
4294
- // its window to begin with — the overflow-recovery case — the auxiliary
4295
- // request can then be over it too, and there is no truncation to save it
4296
- // any more.
4297
- //
4298
- // The fix keeps the property the replay exists for: REDUCE THE CUT, which
4299
- // sends FEWER LEADING messages, so the request stays a cacheable prefix of
4300
- // the routed request while shrinking. (Dropping the middle would break
4301
- // alignment; dropping the tail would discard the newest context.) ZCode
4302
- // handles the same case with a bounded `prompt-too-long` retry, and this is
4303
- // bounded for the same reason: an unbounded loop here would burn tokens
4304
- // against a provider that will refuse every size.
4305
2450
  for (let attempt = 0; attempt <= SUMMARIZE_OVERFLOW_RETRIES; attempt++) {
4306
2451
  try {
4307
2452
  summary = await this.summarize(old, (text) => {
4308
2453
  bus.emit("update", {
4309
2454
  kind: "llm-delta",
4310
2455
  agentId: this.opts.id,
4311
- // the bubble is session-scoped, so the stream must say which session
4312
2456
  sessionId: this.currentSession,
4313
2457
  text: `[compact] ${text}`,
4314
2458
  reasoning: "",
4315
2459
  });
4316
2460
  }, previousSummary);
4317
- /**
4318
- * Rules a pass dropped are put BACK.
4319
- *
4320
- * The summarizer is asked for one keyword per line, but it is still generated text:
4321
- * a pass can merge three constraints into a sentence, or simply not mention one.
4322
- * Facts survive that (the code can be re-read); a rule cannot — "never install this
4323
- * into Wine" was stated once and is nowhere else. Reported as "compact時にルール
4324
- * (must/must notが中心だけど、shouldレベルのものも)が確実に引き継げる機構があると良いね".
4325
- *
4326
- * So the rules known BEFORE this pass are compared against the ones it produced, and
4327
- * any missing are appended. A pass may add and reword freely; it cannot lose one.
4328
- */
4329
2461
  {
4330
- /**
4331
- * Seed from the previous summary ONCE, for a session that compacted before this
4332
- * mechanism existed — after that the stored set is the authority.
4333
- *
4334
- * The set lives in `this.compactRules`, not in the previous summary: a pass that
4335
- * dropped every rule would otherwise leave nothing to restore from, which is the
4336
- * exact failure this exists to prevent.
4337
- */
4338
2462
  if (!this.compactRules.length && previousSummary) {
4339
2463
  this.compactRules = extractRules(previousSummary);
4340
2464
  }
4341
2465
  const enforced = enforceRules(summary, this.compactRules);
4342
2466
  summary = enforced.summary;
4343
- // a pass may ADD rules (the operator said something new); it may never remove one
4344
2467
  this.compactRules = extractRules(summary);
4345
2468
  if (enforced.restored.length) {
4346
2469
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
@@ -4363,7 +2486,7 @@ export class Agent {
4363
2486
  throw e;
4364
2487
  const smaller = this.safeCut(Math.max(1, Math.floor(old.length * SUMMARIZE_SHRINK)));
4365
2488
  if (smaller <= 0 || smaller >= old.length)
4366
- throw e; // cannot shrink further
2489
+ throw e;
4367
2490
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
4368
2491
  event: "compaction-summarize-overflow",
4369
2492
  attempt: attempt + 1,
@@ -4376,8 +2499,6 @@ export class Agent {
4376
2499
  }
4377
2500
  }
4378
2501
  else {
4379
- // shallow saving: truncation reclaims the budget for free, so skip the
4380
- // one full-history LLM call the summary would cost
4381
2502
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
4382
2503
  event: "context-compaction-skipped-summarize",
4383
2504
  reason: "shallow: truncation reclaims the budget without a full-history LLM call",
@@ -4388,47 +2509,26 @@ export class Agent {
4388
2509
  this.compactPhase = "harvesting";
4389
2510
  bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "harvesting" });
4390
2511
  if (summary)
4391
- await this.harvestLessons(summary); // durable knowledge → memory.md
2512
+ await this.harvestLessons(summary);
4392
2513
  }
4393
2514
  catch (err) {
4394
2515
  await this.log.append("error", this.currentSession, this.currentBranch, {
4395
2516
  message: `compaction summarize failed, falling back to truncation: ${err.message}`,
4396
2517
  });
4397
- // ZCode's circuit breaker counts the failure and never fails the turn for it
4398
- // ("Auto compact 失败不应让当前用户 turn 失败"): the pass still falls back to a
4399
- // truncation below, so work continues, and after MAX_AUTO_COMPACT_FAILURES
4400
- // consecutive auto failures the retry stops burning the provider.
4401
2518
  this.consecutiveCompactFailures += 1;
4402
2519
  }
4403
2520
  if (!summary) {
4404
- // fallback: drop the oldest half without LLM help so work can continue
4405
2521
  mode = "truncate";
4406
2522
  summarizedCount = 0;
4407
- // Cut at a safe boundary, but never at index 0 — that would drop the
4408
- // ENTIRE history (including the prompt just delivered) and leave the agent
4409
- // with nothing. On a short history floor(oldCount/2) can round to 0, and
4410
- // the old code then bailed out silently: compaction "ran", reported
4411
- // nothing, and the context stayed over budget, so the very next provider
4412
- // call overflowed — the "unstable after compact" report. Always drop
4413
- // at least one message, and say so.
4414
2523
  const halfCut = this.safeCut(Math.max(1, Math.floor(oldCount / 2)));
4415
2524
  if (halfCut <= 0) {
4416
- // nothing in the prefix is safe to drop (e.g. it is all tool results).
4417
- // Report it instead of pretending compaction happened.
4418
2525
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
4419
2526
  event: "context-compaction-skipped",
4420
2527
  reason: "summarize failed and no safe prefix to truncate",
4421
2528
  });
4422
- return; // the finally clears the phase and tells the UI it ended
2529
+ return;
4423
2530
  }
4424
2531
  droppedCount = halfCut;
4425
- // Tell the model its history was cut. Dropping a prefix SILENTLY is the
4426
- // one failure it cannot detect or work around: it would see a conversation
4427
- // that simply starts mid-way and could not distinguish "I forgot this" from
4428
- // "this never happened", then confidently act on a gap. Truncation is now
4429
- // the COMMON path (the cost gate keeps the summarize call for deep cuts
4430
- // only), which makes this the difference between a known gap and a wrong
4431
- // belief. The dropped text stays in the log — nothing durable is lost.
4432
2532
  this.messages = [
4433
2533
  {
4434
2534
  role: "user",
@@ -4454,28 +2554,10 @@ export class Agent {
4454
2554
  }
4455
2555
  const after = this.estimateTokens();
4456
2556
  this.stats.compactions++;
4457
- // A pass that got here reclaimed the budget, so autos may try again — ZCode
4458
- // resets its count on success for the same reason. But NOT when the summary
4459
- // failed: the truncate fallback still lands here, and resetting on it made the
4460
- // breaker unreachable (measured: 217 consecutive summarize failures, counter
4461
- // stuck at 0, so the provider was re-burned on every turn).
4462
2557
  if (mode === "summarize")
4463
2558
  this.consecutiveCompactFailures = 0;
4464
2559
  this.compactedAtLen = this.messages.length;
4465
- // Post-compact file restore (Claude Code style): re-attach the most
4466
- // recently read files so the model doesn't burn its first turns re-reading
4467
- // exactly what it had in context a moment ago. Budget: 3 files × 400 lines.
4468
2560
  if (mode === "summarize") {
4469
- // the durable notes go back in HERE, and only here.
4470
- //
4471
- // `read_memory` AFTER writing only 13 times — the notes were written and
4472
- // never consulted, so the whole system was decoration. The prompt made it
4473
- // worse by claiming memory.md was injected when it never was.
4474
- //
4475
- // Compaction is the one moment where re-attaching is free and obviously
4476
- // right: the history that held the notes has just been summarised away, and
4477
- // the prompt has to move anyway. Everywhere else they stay behind
4478
- // `read_memory()` so the prefix cache is never disturbed.
4479
2561
  try {
4480
2562
  const notes = (await fs.readFile(this.memoryFile, "utf8")).trim();
4481
2563
  if (notes) {
@@ -4487,7 +2569,6 @@ export class Agent {
4487
2569
  }
4488
2570
  }
4489
2571
  catch {
4490
- /* no notes yet — nothing to re-attach */
4491
2572
  }
4492
2573
  }
4493
2574
  if (this.recentReads.length && mode === "summarize") {
@@ -4502,7 +2583,6 @@ export class Agent {
4502
2583
  restoredPaths.add(rel);
4503
2584
  }
4504
2585
  catch {
4505
- /* file vanished since the read — skip */
4506
2586
  }
4507
2587
  }
4508
2588
  if (restored.length) {
@@ -4511,30 +2591,9 @@ export class Agent {
4511
2591
  content: `[harness] These files were recently read and are re-attached for continuity ` +
4512
2592
  `(they may have changed — re-read if precision matters):\n\n${restored.join("\n\n")}`,
4513
2593
  });
4514
- // keep exactly what restored, in order — slicing by COUNT kept a
4515
- // vanished middle entry while dropping the surviving tail
4516
2594
  this.recentReads = this.recentReads.filter((rel) => restoredPaths.has(rel));
4517
2595
  }
4518
2596
  }
4519
- // #64 follow-up: compaction is the ONE point in a session where re-reading
4520
- // AGENTS.md is both safe and expected.
4521
- //
4522
- // It is expected because compaction exists precisely to summarise work
4523
- // against the CURRENT instructions: an agent that spent an hour under one
4524
- // set of project rules, then compacted, would otherwise carry the old rules
4525
- // over the summary and never see the edit.
4526
- //
4527
- // It is safe because the prefix-cache concern that motivates freezing the
4528
- // prompt does not apply here. That concern is about a prompt changing
4529
- // mid-turn, re-pricing the whole prefix on every call. A compaction
4530
- // ALREADY rewrote the history the prompt sits on top of, so the cache is
4531
- // being re-priced regardless — one more changed block costs nothing extra.
4532
- //
4533
- // Only AGENTS.md is re-read, deliberately. The workspace listing and the
4534
- // skills catalogue stay frozen: both are a snapshot of the workspace as it
4535
- // was, and re-listing mid-session would contradict the instruction to
4536
- // "not list again what is already here". AGENTS.md is different — it is
4537
- // instructions, not a listing, and a stale copy is actively misleading.
4538
2597
  const refreshed = this.reloadAgentsMd();
4539
2598
  if (refreshed) {
4540
2599
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
@@ -4542,8 +2601,6 @@ export class Agent {
4542
2601
  bytes: refreshed.length,
4543
2602
  });
4544
2603
  }
4545
- // the operator can inspect WHAT was remembered — a dedicated typed event
4546
- // (not an agent message) keeps it out of the model's own voice
4547
2604
  await this.log.append("compaction", this.currentSession, this.currentBranch, {
4548
2605
  tokensBefore: before,
4549
2606
  tokensAfter: after,
@@ -4552,7 +2609,6 @@ export class Agent {
4552
2609
  mode,
4553
2610
  ...(summary ? { summary: String(summary).slice(0, 20_000) } : {}),
4554
2611
  });
4555
- // remember the notes for the NEXT compaction to update (and for a restore)
4556
2612
  if (summary)
4557
2613
  this.lastCompactSummary = summary;
4558
2614
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
@@ -4566,88 +2622,22 @@ export class Agent {
4566
2622
  }
4567
2623
  finally {
4568
2624
  this.compactPhase = "";
4569
- // the end signal lives in the FINALLY: a compaction that dies mid-flight
4570
- // (a log write rejecting on disk-full) used to leave the phase cleared
4571
- // server-side while the UI banner stayed "summarizing" forever — the
4572
- // client never got the done event. Emitting here covers every exit.
4573
2625
  bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "done" });
4574
2626
  }
4575
2627
  }
4576
- /**
4577
- * Summarize `old` into continuation notes.
4578
- *
4579
- * PREFIX-CACHE SHAPE (measured): the summarizer request is built as the
4580
- * conversation's OWN prefix — the session's system prompt, the same tool schemas,
4581
- * and the messages being shadowed — with the compaction instruction appended as a
4582
- * trailing `user` message. That makes the auxiliary call a genuine prefix-EXTENSION
4583
- * of the last routed request, so the provider reuses the KV cache instead of
4584
- * invalidating it at the first token.
4585
- *
4586
- * The alternative (a bespoke summarizer `system` prompt + a flattened transcript)
4587
- * was measured to share NOTHING with the conversation request: a provider caches on
4588
- * the leading token sequence, so a different first message discards the whole
4589
- * prefix and the entire history is re-prefilled at full price — exactly when the
4590
- * conversation is largest. DeepSeek Harness documents the same finding and the same
4591
- * fix ("the summarization directive moves from the front of the request to the end
4592
- * of the conversation"); the reasoning is reproduced here because it is not
4593
- * obvious, and the previous version of this function did the expensive thing.
4594
- *
4595
- * `tools` ride along even though the summarizer never calls one: they are part of
4596
- * the cached token sequence, so omitting them would misalign everything after them.
4597
- */
4598
2628
  async summarize(old, onDelta, previousSummary) {
4599
2629
  const fn = this.opts.chatFn ?? chat;
4600
- // the conversation's own head: system prompt + the shadowed region, in order.
4601
- // `buildMessages()` supplies the live system prompt, so the replayed prefix is
4602
- // byte-identical to what the last routed request carried.
4603
2630
  const prefix = this.buildMessages().slice(0, 1 + old.length);
4604
- // #127 kept the head: dropping the OLDEST text silently discarded the original
4605
- // request, which sits at the front of the history and is the one thing compaction
4606
- // cannot recover from. Replaying the prefix VERBATIM (rather than a flattened,
4607
- // head+tail-elided transcript) keeps that property AND the cache: the model sees
4608
- // the real messages, in their real order.
4609
- //
4610
- // The older head+tail truncation guarded against a summarizer input larger than
4611
- // the model accepts; that is now handled by what gets shadowed instead — the
4612
- // region is what the conversation itself just carried, so it already fits the
4613
- // window that request fitted.
4614
2631
  const instruction = COMPACT_INSTRUCTION + (previousSummary ? UPDATE_INSTRUCTION : "");
4615
- // Structured sections (Claude Code style): fixed headings survive MULTIPLE
4616
- // successive compactions far better than free-form prose — each pass knows
4617
- // where "pending tasks" vs "user messages" live, so nothing silently
4618
- // merges away. The <analysis> scratchpad gives the model a thinking space;
4619
- // it is stripped from the stored summary (formatCompactSummary).
4620
- //
4621
- // `tools` are forwarded even though the summarizer never calls one: they are
4622
- // part of the cached token sequence, so dropping them (or sending a different
4623
- // set) misaligns everything after them and costs the cache the whole point of
4624
- // this shape. MEASURED while writing this: `toolSpecs()` returns 15 and the
4625
- // conversation sends `allToolSpecs()`'s 29, so the obvious call was wrong — the
4626
- // exact array the main loop uses is what must ride along.
4627
- const res = await fn(
4628
- // one-off prompt: ask the provider not to retain it — but it is still the
4629
- // SAME conversation, so it carries the same session id
4630
- this.requestConfig({ oneOff: true }), [
2632
+ const res = await fn(this.requestConfig({ oneOff: true }), [
4631
2633
  ...prefix,
4632
2634
  {
4633
2635
  role: "user",
4634
2636
  content: (previousSummary ? `${SUMMARY_OPEN}${previousSummary}${SUMMARY_CLOSE}\n\n` : "") + instruction,
4635
2637
  },
4636
- ], allToolSpecs(), undefined, // never abortable by stop(): losing the summary would lose history
4637
- onDelta
2638
+ ], allToolSpecs(), undefined, onDelta
4638
2639
  ? (s) => onDelta(s.text)
4639
2640
  : undefined);
4640
- // Record what the summarizer itself cost, especially `cachedInputTokens`.
4641
- //
4642
- // This is the ONLY way to observe the property the whole replay-the-prefix shape
4643
- // exists for: the summarizer is supposed to be served from the provider's KV
4644
- // cache because its input is a byte-identical prefix of the request the
4645
- // conversation just made. Without this line the claim was UNVERIFIABLE — a
4646
- // compaction event reported tokensBefore/tokensAfter (the CONVERSATION's size)
4647
- // and nothing at all about the call that produced the summary, so a shape that
4648
- // silently stopped sharing the prefix would look identical in the log.
4649
- //
4650
- // usage events, none of them the summarizer's.
4651
2641
  if (res.usage) {
4652
2642
  const u = res.usage;
4653
2643
  this.stats.inputTokens += u.inputTokens ?? 0;
@@ -4655,41 +2645,20 @@ export class Agent {
4655
2645
  this.stats.cachedInputTokens += u.cachedInputTokens ?? 0;
4656
2646
  await this.log.append("usage", this.currentSession, this.currentBranch, {
4657
2647
  ...u,
4658
- // distinguishes it from a conversation turn when reading the log back
4659
2648
  kind: "summarize",
4660
2649
  });
4661
2650
  }
4662
2651
  return this.formatCompactSummary(res.message.content ?? "");
4663
2652
  }
4664
- /** strip the <analysis> scratchpad — it improves thinking but must not
4665
- consume post-compact context tokens */
4666
2653
  formatCompactSummary(raw) {
4667
2654
  const m = raw.match(/<analysis>[\s\S]*?<\/analysis>\s*/);
4668
2655
  return m ? raw.slice(m[0].length).trim() : raw.trim();
4669
2656
  }
4670
- /**
4671
- * Extract the "## Durable lessons" block from a compaction summary and
4672
- * append it to memory.md — knowledge survives compaction automatically
4673
- * (inspired by hook-driven CLAUDE.md growers; zero extra LLM calls).
4674
- */
4675
2657
  async harvestLessons(summary) {
4676
2658
  const m = summary.match(/##\s*Durable lessons?\s*\n([\s\S]*?)(?=\n##\s|$)/i);
4677
2659
  const lessons = m?.[1]?.trim();
4678
2660
  if (!lessons)
4679
2661
  return;
4680
- /**
4681
- * PROVENANCE, in a form the model can READ.
4682
- *
4683
- * The stamp used to be an HTML comment. It recorded exactly the right thing — which
4684
- * session and which compaction a note came from — and nothing ever read it back: a
4685
- * `read_memory(query)` returned the lesson with no way to tell whether it was observed
4686
- * today or inherited from a compaction three weeks ago. A note you cannot date is a note
4687
- * you cannot safely act on.
4688
- *
4689
- * It is now a visible `_from:_` line, which survives search, survives the digest (it is
4690
- * indented under the heading and skipped as a continuation), and answers "where did this
4691
- * come from?" in the reply itself.
4692
- */
4693
2662
  const session = path.basename(this.opts.sessionDir);
4694
2663
  const stamped = `\n## lessons from compaction ${new Date().toISOString().slice(0, 16).replace("T", " ")}\n` +
4695
2664
  `_from: session ${session}, branch ${this.currentBranch}_\n${lessons}\n`;
@@ -4705,7 +2674,6 @@ export class Agent {
4705
2674
  next: str(a.next) || undefined,
4706
2675
  ts: new Date().toISOString(),
4707
2676
  };
4708
- // a report (voluntary or requested) restarts the progress gates
4709
2677
  this.lastProgressAt = Date.now();
4710
2678
  this.activityChars = 0;
4711
2679
  this.turnsSinceProgress = 0;
@@ -4714,26 +2682,6 @@ export class Agent {
4714
2682
  }
4715
2683
  sleepInterruptible(ms) {
4716
2684
  return new Promise((resolve) => {
4717
- // #158-follow-up: a STOP that arrived before this sleep armed must end it.
4718
- //
4719
- // `stop()` releases the sleep through `this.wake`, which is a SINGLE slot
4720
- // created when the sleep starts. So a stop issued during the `await` BEFORE
4721
- // the sleep — the `log.append` of the retry note, which is a real disk write —
4722
- // found `wake === null`, did nothing, and the sleep it was meant to cancel
4723
- // then began on its own and ran to completion.
4724
- //
4725
- //
4726
- // PRE : status=running live=true (the stop lands here)
4727
- // POST: TIMEOUT after 6000ms status=error live=false
4728
- //
4729
- // — the operator's stop was swallowed and the agent sat in a 60s backoff,
4730
- // which is the reported "LLM APIエラーリトライ中はstopが効かなかった".
4731
- //
4732
- // `stopRequested` is exactly the flag that means "do not continue", and it is
4733
- // set SYNCHRONOUSLY by stop(), so testing it here closes the window without
4734
- // caring how long the preceding await took. Every dialog through this helper
4735
- // (the round backoff, the inner 5s/5s/30s staircase, the auto-continue delay)
4736
- // inherits the fix.
4737
2685
  if (this.stopRequested)
4738
2686
  return resolve();
4739
2687
  const timer = setTimeout(() => {
@@ -4749,63 +2697,21 @@ export class Agent {
4749
2697
  }
4750
2698
  async dispose() {
4751
2699
  this.stop("disposed");
4752
- // kill any in-flight subprocess group NOW so shutdown never waits out a
4753
- // long-running command (up to 10 min otherwise)
4754
2700
  this.toolAbort.abort();
4755
2701
  await this.runChain.catch(() => { });
4756
2702
  await this.log.close();
4757
2703
  }
4758
- /**
4759
- * Stop at the NEXT TURN BOUNDARY, without aborting anything.
4760
- *
4761
- * This is the live-update stop. `dispose()`/`stop()` abort the in-flight LLM call and kill the
4762
- * running tool, which is right for ■ but wrong for an update: a tool cut off mid-write leaves a
4763
- * half-written file, and a call whose result never arrives means the session resumes from a state
4764
- * that never existed.
4765
- *
4766
- * Asked for as "動作中のエージェントループを適切なタイミング(ツール実行完了や生成終了などで1ターン
4767
- * が終わる)で停止する". `loop()` checks `windDownRequested` at the top of each round, so setting the
4768
- * flag is enough for a busy agent — the round in flight finishes normally.
4769
- *
4770
- * A PARKED agent is the case the flag alone cannot reach: `wait_children` and a background-shell
4771
- * wait sit in `await` with no round to finish, so they would hold the update until the timeout.
4772
- * Those are released by waking them (the wait resolves, the loop comes round, and the flag is
4773
- * seen at the top); nothing is killed, so the child/job keeps existing and the resumed session
4774
- * can still reach it.
4775
- *
4776
- * `idle` agents are already at a boundary — their `windDownRequested` is simply set, so a prompt
4777
- * arriving between now and the exec does not start a round that would be cut off.
4778
- */
4779
2704
  async windDownForUpdate(timeoutMs) {
4780
2705
  this.windDownRequested = true;
4781
- // release a parked wait (wait_children / bg-shell) so the loop can come round to the check
4782
2706
  this.wake?.();
4783
2707
  if (this.status === "idle") {
4784
2708
  this.status = "idle";
4785
2709
  this.statusReason = "paused for a live update";
4786
2710
  return;
4787
2711
  }
4788
- /**
4789
- * Wait for the round to end, bounded.
4790
- *
4791
- * `settled()` resolves when the run chain drains, which is "the turn finished" — but a
4792
- * BACKGROUND SHELL is not on that chain. The round deliberately ends while a dev server,
4793
- * watcher or long build keeps running (`hasRunningBgShells` breaks the loop instead of nagging),
4794
- * so `settled()` is already resolved and this used to return immediately.
4795
- *
4796
- * MEASURED: an update then reached `dispose()`, which calls `killBgShells` — a live update killed
4797
- * every background job on the machine, with no wait and no notice. A background job is work in
4798
- * flight, which is the whole reason `isLive()` counts it, so it must be waited for here too.
4799
- *
4800
- * The timeout exists only for a wedged agent (an LLM call that never returns, or a job that will
4801
- * never exit); the caller reports which agents hit it rather than leaving them silently
4802
- * half-stopped.
4803
- */
4804
2712
  let timer;
4805
2713
  const done = async () => {
4806
2714
  await this.settled();
4807
- // wait out any background job this agent owns, polling the same owner-scoped question
4808
- // `isLive()` uses — a sibling's job in the shared workspace is not this agent's work
4809
2715
  while (hasRunningBgShellsFor(this.toolCtx.cwd, this.opts.id)) {
4810
2716
  if (this.stopRequested)
4811
2717
  break;
@@ -4827,38 +2733,22 @@ export class Agent {
4827
2733
  clearTimeout(timer);
4828
2734
  }
4829
2735
  }
4830
- /** true once `windDownForUpdate()` has been asked and the round has ended */
4831
2736
  get windingDownForUpdate() {
4832
2737
  return this.windDownRequested;
4833
2738
  }
4834
- /* ---------- fork / branch management (session layer) ---------- */
4835
- /**
4836
- * Fork the conversation: copy the message history into a fresh branch of the
4837
- * SAME log file, recording lineage so reconstruction stays possible.
4838
- */
4839
2739
  async fork(fromEventId) {
4840
2740
  const newBranch = `br${this.branchCount()}${Date.now().toString(36).slice(-4)}`;
4841
2741
  const parentBranch = this.currentBranch;
4842
- // Where the fork leaves the source branch. Recorded on the event AND used
4843
- // to seed the new branch, so the two can never disagree.
4844
2742
  const fromEvent = fromEventId ?? this.log.lastEventId(parentBranch);
4845
- // the fork event must be appended under the SOURCE branch. Appending it
4846
- // under the new one gave it parent:null (nothing had been written to that
4847
- // branch yet), so lineageOf() walked an empty chain and a restart rebuilt
4848
- // the fork with NO history at all — the same class of bug editPromptAt had,
4849
- // fixed on one path only. editPromptAt already appends under the old
4850
- // branch for exactly this reason.
4851
2743
  await this.log.append("fork", this.currentSession, parentBranch, {
4852
2744
  fromSession: this.currentSession,
4853
2745
  fromBranch: parentBranch,
4854
2746
  fromEvent,
4855
2747
  newBranch,
4856
2748
  });
4857
- // Seed the new branch's parent to the point it leaves the source, so its
4858
- // first event chains across instead of starting at parent:null.
4859
2749
  this.log.seedBranch(newBranch, fromEvent);
4860
2750
  this.currentBranch = newBranch;
4861
- this.messages = [...this.messages]; // independent history copy
2751
+ this.messages = [...this.messages];
4862
2752
  return { session: this.currentSession, branch: newBranch };
4863
2753
  }
4864
2754
  branchCount() {
@@ -4869,27 +2759,9 @@ export class Agent {
4869
2759
  this.currentBranch = branch;
4870
2760
  }
4871
2761
  }
4872
- /**
4873
- * workspace tools + agent-meta tools + every tool from every CONNECTED MCP server.
4874
- *
4875
- * The MCP tools are appended LAST and their names are namespaced (`mcp__server__tool`),
4876
- * so adding one can never shadow a built-in — a server exposing `bash` must not be able
4877
- * to take over the shell. `mcpSpecs()` is empty when nothing is configured, which is
4878
- * the common case, so the cost of the feature is one empty spread.
4879
- */
4880
2762
  function allToolSpecs() {
4881
- // meta tools come from ONE table (src/agent/meta-tools.ts) so a declared tool always has
4882
- // a handler; see the note there for the two silent failure modes this removes.
4883
2763
  return [...toolSpecs(), ...metaToolSpecs(), ...mcpSpecs()];
4884
2764
  }
4885
- /**
4886
- * MCP tools as function specs, flattened and namespaced.
4887
- *
4888
- * Descriptions are prefixed with their origin so the model can reason about which
4889
- * server a result came from — with three servers exposing `search`, a bare description
4890
- * gives it nothing to choose with. The server's own JSON Schema is forwarded as-is:
4891
- * rewriting it would drop constraints the server enforces.
4892
- */
4893
2765
  function mcpSpecs() {
4894
2766
  return mcp.allTools().map((t) => ({
4895
2767
  type: "function",
@@ -4911,7 +2783,6 @@ function safeParse(json) {
4911
2783
  return {};
4912
2784
  }
4913
2785
  }
4914
- /** Walk parent links backwards from the newest event, then flip forward. */
4915
2786
  function lineageOf(events) {
4916
2787
  if (events.length === 0)
4917
2788
  return [];
@@ -4926,16 +2797,10 @@ function lineageOf(events) {
4926
2797
  lineage.push(cur);
4927
2798
  }
4928
2799
  lineage.reverse();
4929
- // the trailing fork event itself is bookkeeping, not conversation
4930
2800
  while (lineage.length && lineage[0].type === "fork")
4931
2801
  lineage.shift();
4932
2802
  return lineage;
4933
2803
  }
4934
- /**
4935
- * Replay ordered events into ChatMessages (shared by session restore and
4936
- * prompt-edit forks). Prompts logged inside an open tool batch are buffered
4937
- * until it closes, so user messages never split a tool_call/tool_result pair.
4938
- */
4939
2804
  function rebuildMessagesFrom(list) {
4940
2805
  const msgs = [];
4941
2806
  const META_TOOLS = new Set([
@@ -4943,19 +2808,15 @@ function rebuildMessagesFrom(list) {
4943
2808
  "read_memory", "set_memory", "add_memory", "forget_memory", "list_skills", "get_todo", "set_todo",
4944
2809
  "get_feedback", "add_feedback", "record_decision", "get_decisions",
4945
2810
  ]);
4946
- const openCalls = new Map(); // real tool_call id -> name
4947
- // callId -> the tool_call entry on its assistant message (fast arg enrichment)
2811
+ const openCalls = new Map();
4948
2812
  const assistantCalls = new Map();
4949
2813
  const rememberAssistantCalls = (m) => {
4950
2814
  if (Array.isArray(m.tool_calls))
4951
2815
  for (const t of m.tool_calls)
4952
2816
  assistantCalls.set(t.id, t);
4953
2817
  };
4954
- // meta answers are now logged as regular tool_results; the legacy progress
4955
- // synthesizer below must not duplicate them
4956
2818
  const loggedResults = new Set(list.filter((e) => e.type === "tool_result").map((e) => String(e.data.callId ?? "")));
4957
2819
  const bufferedUsers = [];
4958
- // image attachments paired with buffered prompts (multimodal replay)
4959
2820
  const bufferedParts = [];
4960
2821
  const flushUsers = () => {
4961
2822
  if (openCalls.size === 0) {
@@ -4979,8 +2840,6 @@ function rebuildMessagesFrom(list) {
4979
2840
  for (const e of list) {
4980
2841
  const d = e.data;
4981
2842
  if (e.type === "prompt" && typeof d.text === "string") {
4982
- // restore image attachments so replays stay byte-identical with the
4983
- // original request (prefix caches stay warm across restarts)
4984
2843
  const text = d.text;
4985
2844
  const imgs = Array.isArray(d.images) ? d.images : [];
4986
2845
  const mk = () => imgs.length
@@ -4999,24 +2858,11 @@ function rebuildMessagesFrom(list) {
4999
2858
  msgs.push(mk());
5000
2859
  }
5001
2860
  else if (e.type === "message") {
5002
- // Operator-facing rows are TIMELINE content, not conversation: the live
5003
- // loop never put them in `messages`, so a restore must not either.
5004
- // `final` is the finish() summary; `operatorFacing` covers harness notes
5005
- // such as the completion audit verdict. Replaying either produced a
5006
- // history shape no live run emits, and a provider given that answers
5007
- // 200 with zero choices.
5008
2861
  if (d.final === true || d.operatorFacing === true)
5009
2862
  continue;
5010
2863
  const role = d.role === "assistant" ? "assistant" : "user";
5011
2864
  const content = typeof d.content === "string" ? d.content : "";
5012
2865
  const hasCalls = Array.isArray(d.toolCalls) && d.toolCalls.length > 0;
5013
- // A blank assistant turn that issued NO tool calls is an artefact of a
5014
- // kill mid-stream: the model streamed nothing, so the live run never had
5015
- // this turn, yet the restore replays it as a real utterance. sanitize()
5016
- // then rewrites blank assistant content to the literal "(no content)",
5017
- // so the agent is told it previously said a sentence it never said — a
5018
- // history shape no live run produces. A blank turn that DID call
5019
- // tools is legitimate and must stay, or its results are orphaned.
5020
2866
  if (role === "assistant" && !content && !hasCalls)
5021
2867
  continue;
5022
2868
  const m = { role, content };
@@ -5026,8 +2872,6 @@ function rebuildMessagesFrom(list) {
5026
2872
  type: "function",
5027
2873
  function: { name: c.name, arguments: "{}" },
5028
2874
  }));
5029
- // meta tools are answered inline by the harness (no logged result);
5030
- // the hole-filling pass below synthesizes theirs where they belong
5031
2875
  for (const t of m.tool_calls)
5032
2876
  if (!META_TOOLS.has(t.function.name))
5033
2877
  openCalls.set(t.id, t.function.name);
@@ -5036,11 +2880,6 @@ function rebuildMessagesFrom(list) {
5036
2880
  msgs.push(m);
5037
2881
  }
5038
2882
  else if (e.type === "tool_call") {
5039
- // enrich the preceding assistant tool_calls with real arguments —
5040
- // prefer the provider's raw string so restored requests stay
5041
- // byte-identical (prefix caches stay warm across restarts).
5042
- // Indexed by call id: the old [...msgs].reverse().find() scanned the
5043
- // whole message list per call — O(n²) on multi-thousand-event logs.
5044
2883
  let tc = assistantCalls.get(String(d.callId ?? ""));
5045
2884
  if (!tc) {
5046
2885
  const prev = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.some((t) => t.id === d.callId));
@@ -5063,32 +2902,12 @@ function rebuildMessagesFrom(list) {
5063
2902
  flushUsers();
5064
2903
  }
5065
2904
  else if (e.type === "compaction") {
5066
- // A compaction REPLACED everything before this point with a summary.
5067
- // Replaying the raw prefix as well resurrected the full pre-compact
5068
- // history after every restart/fork (one live session came back at
5069
- // ~580k tokens after a 580k→207k compaction): the model re-read weeks
5070
- // of settled work it was told had been summarized, and its behavior
5071
- // fell apart. Mirror the runtime transform instead — the summary note
5072
- // becomes the history's head. Compactions only run at turn boundaries
5073
- // (serialized on the run chain), so no tool batch can be split here.
5074
2905
  if (d.mode === "truncate") {
5075
- // `dropped` IS the number of leading messages the live pass removed
5076
2906
  msgs.splice(0, Math.min(msgs.length, Number(d.dropped ?? 0)));
5077
2907
  }
5078
2908
  else {
5079
2909
  const sum = typeof d.summary === "string" ? d.summary.trim() : "";
5080
2910
  const summarized = Math.min(msgs.length, Number(d.summarized ?? 0) || msgs.length);
5081
- // A `summarize` boundary with no summary text is a HALF-WRITTEN record, and
5082
- // acting on it would delete history to replace it with nothing — the worst
5083
- // failure compaction can have, because the discarded prefix holds the original
5084
- // request and the summary is supposed to be its only record.
5085
- //
5086
- // So a damaged/absent summary falls back CONSERVATIVELY: the compaction is not
5087
- // applied at all, the full history stays, and the operator sees why. (ZCode
5088
- // draws the same line for a damaged preserved segment — "返回保守 fallback,并产生
5089
- // 结构化 warning" — and refuses to write a half boundary in the first place.)
5090
- // Reaching this state needs a hand-edited or truncated log, since the live path
5091
- // only writes `mode: "summarize"` when it has a summary.
5092
2911
  if (!sum) {
5093
2912
  console.warn(`[teapot] compaction at seq ${e.seq} has no summary text — keeping the full history rather than discarding it`);
5094
2913
  }
@@ -5104,8 +2923,6 @@ function rebuildMessagesFrom(list) {
5104
2923
  }
5105
2924
  }
5106
2925
  else if (e.type === "progress") {
5107
- // progress events may follow an assistant report_progress call that
5108
- // has no logged tool result — patch it in when present
5109
2926
  const lastAssistant = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.length);
5110
2927
  if (lastAssistant?.tool_calls?.some((t) => t.function.name === "report_progress")) {
5111
2928
  for (const t of lastAssistant.tool_calls) {
@@ -5118,8 +2935,6 @@ function rebuildMessagesFrom(list) {
5118
2935
  }
5119
2936
  }
5120
2937
  }
5121
- // every assistant tool_call must be answered by a tool message, or the
5122
- // API rejects the sequence — close any holes left by meta tools (finish)
5123
2938
  for (let i = 0; i < msgs.length; i++) {
5124
2939
  const m = msgs[i];
5125
2940
  if (m.role === "assistant" && m.tool_calls?.length) {
@@ -5135,7 +2950,6 @@ function rebuildMessagesFrom(list) {
5135
2950
  }
5136
2951
  }
5137
2952
  }
5138
- // prompts that were still waiting on a hole-filled tail land here
5139
2953
  {
5140
2954
  const texts = bufferedUsers.splice(0);
5141
2955
  const parts = bufferedParts.splice(0);