@visual-z/xeno 0.1.0

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 (180) hide show
  1. package/README.md +498 -0
  2. package/docs/install.md +130 -0
  3. package/package.json +43 -0
  4. package/src/apps/approve.ts +49 -0
  5. package/src/apps/cli.ts +37 -0
  6. package/src/apps/commands.ts +226 -0
  7. package/src/apps/completions.ts +28 -0
  8. package/src/apps/input.ts +297 -0
  9. package/src/apps/render.ts +207 -0
  10. package/src/apps/shell.ts +2353 -0
  11. package/src/apps/startup.ts +1302 -0
  12. package/src/core/benchmark.ts +195 -0
  13. package/src/core/boundary.ts +174 -0
  14. package/src/core/churn.ts +59 -0
  15. package/src/core/completion-review.ts +66 -0
  16. package/src/core/context-policy.ts +60 -0
  17. package/src/core/context-window.ts +54 -0
  18. package/src/core/continuation.ts +114 -0
  19. package/src/core/cycle.ts +17 -0
  20. package/src/core/data-query.ts +201 -0
  21. package/src/core/deadline.ts +39 -0
  22. package/src/core/derive.ts +489 -0
  23. package/src/core/edit-plan.ts +200 -0
  24. package/src/core/environment.ts +29 -0
  25. package/src/core/event-validation.ts +99 -0
  26. package/src/core/events.ts +486 -0
  27. package/src/core/evidence-links.ts +71 -0
  28. package/src/core/evidence.ts +89 -0
  29. package/src/core/execution.ts +12 -0
  30. package/src/core/exhaustive.ts +18 -0
  31. package/src/core/experiment.ts +40 -0
  32. package/src/core/export.ts +169 -0
  33. package/src/core/file-references.ts +35 -0
  34. package/src/core/frontmatter.ts +213 -0
  35. package/src/core/handoff.ts +397 -0
  36. package/src/core/harness-comparison.ts +90 -0
  37. package/src/core/history-query.ts +103 -0
  38. package/src/core/history.ts +100 -0
  39. package/src/core/json-event.ts +135 -0
  40. package/src/core/json-pointer.ts +46 -0
  41. package/src/core/keymap.ts +241 -0
  42. package/src/core/observation-view.ts +87 -0
  43. package/src/core/outcome.ts +45 -0
  44. package/src/core/overflow.ts +59 -0
  45. package/src/core/permissions.ts +182 -0
  46. package/src/core/policy-evaluation.ts +55 -0
  47. package/src/core/progress.ts +101 -0
  48. package/src/core/project.ts +372 -0
  49. package/src/core/prompts.ts +83 -0
  50. package/src/core/queue.ts +60 -0
  51. package/src/core/read-bounds.ts +15 -0
  52. package/src/core/recall-analysis.ts +90 -0
  53. package/src/core/recall-trial.ts +153 -0
  54. package/src/core/response-recovery.ts +46 -0
  55. package/src/core/scheduling.ts +8 -0
  56. package/src/core/settings.ts +174 -0
  57. package/src/core/skills.ts +121 -0
  58. package/src/core/stall.ts +48 -0
  59. package/src/core/text-normalize.ts +81 -0
  60. package/src/core/theme.ts +179 -0
  61. package/src/core/tokens.ts +149 -0
  62. package/src/core/tool-contract.ts +102 -0
  63. package/src/core/tool-input.ts +38 -0
  64. package/src/core/transcript.ts +60 -0
  65. package/src/core/window-trial.ts +65 -0
  66. package/src/core/work-state.ts +103 -0
  67. package/src/core/yaml.ts +282 -0
  68. package/src/index.ts +10 -0
  69. package/src/runtime/auth.ts +255 -0
  70. package/src/runtime/benchmark.ts +187 -0
  71. package/src/runtime/catalog.ts +365 -0
  72. package/src/runtime/evidence-notes.ts +33 -0
  73. package/src/runtime/exec-sessions.ts +214 -0
  74. package/src/runtime/execution-control.ts +39 -0
  75. package/src/runtime/harness-comparison.ts +356 -0
  76. package/src/runtime/history-tools.ts +108 -0
  77. package/src/runtime/loop.ts +1983 -0
  78. package/src/runtime/model.ts +614 -0
  79. package/src/runtime/models-fetch.ts +155 -0
  80. package/src/runtime/oauth-device.ts +86 -0
  81. package/src/runtime/oauth.ts +759 -0
  82. package/src/runtime/openai-responses.ts +286 -0
  83. package/src/runtime/openai.ts +675 -0
  84. package/src/runtime/provider.ts +498 -0
  85. package/src/runtime/recall-trial.ts +226 -0
  86. package/src/runtime/resume.ts +66 -0
  87. package/src/runtime/retry.ts +297 -0
  88. package/src/runtime/tools.ts +1193 -0
  89. package/src/runtime/usage.ts +225 -0
  90. package/src/runtime/window-trial.ts +140 -0
  91. package/src/runtime/working-notes.ts +30 -0
  92. package/src/store/atomic.ts +31 -0
  93. package/src/store/auth-store.ts +50 -0
  94. package/src/store/blobs.ts +142 -0
  95. package/src/store/command.ts +75 -0
  96. package/src/store/config.ts +69 -0
  97. package/src/store/context-files.ts +356 -0
  98. package/src/store/file-index.ts +81 -0
  99. package/src/store/git-branch.ts +35 -0
  100. package/src/store/history-search.ts +19 -0
  101. package/src/store/log.ts +642 -0
  102. package/src/store/models.ts +41 -0
  103. package/src/store/observations.ts +181 -0
  104. package/src/store/query-source.ts +44 -0
  105. package/src/store/recovery.ts +39 -0
  106. package/src/store/replay.ts +63 -0
  107. package/src/store/resources.ts +361 -0
  108. package/src/store/restore.ts +302 -0
  109. package/src/store/search.ts +644 -0
  110. package/src/store/serial.ts +13 -0
  111. package/src/store/sessions.ts +463 -0
  112. package/src/store/trust.ts +69 -0
  113. package/src/store/workspace.ts +256 -0
  114. package/src/store/worktree.ts +233 -0
  115. package/src/tui/ansi.ts +67 -0
  116. package/src/tui/autocomplete.ts +29 -0
  117. package/src/tui/complete.ts +87 -0
  118. package/src/tui/diff.ts +160 -0
  119. package/src/tui/editor.ts +893 -0
  120. package/src/tui/exhaustive.ts +14 -0
  121. package/src/tui/fuzzy.ts +75 -0
  122. package/src/tui/host.ts +223 -0
  123. package/src/tui/input.ts +69 -0
  124. package/src/tui/keys.ts +378 -0
  125. package/src/tui/markdown.ts +17 -0
  126. package/src/tui/render.ts +153 -0
  127. package/src/tui/select.ts +240 -0
  128. package/src/tui/settings.ts +40 -0
  129. package/src/tui/statusline.ts +162 -0
  130. package/src/tui/style.ts +205 -0
  131. package/src/tui/terminal.ts +30 -0
  132. package/src/tui/text.ts +52 -0
  133. package/src/tui/toolview.ts +386 -0
  134. package/src/tui/transcript.ts +71 -0
  135. package/src/tui/tree.ts +405 -0
  136. package/src/tui/width.ts +567 -0
  137. package/src/vendor/pi-tui/LICENSE.upstream +21 -0
  138. package/src/vendor/pi-tui/VENDOR.md +23 -0
  139. package/src/vendor/pi-tui/alt-screen-search.ts +327 -0
  140. package/src/vendor/pi-tui/autocomplete.ts +835 -0
  141. package/src/vendor/pi-tui/components/alt-screen-flash.ts +51 -0
  142. package/src/vendor/pi-tui/components/box.ts +167 -0
  143. package/src/vendor/pi-tui/components/cancellable-loader.ts +40 -0
  144. package/src/vendor/pi-tui/components/editor.ts +2470 -0
  145. package/src/vendor/pi-tui/components/h-stack.ts +44 -0
  146. package/src/vendor/pi-tui/components/image.ts +127 -0
  147. package/src/vendor/pi-tui/components/input.ts +494 -0
  148. package/src/vendor/pi-tui/components/loader.ts +101 -0
  149. package/src/vendor/pi-tui/components/markdown.ts +1015 -0
  150. package/src/vendor/pi-tui/components/mouse-region.ts +33 -0
  151. package/src/vendor/pi-tui/components/scroll-view.ts +224 -0
  152. package/src/vendor/pi-tui/components/select-list.ts +273 -0
  153. package/src/vendor/pi-tui/components/settings-list.ts +328 -0
  154. package/src/vendor/pi-tui/components/spacer.ts +28 -0
  155. package/src/vendor/pi-tui/components/stack.ts +154 -0
  156. package/src/vendor/pi-tui/components/text.ts +107 -0
  157. package/src/vendor/pi-tui/components/truncated-text.ts +65 -0
  158. package/src/vendor/pi-tui/components/v-stack.ts +33 -0
  159. package/src/vendor/pi-tui/editor-component.ts +74 -0
  160. package/src/vendor/pi-tui/fuzzy.ts +138 -0
  161. package/src/vendor/pi-tui/index.ts +156 -0
  162. package/src/vendor/pi-tui/keybindings.ts +320 -0
  163. package/src/vendor/pi-tui/keys.ts +1401 -0
  164. package/src/vendor/pi-tui/kill-ring.ts +46 -0
  165. package/src/vendor/pi-tui/latex.ts +1506 -0
  166. package/src/vendor/pi-tui/layout-node.ts +51 -0
  167. package/src/vendor/pi-tui/layout.ts +449 -0
  168. package/src/vendor/pi-tui/native-modifiers.ts +13 -0
  169. package/src/vendor/pi-tui/native-module-path.ts +31 -0
  170. package/src/vendor/pi-tui/native-platform.ts +63 -0
  171. package/src/vendor/pi-tui/stdin-buffer.ts +444 -0
  172. package/src/vendor/pi-tui/terminal-colors.ts +73 -0
  173. package/src/vendor/pi-tui/terminal-image.ts +696 -0
  174. package/src/vendor/pi-tui/terminal.ts +547 -0
  175. package/src/vendor/pi-tui/tui-alt-screen.ts +1742 -0
  176. package/src/vendor/pi-tui/tui-main-screen.ts +655 -0
  177. package/src/vendor/pi-tui/tui.ts +1456 -0
  178. package/src/vendor/pi-tui/undo-stack.ts +28 -0
  179. package/src/vendor/pi-tui/utils.ts +1345 -0
  180. package/src/vendor/pi-tui/word-navigation.ts +117 -0
@@ -0,0 +1,1983 @@
1
+ /**
2
+ * The agent loop.
3
+ *
4
+ * Deliberately thin. Everything that decides anything -- what the model sees,
5
+ * when to cut a node, what the handoff says -- is a pure function in core, so
6
+ * this file only sequences them and performs the I/O between. That split is
7
+ * what makes the interesting behaviour testable without a network, and what
8
+ * keeps the loop cheap to throw away when the next model generation wants a
9
+ * different shape.
10
+ *
11
+ * One turn:
12
+ *
13
+ * project the log -> send -> record the assistant turn -> run tools ->
14
+ * record results -> decide whether to cut -> maybe cut
15
+ *
16
+ * The cut decision happens only here, between complete turns, because a node
17
+ * boundary between a tool_use and its result would produce an unpairable
18
+ * request.
19
+ */
20
+
21
+ import type { LogEvent, AssistantBlock, NodeRef, CutReason } from "../core/events.ts";
22
+ import type { Handoff } from "../core/handoff.ts";
23
+ import { boundHandoff, DEFAULT_HANDOFF_TOKENS } from "../core/history.ts";
24
+ import { boundaryPreparation, continuationGuidance } from "../core/continuation.ts";
25
+ import type { FrozenPrefix, Message } from "../core/project.ts";
26
+ import { projectContext } from "../core/project.ts";
27
+ import { deriveHandoff, carryForward } from "../core/derive.ts";
28
+ import { DEFAULT_BOUNDARY, shouldStopAndAsk, type BoundaryConfig } from "../core/boundary.ts";
29
+ import { decideContext, DEFAULT_CONTEXT_POLICY, estimateBoundaryCost, isContextPolicy, type ContextPolicy } from "../core/context-policy.ts";
30
+ import { retainedContext, selectRecentContext, windowBudget } from "../core/context-window.ts";
31
+ import { advanceStall, emptyStallState, stallDecision, DEFAULT_STALL_LIMIT, type StallState } from "../core/stall.ts";
32
+ import { DEFAULT_OUTPUT_RECOVERY_LIMIT, isCompleteResponse, incompleteToolText, knownUnexecutedTool, outputRecoveryNotice, validateOutputRecoveryLimit } from "../core/response-recovery.ts";
33
+ import { join } from "node:path";
34
+ import { randomUUID } from "node:crypto";
35
+ import { pendingToolCalls } from "../core/execution.ts";
36
+ import { executionOutcome, modelOutcome, type ExecutionOutcome } from "../core/outcome.ts";
37
+ import { ExecutionControl } from "./execution-control.ts";
38
+ import { schedulingWindow } from "../core/scheduling.ts";
39
+ import { estimateTokens, anchoredCount, scaledCounter, type TokenCounter, type Anchor } from "../core/tokens.ts";
40
+ import { isContextOverflow } from "../core/overflow.ts";
41
+ import { completionReview } from "../core/completion-review.ts";
42
+ import { churnReview } from "../core/churn.ts";
43
+ import { deadlineNotice } from "../core/deadline.ts";
44
+ import { statusOf } from "./retry.ts";
45
+ import { assertNever } from "../core/exhaustive.ts";
46
+ import {
47
+ initNode, appendEvent, readNode, closeNode, digestNode, handoffPath, nodePath, manifestPath,
48
+ listNodes, readCursor, writeCursor,
49
+ } from "../store/log.ts";
50
+ import { replayContext } from "../store/replay.ts";
51
+ import { recoverCut } from "../store/recovery.ts";
52
+ import { runHistoryTool } from "./history-tools.ts";
53
+ import { prepareWorkingNote } from "./working-notes.ts";
54
+ import { copyAncestryTo } from "../store/sessions.ts";
55
+ import type { Provider, Turn, StreamDelta } from "./provider.ts";
56
+ import {
57
+ scanWorkspace, diffScans, reconcile, DEFAULT_IGNORE, workspaceVersion,
58
+ type FileSet, type RestoreResult,
59
+ } from "../store/workspace.ts";
60
+ import { putBlob, getBlob, putFileBlob } from "../store/blobs.ts";
61
+ import { readNodeHistory, planRestoreToNode, applyRestorePlan } from "../store/restore.ts";
62
+ import { createWorktree, removeWorktree } from "../store/worktree.ts";
63
+ import {
64
+ runTool, parseNote, matchGate, branchTools, gateTool, finishToolResult, prepareTool, toolDefinition,
65
+ BRANCH_TOOL_NAMES, WRITING_BRANCH_TOOL_NAMES,
66
+ type ToolContext, type Annotation, type ToolResult,
67
+ } from "./tools.ts";
68
+ import { diagnosePrefix, totalUsage, type UsageSample, type UsageTotals } from "./usage.ts";
69
+ import { planResume } from "./resume.ts";
70
+ import type { Phase } from "../core/queue.ts";
71
+
72
+ /** Ids and timestamps come from here so the loop stays the only impure part. */
73
+ export interface Clock {
74
+ now(): string;
75
+ nextId(): string;
76
+ }
77
+
78
+ export function systemClock(): Clock {
79
+ return {
80
+ now: () => new Date().toISOString(),
81
+ nextId: () => randomUUID(),
82
+ };
83
+ }
84
+
85
+ export interface LoopEvent {
86
+ kind:
87
+ | "turn_start"
88
+ | "assistant_text"
89
+ | "thinking"
90
+ | "text_delta"
91
+ | "thinking_delta"
92
+ | "discard"
93
+ | "tool_call"
94
+ | "tool_result"
95
+ | "usage"
96
+ | "cut"
97
+ | "stop_and_ask"
98
+ | "branch_start"
99
+ | "branch_done"
100
+ | "snapshot_failed"
101
+ | "undeclared_change"
102
+ | "done";
103
+ text?: string;
104
+ tool?: { name: string; input: unknown };
105
+ ok?: boolean;
106
+ usage?: UsageSample;
107
+ prefix?: string;
108
+ node?: number;
109
+ reason?: string;
110
+ /** branch_start: wall-clock milliseconds, for asserting the stagger. */
111
+ at?: number;
112
+ outcome?: ExecutionOutcome;
113
+ }
114
+
115
+ /**
116
+ * Ceilings on branching. Every one of them is a bound on an otherwise
117
+ * unbounded resource, and the defaults are conservative because the measured
118
+ * evidence says fan-out is usually a loss: 2.6x the tokens and about five
119
+ * times the cost of the sequential path, while being slower.
120
+ */
121
+ export interface BranchLimits {
122
+ /** How deep branching may nest. 2 means the parent may branch, a branch may not. */
123
+ maxBranchDepth: number;
124
+ /** Branches one assistant turn may open. */
125
+ maxBranchesPerTurn: number;
126
+ /** Branches a whole session may open. */
127
+ maxBranchesPerSession: number;
128
+ /**
129
+ * Delay between starting sibling branches.
130
+ *
131
+ * Simultaneous cold spawns race the prompt cache: a sibling six seconds
132
+ * behind has been measured writing its full 52k prefix with zero cache
133
+ * read, because the first had not finished writing it.
134
+ */
135
+ branchStaggerMs: number;
136
+ }
137
+
138
+ export const DEFAULT_BRANCH_LIMITS: BranchLimits = {
139
+ maxBranchDepth: 2,
140
+ maxBranchesPerTurn: 3,
141
+ maxBranchesPerSession: 10,
142
+ branchStaggerMs: 1500,
143
+ };
144
+
145
+ export interface SessionConfig {
146
+ logDir: string;
147
+ frozen: FrozenPrefix;
148
+ tools: ToolContext;
149
+ boundary?: Partial<BoundaryConfig>;
150
+ /** New sessions default to windowed replay; saved sessions keep their policy. */
151
+ contextPolicy?: ContextPolicy;
152
+ /** Complete tool exchanges without new evidence; zero disables this watchdog. */
153
+ stallLimit?: number;
154
+ /** Bounded appended continuations after output truncation; saved sessions keep this policy. */
155
+ outputRecoveryLimit?: number;
156
+ counter?: TokenCounter;
157
+ /** Hard ceiling on turns, so a non-converging run cannot spend forever. */
158
+ maxTurns: number;
159
+ /** Experimental ablation only; normal sessions append durable working-state guidance. */
160
+ guidance?: boolean;
161
+ /**
162
+ * One-time reconciliation notices: an unverified finish
163
+ * (core/completion-review.ts) and a rut of repeated failures or edits
164
+ * (core/churn.ts). On unless false. Benchmark runners turn them off so their
165
+ * protocol matches the runs they are compared against; a runtime policy,
166
+ * not persisted.
167
+ */
168
+ harnessReviews?: boolean;
169
+ /**
170
+ * Wall-clock budget per request, for runs judged against a deadline
171
+ * (core/deadline.ts). Absent means no notice; nothing is ever stopped by it.
172
+ */
173
+ timeBudgetMs?: number;
174
+ /** Replies before a missing next step is reminded (core/continuation.ts); absent means the core default. */
175
+ continuationAfter?: number;
176
+ branches?: Partial<BranchLimits>;
177
+ /**
178
+ * Provider for branches. A branch legitimately runs on its own model -- a
179
+ * cheaper one is often right for read-only exploration -- so this is a
180
+ * seam rather than a hook. Defaults to the parent's provider.
181
+ */
182
+ branchProvider?: () => Provider;
183
+ /** Depth of this session in the branch tree. Zero for a top-level session. */
184
+ branchDepth?: number;
185
+ /** O.2: stable across segment cuts; branches get independent work identities. */
186
+ workNamespace?: string;
187
+ /** O.5: default 3 is a conservative runtime quota, not a context threshold. */
188
+ maxParallelTools?: number;
189
+ /**
190
+ * Where blobs go. Snapshots are off when unset, which keeps the loop
191
+ * usable without the store -- a read-only branch has nothing to snapshot.
192
+ */
193
+ blobDir?: string;
194
+ /** Directory names the scan skips, relative to the workspace root. */
195
+ snapshotIgnore?: readonly string[];
196
+ }
197
+
198
+ export interface SessionState {
199
+ node: number;
200
+ depth: number;
201
+ handoff: Handoff | null;
202
+ events: LogEvent[];
203
+ usage: UsageSample[];
204
+ /**
205
+ * The last prompt size the provider actually charged for. Boundary decisions
206
+ * anchor to it so only the delta since is estimated -- see core/tokens.ts.
207
+ */
208
+ anchor: Anchor | null;
209
+ /** Branches opened so far, against maxBranchesPerSession. */
210
+ branchesOpened: number;
211
+ /** Next free node number. Branches take one each. */
212
+ nextNode: number;
213
+ /** The workspace as of the last snapshot, for diffing the next one. */
214
+ lastScan: FileSet;
215
+ /** Worktrees created for writing branches, by node. */
216
+ worktrees: Map<number, { path: string; branch: string }>;
217
+ }
218
+
219
+ function selfRef(dir: string, node: number, sha: string): NodeRef {
220
+ return { node, log_ref: nodePath(dir, node), sha256: sha };
221
+ }
222
+
223
+ /** Digest is I/O; core stays pure and the caller supplies the bytes. */
224
+ async function digestedRef(dir: string, node: number): Promise<NodeRef> {
225
+ return selfRef(dir, node, await digestNode(dir, node));
226
+ }
227
+
228
+ /**
229
+ * One completed exchange that changed files -- somewhere to go back to.
230
+ *
231
+ * The unit is a whole exchange, not a snapshot. A snapshot lands mid-turn,
232
+ * after the tool results and before the model's closing message, so forking
233
+ * there would cut a turn in half and drop the model's own summary of what it
234
+ * just did. Claude Code lists prompts for the same reason: the thing a
235
+ * person means by "before that" is a message they sent, not a tool call.
236
+ */
237
+ export interface RestorePoint {
238
+ node: number;
239
+ /** Last event of the exchange. Inclusive: the exchange itself is kept. */
240
+ atEvent: string;
241
+ /** The prompt that opened the exchange, for a caller that has to show it. */
242
+ prompt: string;
243
+ /** How many paths the exchange added, changed or removed. */
244
+ changed: number;
245
+ /**
246
+ * True when this exchange is in the context the session is currently in.
247
+ *
248
+ * False means a branch the session has left -- usually one it rewound away
249
+ * from. Those are listed too: a node that survives on disk but cannot be
250
+ * found again is undestroyed and unreachable, which is the same loss in a
251
+ * different shape.
252
+ */
253
+ onPath: boolean;
254
+ }
255
+
256
+ export type RewindPreview =
257
+ | { ok: true; willWrite: string[]; willDelete: string[] }
258
+ | { ok: false; error: string };
259
+
260
+ /**
261
+ * Which of the two halves move.
262
+ *
263
+ * both the default. Context and disk stay in agreement.
264
+ * files the workspace goes back, the conversation does not.
265
+ * context the conversation goes back, the workspace does not.
266
+ */
267
+ export type RewindMode = "both" | "files" | "context";
268
+
269
+ export type RewindResult =
270
+ | { ok: true; node: number; restored: string[]; removed: string[]; unrecoverable: string[]; note: string }
271
+ | { ok: false; error: string };
272
+
273
+ /** O.5: child execution returns facts; only the parent coordinator accepts them. */
274
+ type DeferredToolResult = ToolResult & { accept?: () => Promise<LoopEvent[]> };
275
+ type ToolCall = Extract<AssistantBlock, { type: "tool_use" }>;
276
+
277
+ export class Session {
278
+ private readonly execution = new ExecutionControl();
279
+ private stall: StallState | null = null;
280
+ private pendingAdoption: number | null = null;
281
+ private readonly allocatedEventIds = new Set<string>();
282
+ readonly config: SessionConfig;
283
+ /**
284
+ * Replaced by switchModel. Not readonly: a live session can change which
285
+ * adapter it talks to, and the replacement has to be the same field every
286
+ * send already reads.
287
+ */
288
+ provider: Provider;
289
+ readonly clock: Clock;
290
+ state: SessionState;
291
+ private readonly allocation: { next: number };
292
+
293
+ constructor(config: SessionConfig, provider: Provider, clock: Clock = systemClock(), allocation = { next: 1 }) {
294
+ if (config.contextPolicy !== undefined && !isContextPolicy(config.contextPolicy)) throw new Error("unknown context policy");
295
+ stallDecision(emptyStallState(), config.stallLimit ?? DEFAULT_STALL_LIMIT);
296
+ validateOutputRecoveryLimit(config.outputRecoveryLimit ?? DEFAULT_OUTPUT_RECOVERY_LIMIT);
297
+ schedulingWindow([], 0, config.maxParallelTools ?? 3);
298
+ this.allocation = allocation;
299
+ this.config = config;
300
+ this.config.tools.outputStore ??= join(config.logDir, "artifacts");
301
+ this.provider = provider;
302
+ this.clock = {
303
+ now: () => clock.now(),
304
+ nextId: () => {
305
+ let id = clock.nextId();
306
+ // Injected clocks may restart their sequence too. Event identity is a
307
+ // session invariant, independent of the clock's implementation.
308
+ if (this.allocatedEventIds.has(id)) id = randomUUID();
309
+ this.allocatedEventIds.add(id);
310
+ return id;
311
+ },
312
+ };
313
+ this.state = {
314
+ node: 0, depth: 1, handoff: null, events: [], usage: [],
315
+ anchor: null, branchesOpened: 0, lastScan: {},
316
+ get nextNode() { return allocation.next; },
317
+ set nextNode(value: number) { allocation.next = value; },
318
+ worktrees: new Map(),
319
+ };
320
+ }
321
+
322
+ /**
323
+ * Opens the session: a new one, or the one this log already holds.
324
+ *
325
+ * Resuming restores the state a running session would have had, from the
326
+ * same ancestry walk the projection itself uses -- so what the model sees
327
+ * after a restart is what it would have seen had the process never exited.
328
+ * That equality is the whole contract; anything less makes a resumed
329
+ * session quietly different from a continuous one.
330
+ */
331
+ async start(): Promise<void> {
332
+ this.stall = null;
333
+ const dir = this.config.logDir;
334
+ const model = this.provider.config.spec.id;
335
+ const existing = await listNodes(dir);
336
+ const recordedCursor = await readCursor(dir);
337
+ const cursor = recordedCursor === null ? null : await recoverCut(dir, recordedCursor);
338
+ const plan = planResume(cursor, model, existing.length > 0);
339
+
340
+ switch (plan.kind) {
341
+ case "refuse":
342
+ throw new Error(plan.reason);
343
+
344
+ case "fresh":
345
+ this.config.contextPolicy ??= DEFAULT_CONTEXT_POLICY;
346
+ this.config.stallLimit ??= DEFAULT_STALL_LIMIT;
347
+ this.config.outputRecoveryLimit ??= DEFAULT_OUTPUT_RECOVERY_LIMIT;
348
+ await initNode(dir, 0, null, this.clock.now());
349
+ for await (const event of this.snapshotTurn([], true)) {
350
+ if (event.kind === "snapshot_failed") throw new Error(`cannot establish workspace baseline: ${event.reason}`);
351
+ }
352
+ break;
353
+
354
+ case "resume": {
355
+ // Logs predating explicit policies meant segmented, regardless of today's default.
356
+ this.config.contextPolicy = "segmented";
357
+ this.config.maxParallelTools = 1;
358
+ this.config.stallLimit = 0;
359
+ this.config.outputRecoveryLimit = 0;
360
+ const replayed = await replayContext(dir, plan.cursor.node);
361
+ if (plan.cursor.context_ref) {
362
+ const saved = await getBlob(join(dir, "artifacts"), plan.cursor.context_ref);
363
+ if (saved === null) throw new Error("missing frozen session context");
364
+ const context = JSON.parse(saved) as { frozen: FrozenPrefix; boundary?: Partial<BoundaryConfig>; contextPolicy?: unknown; stallLimit?: number; outputRecoveryLimit?: number; guidance?: boolean; gates: Record<string, string>; snapshotIgnore?: readonly string[]; workNamespace?: string; maxParallelTools?: number };
365
+ this.config.workNamespace = context.workNamespace ?? "main";
366
+ this.config.maxParallelTools = context.maxParallelTools ?? 1;
367
+ schedulingWindow([], 0, this.config.maxParallelTools);
368
+ stallDecision(emptyStallState(), context.stallLimit ?? 0);
369
+ this.config.stallLimit = context.stallLimit ?? 0;
370
+ validateOutputRecoveryLimit(context.outputRecoveryLimit ?? 0);
371
+ this.config.outputRecoveryLimit = context.outputRecoveryLimit ?? 0;
372
+ this.config.frozen = context.frozen;
373
+ this.config.tools.gates = context.gates;
374
+ if (context.boundary !== undefined) this.config.boundary = context.boundary;
375
+ else delete this.config.boundary;
376
+ if (context.contextPolicy !== undefined) {
377
+ if (!isContextPolicy(context.contextPolicy)) throw new Error("unknown saved context policy");
378
+ this.config.contextPolicy = context.contextPolicy;
379
+ } else this.config.contextPolicy = "segmented";
380
+ if (context.guidance !== undefined) this.config.guidance = context.guidance;
381
+ else delete this.config.guidance;
382
+ if (context.snapshotIgnore !== undefined) this.config.snapshotIgnore = context.snapshotIgnore;
383
+ else delete this.config.snapshotIgnore;
384
+ }
385
+ if (plan.cursor.workspace !== undefined) this.config.tools.root = plan.cursor.workspace;
386
+ for (const id of existing) {
387
+ for (const event of (await readNode(dir, id)).events) {
388
+ this.allocatedEventIds.add(event.id);
389
+ if (event.t === "spawn") {
390
+ this.state.branchesOpened++;
391
+ if (event.workspace) this.state.worktrees.set(event.branch.node, event.workspace);
392
+ }
393
+ if (event.t === "discard") this.state.worktrees.delete(event.branch.node);
394
+ }
395
+ }
396
+
397
+ this.state.node = plan.cursor.node;
398
+ // From the allocator's recorded state, never from a node count: dead
399
+ // limbs and resolved branches hold ids, and recomputing this is the
400
+ // shape of the bug that once had a cut overwrite a branch's log.
401
+ // A crashed branch can reserve a node before the parent updates its
402
+ // allocator. Recovery only raises the recorded high-water mark.
403
+ this.state.nextNode = Math.max(plan.cursor.next_node, ...existing.map((n) => n + 1));
404
+ this.state.depth = replayed.depth;
405
+ this.state.events = replayed.contextEvents;
406
+ this.state.handoff = replayed.handoff;
407
+ await this.answerUnpairedTools("interrupted: tool outcome is unknown after restart; inspect the workspace or external operation before retrying");
408
+ // Nothing has been sent this process, so there is no billed prompt
409
+ // size to anchor to yet; the first response re-establishes it.
410
+ this.state.anchor = null;
411
+ // Compare disk against the durable file projection. Treating current
412
+ // disk as the baseline would erase unlogged effects of a crashed tool.
413
+ if (this.config.blobDir) {
414
+ const files = await planRestoreToNode(dir, this.state.node);
415
+ if (!files.ok) throw new Error(files.error);
416
+ this.state.lastScan = files.files;
417
+ for await (const event of this.snapshotTurn([], false, "idle")) {
418
+ if (event.kind === "snapshot_failed") throw new Error(event.reason);
419
+ }
420
+ }
421
+ const pending = (await readNode(dir, plan.cursor.node)).events.findLast((e) => e.t === "adopt" && !e.applied);
422
+ if (pending?.t === "adopt") {
423
+ this.pendingAdoption = pending.branch.node;
424
+ await this.adoptPendingBranch();
425
+ }
426
+ break;
427
+ }
428
+
429
+ default:
430
+ return assertNever(plan, "Session.start");
431
+ }
432
+
433
+ await this.saveCursor();
434
+ }
435
+
436
+ /**
437
+ * Copies this session's current path into `destLogDir`, optionally
438
+ * stopping at `untilEvent` (inclusive). The source files are only read:
439
+ * a fork that rewrote the original would leave the user with one line
440
+ * when they thought they had two.
441
+ */
442
+ async fork(destLogDir: string, untilEvent?: string): Promise<void> {
443
+ await copyAncestryTo(this.config.logDir, destLogDir, this.clock.now(), untilEvent);
444
+ }
445
+
446
+ /**
447
+ * Cuts the current node, then runs on a different provider.
448
+ *
449
+ * The cut is the whole point. Thinking blocks belong to the model that
450
+ * produced them, and every provider this harness talks to requires them
451
+ * replayed unchanged. Handing Anthropic's signed block to DeepSeek -- or
452
+ * even to another Anthropic model -- is a bad request. Filtering the
453
+ * blocks out of the projection would make what the model sees depend on
454
+ * a "we switched" flag that is not in the log. Cutting makes the switch
455
+ * a fact in the log: the new node starts from a handoff, which is plain
456
+ * YAML, and inherits none of the old blocks.
457
+ *
458
+ * Bypasses minNodeWorkTokens. That floor is a cost argument (a handoff
459
+ * only pays for itself once the node's own history exceeds it). A switch
460
+ * is a correctness argument, and must be allowed after two turns.
461
+ *
462
+ * Only legal between turns. A cut between tool_use and tool_result would
463
+ * leave the log unpairable (D5).
464
+ *
465
+ * Recorded as model_checkpoint, not trigger. "trigger" means this node
466
+ * reached the token threshold. A switch is not that -- it can happen on
467
+ * turn two -- and a later reader reconstructing the session from the log
468
+ * must see that a person changed models, not that the node filled up.
469
+ * boundary.ts already produces this reason when checkpointRequested is
470
+ * set; switchModel is the other caller that wants it.
471
+ */
472
+ async switchModel(next: Provider): Promise<void> {
473
+ if (this.isHalfRound()) {
474
+ throw new Error(
475
+ "cannot switch models mid-turn: a tool_use is still unpaired. " +
476
+ "Wait for the turn to finish so every call has a result",
477
+ );
478
+ }
479
+
480
+ await this.performCut("model_checkpoint", next.config.spec.id);
481
+ this.provider = next;
482
+ await this.saveCursor();
483
+ }
484
+
485
+ /**
486
+ * The same pairing check switchModel already used. A user message
487
+ * between tool_use and tool_result is the same unpairable request a
488
+ * model switch would produce — so the queue reuses this, it does not
489
+ * invent a second half-round test.
490
+ */
491
+ private isHalfRound(): boolean {
492
+ const projected = projectContext({
493
+ frozen: this.config.frozen,
494
+ handoff: this.state.handoff,
495
+ events: this.state.events,
496
+ });
497
+ return projected.violations.some((v) => v.code === "UNPAIRED_TOOL_USE");
498
+ }
499
+
500
+ /**
501
+ * Offers after_tools only when the log is pairable. The UI still owns
502
+ * the queue; we just ask. A half-round returns null even if the UI
503
+ * offered a line — writing it would be the pairing bug this guards.
504
+ */
505
+ private async pullSteering(
506
+ pull?: (phase: Phase) => Promise<string | null>,
507
+ ): Promise<string | null> {
508
+ if (pull === undefined || this.isHalfRound()) return null;
509
+ const text = await pull("after_tools");
510
+ if (text === null || text.length === 0) return null;
511
+ return text;
512
+ }
513
+
514
+ /**
515
+ * Records where the session is, after every move.
516
+ *
517
+ * Written at each move rather than at exit, because the failures worth
518
+ * resuming from -- a crash, a kill, a closed laptop -- do not run exit
519
+ * handlers.
520
+ */
521
+ private async saveCursor(): Promise<void> {
522
+ // Branches share storage but never own the parent's active cursor.
523
+ if ((this.config.branchDepth ?? 0) > 0) return;
524
+ const contextRef = await putBlob(join(this.config.logDir, "artifacts"), JSON.stringify({
525
+ frozen: this.config.frozen, gates: this.config.tools.gates,
526
+ workNamespace: this.config.workNamespace ?? "main",
527
+ maxParallelTools: this.config.maxParallelTools ?? 3,
528
+ ...(this.config.boundary ? { boundary: this.config.boundary } : {}),
529
+ contextPolicy: this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
530
+ stallLimit: this.config.stallLimit ?? DEFAULT_STALL_LIMIT,
531
+ outputRecoveryLimit: this.config.outputRecoveryLimit ?? DEFAULT_OUTPUT_RECOVERY_LIMIT,
532
+ ...(this.config.guidance === undefined ? {} : { guidance: this.config.guidance }),
533
+ ...(this.config.snapshotIgnore ? { snapshotIgnore: this.config.snapshotIgnore } : {}),
534
+ }));
535
+ await writeCursor(this.config.logDir, {
536
+ node: this.state.node,
537
+ next_node: this.state.nextNode,
538
+ model: this.provider.config.spec.id,
539
+ updated_at: this.clock.now(),
540
+ workspace: this.config.tools.root,
541
+ context_ref: contextRef,
542
+ });
543
+ }
544
+
545
+ private async record(event: LogEvent): Promise<void> {
546
+ await appendEvent(this.config.logDir, this.state.node, event);
547
+ this.state.events.push(event);
548
+ if (this.stall !== null) this.stall = advanceStall(this.stall, [{ node: this.state.node, event }]);
549
+ }
550
+
551
+ get totals(): UsageTotals {
552
+ return totalUsage(this.state.usage);
553
+ }
554
+
555
+ cancel(): void { this.execution.cancel(); }
556
+ whenIdle(): Promise<void> { return this.execution.whenIdle(); }
557
+ get running(): boolean { return this.execution.running; }
558
+
559
+ /**
560
+ * Runs one user message to completion: model turns and tool calls until the
561
+ * model stops asking for tools.
562
+ *
563
+ * `signal` is per-send, matching Provider.send. Abort is a decision, not a
564
+ * failure: the generator yields `done` with reason `interrupted` and
565
+ * returns. Any tool_use already on disk is answered with a tool_result
566
+ * that says so -- otherwise the next request is unpairable (D8: the log
567
+ * is append-only, so the alternative of deleting the tool_use is not
568
+ * available, and would also lie about a call that may have had effects).
569
+ */
570
+ async *send(
571
+ userText: string,
572
+ signal?: AbortSignal,
573
+ pull?: (phase: Phase) => Promise<string | null>,
574
+ ): AsyncGenerator<LoopEvent> {
575
+ const owned = this.execution.begin(signal);
576
+ const events = this.run(userText, owned.signal, pull);
577
+ let terminal = false;
578
+ let exhausted = false;
579
+ try {
580
+ for (;;) {
581
+ const item = await events.next();
582
+ if (item.done) { exhausted = true; break; }
583
+ if (item.value.kind === "done") terminal = true;
584
+ yield item.value;
585
+ }
586
+ if (!terminal) yield { kind: "done", reason: "stopped: workspace snapshot failed", outcome: executionOutcome("snapshot_failed") };
587
+ } finally {
588
+ // Closing a consumer is not a user cancellation. Preserve the existing
589
+ // crash/recovery points; only an explicit signal can claim cancellation.
590
+ try {
591
+ if (!exhausted) {
592
+ await events.return(undefined);
593
+ }
594
+ } finally { owned.finish(); }
595
+ }
596
+ }
597
+
598
+ private async *run(
599
+ userText: string,
600
+ signal?: AbortSignal,
601
+ pull?: (phase: Phase) => Promise<string | null>,
602
+ ): AsyncGenerator<LoopEvent> {
603
+ await this.record({
604
+ t: "user", id: this.clock.nextId(), at: this.clock.now(), text: userText,
605
+ });
606
+ // Idle changes belong to the new prompt. Recording them before `user`
607
+ // would silently move the preceding exchange's restore point forward.
608
+ for await (const event of this.snapshotTurn([])) {
609
+ yield event;
610
+ if (event.kind === "snapshot_failed") return;
611
+ }
612
+
613
+ const counter = this.config.counter ?? estimateTokens;
614
+ const completedThisTurn: string[] = [];
615
+
616
+ try {
617
+ let overflowRetried = false;
618
+ const requestStarted = this.clock.now();
619
+ for (let turn = 0; turn < this.config.maxTurns; turn++) {
620
+ completedThisTurn.length = 0;
621
+ if (signal?.aborted) {
622
+ yield* this.closeInterrupted(completedThisTurn);
623
+ return;
624
+ }
625
+
626
+ if (yield* this.checkStall()) {
627
+ yield { kind: "done", reason: "stopped: consecutive tool exchanges without new evidence", outcome: executionOutcome("stalled") };
628
+ return;
629
+ }
630
+ yield { kind: "turn_start", node: this.state.node };
631
+
632
+ if (this.config.guidance !== false) {
633
+ const names = this.config.frozen.tools.flatMap((tool) => {
634
+ if (tool === null || typeof tool !== "object" || !("name" in tool) || typeof tool.name !== "string") return [];
635
+ return this.config.tools.allow === undefined || this.config.tools.allow.includes(tool.name) ? [tool.name] : [];
636
+ });
637
+ const guidance = continuationGuidance({ events: this.state.events, handoff: this.state.handoff,
638
+ tools: names, remainingRequests: this.config.maxTurns - turn,
639
+ ...(this.config.continuationAfter === undefined ? {} : { minAssistantTurns: this.config.continuationAfter }) }) ??
640
+ (this.config.timeBudgetMs === undefined ? null
641
+ : deadlineNotice(requestStarted, this.clock.now(), this.config.timeBudgetMs, this.state.events)) ??
642
+ (this.config.harnessReviews === false ? null : churnReview(this.state.events));
643
+ if (guidance) await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...guidance });
644
+ }
645
+
646
+ // New user/steering input and guidance can overflow a previously safe view.
647
+ if ((this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY) === "windowed" &&
648
+ (yield* this.maybeWindowCut(counter, true))) {
649
+ yield { kind: "done", reason: "stopped: context cannot fit the model window", outcome: executionOutcome("context_budget") };
650
+ return;
651
+ }
652
+ const projected = projectContext({
653
+ frozen: this.config.frozen,
654
+ handoff: this.state.handoff,
655
+ events: this.state.events,
656
+ logHint: {
657
+ manifestPath: manifestPath(this.config.logDir),
658
+ segmentPath: nodePath(this.config.logDir, Math.max(0, this.state.node - 1)),
659
+ },
660
+ });
661
+
662
+ // Pairing violations are a 400 waiting to happen; surface them here,
663
+ // where the cause is still visible, rather than as a request failure.
664
+ if (projected.violations.length > 0) {
665
+ yield {
666
+ kind: "done",
667
+ reason: `projection violations: ${projected.violations.map((v) => v.code).join(", ")}`,
668
+ outcome: executionOutcome("projection_invalid"),
669
+ };
670
+ return;
671
+ }
672
+
673
+ const window = this.provider.config.spec.contextWindow;
674
+ const estimated = anchoredCount(projected.messages, projected.system, this.state.anchor,
675
+ counter, this.config.frozen.tools).tokens;
676
+ if (window !== undefined && estimated + this.provider.config.maxTokens > window) {
677
+ yield { kind: "done", reason: `context budget exceeded: estimated input ${estimated} + output reserve ${this.provider.config.maxTokens} > window ${window}; shorten the input or switch to a larger verified window`, outcome: executionOutcome("context_budget") };
678
+ return;
679
+ }
680
+
681
+ let result: Turn;
682
+ try {
683
+ result = yield* this.awaitProvider(projected.messages, signal);
684
+ } catch (error) {
685
+ if (signal?.aborted) {
686
+ yield* this.closeInterrupted(completedThisTurn);
687
+ return;
688
+ }
689
+ if (!(error instanceof Error) || !isContextOverflow(error.message, statusOf(error))) throw error;
690
+ // The estimate said this fit and the provider says it does not: the
691
+ // estimate is wrong for this content. The only projection that can
692
+ // shrink is a cut, so force one and send once more; a second
693
+ // overflow means even the retained tail is misjudged, and another
694
+ // attempt would only be paid for.
695
+ if (overflowRetried || (this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY) !== "windowed" ||
696
+ windowBudget(this.provider.config.spec.contextWindow, this.provider.config.maxTokens) === null) {
697
+ yield { kind: "done", reason: `stopped: provider rejected the prompt as too long (${error.message})`, outcome: executionOutcome("context_budget") };
698
+ return;
699
+ }
700
+ overflowRetried = true;
701
+ if (yield* this.maybeWindowCut(counter, true, true)) {
702
+ yield { kind: "done", reason: "stopped: context cannot fit the model window", outcome: executionOutcome("context_budget") };
703
+ return;
704
+ }
705
+ continue;
706
+ }
707
+ overflowRetried = false;
708
+
709
+ const previous = this.state.usage.at(-1);
710
+ this.state.usage.push(result.usage);
711
+ const promptTokens =
712
+ result.usage.input_tokens +
713
+ result.usage.cache_creation_input_tokens +
714
+ result.usage.cache_read_input_tokens;
715
+ // A stream that reported no usage at all maps onto zeros in the
716
+ // adapter. Anchoring to that fabrication would make shouldCut compare
717
+ // against a fake small number and never fire. Leave the previous
718
+ // anchor (null before the first measurement); anchoredCount already
719
+ // falls back to a plain estimate when anchor is null. Do not invent
720
+ // a substitute count — diagnosePrefix and /usage must not see a
721
+ // fabricated prompt size.
722
+ if (promptTokens > 0 || result.usage.output_tokens > 0) {
723
+ this.state.anchor = {
724
+ promptTokens,
725
+ outputTokens: result.usage.output_tokens,
726
+ messageCount: projected.messages.length,
727
+ };
728
+ }
729
+ const cache = this.provider.config.spec.cache;
730
+ if (cache === undefined) {
731
+ yield {
732
+ kind: "usage",
733
+ usage: result.usage,
734
+ prefix: "cache terms unknown",
735
+ ok: true,
736
+ };
737
+ } else {
738
+ const verdict = diagnosePrefix(result.usage, previous, cache);
739
+ yield {
740
+ kind: "usage",
741
+ usage: result.usage,
742
+ prefix: verdict.kind === "healthy"
743
+ ? `cache read ${verdict.read}, wrote ${verdict.wrote}`
744
+ : verdict.detail,
745
+ ok: verdict.kind !== "suspicious",
746
+ };
747
+ }
748
+
749
+ await this.record({
750
+ t: "assistant", id: this.clock.nextId(), at: this.clock.now(), blocks: result.blocks, stop_reason: result.stopReason,
751
+ });
752
+
753
+ for (const b of result.blocks) {
754
+ if (b.type === "text" && b.text.trim()) yield { kind: "assistant_text", text: b.text };
755
+ if (b.type === "thinking" && b.text.trim()) yield { kind: "thinking", text: b.text };
756
+ }
757
+
758
+ const calls = result.blocks.filter(
759
+ (b): b is Extract<AssistantBlock, { type: "tool_use" }> => b.type === "tool_use",
760
+ );
761
+
762
+ // N.12: syntactically valid arguments in an unfinished response are not
763
+ // authorization to execute a partial batch. Pair without side effects.
764
+ if (!isCompleteResponse(result.stopReason)) {
765
+ for (const item of await this.answerUnpairedTools(incompleteToolText(result.stopReason))) {
766
+ yield { kind: "tool_result", text: item.content, ok: false };
767
+ }
768
+ const extra = await this.pullSteering(pull);
769
+ if (extra !== null) {
770
+ await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), text: extra });
771
+ continue;
772
+ }
773
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
774
+ if (!history.ok) throw new Error(history.error);
775
+ const notice = outputRecoveryNotice(history.nodes.flatMap(node => node.events),
776
+ this.config.outputRecoveryLimit ?? DEFAULT_OUTPUT_RECOVERY_LIMIT, this.config.maxTurns - turn - 1);
777
+ if (notice) {
778
+ await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...notice });
779
+ if (yield* this.maybeCut(counter, true)) {
780
+ yield { kind: "done", reason: "stopped: no verifiable progress", outcome: executionOutcome("no_progress") };
781
+ return;
782
+ }
783
+ continue;
784
+ }
785
+ yield { kind: "done", reason: result.stopReason ?? "unknown model stop", outcome: modelOutcome(result.stopReason) };
786
+ return;
787
+ }
788
+
789
+ if (calls.length === 0) {
790
+ const extra = await this.pullSteering(pull);
791
+ if (extra !== null) {
792
+ await this.record({
793
+ t: "user", id: this.clock.nextId(), at: this.clock.now(), text: extra,
794
+ });
795
+ continue;
796
+ }
797
+ const review = this.config.guidance === false || this.config.harnessReviews === false ? null
798
+ : completionReview(this.state.events, this.config.maxTurns - turn - 1);
799
+ if (review) {
800
+ await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...review });
801
+ if (yield* this.maybeCut(counter, true)) {
802
+ yield { kind: "done", reason: "stopped: no verifiable progress", outcome: executionOutcome("no_progress") };
803
+ return;
804
+ }
805
+ continue;
806
+ }
807
+ const stopped = yield* this.maybeCut(counter, false);
808
+ yield {
809
+ kind: "done",
810
+ reason: stopped ? "stopped: no verifiable progress" : result.stopReason ?? "end_turn",
811
+ outcome: stopped ? executionOutcome("no_progress") : modelOutcome(result.stopReason),
812
+ };
813
+ return;
814
+ }
815
+
816
+ let branchesThisTurn = 0;
817
+ type Settled = { ok: true; value: DeferredToolResult } | { ok: false; error: unknown };
818
+ const pending = new Map<string, Promise<Settled>>();
819
+ const modes = calls.map(call => this.executionMode(call));
820
+ try { for (let callIndex = 0; callIndex < calls.length; callIndex++) {
821
+ const call = calls[callIndex]!;
822
+ if (signal?.aborted && !pending.has(call.call_id)) {
823
+ yield* this.closeInterrupted(completedThisTurn);
824
+ return;
825
+ }
826
+
827
+ if (!pending.has(call.call_id)) {
828
+ const end = schedulingWindow(modes, callIndex, this.config.maxParallelTools ?? 3);
829
+ if (end - callIndex > 1) for (let index = callIndex; index < end; index++) {
830
+ const planned = calls[index]!;
831
+ yield { kind: "tool_call", tool: { name: planned.name, input: planned.input } };
832
+ if (signal?.aborted) break;
833
+ const dispatch = yield* this.prepareConcurrentCall(planned, planned.name === "branch" ? branchesThisTurn++ : 0, signal);
834
+ pending.set(planned.call_id, dispatch().then(value => ({ ok: true as const, value }), error => ({ ok: false as const, error })));
835
+ }
836
+ }
837
+
838
+ let out: DeferredToolResult;
839
+ let annotation: LogEvent | undefined;
840
+ const dispatched = pending.get(call.call_id);
841
+ if (dispatched) {
842
+ const settled = await dispatched;
843
+ pending.delete(call.call_id);
844
+ if (!settled.ok) throw settled.error;
845
+ out = settled.value;
846
+ } else {
847
+ if (signal?.aborted) { yield* this.closeInterrupted(completedThisTurn); return; }
848
+
849
+ yield { kind: "tool_call", tool: { name: call.name, input: call.input } };
850
+
851
+ // Checked again after the yield: that is the window a consumer has
852
+ // after seeing the call and before it runs. The first check above
853
+ // covers abort arriving between calls.
854
+ if (signal?.aborted) {
855
+ yield* this.closeInterrupted(completedThisTurn);
856
+ return;
857
+ }
858
+
859
+ // Three tools are not workspace actions and are handled here:
860
+ // `note` writes an annotation the handoff is built from, and
861
+ // `branch`/`resolve` act on the tree. Each still produces an ordinary
862
+ // tool_result so the turn stays pairable. They go through the same
863
+ // gate as read/write/edit/bash, which runTool applies itself.
864
+ const toolContext = { ...this.config.tools, ...(signal ? { signal } : {}) };
865
+ if (call.name === "note" || call.name === "branch" || call.name === "resolve" || call.name === "history") {
866
+ const gated = await gateTool(toolContext, call.name, call.input);
867
+ if (gated) {
868
+ out = gated;
869
+ } else if (call.name === "history") {
870
+ out = await runHistoryTool(this.config.logDir, this.state.node, toolContext, call.input);
871
+ } else if (call.name === "note") {
872
+ const parsed = parseNote(call.input);
873
+ const prepared = await prepareWorkingNote(this.config.logDir, this.state.node, this.config.workNamespace ?? "main",
874
+ parsed.annotation ? this.toEvent(parsed.annotation) : undefined, parsed.result);
875
+ out = prepared.out; annotation = prepared.annotation;
876
+ } else if (call.name === "branch") {
877
+ out = yield* this.runBranch(call.input, branchesThisTurn++, signal);
878
+ } else {
879
+ out = await this.runResolve(call.input);
880
+ }
881
+ } else {
882
+ out = await runTool(toolContext, call.name, call.input);
883
+ }
884
+ }
885
+ if (out.accept) for (const event of await out.accept()) yield event;
886
+ out = finishToolResult(out);
887
+ // Observation precedes persistence, preserving the after-effect crash
888
+ // boundary. A lost process here recovers as unknown, never as success.
889
+ yield { kind: "tool_result", text: out.content, ok: out.ok, outcome: out.outcome! };
890
+
891
+ // Recorded before the tool_result so a claim can never precede the
892
+ // evidence it cites -- deriveHandoff resolves a claim against runs at
893
+ // or before it, and an annotation is about work already done.
894
+ if (annotation) await this.record(annotation);
895
+
896
+ await this.record({
897
+ t: "tool_result",
898
+ id: this.clock.nextId(),
899
+ at: this.clock.now(),
900
+ call_id: call.call_id,
901
+ ok: out.ok,
902
+ content: out.content,
903
+ outcome: out.outcome!,
904
+ ...(out.data !== undefined ? { data: out.data } : {}),
905
+ ...(out.observation_sha256 !== undefined ? { observation_sha256: out.observation_sha256 } : {}),
906
+ ...(out.diagnostic !== undefined ? { diagnostic: out.diagnostic } : {}),
907
+ ...(out.content_sha256 ? { content_sha256: out.content_sha256 } : {}),
908
+ ...(out.content_ref ? { content_ref: out.content_ref } : {}),
909
+ ...(out.effect ? { effect: out.effect } : {}),
910
+ });
911
+ completedThisTurn.push(call.call_id);
912
+ if (signal?.aborted && pending.size === 0) {
913
+ yield* this.closeInterrupted(completedThisTurn);
914
+ return;
915
+ }
916
+ } } finally { await Promise.allSettled(pending.values()); }
917
+
918
+ // One scan per turn, not per tool call: a turn is the smallest unit
919
+ // that means anything, and per-call would be the character-level
920
+ // granularity the research warns against.
921
+ yield* this.snapshotTurn(calls.map((c) => c.call_id));
922
+ if (this.pendingAdoption !== null) await this.adoptPendingBranch();
923
+
924
+ // Between complete exchanges is the only safe place to cut.
925
+ if (yield* this.maybeCut(counter, turn + 1 < this.config.maxTurns)) {
926
+ yield { kind: "done", reason: "stopped: no verifiable progress", outcome: executionOutcome("no_progress") };
927
+ return;
928
+ }
929
+
930
+ const extra = await this.pullSteering(pull);
931
+ if (extra !== null) {
932
+ await this.record({
933
+ t: "user", id: this.clock.nextId(), at: this.clock.now(), text: extra,
934
+ });
935
+ }
936
+ }
937
+
938
+ yield { kind: "done", reason: `hit maxTurns (${this.config.maxTurns})`, outcome: executionOutcome("max_turns") };
939
+ } finally {
940
+ // If the consumer aborts and stops pulling, the generator is closed
941
+ // without running the yield* closeInterrupted path. Pairing still
942
+ // has to happen: an unanswered tool_use on disk is a 400 next turn.
943
+ if (signal?.aborted) await this.answerUnpairedTools();
944
+ }
945
+ }
946
+
947
+ /**
948
+ * Answers every tool_use that has no tool_result yet.
949
+ *
950
+ * Append, never delete: the call was issued, and rolling it back would
951
+ * rewrite the log (D8) and hide a call that may already have touched
952
+ * disk. Same shape as a refused branch -- a result that says why.
953
+ */
954
+ private async answerUnpairedTools(
955
+ content = "interrupted: the user cancelled before this tool completed",
956
+ ): Promise<{ callId: string; content: string }[]> {
957
+ const added: { callId: string; content: string }[] = [];
958
+ for (const block of pendingToolCalls(this.state.events)) {
959
+ const resultText = knownUnexecutedTool(this.state.events, block.call_id) ?? content;
960
+ await this.record({
961
+ t: "tool_result",
962
+ id: this.clock.nextId(),
963
+ at: this.clock.now(),
964
+ call_id: block.call_id,
965
+ ok: false,
966
+ content: resultText,
967
+ outcome: executionOutcome("unknown"),
968
+ });
969
+ added.push({ callId: block.call_id, content: resultText });
970
+ }
971
+ return added;
972
+ }
973
+
974
+ private async *closeInterrupted(completedCallIds: readonly string[]): AsyncGenerator<LoopEvent> {
975
+ const added = await this.answerUnpairedTools();
976
+ for (const item of added) {
977
+ yield { kind: "tool_result", text: item.content, ok: false };
978
+ }
979
+ if (completedCallIds.length > 0) {
980
+ yield* this.snapshotTurn(completedCallIds);
981
+ }
982
+ await this.recordInterrupt();
983
+ yield { kind: "done", reason: "interrupted", outcome: executionOutcome("cancelled") };
984
+ }
985
+
986
+ /**
987
+ * Appends an interrupt event for the user turn this send opened.
988
+ *
989
+ * The cancel happened after that user event, so it is a later record --
990
+ * not a flag written back onto it (D8). Partial assistant text is omitted:
991
+ * a cancelled generation was never delivered (AGENT_PLAYBOOK: a failed
992
+ * request is not a conversation that happened).
993
+ */
994
+ private async recordInterrupt(): Promise<void> {
995
+ for (let i = this.state.events.length - 1; i >= 0; i--) {
996
+ const event = this.state.events[i]!;
997
+ if (event.t !== "user") continue;
998
+ await this.record({
999
+ t: "interrupt",
1000
+ id: this.clock.nextId(),
1001
+ at: this.clock.now(),
1002
+ user_id: event.id,
1003
+ });
1004
+ return;
1005
+ }
1006
+ }
1007
+
1008
+ /**
1009
+ * Runs the provider and yields display deltas as they arrive, so a long
1010
+ * reply is visible before `send` resolves. The returned Turn is still the
1011
+ * complete message -- that is what gets logged.
1012
+ */
1013
+ private async *awaitProvider(
1014
+ messages: readonly Message[],
1015
+ signal?: AbortSignal,
1016
+ ): AsyncGenerator<LoopEvent, Turn> {
1017
+ const queue: LoopEvent[] = [];
1018
+ let notify: (() => void) | null = null;
1019
+ const box: { settled: { ok: true; turn: Turn } | { ok: false; error: unknown } | null } = {
1020
+ settled: null,
1021
+ };
1022
+
1023
+ const onDelta = (delta: StreamDelta): void => {
1024
+ switch (delta.kind) {
1025
+ case "text":
1026
+ queue.push({ kind: "text_delta", text: delta.text });
1027
+ break;
1028
+ case "thinking":
1029
+ queue.push({ kind: "thinking_delta", text: delta.text });
1030
+ break;
1031
+ case "discard":
1032
+ queue.push({ kind: "discard" });
1033
+ break;
1034
+ default:
1035
+ return assertNever(delta, "onDelta");
1036
+ }
1037
+ notify?.();
1038
+ };
1039
+
1040
+ const pending = this.provider
1041
+ .send(this.config.frozen, messages, this.state.node, signal, onDelta)
1042
+ .then(
1043
+ (turn) => {
1044
+ box.settled = { ok: true, turn };
1045
+ notify?.();
1046
+ },
1047
+ (error: unknown) => {
1048
+ box.settled = { ok: false, error };
1049
+ notify?.();
1050
+ },
1051
+ );
1052
+
1053
+ while (box.settled === null || queue.length > 0) {
1054
+ if (queue.length > 0) {
1055
+ yield queue.shift()!;
1056
+ continue;
1057
+ }
1058
+ await new Promise<void>((resolve) => {
1059
+ if (box.settled !== null || queue.length > 0) {
1060
+ resolve();
1061
+ return;
1062
+ }
1063
+ notify = resolve;
1064
+ });
1065
+ notify = null;
1066
+ }
1067
+
1068
+ await pending;
1069
+ const end = box.settled;
1070
+ if (end === null) throw new Error("provider send settled without a result");
1071
+ if (!end.ok) throw end.error;
1072
+ return end.turn;
1073
+ }
1074
+
1075
+ /**
1076
+ * Yields any boundary events and returns true when the session should stop.
1077
+ *
1078
+ * Returning the verdict rather than only announcing it is the point: an
1079
+ * advisory the loop ignores looks like a guard while the budget drains.
1080
+ */
1081
+ /**
1082
+ * Records what the workspace actually did this turn.
1083
+ *
1084
+ * Content goes to the blob store so a later restore has something to
1085
+ * restore from; the event holds only digests, which is what keeps it a
1086
+ * delta rather than a copy of the workspace.
1087
+ */
1088
+ private async *snapshotTurn(callIds: readonly string[], baseline = false, phase: "turn" | "idle" = "turn"): AsyncGenerator<LoopEvent> {
1089
+ const blobDir = this.config.blobDir;
1090
+ if (!blobDir) return;
1091
+
1092
+ const scan = await scanWorkspace(
1093
+ this.config.tools.root,
1094
+ this.config.snapshotIgnore ?? DEFAULT_IGNORE,
1095
+ );
1096
+ if (!scan.ok) {
1097
+ // Reported rather than skipped: without a scan a restore would delete
1098
+ // files it never recorded, so the caller has to know it is blind.
1099
+ yield { kind: "snapshot_failed", reason: scan.error };
1100
+ return;
1101
+ }
1102
+
1103
+ const delta = diffScans(this.state.lastScan, scan.files);
1104
+ const nothingHappened =
1105
+ Object.keys(delta.added).length === 0 &&
1106
+ Object.keys(delta.changed).length === 0 &&
1107
+ delta.removed.length === 0;
1108
+ if (nothingHappened) return;
1109
+
1110
+ // Only this turn's effects: reconciling against the whole node would
1111
+ // report every earlier edit as declared-but-unchanged.
1112
+ const answered = new Set(callIds);
1113
+ const effects = this.state.events
1114
+ .filter((e) => e.t === "tool_result" && answered.has(e.call_id) && e.effect)
1115
+ .map((e) => (e as Extract<LogEvent, { t: "tool_result" }>).effect!);
1116
+ const rec = reconcile(effects, delta);
1117
+
1118
+ for (const [path, sha] of Object.entries({ ...delta.added, ...delta.changed })) {
1119
+ const stored = await putFileBlob(blobDir, join(this.config.tools.root, path));
1120
+ if (stored !== sha) {
1121
+ // The file changed between the scan and the read. Recording the
1122
+ // digest that was actually stored keeps the event honest.
1123
+ if (path in delta.added) delta.added[path] = stored;
1124
+ else delta.changed[path] = stored;
1125
+ scan.files[path] = stored;
1126
+ }
1127
+ }
1128
+
1129
+ await this.record({
1130
+ t: "snapshot",
1131
+ id: this.clock.nextId(),
1132
+ at: this.clock.now(),
1133
+ added: delta.added,
1134
+ changed: delta.changed,
1135
+ removed: delta.removed,
1136
+ undeclared: baseline ? [] : rec.undeclared,
1137
+ declared_but_unchanged: rec.declaredButUnchanged,
1138
+ workspace_sha256: workspaceVersion(scan.files),
1139
+ phase,
1140
+ });
1141
+ this.state.lastScan = scan.files;
1142
+
1143
+ if (!baseline && rec.undeclared.length > 0) {
1144
+ yield {
1145
+ kind: "undeclared_change",
1146
+ reason: `changed with no tool claiming it: ${rec.undeclared.join(", ")}`,
1147
+ };
1148
+ }
1149
+ }
1150
+
1151
+ private get limits(): BranchLimits {
1152
+ return { ...DEFAULT_BRANCH_LIMITS, ...this.config.branches };
1153
+ }
1154
+
1155
+ private nodeRefFor(node: number): Promise<NodeRef> {
1156
+ return digestedRef(this.config.logDir, node);
1157
+ }
1158
+
1159
+ /**
1160
+ * Runs a read-only branch in its own node and returns a summary.
1161
+ *
1162
+ * The branch is an ordinary Session with a different node, a reduced tool
1163
+ * set and no handoff -- the recursion is what keeps a branch and a session
1164
+ * the same thing, so nothing here has to reimplement the loop.
1165
+ */
1166
+ private executionMode(call: ToolCall): "parallel" | "exclusive" {
1167
+ if (call.name === "branch" && typeof call.input === "object" && call.input !== null &&
1168
+ (call.input as Record<string, unknown>)["writes"] !== true) return "parallel";
1169
+ return toolDefinition(call.name)?.execution ?? "exclusive";
1170
+ }
1171
+
1172
+ private async *prepareConcurrentCall(call: ToolCall, branchIndex: number, signal?: AbortSignal): AsyncGenerator<LoopEvent, () => Promise<DeferredToolResult>> {
1173
+ const ctx = { ...this.config.tools, ...(signal ? { signal } : {}) };
1174
+ if (call.name !== "branch") {
1175
+ const prepared = await prepareTool(ctx, call.name, call.input);
1176
+ return typeof prepared === "function" ? prepared : async () => prepared;
1177
+ }
1178
+ const gated = await gateTool(ctx, call.name, call.input);
1179
+ if (gated) return async () => gated;
1180
+ const branch = this.runBranch(call.input, branchIndex, signal);
1181
+ while (true) {
1182
+ const step = await branch.next();
1183
+ if (step.done) return async () => step.value;
1184
+ yield step.value;
1185
+ // Spawn and snapshot preparation remain ordered. Advancing past this
1186
+ // marker starts only the child's execution, which may overlap siblings.
1187
+ if (step.value.kind === "branch_start") return async () => {
1188
+ const result = await branch.next();
1189
+ if (result.done) return result.value;
1190
+ throw new Error(`unexpected child execution event: ${result.value.kind}`);
1191
+ };
1192
+ }
1193
+ }
1194
+
1195
+ private async *runBranch(
1196
+ input: unknown,
1197
+ indexThisTurn: number,
1198
+ signal?: AbortSignal,
1199
+ ): AsyncGenerator<LoopEvent, DeferredToolResult> {
1200
+ const limits = this.limits;
1201
+ const depth = this.config.branchDepth ?? 0;
1202
+
1203
+ // Every refusal is a normal tool_result: an unanswered tool_use makes the
1204
+ // next request unpairable, and a refusal can say what to do instead.
1205
+ if (depth + 1 >= limits.maxBranchDepth) {
1206
+ return {
1207
+ content:
1208
+ `refused: branch depth limit (${limits.maxBranchDepth}) reached. ` +
1209
+ "Do this work directly with your own tools.",
1210
+ ok: false,
1211
+ };
1212
+ }
1213
+ if (indexThisTurn >= limits.maxBranchesPerTurn) {
1214
+ return {
1215
+ content:
1216
+ `refused: at most ${limits.maxBranchesPerTurn} branches per turn. ` +
1217
+ "Run the rest in a later turn, or do them yourself -- branching is " +
1218
+ "usually more expensive than working sequentially.",
1219
+ ok: false,
1220
+ };
1221
+ }
1222
+ if (this.state.branchesOpened >= limits.maxBranchesPerSession) {
1223
+ return {
1224
+ content:
1225
+ `refused: this session has opened its ${limits.maxBranchesPerSession} branches. ` +
1226
+ "Continue directly.",
1227
+ ok: false,
1228
+ };
1229
+ }
1230
+
1231
+ const args = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
1232
+ const purpose = String(args["purpose"] ?? "").trim();
1233
+ const prompt = String(args["prompt"] ?? "").trim();
1234
+ const writes = args["writes"] === true;
1235
+ if (!purpose || !prompt) {
1236
+ return { content: "a branch needs both a purpose and a prompt", ok: false };
1237
+ }
1238
+ const parentAllow = this.config.tools.allow;
1239
+ const branchNames = writes ? WRITING_BRANCH_TOOL_NAMES : BRANCH_TOOL_NAMES;
1240
+ const allowed: string[] = parentAllow
1241
+ ? branchNames.filter(name => parentAllow.includes(name)) : [...branchNames];
1242
+ const sourceEvent = this.state.events.findLast(event => event.t === "assistant")?.id ?? this.state.events.at(-1)!.id;
1243
+ let inputWorkspace: { kind: "shared_readonly" } | { kind: "git_tree"; head: string; tree: string } = { kind: "shared_readonly" };
1244
+
1245
+ // Siblings are staggered so the second does not race the first for the
1246
+ // prompt cache and rewrite the whole prefix.
1247
+ if (indexThisTurn > 0 && limits.branchStaggerMs > 0) {
1248
+ await new Promise((r) => setTimeout(r, limits.branchStaggerMs));
1249
+ }
1250
+
1251
+ const node = this.state.nextNode++;
1252
+ this.state.branchesOpened += 1;
1253
+ const ref = await this.nodeRefFor(node);
1254
+ // A fork point must include any preceding tool effects in this batch.
1255
+ yield* this.snapshotTurn([]);
1256
+
1257
+ // A writing branch needs its own checkout, or parallel branches overwrite
1258
+ // each other's files with nothing to show isolation was never in place.
1259
+ // Refused rather than downgraded to read-only: the model asked to change
1260
+ // files, and quietly not letting it would look like the work was done.
1261
+ let worktree: { path: string; branch: string } | null = null;
1262
+ if (writes) {
1263
+ const created = await createWorktree(this.config.tools.root, `b${node}`);
1264
+ if (!created.ok) {
1265
+ this.state.branchesOpened -= 1;
1266
+ this.state.nextNode -= 1;
1267
+ return { content: `refused: ${created.error}`, ok: false };
1268
+ }
1269
+ worktree = { path: created.path, branch: created.branch };
1270
+ inputWorkspace = { kind: "git_tree", ...created.snapshot };
1271
+ this.state.worktrees.set(node, worktree);
1272
+ }
1273
+
1274
+ await this.record({
1275
+ t: "spawn", id: this.clock.nextId(), at: this.clock.now(), branch: ref, purpose,
1276
+ input: { source_event: sourceEvent, tools: allowed, max_turns: Math.min(this.config.maxTurns, 15), workspace: inputWorkspace },
1277
+ ...(worktree ? { workspace: worktree } : {}),
1278
+ });
1279
+
1280
+ // basis "none": the branch starts fresh. The parent pointer is provenance.
1281
+ await initNode(this.config.logDir, node, {
1282
+ ...await this.nodeRefFor(this.state.node), at_event: this.state.events.at(-1)!.id,
1283
+ }, this.clock.now(), "none");
1284
+
1285
+ const branch = new Session(
1286
+ {
1287
+ ...this.config,
1288
+ frozen: { system: this.config.frozen.system, tools: branchTools(writes).filter((t) => allowed.includes(t.name)) },
1289
+ // The same list twice: the schema shapes what the model asks for, the
1290
+ // allow-list decides what actually runs. Intersected with the parent
1291
+ // so `--exclude-tools bash` cannot be undone by opening a branch.
1292
+ tools: {
1293
+ ...this.config.tools,
1294
+ ...(worktree ? { root: worktree.path } : {}),
1295
+ allow: allowed,
1296
+ },
1297
+ branchDepth: depth + 1,
1298
+ workNamespace: `branch-${node}`,
1299
+ maxTurns: Math.min(this.config.maxTurns, 15),
1300
+ },
1301
+ this.config.branchProvider ? this.config.branchProvider() : this.provider,
1302
+ this.clock,
1303
+ this.allocation,
1304
+ );
1305
+ branch.state.node = node;
1306
+ branch.state.nextNode = this.state.nextNode;
1307
+ branch.state.lastScan = { ...this.state.lastScan };
1308
+ yield { kind: "branch_start", node, reason: purpose, at: Date.now() };
1309
+ for await (const event of branch.snapshotTurn([], true)) {
1310
+ if (event.kind === "snapshot_failed") throw new Error(`cannot establish branch baseline: ${event.reason}`);
1311
+ }
1312
+
1313
+ let text = "";
1314
+ let outcome = executionOutcome("unknown");
1315
+ try {
1316
+ for await (const e of branch.send(prompt, signal)) {
1317
+ if (e.kind === "assistant_text" && e.text) text = e.text;
1318
+ if (e.kind === "done") outcome = e.outcome ?? executionOutcome("unknown");
1319
+ }
1320
+ } catch {
1321
+ // Persist a bounded failure classification, never a provider's raw error body.
1322
+ outcome = executionOutcome(signal?.aborted ? "cancelled" : "execution_error");
1323
+ await branch.answerUnpairedTools("the branch execution failed; this tool's outcome is unknown; inspect before retrying");
1324
+ }
1325
+ // Node numbers the branch allocated are taken.
1326
+
1327
+ const tip = branch.state.node;
1328
+ const branchContext = await replayContext(this.config.logDir, tip);
1329
+ // Re-digest now: `ref` was taken at spawn, before this node had events.
1330
+ const handoff = deriveHandoff({
1331
+ prev: branchContext.fullHandoff, depth: branchContext.depth, events: branchContext.events, self: await this.nodeRefFor(tip),
1332
+ config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity },
1333
+ });
1334
+ await closeNode(this.config.logDir, tip, boundHandoff(handoff), (await readNode(this.config.logDir, tip)).events.length, this.clock.now());
1335
+
1336
+ // The worktree stays until the branch is resolved: its uncommitted work
1337
+ // is the branch's result, and removing it here would discard exactly
1338
+ // what the parent is about to decide on.
1339
+ const where = worktree
1340
+ ? `\n\nIts changes are in ${worktree.path} on branch ${worktree.branch}, ` +
1341
+ "not in your files. Adopting continues in that checkout without merging it into this one."
1342
+ : "";
1343
+ const summary = text.trim() || "(the branch produced no closing message)";
1344
+ const joined: LogEvent = {
1345
+ t: "joined", id: this.clock.nextId(), at: this.clock.now(),
1346
+ branch: ref, tip: await this.nodeRefFor(tip), handoff_ref: handoffPath(this.config.logDir, tip), summary,
1347
+ model: branch.provider.config.spec.id,
1348
+ outcome,
1349
+ };
1350
+
1351
+ // Only the summary crosses back. The branch's own calls stay in its node,
1352
+ // which is the entire reason to branch rather than read in place.
1353
+ return {
1354
+ content:
1355
+ `branch ${node} (${purpose}) ended: ${JSON.stringify(outcome)}.\n\n${summary}${where}\n\n` +
1356
+ `Its full state is at ${handoffPath(this.config.logDir, tip)}. ` +
1357
+ `Resolve it with resolve(node: ${node}, ...).`,
1358
+ ok: outcome.status === "completed",
1359
+ outcome,
1360
+ accept: async () => {
1361
+ this.state.usage.push(...branch.state.usage);
1362
+ await this.saveCursor();
1363
+ await this.record(joined);
1364
+ return [{ kind: "branch_done", node, text: summary, outcome, ok: outcome.status === "completed" }];
1365
+ },
1366
+ data: { node, outcome, handoff_ref: handoffPath(this.config.logDir, tip), requests: branch.state.usage.length,
1367
+ input: { source_event: sourceEvent, workspace: inputWorkspace } },
1368
+ };
1369
+ }
1370
+
1371
+ private async runResolve(input: unknown): Promise<ToolResult> {
1372
+ const args = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
1373
+ const node = Number(args["node"]);
1374
+ const resolution = String(args["resolution"] ?? "");
1375
+ const why = typeof args["why"] === "string" ? args["why"].trim() : "";
1376
+
1377
+ if (!Number.isInteger(node)) return { content: "resolve needs a node number", ok: false };
1378
+ const history = await readNodeHistory(this.config.logDir, this.state.node);
1379
+ if (!history.ok) return { ok: false, content: history.error };
1380
+ const events = history.nodes.flatMap((n) => n.events);
1381
+ const spawned = events.some(
1382
+ (e) => (e.t === "spawn" || e.t === "joined") && e.branch.node === node,
1383
+ );
1384
+ if (!spawned) {
1385
+ return { content: `no branch ${node} was opened from this node`, ok: false };
1386
+ }
1387
+ if (events.some((e) => (e.t === "adopt" || e.t === "discard") && e.branch.node === node)) {
1388
+ return { ok: false, content: `branch ${node} is already resolved` };
1389
+ }
1390
+
1391
+ const ref = await this.nodeRefFor(node);
1392
+ if (resolution === "adopt") {
1393
+ if (this.pendingAdoption !== null) return { content: "resolve one adoption per tool round", ok: false };
1394
+ const joined = events.find((e) => e.t === "joined" && e.branch.node === node);
1395
+ if (!joined) return { content: `branch ${node} has not joined`, ok: false };
1396
+ await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: ref });
1397
+ this.pendingAdoption = node;
1398
+ return { content: `adopted branch ${node}; continuation moves after this tool round is paired`, ok: true };
1399
+ }
1400
+ if (resolution === "discard") {
1401
+ await this.record({
1402
+ t: "discard", id: this.clock.nextId(), at: this.clock.now(),
1403
+ branch: ref, reason: why || "no reason given",
1404
+ });
1405
+ // Discarding is the decision that its worktree can go. A dirty one is
1406
+ // reported rather than forced: those changes are what was discarded,
1407
+ // and silently deleting them is the destructive-restore mistake again.
1408
+ const wt = this.state.worktrees.get(node);
1409
+ if (wt) {
1410
+ const removed = await removeWorktree(this.config.tools.root, wt.path, wt.branch);
1411
+ this.state.worktrees.delete(node);
1412
+ if (!removed.ok) {
1413
+ return {
1414
+ content:
1415
+ `discarded branch ${node}, but its checkout is still at ${wt.path}: ${removed.error}`,
1416
+ ok: true,
1417
+ };
1418
+ }
1419
+ }
1420
+ return { content: `discarded branch ${node}`, ok: true };
1421
+ }
1422
+ return { content: `resolution must be "adopt" or "discard"`, ok: false };
1423
+ }
1424
+
1425
+ /** D15: finish the parent's tool round before moving to the chosen branch. */
1426
+ private async adoptPendingBranch(): Promise<void> {
1427
+ const root = this.pendingAdoption;
1428
+ if (root === null) return;
1429
+ const history = await readNodeHistory(this.config.logDir, this.state.node);
1430
+ if (!history.ok) throw new Error(history.error);
1431
+ const events = history.nodes.flatMap((n) => n.events);
1432
+ const joined = events.find((e) => e.t === "joined" && e.branch.node === root);
1433
+ const spawned = events.find((e) => e.t === "spawn" && e.branch.node === root);
1434
+ if (joined?.t !== "joined" || spawned?.t !== "spawn") throw new Error(`missing provenance for adoption ${root}`);
1435
+ const parent = this.state.node;
1436
+ const annotations = carryForward(events, spawned.id, parent);
1437
+ const tip = joined.tip ?? joined.branch;
1438
+ const intent = events.findLast((e) => e.t === "adopt" && e.branch.node === root && e.next_node !== undefined);
1439
+ const node = intent?.t === "adopt" && intent.next_node !== undefined ? intent.next_node : this.state.nextNode++;
1440
+ if (!intent) await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: joined.branch, next_node: node });
1441
+ const parentRef = await this.nodeRefFor(parent);
1442
+ const existing = await readNode(this.config.logDir, node);
1443
+ if (existing.header === null) await initNode(this.config.logDir, node, tip, this.clock.now(), "inherit", { adopted_from: parentRef });
1444
+ else if (existing.header.adopted_from?.node !== parent || existing.header.parent?.node !== tip.node) {
1445
+ throw new Error(`INTEGRITY: adoption successor ${node} belongs to another transition`);
1446
+ }
1447
+ const rebuilt = await replayContext(this.config.logDir, node);
1448
+ this.state.node = node;
1449
+ this.state.nextNode = Math.max(this.state.nextNode, node + 1);
1450
+ this.state.events = rebuilt.contextEvents;
1451
+ this.stall = null;
1452
+ this.state.handoff = rebuilt.handoff;
1453
+ this.state.depth = rebuilt.depth;
1454
+ this.state.anchor = null;
1455
+ const copied = new Set(existing.events.flatMap((e) => "carried_from" in e && e.carried_from ? [e.carried_from.event] : []));
1456
+ for (const event of annotations) if (!copied.has(event.carried_from!.event)) await this.record({ ...event, id: this.clock.nextId() });
1457
+ if (!existing.events.some((e) => e.t === "adopt" && e.applied && e.branch.node === root)) {
1458
+ await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: joined.branch, applied: true });
1459
+ }
1460
+ const checkout = this.state.worktrees.get(root);
1461
+ if (checkout) this.config.tools.root = checkout.path;
1462
+ const scan = await scanWorkspace(this.config.tools.root, this.ignore);
1463
+ if (!scan.ok) throw new Error(`cannot adopt workspace: ${scan.error}`);
1464
+ this.state.lastScan = scan.files;
1465
+ await this.saveCursor();
1466
+ this.pendingAdoption = null;
1467
+ if (joined.model !== undefined && joined.model !== this.provider.config.spec.id) await this.performCut("model_checkpoint");
1468
+ }
1469
+
1470
+ private toEvent(a: Annotation): LogEvent {
1471
+ const stamp = { id: this.clock.nextId(), at: this.clock.now() };
1472
+ switch (a.t) {
1473
+ case "claim": {
1474
+ // A citation naming a configured gate is recorded as a gate query, so
1475
+ // it still resolves when the same suite is invoked differently later.
1476
+ // Everything else stays an exact-command query.
1477
+ const gate = a.proof ? matchGate(this.config.tools, a.proof) : undefined;
1478
+ return {
1479
+ t: "claim", ...stamp, what: a.what, ...(a.work_id ? { work_id: a.work_id } : {}),
1480
+ evidence: gate !== undefined ? { gate } : a.evidence,
1481
+ };
1482
+ }
1483
+ case "next_action":
1484
+ return { t: "next_action", ...stamp, what: a.what, next_action: a.next_action, ...(a.work_id ? { work_id: a.work_id } : {}) };
1485
+ case "decision":
1486
+ return {
1487
+ t: "decision", ...stamp, chose: a.chose, because: a.because,
1488
+ ...(a.rejected ? { rejected: a.rejected } : {}),
1489
+ };
1490
+ case "pitfall":
1491
+ return { t: "pitfall", ...stamp, tried: a.tried, failed_because: a.failed_because };
1492
+ case "blocked":
1493
+ return { t: "blocked", ...stamp, what: a.what, reason: a.reason, ...(a.work_id ? { work_id: a.work_id } : {}) };
1494
+ case "work_status":
1495
+ case "evidence_link":
1496
+ return { ...a, ...stamp };
1497
+ case "question":
1498
+ return { t: "question", ...stamp, text: a.text };
1499
+ default:
1500
+ return assertNever(a, "toEvent");
1501
+ }
1502
+ }
1503
+
1504
+ // ---- rewind ---------------------------------------------------------------
1505
+
1506
+ private get ignore(): readonly string[] {
1507
+ return this.config.snapshotIgnore ?? DEFAULT_IGNORE;
1508
+ }
1509
+
1510
+ /**
1511
+ * Every completed exchange on the current ancestry that changed a file.
1512
+ *
1513
+ * An exchange runs from one user message to just before the next, so its
1514
+ * last event is where a fork belongs: the conversation is whole there, and
1515
+ * the files are as the exchange left them.
1516
+ *
1517
+ * EVERY exchange, including ones that changed no file. Going back to
1518
+ * before a badly phrased instruction is the main use of a context-only
1519
+ * rewind, and such a turn often only talked -- filtering on "this exchange
1520
+ * wrote something" removes exactly those points. Claude Code lists every
1521
+ * prompt for the same reason and hides only the code options; whether
1522
+ * files would move is a question the preview answers, since what matters
1523
+ * is changes AFTER the point, not changes in it.
1524
+ */
1525
+ async restorePoints(): Promise<RestorePoint[]> {
1526
+ const dir = this.config.logDir;
1527
+ const history = await readNodeHistory(dir, this.state.node);
1528
+ if (!history.ok) return [];
1529
+ // Per EVENT, not per node. A rewind usually forks inside the node it is
1530
+ // sitting in, so that node is on the path and the abandoned exchange is
1531
+ // in the same file, just past the fork point. Marking whole nodes would
1532
+ // hide exactly the exchange most worth getting back to.
1533
+ const onPath = new Set(history.nodes.flatMap((n) => n.events.map((e) => e.id)));
1534
+
1535
+ // Every node, read in full. A node the session rewound away from is
1536
+ // still on disk, and listing only the current path would leave it
1537
+ // undestroyed and unfindable at the same time.
1538
+ const all: { node: number; events: LogEvent[] }[] = [];
1539
+ for (const id of await listNodes(dir)) {
1540
+ const { header, events } = await readNode(dir, id);
1541
+ if (header === null) continue;
1542
+ all.push({ node: id, events });
1543
+ }
1544
+
1545
+ const points: RestorePoint[] = [];
1546
+ for (const n of all) {
1547
+ let open: { prompt: string; changed: number; last: string } | null = null;
1548
+ const close = () => {
1549
+ if (open) {
1550
+ points.push({
1551
+ node: n.node, atEvent: open.last, prompt: open.prompt,
1552
+ changed: open.changed, onPath: onPath.has(open.last),
1553
+ });
1554
+ }
1555
+ };
1556
+ for (const e of n.events) {
1557
+ if (e.t === "snapshot" && e.phase === "idle") {
1558
+ close();
1559
+ open = null;
1560
+ points.push({ node: n.node, atEvent: e.id, prompt: "Observed workspace changes",
1561
+ changed: Object.keys(e.added).length + Object.keys(e.changed).length + e.removed.length,
1562
+ onPath: onPath.has(e.id) });
1563
+ continue;
1564
+ }
1565
+ if (e.t === "user") {
1566
+ close();
1567
+ open = { prompt: e.text, changed: 0, last: e.id };
1568
+ continue;
1569
+ }
1570
+ if (!open) continue;
1571
+ open.last = e.id;
1572
+ if (e.t === "snapshot") {
1573
+ open.changed +=
1574
+ Object.keys(e.added).length + Object.keys(e.changed).length + e.removed.length;
1575
+ }
1576
+ }
1577
+ close();
1578
+ }
1579
+ return points;
1580
+ }
1581
+
1582
+ /**
1583
+ * What a rewind would do, without doing it.
1584
+ *
1585
+ * Going back deletes whatever the target did not have, which necessarily
1586
+ * includes a file a person wrote by hand while the session was idle. That
1587
+ * is the right behaviour -- a half-restored workspace is worse -- but it is
1588
+ * the half of the operation nobody asked for, so it is shown first.
1589
+ */
1590
+ async previewRewind(point: RestorePoint): Promise<RewindPreview> {
1591
+ const plan = await planRestoreToNode(
1592
+ this.config.logDir, { node: point.node, atEvent: point.atEvent },
1593
+ undefined, this.config.tools.root, this.ignore,
1594
+ );
1595
+ return plan.ok
1596
+ ? { ok: true, willWrite: plan.willWrite, willDelete: plan.willDelete }
1597
+ : { ok: false, error: plan.error };
1598
+ }
1599
+
1600
+ /**
1601
+ * Goes back to a turn: the files and the conversation move together.
1602
+ *
1603
+ * FORKS, never truncates. claude-code#89646 is the argument for this: their
1604
+ * rewind drops the context and starts a new session, so rewinding too far
1605
+ * loses the work with no way back -- even though the transcript was
1606
+ * append-only on disk the whole time. Here the node rewound away from is
1607
+ * still a node, so it is still a restore target, and "rewound too far" is
1608
+ * recoverable rather than terminal.
1609
+ *
1610
+ * BOTH HALVES OR NEITHER. Moving the cursor without the files leaves the
1611
+ * session on a node whose context describes a workspace that is not on
1612
+ * disk. Claude Code's own docs warn that this divergence causes unexpected
1613
+ * behaviour; we would additionally flag the whole restore as undeclared
1614
+ * damage at the next turn boundary. So the files move first and the node is
1615
+ * opened only if that worked.
1616
+ */
1617
+ async rewind(point: RestorePoint, mode: RewindMode = "both"): Promise<RewindResult> {
1618
+ const dir = this.config.logDir;
1619
+ const root = this.config.tools.root;
1620
+ const target = { node: point.node, atEvent: point.atEvent };
1621
+ const movesFiles = mode !== "context";
1622
+
1623
+ const blobDir = this.config.blobDir;
1624
+ // Only the modes that move files need somewhere to read them from. A
1625
+ // context-only rewind is the one thing still worth offering a session
1626
+ // that never took snapshots, so requiring them would refuse it for no
1627
+ // reason.
1628
+ if (movesFiles && !blobDir) {
1629
+ return {
1630
+ ok: false,
1631
+ error: "this session stores no snapshots, so the files cannot be moved back",
1632
+ };
1633
+ }
1634
+
1635
+ // Planned before anything is written: an unresolvable target must leave
1636
+ // the session exactly where it was, not half moved. The plan is also
1637
+ // what every mode needs -- to apply, or to say what it is NOT applying.
1638
+ const plan = await planRestoreToNode(dir, target, undefined, root, this.ignore);
1639
+ if (!plan.ok) return { ok: false, error: plan.error };
1640
+ // Preserve idle edits before moving away from them. An unobserved file is
1641
+ // not disposable just because the target predates it (D8/appendix H).
1642
+ for await (const event of this.snapshotTurn([], false, "idle")) {
1643
+ if (event.kind === "snapshot_failed") return { ok: false, error: event.reason ?? "snapshot failed" };
1644
+ }
1645
+ const beforeFiles = { ...this.state.lastScan };
1646
+
1647
+ // Computed BEFORE anything is applied. Both partial modes diverge over
1648
+ // the same set -- where the target and the workspace as it stands now
1649
+ // disagree -- and only the story differs, which `mode` already carries.
1650
+ // Computing it afterwards gave "files" an empty set, because by then the
1651
+ // disk had become the target: the note still said to re-read but named
1652
+ // nothing, which is the vague form this is meant to replace.
1653
+ const divergence = mode === "both" ? [] : await this.divergenceFrom(plan.files);
1654
+
1655
+ let moved: RestoreResult = {
1656
+ restored: [], removed: [], unrecoverable: [],
1657
+ note: "The workspace was left exactly as it is; only the conversation moved.",
1658
+ };
1659
+ if (movesFiles && blobDir) {
1660
+ // Applied from the plan just computed rather than through
1661
+ // restoreToNode, which would re-plan: a second traversal, and a guard
1662
+ // here that could never fire because the only way the apply fails is a
1663
+ // plan that failed.
1664
+ moved = await applyRestorePlan(
1665
+ root, blobDir, plan.files, this.ignore,
1666
+ `the point where you were asked: ${point.prompt.slice(0, 40)}`,
1667
+ );
1668
+ if (moved.unrecoverable.length > 0) return { ok: true, node: this.state.node, ...moved };
1669
+ }
1670
+
1671
+ const from: NodeRef = { ...await digestedRef(dir, point.node), at_event: point.atEvent };
1672
+ const stamp = { id: this.clock.nextId(), at: this.clock.now() };
1673
+ const event: LogEvent = {
1674
+ t: "rewind", ...stamp, mode, from, prompt: point.prompt,
1675
+ restored: moved.restored, removed: moved.removed,
1676
+ unrecoverable: moved.unrecoverable, divergence,
1677
+ };
1678
+
1679
+ if (mode === "files") {
1680
+ // No fork. The conversation did not move, so the node did not either;
1681
+ // a new node would be identical to this one but for a single event.
1682
+ await this.record(event);
1683
+ this.state.lastScan = beforeFiles;
1684
+ for await (const result of this.snapshotTurn([], true)) {
1685
+ if (result.kind === "snapshot_failed") throw new Error(result.reason);
1686
+ }
1687
+ return { ok: true, node: this.state.node, ...moved };
1688
+ }
1689
+
1690
+ const node = this.state.nextNode++;
1691
+ // "inherit": the point of a rewind is to continue from the target as if
1692
+ // the turns after it had not happened, so the target's events replay.
1693
+ await initNode(dir, node, from, this.clock.now(), "inherit");
1694
+
1695
+ // The running session projects from state.events, which a new node starts
1696
+ // empty -- correct for a cut, where the handoff replaces them, and wrong
1697
+ // here. The inherited events come from the ancestry walk, which is the
1698
+ // same function the projection would use and already honours both the
1699
+ // fork point and any cut further up.
1700
+ const replayed = await replayContext(dir, node);
1701
+
1702
+ this.state.node = node;
1703
+ this.state.events = [];
1704
+ this.state.handoff = replayed.handoff;
1705
+ this.stall = null;
1706
+ this.state.depth = replayed.depth;
1707
+ // The new node's prefix has never been sent, so nothing to anchor to.
1708
+ this.state.anchor = null;
1709
+ // The scan baseline follows the files, which a context-only rewind did
1710
+ // not move. Setting it to the plan there would make the next turn report
1711
+ // the untouched workspace as a change nobody declared.
1712
+ this.state.lastScan = plan.files;
1713
+ // Path depth comes from replay; the monotonic allocator still accounts
1714
+ // for every node spent outside this selected path.
1715
+
1716
+ await this.record(event);
1717
+ // Recorded first, then replayed, so the rewind event stays the node's own
1718
+ // annotation rather than something the inherited context contains.
1719
+ this.state.events = [...replayed.contextEvents, ...this.state.events];
1720
+ // A context-only fork inherits old file history while the disk stays
1721
+ // newer. Record that delta now, so later replay cannot resurrect a stale
1722
+ // workspace and the next tool starts from the actual files.
1723
+ for await (const result of this.snapshotTurn([], true)) {
1724
+ if (result.kind === "snapshot_failed") throw new Error(result.reason);
1725
+ }
1726
+ await this.saveCursor();
1727
+
1728
+ return { ok: true, node, ...moved };
1729
+ }
1730
+
1731
+ /**
1732
+ * Paths where the target and the workspace as it stands now disagree.
1733
+ *
1734
+ * Read off the disk rather than inferred from the plan, because a file a
1735
+ * person changed by hand belongs in the answer just as much as one a
1736
+ * rewound turn wrote.
1737
+ */
1738
+ private async divergenceFrom(target: FileSet): Promise<string[]> {
1739
+ const current = await scanWorkspace(this.config.tools.root, this.ignore);
1740
+ if (!current.ok) return [];
1741
+ const delta = diffScans(target, current.files);
1742
+ return [
1743
+ ...Object.keys(delta.added), ...Object.keys(delta.changed), ...delta.removed,
1744
+ ].sort();
1745
+ }
1746
+
1747
+ private async *checkStall(): AsyncGenerator<LoopEvent, boolean> {
1748
+ const limit = this.config.stallLimit ?? DEFAULT_STALL_LIMIT;
1749
+ if (limit === 0) return false;
1750
+ if (this.stall === null) {
1751
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
1752
+ if (!history.ok) throw new Error(history.error);
1753
+ this.stall = advanceStall(emptyStallState(), history.nodes.flatMap(({ node, events }) => events.map((event) => ({ node, event }))));
1754
+ }
1755
+ const decision = stallDecision(this.stall, limit);
1756
+ if (decision.stop) {
1757
+ yield { kind: "stop_and_ask", reason: `${decision.consecutive} consecutive tool exchanges without new evidence or fresh verification; change the approach or provide new input` };
1758
+ return true;
1759
+ }
1760
+ if (decision.warn) await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "stall", sources: [],
1761
+ text: `Harness: ${decision.consecutive} consecutive tool exchanges without new evidence or fresh verification. Change the method, run a meaningful check, or give the final result with unresolved limits. The harness will stop at ${limit} such exchanges. Notes and repeated observations do not count as progress.` });
1762
+ return false;
1763
+ }
1764
+
1765
+ /** N.8: build and check the replacement before committing a durable boundary. */
1766
+ /**
1767
+ * `overflowed` is the provider's own verdict that the current projection
1768
+ * does not fit, which outranks any estimate: the cut is taken as a hard
1769
+ * limit, and retention is sized against the estimate scaled so the rejected
1770
+ * prompt reads as exactly over the usable window.
1771
+ */
1772
+ private async *maybeWindowCut(counter: TokenCounter, willContinue: boolean, overflowed = false): AsyncGenerator<LoopEvent, boolean> {
1773
+ const budget = windowBudget(this.provider.config.spec.contextWindow, this.provider.config.maxTokens);
1774
+ if (!willContinue || budget === null || pendingToolCalls(this.state.events).length > 0) return false;
1775
+ const project = () => projectContext({ frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events });
1776
+ let projected = project();
1777
+ const measured = anchoredCount(projected.messages, projected.system, this.state.anchor, counter, this.config.frozen.tools).tokens;
1778
+ const scale = overflowed ? Math.max(1, (budget.usable + 1) / Math.max(1, measured)) : 1;
1779
+ if (overflowed) counter = scaledCounter(counter, scale);
1780
+ let tokens = Math.ceil(measured * scale);
1781
+ if (tokens < budget.prepare) return false;
1782
+ const replayed = await replayContext(this.config.logDir, this.state.node);
1783
+ let draft: Handoff;
1784
+ try {
1785
+ draft = boundHandoff(deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events,
1786
+ depth: replayed.depth, self: await digestedRef(this.config.logDir, this.state.node),
1787
+ config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity } }));
1788
+ } catch (error) {
1789
+ if (!(error instanceof RangeError)) throw error;
1790
+ // An objective too large for a carried handoff cannot be repaired by another cut.
1791
+ if (tokens <= budget.usable) return false;
1792
+ yield { kind: "stop_and_ask", reason: `context cannot fit the model window: ${error instanceof Error ? error.message : "handoff construction failed"}` };
1793
+ return true;
1794
+ }
1795
+ const noteEnabled = this.config.frozen.tools.some((tool) => tool !== null && typeof tool === "object" && "name" in tool && tool.name === "note") &&
1796
+ (this.config.tools.allow === undefined || this.config.tools.allow.includes("note"));
1797
+ if (this.config.guidance !== false && noteEnabled && tokens < budget.trigger && budget.trigger > 1) {
1798
+ const notice = boundaryPreparation({ events: this.state.events, tokens, inheritedTokens: 0,
1799
+ continuationReady: draft.continuation_valid,
1800
+ config: { triggerTokens: budget.trigger, hardTokens: budget.trigger + (budget.trigger - budget.prepare), minNodeWorkTokens: 0 } });
1801
+ if (notice) {
1802
+ await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...notice });
1803
+ projected = project();
1804
+ tokens = anchoredCount(projected.messages, projected.system, this.state.anchor, counter, this.config.frozen.tools).tokens;
1805
+ }
1806
+ }
1807
+ if (tokens < budget.trigger) return false;
1808
+ const lastAssistant = this.state.events.findLastIndex((event) => event.t === "assistant");
1809
+ const pendingUser = this.state.events.slice(lastAssistant + 1).find((event) => event.t === "user");
1810
+ const retained = selectRecentContext({ events: this.state.events, frozen: this.config.frozen, handoff: draft,
1811
+ counter, targetTokens: budget.target,
1812
+ ...(pendingUser === undefined ? {} : { requiredUserId: pendingUser.id }) });
1813
+ if (retained === null || retained.tokens >= tokens) {
1814
+ // A large fresh prompt may still fit; let the model answer once. Never discard
1815
+ // it to manufacture space, or record repeated cuts that cannot shrink it.
1816
+ if (tokens <= budget.usable) return false;
1817
+ yield { kind: "stop_and_ask", reason: "context cannot fit the model window while preserving the current request and a complete recent exchange; reduce the input or use a larger verified window" };
1818
+ return true;
1819
+ }
1820
+ const node = this.state.node;
1821
+ const reason = tokens > budget.usable ? "hard_limit" : "trigger";
1822
+ await this.performCut(reason, this.provider.config.spec.id, retained.from);
1823
+ yield { kind: "cut", node, reason };
1824
+ return false;
1825
+ }
1826
+
1827
+ private async *maybeCut(counter: TokenCounter, canPrepare: boolean): AsyncGenerator<LoopEvent, boolean> {
1828
+ if ((this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY) === "windowed" && this.provider.config.spec.contextWindow !== undefined) {
1829
+ return yield* this.maybeWindowCut(counter, canPrepare);
1830
+ }
1831
+ const projected = projectContext({
1832
+ frozen: this.config.frozen,
1833
+ handoff: this.state.handoff,
1834
+ events: this.state.events,
1835
+ });
1836
+ let { tokens } = anchoredCount(
1837
+ projected.messages, projected.system, this.state.anchor, counter,
1838
+ this.config.frozen.tools,
1839
+ );
1840
+
1841
+ // What this node would cost having done nothing: the frozen prefix plus
1842
+ // the rendered handoff. Cutting cannot reduce it, so it does not count
1843
+ // toward the node having earned a boundary.
1844
+ const floor = projectContext({
1845
+ frozen: this.config.frozen,
1846
+ handoff: this.state.handoff,
1847
+ events: [],
1848
+ });
1849
+ // Counted with the tools, because `tokens` above came from the anchor and
1850
+ // the provider bills schemas on every request. Subtracting a figure that
1851
+ // omitted them would read the difference as work the node had done.
1852
+ const inheritedTokens = counter.count(floor.messages, floor.system, this.config.frozen.tools);
1853
+
1854
+ const policy = this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY;
1855
+ const cfg = { ...DEFAULT_BOUNDARY, ...this.config.boundary };
1856
+ let draft: Handoff | null = null;
1857
+ if (policy !== "continuous" && tokens >= cfg.triggerTokens - (cfg.hardTokens - cfg.triggerTokens)) {
1858
+ // Read the same full ancestry used by the actual cut. A bounded carried
1859
+ // page is insufficient to decide whether all active work is completed.
1860
+ const replayed = await replayContext(this.config.logDir, this.state.node);
1861
+ draft = boundHandoff(deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events,
1862
+ depth: replayed.depth, self: await digestedRef(this.config.logDir, this.state.node),
1863
+ config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity } }));
1864
+ const noteEnabled = this.config.frozen.tools.some((tool) => tool !== null && typeof tool === "object" && "name" in tool && tool.name === "note") &&
1865
+ (this.config.tools.allow === undefined || this.config.tools.allow.includes("note"));
1866
+ if (canPrepare && this.config.guidance !== false && noteEnabled && tokens < cfg.hardTokens) {
1867
+ const notice = boundaryPreparation({ events: this.state.events, tokens, inheritedTokens,
1868
+ continuationReady: draft.continuation_valid, config: cfg });
1869
+ if (notice) {
1870
+ await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...notice });
1871
+ const updated = projectContext({ frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events });
1872
+ tokens = anchoredCount(updated.messages, updated.system, this.state.anchor, counter, this.config.frozen.tools).tokens;
1873
+ }
1874
+ }
1875
+ }
1876
+ const price = this.provider.config.spec.price;
1877
+ const usage = this.state.usage.at(-1);
1878
+ const replacement = draft ? projectContext({ frozen: this.config.frozen, handoff: draft, events: [] }) : null;
1879
+ const cost = replacement && price?.cacheRead !== undefined && price.cacheWrite !== undefined && usage &&
1880
+ usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens > 0
1881
+ ? estimateBoundaryCost({ retainedTokens: tokens,
1882
+ rebuiltTokens: counter.count(replacement.messages, replacement.system, this.config.frozen.tools),
1883
+ observedCachedTokens: usage.node === this.state.node ? usage.cache_read_input_tokens : 0,
1884
+ inputPrice: price.input, cacheReadPrice: price.cacheRead, cacheWritePrice: price.cacheWrite }) : null;
1885
+ const decision = decideContext({
1886
+ policy, continuationReady: draft?.continuation_valid ?? false, cost,
1887
+ tokens,
1888
+ inheritedTokens,
1889
+ atTurnBoundary: true,
1890
+ ...(this.config.boundary ? { config: this.config.boundary } : {}),
1891
+ });
1892
+
1893
+ if (!decision.cut) {
1894
+ // A floor past the hard limit cannot be worked around by cutting; the
1895
+ // caller has to widen the thresholds or shrink the frozen prefix.
1896
+ if (decision.reason === "inherited_floor_exceeds_limit") {
1897
+ yield {
1898
+ kind: "stop_and_ask",
1899
+ reason:
1900
+ `the inherited context alone is ${inheritedTokens} tokens, at or past the hard ` +
1901
+ `limit -- widen the thresholds or shrink the system prompt`,
1902
+ };
1903
+ return true;
1904
+ }
1905
+ return false;
1906
+ }
1907
+
1908
+ const node = this.state.node;
1909
+ await this.performCut(decision.reason);
1910
+ yield { kind: "cut", node, reason: decision.reason };
1911
+
1912
+ // D9 uses observations and fresh successful checks, not model assertions.
1913
+ // The closed node's handoff is now this.state.handoff.
1914
+ const handoff = this.state.handoff;
1915
+ if (handoff !== null && shouldStopAndAsk(handoff.budget.nodes_without_progress)) {
1916
+ yield {
1917
+ kind: "stop_and_ask",
1918
+ reason:
1919
+ `${handoff.budget.nodes_without_progress} nodes with no verifiable progress. ` +
1920
+ `blocked: ${handoff.state.blocked.length}, open questions: ${handoff.open_questions.length}`,
1921
+ };
1922
+ return true;
1923
+ }
1924
+ return false;
1925
+ }
1926
+
1927
+ /**
1928
+ * Closes the current node and opens the next with a handoff basis.
1929
+ *
1930
+ * Shared by the automatic boundary and by switchModel. The caller decides
1931
+ * WHETHER to cut -- this only performs it. Putting the work floor in here
1932
+ * would make a model switch wait for minNodeWorkTokens, and the new model
1933
+ * would see the old thinking blocks in the meantime.
1934
+ */
1935
+ private async performCut(reason: CutReason, nextModel = this.provider.config.spec.id, retainedFrom?: string): Promise<void> {
1936
+ const dir = this.config.logDir;
1937
+ const node = this.state.node;
1938
+
1939
+ const replayed = await replayContext(dir, node);
1940
+ const fromDisk = replayed.events;
1941
+ const retained = retainedFrom === undefined ? [] : retainedContext(replayed.contextEvents, retainedFrom);
1942
+
1943
+ // The cut event is part of the closed file. Digest after appending it
1944
+ // so predecessor.sha256 matches what closeNode writes into the manifest.
1945
+ await this.record({
1946
+ t: "cut",
1947
+ id: this.clock.nextId(),
1948
+ at: this.clock.now(),
1949
+ handoff_ref: handoffPath(dir, node),
1950
+ predecessor: this.state.handoff?.predecessor ?? null,
1951
+ reason,
1952
+ next_node: this.state.nextNode,
1953
+ next_model: nextModel,
1954
+ handoff_tokens: DEFAULT_HANDOFF_TOKENS,
1955
+ ...(retainedFrom === undefined ? {} : { retained_from: retainedFrom }),
1956
+ });
1957
+ const self = await digestedRef(dir, node);
1958
+ const handoff = boundHandoff(deriveHandoff({
1959
+ prev: replayed.fullHandoff,
1960
+ depth: replayed.depth,
1961
+ events: fromDisk,
1962
+ self,
1963
+ config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity },
1964
+ }));
1965
+ await closeNode(dir, node, handoff, (await readNode(dir, node)).events.length, this.clock.now());
1966
+
1967
+ // One allocator for the whole session. A cut used to take node + 1 while
1968
+ // a branch took nextNode++, so a cut after a branch reached the branch's
1969
+ // id and overwrote its log.
1970
+ this.state.node = this.state.nextNode++;
1971
+ this.state.depth += 1;
1972
+ this.state.handoff = handoff;
1973
+ this.state.events = retained;
1974
+ // The new node's prefix has never been sent, so there is nothing to anchor
1975
+ // to until its first response comes back.
1976
+ this.state.anchor = null;
1977
+ // A cut declares a handoff basis: the closed node's events do not replay,
1978
+ // its derived state does. Relying on the default here would silently make
1979
+ // every boundary a fork and replay the whole history.
1980
+ await initNode(dir, this.state.node, self, this.clock.now(), "handoff");
1981
+ await this.saveCursor();
1982
+ }
1983
+ }