@visual-z/xeno 0.8.4 → 0.9.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 (350) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +31 -746
  3. package/bin/xeno.exe +26 -0
  4. package/install.cjs +88 -0
  5. package/package.json +18 -48
  6. package/docs/app-server.md +0 -115
  7. package/docs/foundations.md +0 -88
  8. package/docs/install.md +0 -244
  9. package/docs/providers.md +0 -172
  10. package/docs/research/foundation-clients-2026-10-09.md +0 -27
  11. package/docs/research/foundation-editing-2026-10-09.md +0 -56
  12. package/docs/research/foundation-network-2026-10-09.md +0 -37
  13. package/docs/research/foundation-workflow-2026-10-09.md +0 -37
  14. package/src/apps/acp-server.ts +0 -41
  15. package/src/apps/agent-profiles.ts +0 -19
  16. package/src/apps/app-server.ts +0 -30
  17. package/src/apps/approve.ts +0 -49
  18. package/src/apps/cli.ts +0 -99
  19. package/src/apps/commands.ts +0 -319
  20. package/src/apps/completions.ts +0 -87
  21. package/src/apps/control-transport.ts +0 -90
  22. package/src/apps/export-cli.ts +0 -24
  23. package/src/apps/goal-flow.ts +0 -70
  24. package/src/apps/image-draft.ts +0 -46
  25. package/src/apps/input.ts +0 -312
  26. package/src/apps/model-catalog.ts +0 -69
  27. package/src/apps/preferences.ts +0 -20
  28. package/src/apps/project-trust.ts +0 -9
  29. package/src/apps/provider-connect.ts +0 -346
  30. package/src/apps/question-ui.ts +0 -27
  31. package/src/apps/render.ts +0 -181
  32. package/src/apps/resources.ts +0 -66
  33. package/src/apps/review.ts +0 -109
  34. package/src/apps/session-hub.ts +0 -96
  35. package/src/apps/session-lifecycle.ts +0 -64
  36. package/src/apps/shell.ts +0 -2071
  37. package/src/apps/startup-input.ts +0 -135
  38. package/src/apps/startup.ts +0 -1677
  39. package/src/apps/ui/app.ts +0 -1818
  40. package/src/apps/ui/control-model.ts +0 -43
  41. package/src/apps/ui/extension-host.ts +0 -194
  42. package/src/apps/ui/image-cache.ts +0 -121
  43. package/src/apps/ui/palette.ts +0 -88
  44. package/src/apps/ui/project.ts +0 -127
  45. package/src/apps/ui/standalone.ts +0 -148
  46. package/src/apps/ui/status-model.ts +0 -68
  47. package/src/apps/ui/surface.ts +0 -91
  48. package/src/apps/ui/theme.ts +0 -86
  49. package/src/apps/update-check.ts +0 -112
  50. package/src/apps/workflow.ts +0 -58
  51. package/src/core/acceptance.ts +0 -139
  52. package/src/core/action-defaults.ts +0 -58
  53. package/src/core/activity-status.ts +0 -52
  54. package/src/core/agent-profile.ts +0 -130
  55. package/src/core/attachment-input.ts +0 -17
  56. package/src/core/blind-checks.ts +0 -72
  57. package/src/core/boundary.ts +0 -174
  58. package/src/core/branch-receipt.ts +0 -26
  59. package/src/core/catalog-merge.ts +0 -19
  60. package/src/core/checkpoints.ts +0 -85
  61. package/src/core/churn.ts +0 -61
  62. package/src/core/command-artifacts.ts +0 -94
  63. package/src/core/command-contract.ts +0 -151
  64. package/src/core/completion-action.ts +0 -20
  65. package/src/core/completion-review.ts +0 -98
  66. package/src/core/context-detail.ts +0 -37
  67. package/src/core/context-policy.ts +0 -133
  68. package/src/core/context-window.ts +0 -54
  69. package/src/core/continuation.ts +0 -130
  70. package/src/core/control-protocol.ts +0 -69
  71. package/src/core/control-view.ts +0 -39
  72. package/src/core/cycle.ts +0 -17
  73. package/src/core/data-query.ts +0 -201
  74. package/src/core/deadline.ts +0 -40
  75. package/src/core/derive.ts +0 -516
  76. package/src/core/draft-images.ts +0 -56
  77. package/src/core/edit-plan.ts +0 -200
  78. package/src/core/environment.ts +0 -181
  79. package/src/core/event-validation.ts +0 -220
  80. package/src/core/events.ts +0 -670
  81. package/src/core/evidence-ladder.ts +0 -62
  82. package/src/core/evidence-links.ts +0 -68
  83. package/src/core/evidence.ts +0 -240
  84. package/src/core/execution.ts +0 -18
  85. package/src/core/exhaustive.ts +0 -18
  86. package/src/core/export.ts +0 -179
  87. package/src/core/extension-flags.ts +0 -14
  88. package/src/core/external-effects.ts +0 -488
  89. package/src/core/failure-recovery.ts +0 -173
  90. package/src/core/file-precondition.ts +0 -78
  91. package/src/core/file-references.ts +0 -35
  92. package/src/core/frontmatter.ts +0 -213
  93. package/src/core/git-status.ts +0 -29
  94. package/src/core/goal-view.ts +0 -64
  95. package/src/core/goal.ts +0 -200
  96. package/src/core/handoff.ts +0 -479
  97. package/src/core/history-query.ts +0 -139
  98. package/src/core/history.ts +0 -237
  99. package/src/core/image-limits.ts +0 -72
  100. package/src/core/images.ts +0 -46
  101. package/src/core/integration-health.ts +0 -34
  102. package/src/core/json-event.ts +0 -131
  103. package/src/core/json-pointer.ts +0 -46
  104. package/src/core/keymap.ts +0 -153
  105. package/src/core/lsp-framing.ts +0 -34
  106. package/src/core/mcp-config.ts +0 -68
  107. package/src/core/model-picker.ts +0 -64
  108. package/src/core/model-preferences.ts +0 -28
  109. package/src/core/model-replay.ts +0 -22
  110. package/src/core/model-scope.ts +0 -53
  111. package/src/core/note-guidance.ts +0 -34
  112. package/src/core/oauth-fields.ts +0 -44
  113. package/src/core/observation-view.ts +0 -152
  114. package/src/core/outcome.ts +0 -44
  115. package/src/core/overflow.ts +0 -59
  116. package/src/core/packages.ts +0 -47
  117. package/src/core/patch.ts +0 -130
  118. package/src/core/path-suggestions.ts +0 -59
  119. package/src/core/permissions.ts +0 -410
  120. package/src/core/process-receipt.ts +0 -105
  121. package/src/core/process-state.ts +0 -37
  122. package/src/core/program-status.ts +0 -40
  123. package/src/core/progress.ts +0 -150
  124. package/src/core/project.ts +0 -431
  125. package/src/core/prompt-policy.ts +0 -46
  126. package/src/core/prompts.ts +0 -111
  127. package/src/core/question.ts +0 -94
  128. package/src/core/queue.ts +0 -78
  129. package/src/core/read-bounds.ts +0 -24
  130. package/src/core/read-observation.ts +0 -106
  131. package/src/core/recovery-sources.ts +0 -74
  132. package/src/core/regex-dialect.ts +0 -25
  133. package/src/core/request-budget.ts +0 -23
  134. package/src/core/request-progress.ts +0 -87
  135. package/src/core/request-state.ts +0 -273
  136. package/src/core/resource-selection.ts +0 -36
  137. package/src/core/response-recovery.ts +0 -59
  138. package/src/core/resume.ts +0 -59
  139. package/src/core/review.ts +0 -68
  140. package/src/core/ripgrep-release.ts +0 -64
  141. package/src/core/runtime-target.ts +0 -94
  142. package/src/core/scheduling.ts +0 -8
  143. package/src/core/search-observation.ts +0 -97
  144. package/src/core/secrets.ts +0 -188
  145. package/src/core/session-archive.ts +0 -4
  146. package/src/core/session-browser.ts +0 -82
  147. package/src/core/session-entry.ts +0 -61
  148. package/src/core/session-exit.ts +0 -42
  149. package/src/core/session-format.ts +0 -169
  150. package/src/core/session-position.ts +0 -26
  151. package/src/core/session-tree.ts +0 -147
  152. package/src/core/settings.ts +0 -192
  153. package/src/core/skills.ts +0 -165
  154. package/src/core/sql-query.ts +0 -211
  155. package/src/core/stall.ts +0 -128
  156. package/src/core/stop-report.ts +0 -68
  157. package/src/core/text-normalize.ts +0 -81
  158. package/src/core/theme.ts +0 -166
  159. package/src/core/thinking.ts +0 -26
  160. package/src/core/tokens.ts +0 -151
  161. package/src/core/tool-contract.ts +0 -153
  162. package/src/core/tool-discovery.ts +0 -87
  163. package/src/core/tool-failure.ts +0 -81
  164. package/src/core/tool-input.ts +0 -44
  165. package/src/core/transcript.ts +0 -108
  166. package/src/core/tui-mode.ts +0 -29
  167. package/src/core/update-check.ts +0 -157
  168. package/src/core/version.ts +0 -55
  169. package/src/core/web-content.ts +0 -80
  170. package/src/core/work-state.ts +0 -154
  171. package/src/core/workflow-mode.ts +0 -34
  172. package/src/core/working-set.ts +0 -119
  173. package/src/core/yaml.ts +0 -282
  174. package/src/runtime/acp-agent.ts +0 -289
  175. package/src/runtime/agent-profiles.ts +0 -21
  176. package/src/runtime/auth.ts +0 -367
  177. package/src/runtime/blind-check.ts +0 -36
  178. package/src/runtime/branch-jobs.ts +0 -36
  179. package/src/runtime/catalog.ts +0 -285
  180. package/src/runtime/clipboard.ts +0 -125
  181. package/src/runtime/command-artifacts.ts +0 -64
  182. package/src/runtime/connection-reuse.ts +0 -40
  183. package/src/runtime/control-client.ts +0 -149
  184. package/src/runtime/control-connection.ts +0 -139
  185. package/src/runtime/counter-check.ts +0 -74
  186. package/src/runtime/environment-tools.ts +0 -60
  187. package/src/runtime/evidence-notes.ts +0 -35
  188. package/src/runtime/exec-sessions.ts +0 -456
  189. package/src/runtime/execution-control.ts +0 -40
  190. package/src/runtime/extension-loader.ts +0 -37
  191. package/src/runtime/extension-ui.ts +0 -47
  192. package/src/runtime/extensions.ts +0 -370
  193. package/src/runtime/formatter-tool.ts +0 -38
  194. package/src/runtime/formatter.ts +0 -50
  195. package/src/runtime/foundation.ts +0 -116
  196. package/src/runtime/history-cache.ts +0 -57
  197. package/src/runtime/history-tools.ts +0 -168
  198. package/src/runtime/images.ts +0 -80
  199. package/src/runtime/integrations.ts +0 -216
  200. package/src/runtime/loop.ts +0 -3108
  201. package/src/runtime/lsp-client.ts +0 -201
  202. package/src/runtime/mcp-client.ts +0 -264
  203. package/src/runtime/mcp-oauth.ts +0 -168
  204. package/src/runtime/mcp-schema.ts +0 -51
  205. package/src/runtime/model-config.ts +0 -232
  206. package/src/runtime/model-data.LICENSE +0 -27
  207. package/src/runtime/model-data.ts +0 -21
  208. package/src/runtime/model-discovery.ts +0 -208
  209. package/src/runtime/model-replay.ts +0 -10
  210. package/src/runtime/model-scope.ts +0 -20
  211. package/src/runtime/model-sources.ts +0 -151
  212. package/src/runtime/model.ts +0 -686
  213. package/src/runtime/models-fetch.ts +0 -57
  214. package/src/runtime/oauth-device.ts +0 -86
  215. package/src/runtime/oauth.ts +0 -853
  216. package/src/runtime/open-image.ts +0 -42
  217. package/src/runtime/openai-responses.ts +0 -304
  218. package/src/runtime/openai.ts +0 -697
  219. package/src/runtime/output-schema.ts +0 -26
  220. package/src/runtime/package-manager.ts +0 -125
  221. package/src/runtime/patch-tool.ts +0 -38
  222. package/src/runtime/permission-resources.ts +0 -18
  223. package/src/runtime/pi-provider.ts +0 -423
  224. package/src/runtime/program-status.ts +0 -38
  225. package/src/runtime/progress.ts +0 -26
  226. package/src/runtime/provider-access.ts +0 -21
  227. package/src/runtime/provider-connect.ts +0 -109
  228. package/src/runtime/provider-presets.ts +0 -98
  229. package/src/runtime/provider-protocols.ts +0 -29
  230. package/src/runtime/provider-request.ts +0 -87
  231. package/src/runtime/provider-tool-policy.ts +0 -33
  232. package/src/runtime/provider.ts +0 -580
  233. package/src/runtime/question-tool.ts +0 -62
  234. package/src/runtime/retry.ts +0 -447
  235. package/src/runtime/review-tool.ts +0 -48
  236. package/src/runtime/rich-text.ts +0 -36
  237. package/src/runtime/runtime-target.ts +0 -149
  238. package/src/runtime/secrets.ts +0 -79
  239. package/src/runtime/self-update.ts +0 -74
  240. package/src/runtime/session-control.ts +0 -265
  241. package/src/runtime/session-hub.ts +0 -85
  242. package/src/runtime/stream-idle.ts +0 -80
  243. package/src/runtime/tool-broker.ts +0 -137
  244. package/src/runtime/tool-discovery.ts +0 -58
  245. package/src/runtime/tool-queue.ts +0 -77
  246. package/src/runtime/tools.ts +0 -1820
  247. package/src/runtime/turn-effects.ts +0 -31
  248. package/src/runtime/update-check.ts +0 -48
  249. package/src/runtime/usage.ts +0 -253
  250. package/src/runtime/version.ts +0 -12
  251. package/src/runtime/web-http.ts +0 -56
  252. package/src/runtime/web-tools.ts +0 -88
  253. package/src/runtime/working-notes.ts +0 -60
  254. package/src/runtime/workspace-lifecycle.ts +0 -141
  255. package/src/store/acceptance.ts +0 -15
  256. package/src/store/agent-profile-files.ts +0 -13
  257. package/src/store/atomic.ts +0 -31
  258. package/src/store/auth-store.ts +0 -50
  259. package/src/store/blind-checks.ts +0 -32
  260. package/src/store/blobs.ts +0 -143
  261. package/src/store/checkpoint-history.ts +0 -32
  262. package/src/store/command-artifacts.ts +0 -96
  263. package/src/store/command.ts +0 -111
  264. package/src/store/config.ts +0 -69
  265. package/src/store/context-files.ts +0 -436
  266. package/src/store/file-checkpoints.ts +0 -108
  267. package/src/store/file-index.ts +0 -174
  268. package/src/store/foundation-config.ts +0 -24
  269. package/src/store/git-checkpoints.ts +0 -67
  270. package/src/store/git-status.ts +0 -15
  271. package/src/store/history-search.ts +0 -19
  272. package/src/store/log.ts +0 -725
  273. package/src/store/lsp-document.ts +0 -30
  274. package/src/store/mcp-credentials.ts +0 -85
  275. package/src/store/models.ts +0 -60
  276. package/src/store/observations.ts +0 -224
  277. package/src/store/packages.ts +0 -98
  278. package/src/store/patch.ts +0 -92
  279. package/src/store/permission-paths.ts +0 -34
  280. package/src/store/process-receipts.ts +0 -34
  281. package/src/store/query-source.ts +0 -49
  282. package/src/store/recovery.ts +0 -44
  283. package/src/store/replay.ts +0 -76
  284. package/src/store/resource-index.ts +0 -170
  285. package/src/store/resources.ts +0 -285
  286. package/src/store/restore.ts +0 -110
  287. package/src/store/review.ts +0 -109
  288. package/src/store/runtime-target.ts +0 -64
  289. package/src/store/search.ts +0 -728
  290. package/src/store/secrets.ts +0 -44
  291. package/src/store/serial.ts +0 -13
  292. package/src/store/session-archive.ts +0 -165
  293. package/src/store/session-browser.ts +0 -68
  294. package/src/store/session-position.ts +0 -27
  295. package/src/store/sessions.ts +0 -525
  296. package/src/store/source-cancellation.ts +0 -6
  297. package/src/store/tool-binaries.ts +0 -90
  298. package/src/store/trust.ts +0 -82
  299. package/src/store/update-cache.ts +0 -36
  300. package/src/store/user-settings.ts +0 -30
  301. package/src/store/workspace-clone.ts +0 -42
  302. package/src/store/workspace-observation.ts +0 -113
  303. package/src/store/workspace.ts +0 -115
  304. package/src/store/worktree.ts +0 -278
  305. package/src/tui/ansi-spans.ts +0 -126
  306. package/src/tui/diff.ts +0 -160
  307. package/src/tui/exhaustive.ts +0 -14
  308. package/src/tui/fuzzy.ts +0 -75
  309. package/src/tui/style.ts +0 -208
  310. package/src/tui/ui/atomic-draft.ts +0 -121
  311. package/src/tui/ui/branch-panel.ts +0 -47
  312. package/src/tui/ui/canvas.ts +0 -96
  313. package/src/tui/ui/colors.ts +0 -70
  314. package/src/tui/ui/composer.ts +0 -199
  315. package/src/tui/ui/control-panel.ts +0 -25
  316. package/src/tui/ui/control-sidebar.ts +0 -49
  317. package/src/tui/ui/dialog.ts +0 -398
  318. package/src/tui/ui/draft.ts +0 -23
  319. package/src/tui/ui/duration.ts +0 -16
  320. package/src/tui/ui/edge.ts +0 -186
  321. package/src/tui/ui/image-size.ts +0 -44
  322. package/src/tui/ui/ink.ts +0 -210
  323. package/src/tui/ui/items.ts +0 -42
  324. package/src/tui/ui/keys.ts +0 -40
  325. package/src/tui/ui/layout.ts +0 -74
  326. package/src/tui/ui/list-model.ts +0 -86
  327. package/src/tui/ui/list-view.ts +0 -172
  328. package/src/tui/ui/mascot.ts +0 -67
  329. package/src/tui/ui/messages.ts +0 -518
  330. package/src/tui/ui/motion.ts +0 -28
  331. package/src/tui/ui/paths.ts +0 -10
  332. package/src/tui/ui/pictures.ts +0 -108
  333. package/src/tui/ui/prompt-history.ts +0 -60
  334. package/src/tui/ui/screen.ts +0 -579
  335. package/src/tui/ui/status.ts +0 -403
  336. package/src/tui/ui/styled.ts +0 -85
  337. package/src/tui/ui/tool-card.ts +0 -150
  338. package/src/tui/width.ts +0 -567
  339. package/src/vendor/pi-rich/LICENSE.upstream +0 -21
  340. package/src/vendor/pi-rich/UPSTREAM.json +0 -17
  341. package/src/vendor/pi-rich/clipboard-command.ts +0 -44
  342. package/src/vendor/pi-rich/exif-orientation.ts +0 -182
  343. package/src/vendor/pi-rich/html.ts +0 -51
  344. package/src/vendor/pi-rich/image-convert.ts +0 -49
  345. package/src/vendor/pi-rich/image-resize-core.ts +0 -164
  346. package/src/vendor/pi-rich/image-resize-worker.ts +0 -42
  347. package/src/vendor/pi-rich/image-resize.ts +0 -123
  348. package/src/vendor/pi-rich/mime.ts +0 -116
  349. package/src/vendor/pi-rich/photon.ts +0 -139
  350. package/src/vendor/pi-rich/syntax-highlight.ts +0 -214
@@ -1,3108 +0,0 @@
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, ToolInvocationEvent, FailureCause } from "../core/events.ts";
22
- import { userInputData, submissionImages, type UserInput, type ImageRef } from "../core/images.ts";
23
- import { hydrateImages } from "./images.ts";
24
- import type { Handoff } from "../core/handoff.ts";
25
- import { boundHandoff, DEFAULT_HANDOFF_TOKENS } from "../core/history.ts";
26
- import { CURRENT_WORKING_SET_VERSION } from "../core/working-set.ts";
27
- import { boundaryPreparation, continuationGuidance } from "../core/continuation.ts";
28
- import type { FrozenPrefix, Message, ProjectInput } from "../core/project.ts";
29
- import { projectContext, toolCallIdentityViolations } from "../core/project.ts";
30
- import { parseSessionContext, type SessionContext } from "../core/session-format.ts";
31
- import { activeGoal, goalContract, goalDecision, idleGoalChecks, type ActiveGoal } from "../core/goal.ts";
32
- import { counterCheck } from "./counter-check.ts";
33
- import { runBlindChecks } from "./blind-check.ts";
34
- import { blindExposure, type BlindChecks } from "../core/blind-checks.ts";
35
- import { deriveHandoff, carryForward } from "../core/derive.ts";
36
- import { latestObservedFileVersion } from "../core/file-precondition.ts";
37
- import { latestProcessReceipt } from "../core/process-receipt.ts";
38
- import { advanceRecovery, emptyRecoveryState, recoveryReview, type RecoveryState } from "../core/failure-recovery.ts";
39
- import { shouldStopAndAsk, type BoundaryConfig } from "../core/boundary.ts";
40
- import {
41
- contextLimits, decideContext, DEFAULT_CONTEXT_POLICY, estimateBoundaryCost, isContextPolicy,
42
- type ContextLimits, type ContextPolicy,
43
- } from "../core/context-policy.ts";
44
- import { retainedContext, selectRecentContext } from "../core/context-window.ts";
45
- import { advanceStall, emptyStallState, stallDecision, DEFAULT_STALL_LIMIT, type StallState } from "../core/stall.ts";
46
- import { questionRequest, validateQuestionReply } from "../core/question.ts";
47
- import { DEFAULT_OUTPUT_RECOVERY_LIMIT, isCompleteResponse, incompleteToolText, knownUnexecutedTool, outputRecoveryNotice, validateOutputRecoveryLimit } from "../core/response-recovery.ts";
48
- import { join, relative, resolve } from "node:path";
49
- import { randomUUID } from "node:crypto";
50
- import { pendingToolCalls } from "../core/execution.ts";
51
- import { declaredToolNames, recordedDeclarations } from "../core/tool-discovery.ts";
52
- import { DISCOVERY_TOOL, DISCOVERY_TOOL_NAME, currentDeferredTools, discoveryFingerprint, discoveryPermitted, searchDeferredTools } from "./tool-discovery.ts";
53
- import { executionOutcome, modelOutcome, type ExecutionOutcome, type ExecutionReason } from "../core/outcome.ts";
54
- import { finalizationNotice, requestPhase, validateRequestBudget } from "../core/request-budget.ts";
55
- import { stopReport } from "../core/stop-report.ts";
56
- import { supportsToolDisabling, type ProviderSendOptions } from "./provider-tool-policy.ts";
57
- import { commandCompletion } from "../core/command-contract.ts";
58
- import { ExecutionControl } from "./execution-control.ts";
59
- import { BranchJobs } from "./branch-jobs.ts";
60
- import { HistoryCache } from "./history-cache.ts";
61
- import { RuntimeTargets } from "./runtime-target.ts";
62
- import { ToolBroker, MAX_NESTED_ARGUMENT_BYTES, MAX_INLINE_RESULT_DATA_BYTES, type BrokerReceipt, type BrokerExecution } from "./tool-broker.ts";
63
- import { ToolQueue } from "./tool-queue.ts";
64
- import { NATIVE_COMMAND_NAME, nativeCommandContext, nativeCommandResult, type NativeCommandInput, type NativeCommandResult } from "./tools.ts";
65
- import { isJsonValue, type JsonValue } from "../core/tool-contract.ts";
66
- import { archiveObservation } from "../store/observations.ts";
67
- import { disposeExecSessions, runningSessions } from "./exec-sessions.ts";
68
- import { branchStatus, processStatus, deriveProcesses, type ProcessObservation } from "../core/process-state.ts";
69
- import { schedulingWindow } from "../core/scheduling.ts";
70
- import { estimateTokens, anchoredCount, scaledCounter, type TokenCounter, type Anchor } from "../core/tokens.ts";
71
- import { isContextOverflow } from "../core/overflow.ts";
72
- import { requestCompletion } from "../core/completion-review.ts";
73
- import { currentRequestRecords, deriveRequestState, type RequestState } from "../core/request-state.ts";
74
- import { churnReview } from "../core/churn.ts";
75
- import { deriveBranchReceipt } from "../core/branch-receipt.ts";
76
- import { skillTools, type Skill } from "../core/skills.ts";
77
- import type { WorkItem } from "../core/work-state.ts";
78
- import { parseAcceptanceContract, type AcceptanceContract, type AcceptanceCriterion, type AcceptanceState } from "../core/acceptance.ts";
79
- import { deadlineNotice } from "../core/deadline.ts";
80
- import { describeFailure, failureCause, statusOf } from "./retry.ts";
81
- import { XENO_VERSION } from "./version.ts";
82
- import { SECRET_GUIDANCE, SecretKeeper } from "./secrets.ts";
83
- import { secretEnvironmentValues } from "../core/secrets.ts";
84
- import { copyVault } from "../store/secrets.ts";
85
- import { assertNever } from "../core/exhaustive.ts";
86
- import { isLogEvent } from "../core/event-validation.ts";
87
- import {
88
- initNode, appendEvent, readNode, closeNode, digestNode, handoffPath, nodePath, manifestPath,
89
- listNodes, readManifest, readCursor, readCursorAt, writeCursor,
90
- } from "../store/log.ts";
91
- import { replayContext, carriedHandoff } from "../store/replay.ts";
92
- import { recoverCut } from "../store/recovery.ts";
93
- import { runHistoryTool } from "./history-tools.ts";
94
- import { prepareWorkingNote } from "./working-notes.ts";
95
- import { copyAncestryTo } from "../store/sessions.ts";
96
- import { FailedResponseError, type Provider, type Turn, type StreamDelta } from "./provider.ts";
97
- import { putBlob, getBlob } from "../store/blobs.ts";
98
- import { hydrateProcessReceipts } from "../store/process-receipts.ts";
99
- import { readNodeHistory } from "../store/restore.ts";
100
- import { createWorktree, listWorktrees, mainWorktree, persistentWorktreeParent, removeWorktree } from "../store/worktree.ts";
101
- import {
102
- parseNote, matchGate, branchTools, finishToolResult, prepareNormalizedTool, presentObservation, toolDefinition, modelToolSchema, adviceTools,
103
- BRANCH_TOOL_NAMES, WRITING_BRANCH_TOOL_NAMES,
104
- type ToolContext, type Annotation, type ToolResult,
105
- } from "./tools.ts";
106
- import { diagnosePrefix, totalUsage, type UsageSample, type UsageTotals } from "./usage.ts";
107
- import { planResume } from "../core/resume.ts";
108
- import type { Phase } from "../core/queue.ts";
109
- import { sameModel } from "../core/session-position.ts";
110
- import type { Effort } from "./model.ts";
111
- import { providerReplayKey, nativeReplay } from "./model-replay.ts";
112
- import { needsReplayCheckpoint } from "../core/model-replay.ts";
113
- import { resolveHistoryPosition } from "../store/session-position.ts";
114
- import { observeCommand } from "../store/command.ts";
115
- import { headTailView } from "../core/observation-view.ts";
116
- import { withProgress } from "./progress.ts";
117
- import { WorkspaceLifecycle } from "./workspace-lifecycle.ts";
118
- import { changedPaths, invertFileChanges, sameVersion, type CheckpointEvent, type FileChanges } from "../core/checkpoints.ts";
119
- import type { GitCheckpoint } from "../store/git-checkpoints.ts";
120
- import { readEditableFile, preflightChanges, applyFileChanges } from "../store/file-checkpoints.ts";
121
- import { planCheckpointRestore } from "../store/checkpoint-history.ts";
122
- import { configDir } from "../store/config.ts";
123
-
124
- /** Ids and timestamps come from here so the loop stays the only impure part. */
125
- export interface Clock {
126
- now(): string;
127
- nextId(): string;
128
- }
129
-
130
- export function systemClock(): Clock {
131
- return {
132
- now: () => new Date().toISOString(),
133
- nextId: () => randomUUID(),
134
- };
135
- }
136
-
137
- /** Per-request options that are not session configuration. */
138
- export interface RequestOptions {
139
- /**
140
- * A read-only planning request (S.5 goal drafts). The Plan-mode tool gate
141
- * refuses anything that could change the workspace, the request runs at
142
- * most READ_ONLY_REQUEST_LIMIT model requests with the last one answering
143
- * without tools, and no goal or completion check applies. A draft written
144
- * without seeing the workspace guessed its commands, and a request with
145
- * tools disabled outright was answered with tool calls anyway
146
- * (fix-goal-read-only-draft).
147
- */
148
- readOnly?: boolean;
149
- }
150
-
151
- /**
152
- * Assumed, not measured: room to list the workspace and read a few files
153
- * before answering. The last request answers with tools disabled.
154
- */
155
- export const READ_ONLY_REQUEST_LIMIT = 8;
156
- const READ_ONLY_HINT = "this request only reads the workspace. Use read, find or grep, then answer.";
157
-
158
- export interface LoopEvent {
159
- kind:
160
- | "turn_start"
161
- | "assistant_text"
162
- | "thinking"
163
- | "text_delta"
164
- | "thinking_delta"
165
- | "discard"
166
- | "progress"
167
- | "tool_call"
168
- | "tool_result"
169
- | "usage"
170
- | "cut"
171
- | "stop_and_ask"
172
- | "branch_start"
173
- | "branch_done"
174
- | "done";
175
- text?: string;
176
- tool?: { name: string; input: unknown };
177
- ok?: boolean;
178
- usage?: UsageSample;
179
- prefix?: string;
180
- node?: number;
181
- reason?: string;
182
- /** branch_start: wall-clock milliseconds, for asserting the stagger. */
183
- at?: number;
184
- outcome?: ExecutionOutcome;
185
- /** done: credential-free cause of a failed request, recorded on its outcome. */
186
- failure?: FailureCause;
187
- /** Q23: task evidence is separate from the execution outcome; rebuilt from original receipts. */
188
- request?: RequestState;
189
- progress?: Extract<StreamDelta, { kind: "tool" | "retry" }> | { kind: "output"; text: string; name: string }
190
- | { kind: "waiting"; session: number; active: boolean };
191
- }
192
-
193
- /**
194
- * Ceilings on branching. Every one of them is a bound on an otherwise
195
- * unbounded resource, and the defaults are conservative because the measured
196
- * evidence says fan-out is usually a loss: 2.6x the tokens and about five
197
- * times the cost of the sequential path, while being slower.
198
- */
199
- export interface BranchLimits {
200
- /** How deep branching may nest. 2 means the parent may branch, a branch may not. */
201
- maxBranchDepth: number;
202
- /** Branches one assistant turn may open. */
203
- maxBranchesPerTurn: number;
204
- /** Branches a whole session may open. */
205
- maxBranchesPerSession: number;
206
- /**
207
- * Delay between starting sibling branches.
208
- *
209
- * Simultaneous cold spawns race the prompt cache: a sibling six seconds
210
- * behind has been measured writing its full 52k prefix with zero cache
211
- * read, because the first had not finished writing it.
212
- */
213
- branchStaggerMs: number;
214
- }
215
-
216
- export const DEFAULT_BRANCH_LIMITS: BranchLimits = {
217
- maxBranchDepth: 2,
218
- maxBranchesPerTurn: 3,
219
- maxBranchesPerSession: 10,
220
- branchStaggerMs: 1500,
221
- };
222
-
223
- export interface SessionConfig {
224
- /**
225
- * Where secret-named environment values are read from (S.9). Defaults to
226
- * process.env; a test supplies its own so it never depends on the machine.
227
- */
228
- secretEnvironment?: Readonly<Record<string, string | undefined>>;
229
- /** Runtime-only output contract, explicitly selected by the current caller. */
230
- validateOutput?: (text: string) => { ok: boolean; error?: string };
231
- /** Persist a selector, never persisted profile code or authorization (D6/D13). */
232
- agentProfile?: { name: string; workflowExplicit: boolean };
233
- /** An explicit mode command's capability default, independent of role authority. */
234
- workflowToolProfile?: "foundation";
235
- shellCheckpoints?: boolean;
236
- skills?: readonly Skill[];
237
- acceptanceContract?: AcceptanceContract | null;
238
- /** feat-blind-checks: hidden checks, frozen at session start; never part of any projection. */
239
- blindChecks?: BlindChecks;
240
- strictVerification?: boolean;
241
- /** O.10: opt-in carried view (source-priority, R9); evaluations compare it against D20's plain handoff. */
242
- workingSet?: boolean;
243
- logDir: string;
244
- frozen: FrozenPrefix;
245
- tools: ToolContext;
246
- boundary?: Partial<BoundaryConfig>;
247
- /** New sessions default to windowed replay; saved sessions keep their policy. */
248
- contextPolicy?: ContextPolicy;
249
- /** Complete tool exchanges without new evidence; zero disables this watchdog. */
250
- stallLimit?: number;
251
- /** Bounded appended continuations after output truncation; saved sessions keep this policy. */
252
- outputRecoveryLimit?: number;
253
- counter?: TokenCounter;
254
- /** Hard ceiling on turns, so a non-converging run cannot spend forever. */
255
- maxTurns: number;
256
- /** Experimental ablation only; normal sessions append durable working-state guidance. */
257
- guidance?: boolean;
258
- /**
259
- * One-time reconciliation notices: an unverified finish
260
- * (core/completion-review.ts) and a rut of repeated failures or edits
261
- * (core/churn.ts). On unless false. Benchmark runners turn them off so their
262
- * protocol matches the runs they are compared against; a runtime policy,
263
- * not persisted.
264
- */
265
- harnessReviews?: boolean;
266
- /**
267
- * Wall-clock budget per request, for runs judged against a deadline
268
- * (core/deadline.ts). Absent means no notice; nothing is ever stopped by it.
269
- */
270
- timeBudgetMs?: number;
271
- /** Replies before a missing next step is reminded (core/continuation.ts); absent means the core default. */
272
- continuationAfter?: number;
273
- branches?: Partial<BranchLimits>;
274
- /**
275
- * Provider for branches. A branch legitimately runs on its own model -- a
276
- * cheaper one is often right for read-only exploration -- so this is a
277
- * seam rather than a hook. Defaults to the parent's provider.
278
- */
279
- branchProvider?: () => Provider;
280
- /** Depth of this session in the branch tree. Zero for a top-level session. */
281
- branchDepth?: number;
282
- /** O.2: stable across segment cuts; branches get independent work identities. */
283
- workNamespace?: string;
284
- /** O.5: default 3 is a conservative runtime quota, not a context threshold. */
285
- maxParallelTools?: number;
286
- /**
287
- * Where blobs go. Snapshots are off when unset, which keeps the loop
288
- * usable without the store -- a read-only branch has nothing to snapshot.
289
- */
290
- blobDir?: string;
291
- imageOptions?: { readonly autoResize?: boolean; readonly blockImages?: boolean };
292
- }
293
-
294
- export interface SessionState {
295
- node: number;
296
- depth: number;
297
- handoff: Handoff | null;
298
- events: LogEvent[];
299
- usage: UsageSample[];
300
- /**
301
- * The last prompt size the provider actually charged for. Boundary decisions
302
- * anchor to it so only the delta since is estimated -- see core/tokens.ts.
303
- */
304
- anchor: Anchor | null;
305
- /** Branches opened so far, against maxBranchesPerSession. */
306
- branchesOpened: number;
307
- /** Next free node number. Branches take one each. */
308
- nextNode: number;
309
- /** The workspace as of the last snapshot, for diffing the next one. */
310
- /** Worktrees created for writing branches, by node. */
311
- worktrees: Map<number, { path: string; branch: string }>;
312
- }
313
-
314
- function selfRef(dir: string, node: number, sha: string): NodeRef {
315
- return { node, log_ref: nodePath(dir, node), sha256: sha };
316
- }
317
-
318
- /** Digest is I/O; core stays pure and the caller supplies the bytes. */
319
- async function digestedRef(dir: string, node: number): Promise<NodeRef> {
320
- return selfRef(dir, node, await digestNode(dir, node));
321
- }
322
-
323
- /**
324
- * One completed exchange that changed files -- somewhere to go back to.
325
- *
326
- * The unit is a whole exchange, not a snapshot. A snapshot lands mid-turn,
327
- * after the tool results and before the model's closing message, so forking
328
- * there would cut a turn in half and drop the model's own summary of what it
329
- * just did. Claude Code lists prompts for the same reason: the thing a
330
- * person means by "before that" is a message they sent, not a tool call.
331
- */
332
- export interface RestorePoint {
333
- node: number;
334
- /** The user message is the editable navigation target; idle snapshots have none. */
335
- userId?: string;
336
- /** Last event of the exchange. Inclusive: the exchange itself is kept. */
337
- atEvent: string;
338
- /** The prompt that opened the exchange, for a caller that has to show it. */
339
- prompt: string;
340
- /** How many paths the exchange added, changed or removed. */
341
- changed: number;
342
- /**
343
- * True when this exchange is in the context the session is currently in.
344
- *
345
- * False means a branch the session has left -- usually one it rewound away
346
- * from. Those are listed too: a node that survives on disk but cannot be
347
- * found again is undestroyed and unreachable, which is the same loss in a
348
- * different shape.
349
- */
350
- onPath: boolean;
351
- }
352
-
353
- export type RewindPreview =
354
- | { ok: true; willWrite: string[]; willDelete: string[] }
355
- | { ok: false; error: string };
356
-
357
- /**
358
- * Which of the two halves move.
359
- *
360
- * both the default. Context and disk stay in agreement.
361
- * files the workspace goes back, the conversation does not.
362
- * context the conversation goes back, the workspace does not.
363
- */
364
- export type RewindMode = "both" | "files" | "context";
365
-
366
- export type RewindResult =
367
- | { ok: true; node: number; restored: string[]; removed: string[]; unrecoverable: string[]; note: string }
368
- | { ok: false; error: string };
369
-
370
- /** O.5: child execution returns facts; only the parent coordinator accepts them. */
371
- type DeferredToolResult = ToolResult & { accept?: () => Promise<LoopEvent[]>; annotation?: LogEvent; finalText?: string };
372
- type ToolCall = Extract<AssistantBlock, { type: "tool_use" }>;
373
- type SessionBrokerReceipt = BrokerReceipt<DeferredToolResult> & { events: LoopEvent[] };
374
- type PreparedBrokerCall = { run(emit?: (event: LoopEvent) => void): Promise<SessionBrokerReceipt> };
375
- export type CommittedItem = { node: number; id: string; type: LogEvent["t"] };
376
-
377
- export class Session {
378
- private readonly execOwner = randomUUID();
379
- private readonly jobs = new BranchJobs<DeferredToolResult>();
380
- /**
381
- * Branches the user started (/review-code), owned apart from `jobs`: an Esc
382
- * on a parent turn cancels the model's branches, never these. Their joins
383
- * wait in `pendingJoins` until the parent is idle and pairable.
384
- */
385
- private readonly userJobs = new BranchJobs<Extract<LogEvent, { t: "joined" }>>();
386
- private readonly pendingJoins: { joined: LogEvent; note: string; usage: readonly UsageSample[] }[] = [];
387
- /** Spawned but not yet handed to `userJobs` (its prelude is still recording): already running to an observer. */
388
- private readonly startingUserBranches = new Set<number>();
389
- private readonly children = new Map<number, Session>();
390
- private cleanup: Promise<void> | null = null;
391
- private closing: Promise<void> | null = null;
392
- private turnTools: ToolContext | null = null;
393
- /** The running request is read-only (RequestOptions.readOnly); every tool call goes through the Plan gate. */
394
- private readOnlyRequest = false;
395
- /** The source snapshot taken when the current goal request began; a counter-check reverts to it. In memory: a request never outlives its process. */
396
- private goalBaseline: { request: string; snapshot: GitCheckpoint } | null = null;
397
- private readonly execution = new ExecutionControl();
398
- private readonly runtimeTargets = new RuntimeTargets();
399
- private rootTools: ToolQueue | undefined;
400
- private hostOwner: ReturnType<ExecutionControl["begin"]> | undefined;
401
- private readonly hostPending = new Set<Promise<NativeCommandResult>>();
402
- private stall: StallState | null = null;
403
- private recovery: RecoveryState | null = null;
404
- private processWake = new AbortController();
405
- /**
406
- * The exception the last provider request ended with, compared by identity
407
- * in send()'s catch. Wrapping the error instead would break every check that
408
- * reads it (FailedResponseError, statusOf, context overflow).
409
- */
410
- private providerFailure: { error: unknown; retries: number } | undefined;
411
- private pendingAdoption: number | null = null;
412
- /** A worktree enter/exit moves the root only once its tool round is paired, like adoption. */
413
- private pendingRoot: string | null = null;
414
- private readonly allocatedEventIds = new Set<string>();
415
- private recordTail: Promise<void> = Promise.resolve();
416
- private readonly recordListeners = new Set<(item: CommittedItem) => void>();
417
- private readonly branchRecordListeners = new Set<(item: CommittedItem) => void>();
418
- readonly config: SessionConfig;
419
- /**
420
- * Replaced by switchModel. Not readonly: a live session can change which
421
- * adapter it talks to, and the replacement has to be the same field every
422
- * send already reads.
423
- */
424
- provider: Provider;
425
- readonly clock: Clock;
426
- state: SessionState;
427
- private readonly allocation: { next: number };
428
- /** S.9: the vault behind placeholders. A branch is handed its parent's, like `allocation`. */
429
- private secrets: SecretKeeper;
430
-
431
- constructor(config: SessionConfig, provider: Provider, clock: Clock = systemClock(), allocation = { next: 1 }) {
432
- if (config.acceptanceContract) parseAcceptanceContract(config.acceptanceContract);
433
- if (config.contextPolicy !== undefined && !isContextPolicy(config.contextPolicy)) throw new Error("unknown context policy");
434
- stallDecision(emptyStallState(), config.stallLimit ?? DEFAULT_STALL_LIMIT);
435
- validateOutputRecoveryLimit(config.outputRecoveryLimit ?? DEFAULT_OUTPUT_RECOVERY_LIMIT);
436
- schedulingWindow([], 0, config.maxParallelTools ?? 3);
437
- validateRequestBudget(config.maxTurns, config.stallLimit ?? DEFAULT_STALL_LIMIT);
438
- this.allocation = allocation;
439
- this.secrets = new SecretKeeper(config.logDir, secretEnvironmentValues(config.secretEnvironment ?? process.env));
440
- this.config = { ...config, ...(config.acceptanceContract ? { acceptanceContract: parseAcceptanceContract(config.acceptanceContract) } : {}), tools: { ...config.tools, execOwner: this.execOwner, historyCache: new HistoryCache(),
441
- processWait: { wake: () => this.processWake.signal } } };
442
- // A child or restored Session must never inherit another live invocation scope.
443
- delete this.config.tools.invoke;
444
- delete this.config.tools.invokeHost;
445
- delete this.config.tools.runOwnedCommand;
446
- delete this.config.tools.drainNested;
447
- delete this.config.tools.origin;
448
- delete this.config.tools.parentCallId;
449
- delete this.config.tools.callId;
450
- // Branch capability sets are owned by branchTools; a parent's optional catalog is not a grant.
451
- if ((config.branchDepth ?? 0) > 0) delete this.config.tools.deferredTools;
452
- this.config.tools.outputStore ??= join(config.logDir, "artifacts");
453
- // Read at call time, so a branch that is handed its parent's keeper expands the parent's values.
454
- this.config.tools.expandSecrets = input => this.secrets.expand(input);
455
- this.config.tools.historyEvents = async () => {
456
- const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
457
- if (!history.ok) throw Error(history.error);
458
- return history.nodes.flatMap(node => node.node === this.state.node ? structuredClone(this.state.events) : node.events);
459
- };
460
- if (config.blobDir) this.config.tools.checkpointBlobs = config.blobDir;
461
- this.config.tools.frozenSystem = () => this.config.frozen.system;
462
- // R4 / D8: full ancestry, not the bounded model view or a second mutable ledger.
463
- // Each child Session installs its own callback even when contexts are copied.
464
- this.config.tools.observedFileVersion = async absolute => {
465
- const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
466
- if (!history.ok) throw new Error(history.error);
467
- return latestObservedFileVersion(history.nodes.flatMap(node => node.events), absolute, path => resolve(this.config.tools.root, path));
468
- };
469
- this.config.tools.observedProcess = async session => {
470
- const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
471
- if (!history.ok) throw new Error(history.error);
472
- return latestProcessReceipt(await hydrateProcessReceipts(history.nodes, this.config.tools.outputStore!), session);
473
- };
474
- this.provider = provider;
475
- this.clock = {
476
- now: () => clock.now(),
477
- nextId: () => {
478
- let id = clock.nextId();
479
- // Injected clocks may restart their sequence too. Event identity is a
480
- // session invariant, independent of the clock's implementation.
481
- if (this.allocatedEventIds.has(id)) id = randomUUID();
482
- this.allocatedEventIds.add(id);
483
- return id;
484
- },
485
- };
486
- this.state = {
487
- node: 0, depth: 1, handoff: null, events: [], usage: [],
488
- anchor: null, branchesOpened: 0,
489
- get nextNode() { return allocation.next; },
490
- set nextNode(value: number) { allocation.next = value; },
491
- worktrees: new Map(),
492
- };
493
- this.installWorkspace();
494
- }
495
-
496
- private installWorkspace(): void {
497
- delete this.config.tools.workspace;
498
- this.config.tools.ownedStatePaths = [this.config.logDir, ...(this.config.blobDir ? [this.config.blobDir] : [])];
499
- if (this.config.blobDir) this.config.tools.workspace = new WorkspaceLifecycle(
500
- this.config.tools.root, this.config.blobDir, this.config.shellCheckpoints === true,
501
- event => this.record({ ...event, id: this.clock.nextId(), at: this.clock.now() }),
502
- join(this.config.logDir, "checkpoints"),
503
- this.config.tools.ownedStatePaths,
504
- );
505
- }
506
-
507
- private async observeEvidence(): Promise<void> {
508
- let proof = this.state.events.findLast(event => event.t === "tool_result" && event.effect?.kind === "command");
509
- let prior = proof?.t === "tool_result" && proof.effect?.kind === "command" ? proof.effect.workspace_sha256 :
510
- this.state.handoff?.state.completed.at(-1)?.evidence.workspace_sha256;
511
- // A short task need not create a completed note. Its last command still
512
- // establishes the comparison version when the current segment is empty.
513
- if (!proof && this.config.tools.workspace) {
514
- const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
515
- if (!history.ok) throw new Error(history.error);
516
- proof = history.nodes.flatMap(part => part.events).findLast(event => event.t === "tool_result" && event.effect?.kind === "command");
517
- if (proof?.t === "tool_result" && proof.effect?.kind === "command") prior = proof.effect.workspace_sha256;
518
- }
519
- if (proof || prior || this.config.acceptanceContract) await this.config.tools.workspace?.observe(prior);
520
- }
521
-
522
- /**
523
- * Opens the session: a new one, or the one this log already holds.
524
- *
525
- * Resuming restores the state a running session would have had, from the
526
- * same ancestry walk the projection itself uses -- so what the model sees
527
- * after a restart is what it would have seen had the process never exited.
528
- * That equality is the whole contract; anything less makes a resumed
529
- * session quietly different from a continuous one.
530
- */
531
- async start(): Promise<void> {
532
- this.resetDerivedToolState();
533
- const dir = this.config.logDir;
534
- await this.secrets.load();
535
- const model = this.provider.config.spec.id;
536
- const existing = await listNodes(dir);
537
- const recordedCursor = await readCursor(dir);
538
- const cursor = recordedCursor === null ? null : await recoverCut(dir, recordedCursor);
539
- const plan = planResume(cursor, model, existing.length > 0, this.provider.config.spec.provider);
540
-
541
- switch (plan.kind) {
542
- case "refuse":
543
- throw new Error(plan.reason);
544
-
545
- case "fresh":
546
- this.config.contextPolicy ??= DEFAULT_CONTEXT_POLICY;
547
- this.config.stallLimit ??= DEFAULT_STALL_LIMIT;
548
- this.config.outputRecoveryLimit ??= DEFAULT_OUTPUT_RECOVERY_LIMIT;
549
- await initNode(dir, 0, null, this.clock.now());
550
- break;
551
-
552
- case "resume": {
553
- const replayed = await replayContext(dir, plan.cursor.node);
554
- if (!plan.cursor.context_ref) throw new Error("missing frozen session context");
555
- const saved = await getBlob(join(dir, "artifacts"), plan.cursor.context_ref);
556
- if (saved === null) throw new Error("missing frozen session context");
557
- // S.3: one format, parsed once. Every field below was written by a
558
- // session of this format, so it is restored as recorded, not defaulted.
559
- const context = parseSessionContext(JSON.parse(saved));
560
- if (context.agentProfile) this.config.agentProfile = { ...context.agentProfile };
561
- else delete this.config.agentProfile;
562
- if (context.workflowMode) this.config.tools.workflowMode = context.workflowMode;
563
- if (context.workflowToolProfile) this.config.workflowToolProfile = context.workflowToolProfile;
564
- this.config.workingSet = context.workingSet;
565
- this.config.shellCheckpoints = context.shellCheckpoints;
566
- this.config.acceptanceContract = context.acceptanceContract ?? null;
567
- this.config.strictVerification = context.strictVerification;
568
- this.config.skills = context.skills;
569
- this.installSkillResources();
570
- this.config.workNamespace = context.workNamespace;
571
- this.config.maxParallelTools = context.maxParallelTools;
572
- this.config.stallLimit = context.stallLimit;
573
- this.config.outputRecoveryLimit = context.outputRecoveryLimit;
574
- this.config.frozen = context.frozen;
575
- this.config.tools.gates = context.gates;
576
- this.config.contextPolicy = context.contextPolicy;
577
- if (context.boundary !== undefined) this.config.boundary = context.boundary;
578
- else delete this.config.boundary;
579
- if (context.guidance !== undefined) this.config.guidance = context.guidance;
580
- else delete this.config.guidance;
581
- if (context.blindChecks) this.config.blindChecks = context.blindChecks;
582
- else delete this.config.blindChecks;
583
- if (plan.cursor.workspace !== undefined) this.config.tools.root = plan.cursor.workspace;
584
- if (plan.cursor.blob_dir !== undefined) this.config.blobDir = plan.cursor.blob_dir;
585
- for (const id of existing) {
586
- for (const event of (await readNode(dir, id)).events) {
587
- this.allocatedEventIds.add(event.id);
588
- if (event.t === "spawn") {
589
- this.state.branchesOpened++;
590
- if (event.workspace) this.state.worktrees.set(event.branch.node, event.workspace);
591
- }
592
- if (event.t === "discard") this.state.worktrees.delete(event.branch.node);
593
- }
594
- }
595
-
596
- this.state.node = plan.cursor.node;
597
- // From the allocator's recorded state, never from a node count: dead
598
- // limbs and resolved branches hold ids, and recomputing this is the
599
- // shape of the bug that once had a cut overwrite a branch's log.
600
- // A crashed branch can reserve a node before the parent updates its
601
- // allocator. Recovery only raises the recorded high-water mark.
602
- this.state.nextNode = Math.max(plan.cursor.next_node, ...existing.map((n) => n + 1));
603
- this.state.depth = replayed.depth;
604
- this.state.events = replayed.contextEvents;
605
- this.state.handoff = replayed.handoff;
606
- await this.answerUnpairedTools("interrupted: tool outcome is unknown after restart; inspect the workspace or external operation before retrying");
607
- // Nothing has been sent this process, so there is no billed prompt
608
- // size to anchor to yet; the first response re-establishes it.
609
- this.state.anchor = null;
610
- // Compare disk against the durable file projection. Treating current
611
- // disk as the baseline would erase unlogged effects of a crashed tool.
612
- const pending = (await readNode(dir, plan.cursor.node)).events.findLast((e) => e.t === "adopt" && !e.applied);
613
- if (pending?.t === "adopt") {
614
- this.pendingAdoption = pending.branch.node;
615
- await this.adoptPendingBranch();
616
- }
617
- break;
618
- }
619
-
620
- default:
621
- return assertNever(plan, "Session.start");
622
- }
623
-
624
- this.installWorkspace();
625
- await this.recoverFileOperations();
626
- this.restoreDeclaredTools();
627
- // Consume the original pending receipt before another checkpoint can replace this projection.
628
- const declarationCut = await this.applyDiscoveredTools();
629
- await this.refreshRuntimeTargets();
630
- if (!declarationCut && (cursor?.needs_model_checkpoint || needsReplayCheckpoint(this.state.events, providerReplayKey(this.provider.config.spec), nativeReplay(this.provider.config.spec)))) {
631
- await this.performCut("model_checkpoint", model);
632
- }
633
- await this.saveCursor();
634
- }
635
-
636
- /**
637
- * Copies this session's current path into `destLogDir`, optionally
638
- * stopping at `untilEvent` (inclusive). The source files are only read:
639
- * a fork that rewrote the original would leave the user with one line
640
- * when they thought they had two.
641
- */
642
- async fork(destLogDir: string, untilEvent?: string, position: "at" | "before" = "at"): Promise<void> {
643
- await copyAncestryTo(this.config.logDir, destLogDir, this.clock.now(), untilEvent, position);
644
- await copyVault(this.config.logDir, destLogDir);
645
- if (untilEvent !== undefined) {
646
- const cursor = await readCursor(destLogDir);
647
- if (!cursor) throw new Error("missing fork cursor");
648
- const tip = (await readNode(this.config.logDir, this.state.node)).events.at(-1);
649
- const filesFrom = { ...await digestedRef(this.config.logDir, this.state.node), ...(tip ? { at_event: tip.id } : {}) };
650
- const header = (await readNode(destLogDir, cursor.node)).header;
651
- const event: LogEvent = { t: "rewind", id: this.clock.nextId(), at: this.clock.now(), mode: "context", from: header?.parent ?? filesFrom,
652
- files_from: filesFrom, prompt: "Forked conversation", restored: [], removed: [], unrecoverable: [], divergence: [] };
653
- await appendEvent(destLogDir, cursor.node, event);
654
- await writeCursor(destLogDir, { ...cursor, at_event: event.id, updated_at: event.at });
655
- }
656
- }
657
-
658
- async setThinking(effort: Effort): Promise<void> {
659
- this.provider.config.effort = effort;
660
- await this.saveCursor();
661
- }
662
-
663
- /** Trusted extension commands share the Session owner without inventing a user turn. */
664
- runExtensionCommand(command: string, args: string[], options?: { cwd?: string; timeout?: number; signal?: AbortSignal }): Promise<NativeCommandResult> {
665
- if (this.cleanup || this.closing) return Promise.reject(new Error("session is draining or disposed"));
666
- const input: NativeCommandInput = { argv: [command, ...args], ...(options?.cwd !== undefined ? { cwd: options.cwd } : {}),
667
- ...(options?.timeout !== undefined ? { timeout: options.timeout } : {}) };
668
- if (!this.execution.running) this.hostOwner = this.execution.begin();
669
- const signals = [this.execution.signal, options?.signal].filter((signal): signal is AbortSignal => signal !== undefined);
670
- const signal = AbortSignal.any(signals);
671
- const result = (async () => {
672
- const call: ToolCall = { type: "tool_use", call_id: `host-${randomUUID()}`, name: NATIVE_COMMAND_NAME, input };
673
- const preparation = this.prepareBrokerCall(call, () => 0, false, signal, true);
674
- let prepared: PreparedBrokerCall;
675
- for (;;) { const step = await preparation.next(); if (step.done) { prepared = step.value; break; } }
676
- const receipt = await prepared.run();
677
- await receipt.commit();
678
- await this.saveCursor();
679
- return nativeCommandResult(receipt.value, this.config.tools.outputStore!);
680
- })();
681
- this.hostPending.add(result);
682
- const finished = () => {
683
- this.hostPending.delete(result);
684
- if (this.hostPending.size === 0 && this.hostOwner) { this.hostOwner.finish(); this.hostOwner = undefined; }
685
- };
686
- void result.then(finished, finished);
687
- return result;
688
- }
689
-
690
- async runUserBash(command: string, excludeFromContext: boolean, signal?: AbortSignal,
691
- onOutput?: (chunk: Uint8Array) => void): Promise<Extract<LogEvent, { t: "user_bash" }>> {
692
- if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
693
- const owned = this.execution.begin(signal);
694
- let release: (() => void) | undefined;
695
- try {
696
- this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
697
- release = await this.rootTools.acquire("exclusive");
698
- if (!command.trim()) throw new Error("empty user command");
699
- const execute = async (): Promise<ToolResult> => {
700
- const captured = await observeCommand({ command, cwd: this.config.tools.root,
701
- outputStore: join(this.config.logDir, "artifacts"), maxOutputBytes: this.config.tools.maxOutputBytes,
702
- timeoutMs: this.config.tools.timeoutMs, signal: owned.signal, ...(onOutput ? { onOutput } : {}) });
703
- const completion = commandCompletion(captured.exit, captured.stopped, captured.cleanup);
704
- const preview = headTailView(captured.head, captured.tail, captured.ref.bytes, this.config.tools.maxOutputBytes,
705
- omitted => `\n[${omitted} bytes omitted; complete output: ${captured.ref.path}]\n`);
706
- return { ...completion, content: `${preview}\nexit ${captured.exit} · ${completion.outcome.status} · ${completion.outcome.reason}` +
707
- (captured.cleanup ? `\n${captured.cleanup.message}` : ""),
708
- content_ref: captured.ref, effect: { kind: "command", cmd: command, exit: captured.exit } };
709
- };
710
- const result = this.config.tools.workspace ? await this.config.tools.workspace.command("bash", { command }, execute, owned.signal) : await execute();
711
- if (result.effect?.kind !== "command" || !result.content_ref || !result.outcome) throw new Error("missing user command observation");
712
- const event: Extract<LogEvent, { t: "user_bash" }> = { t: "user_bash", id: this.clock.nextId(), at: this.clock.now(),
713
- command, content: result.content, exit: result.effect.exit, excludeFromContext, outcome: result.outcome, content_ref: result.content_ref };
714
- await this.record(event);
715
- await this.saveCursor();
716
- return event;
717
- } catch (error) {
718
- await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
719
- reason: "user command failed before completion; inspect the workspace before retrying", outcome: executionOutcome("execution_error") });
720
- throw error;
721
- } finally {
722
- release?.();
723
- while (this.hostPending.size > 0) await Promise.allSettled(this.hostPending);
724
- owned.finish();
725
- }
726
- }
727
-
728
- async userMessages(): Promise<{ id: string; node: number; text: string; images?: readonly ImageRef[] }[]> {
729
- const messages: Awaited<ReturnType<Session["userMessages"]>> = [];
730
- for (const node of await listNodes(this.config.logDir)) {
731
- for (const event of (await readNode(this.config.logDir, node)).events) {
732
- if (event.t === "user") messages.push({ id: event.id, node, ...userInputData(event) });
733
- }
734
- }
735
- return messages;
736
- }
737
-
738
- /** Pi keeps the selected runtime model when navigating; N.8 checkpoints incompatible context. */
739
- async navigateTo(eventId: string): Promise<{ editorText?: string; images?: readonly ImageRef[] }> {
740
- const dir = this.config.logDir;
741
- const users = await this.userMessages();
742
- const target = await resolveHistoryPosition(dir, eventId, users.some(user => user.id === eventId) ? "before" : "at");
743
- const previous = (await readNode(dir, this.state.node)).events.at(-1);
744
- const filesFrom = { ...await digestedRef(dir, this.state.node), ...(previous ? { at_event: previous.id } : {}) };
745
- const node = this.state.nextNode++;
746
- await initNode(dir, node, target.parent, this.clock.now(), target.basis);
747
- await appendEvent(dir, node, {
748
- t: "rewind", id: this.clock.nextId(), at: this.clock.now(), mode: "context", from: target.parent ?? filesFrom,
749
- files_from: filesFrom, prompt: target.editorText ?? "Selected conversation position", restored: [], removed: [], unrecoverable: [], divergence: [],
750
- });
751
- const cursor = await readCursor(dir);
752
- if (!cursor) throw new Error("missing current session cursor");
753
- const next = { ...cursor, node, next_node: this.state.nextNode, updated_at: this.clock.now(),
754
- needs_model_checkpoint: target.parent !== null && (target.selection === null || !sameModel(target.selection, cursor)) };
755
- delete next.at_event;
756
- await writeCursor(dir, next);
757
- await this.start();
758
- const images = users.find(user => user.id === eventId)?.images;
759
- return target.editorText === undefined ? {} : { editorText: target.editorText, ...(images?.length ? { images } : {}) };
760
- }
761
-
762
- /**
763
- * Cuts the current node, then runs on a different provider.
764
- *
765
- * The cut is the whole point. Thinking blocks belong to the model that
766
- * produced them, and every provider this harness talks to requires them
767
- * replayed unchanged. Handing Anthropic's signed block to DeepSeek -- or
768
- * even to another Anthropic model -- is a bad request. Filtering the
769
- * blocks out of the projection would make what the model sees depend on
770
- * a "we switched" flag that is not in the log. Cutting makes the switch
771
- * a fact in the log: the new node starts from a handoff, which is plain
772
- * YAML, and inherits none of the old blocks.
773
- *
774
- * Bypasses minNodeWorkTokens. That floor is a cost argument (a handoff
775
- * only pays for itself once the node's own history exceeds it). A switch
776
- * is a correctness argument, and must be allowed after two turns.
777
- *
778
- * Only legal between turns. A cut between tool_use and tool_result would
779
- * leave the log unpairable (D5).
780
- *
781
- * Recorded as model_checkpoint, not trigger. "trigger" means this node
782
- * reached the token threshold. A switch is not that -- it can happen on
783
- * turn two -- and a later reader reconstructing the session from the log
784
- * must see that a person changed models, not that the node filled up.
785
- * boundary.ts already produces this reason when checkpointRequested is
786
- * set; switchModel is the other caller that wants it.
787
- *
788
- * An empty projection is the exception (fix-switch-model-empty-segment).
789
- * With no events there is no old model's block to keep away from the new
790
- * one, and the frozen prefix does not change here, so the cursor alone
791
- * records the switch. Cutting anyway sealed an empty segment and opened
792
- * the next from an empty handoff, which the model read as earlier work to
793
- * resume. The check is the projection, not the node file: an inherit-basis
794
- * node can be empty on disk while still projecting ancestor thinking.
795
- *
796
- * Returns whether a segment was cut, so the caller can say so truthfully.
797
- */
798
- async switchModel(next: Provider): Promise<boolean> {
799
- if (this.running || this.isHalfRound()) {
800
- throw new Error(
801
- "cannot switch models mid-turn: a tool_use is still unpaired. " +
802
- "Wait for the turn to finish so every call has a result",
803
- );
804
- }
805
-
806
- const cut = this.state.events.length > 0;
807
- if (cut) await this.performCut("model_checkpoint", next.config.spec.id, undefined, next.config.spec.provider, next.config.effort);
808
- else this.state.anchor = null;
809
- this.provider = next;
810
- await this.saveCursor();
811
- return cut;
812
- }
813
-
814
- /** D6: changing the frozen prefix is an explicit checkpoint, never in-place prompt surgery. */
815
- assertResourceIdle(): void {
816
- if (this.running || this.isHalfRound()) throw new Error("cannot reload resources while a turn is active or a tool call is unpaired");
817
- }
818
-
819
- async reloadResources(frozen: FrozenPrefix, skills = this.config.skills, nextProvider?: Provider, forceCheckpoint = false): Promise<boolean> {
820
- if (!nextProvider && !forceCheckpoint && JSON.stringify(frozen) === JSON.stringify(this.config.frozen) && JSON.stringify(skills) === JSON.stringify(this.config.skills)) return false;
821
- this.assertResourceIdle();
822
- const contextRef = await this.persistContext(frozen, skills);
823
- const provider = nextProvider ?? this.provider;
824
- await this.performCut("model_checkpoint", provider.config.spec.id, undefined,
825
- provider.config.spec.provider, provider.config.effort, contextRef);
826
- this.provider = provider;
827
- this.config.frozen = frozen;
828
- if (skills !== undefined) this.config.skills = skills;
829
- this.installSkillResources();
830
- this.config.tools.allow = frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" ? [tool.name] : []);
831
- await this.saveCursor();
832
- return true;
833
- }
834
-
835
- /** Restoring a declaration never installs code: only the current selected local catalog can supply it. */
836
- private restoreDeclaredTools(): void {
837
- const declared = new Set(declaredToolNames(this.config.frozen.tools));
838
- const loaded = currentDeferredTools(this.config.tools).filter(tool => declared.has(tool.name)).map(tool => tool.name);
839
- if (loaded.length && this.config.tools.allow !== undefined) this.config.tools.allow = [...new Set([...this.config.tools.allow, ...loaded])];
840
- }
841
-
842
- /** D5/D6: consume original discovery receipts only after every invocation has its result. */
843
- private async applyDiscoveredTools(): Promise<boolean> {
844
- if (this.isHalfRound() || (this.config.branchDepth ?? 0) > 0 || this.config.tools.extensions?.get(DISCOVERY_TOOL_NAME) !== DISCOVERY_TOOL) return false;
845
- const declared = new Set(declaredToolNames(this.config.frozen.tools));
846
- const requested = new Map(recordedDeclarations(this.state.events).map(item => [item.name, item.fingerprint]));
847
- const additions = currentDeferredTools(this.config.tools).filter(tool => !declared.has(tool.name) &&
848
- requested.get(tool.name) === discoveryFingerprint(tool) && discoveryPermitted(this.config.tools, tool.name));
849
- if (additions.length === 0) return false;
850
- const frozen = { ...this.config.frozen, tools: [...this.config.frozen.tools, ...additions] };
851
- const contextRef = await this.persistContext(frozen);
852
- await this.performCut("model_checkpoint", this.provider.config.spec.id, undefined,
853
- this.provider.config.spec.provider, this.provider.config.effort, contextRef);
854
- this.config.frozen = frozen;
855
- this.restoreDeclaredTools();
856
- // send() restores its base tool scope at the end; keep accepted declarations there too.
857
- if (this.turnTools?.allow !== undefined) this.turnTools.allow = [...new Set([...this.turnTools.allow, ...additions.map(tool => tool.name)])];
858
- await this.saveCursor();
859
- return true;
860
- }
861
-
862
- /**
863
- * The same pairing check switchModel already used. A user message
864
- * between tool_use and tool_result is the same unpairable request a
865
- * model switch would produce — so the queue reuses this, it does not
866
- * invent a second half-round test.
867
- */
868
- private isHalfRound(): boolean {
869
- const projected = projectContext({
870
- frozen: this.config.frozen,
871
- handoff: this.state.handoff,
872
- events: this.state.events,
873
- });
874
- return projected.violations.some((v) => v.code === "UNPAIRED_TOOL_USE");
875
- }
876
-
877
- /**
878
- * Offers after_tools only when the log is pairable. The UI still owns
879
- * the queue; we just ask. A half-round returns null even if the UI
880
- * offered a line — writing it would be the pairing bug this guards.
881
- */
882
- private async pullSteering(
883
- pull?: (phase: Phase) => Promise<UserInput | null>,
884
- ): Promise<UserInput | null> {
885
- if (pull === undefined || this.isHalfRound()) return null;
886
- // Rotate before pulling: an input queued during the async pull still wakes
887
- // the next wait. A wake never cancels the process's lifetime signal.
888
- this.processWake = new AbortController();
889
- const text = await pull("after_tools");
890
- if (text === null || typeof text === "string" && text.length === 0) return null;
891
- if (this.turnTools) this.config.tools = { ...this.turnTools };
892
- return text;
893
- }
894
-
895
- /**
896
- * Records where the session is, after every move.
897
- *
898
- * Written at each move rather than at exit, because the failures worth
899
- * resuming from -- a crash, a kill, a closed laptop -- do not run exit
900
- * handlers.
901
- */
902
- private async persistContext(frozen = this.config.frozen, skills = this.config.skills): Promise<string> {
903
- const context: SessionContext = {
904
- frozen, gates: this.config.tools.gates,
905
- shellCheckpoints: this.config.shellCheckpoints === true,
906
- skills: skills ?? [],
907
- strictVerification: this.config.strictVerification === true,
908
- workingSet: this.config.workingSet === true,
909
- workNamespace: this.config.workNamespace ?? "main",
910
- maxParallelTools: this.config.maxParallelTools ?? 3,
911
- contextPolicy: this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
912
- stallLimit: this.config.stallLimit ?? DEFAULT_STALL_LIMIT,
913
- outputRecoveryLimit: this.config.outputRecoveryLimit ?? DEFAULT_OUTPUT_RECOVERY_LIMIT,
914
- ...(this.config.acceptanceContract ? { acceptanceContract: this.config.acceptanceContract } : {}),
915
- ...(this.config.tools.workflowMode ? { workflowMode: this.config.tools.workflowMode } : {}),
916
- ...(this.config.agentProfile ? { agentProfile: this.config.agentProfile } : {}),
917
- ...(this.config.workflowToolProfile ? { workflowToolProfile: this.config.workflowToolProfile } : {}),
918
- ...(this.config.boundary ? { boundary: this.config.boundary } : {}),
919
- ...(this.config.guidance === undefined ? {} : { guidance: this.config.guidance }),
920
- ...(this.config.blindChecks ? { blindChecks: this.config.blindChecks } : {}),
921
- };
922
- return putBlob(join(this.config.logDir, "artifacts"), JSON.stringify(context));
923
- }
924
-
925
- private async saveCursor(model = this.provider.config.spec.id, provider = this.provider.config.spec.provider,
926
- thinking = this.provider.config.effort, contextRef?: string): Promise<void> {
927
- // Branches share storage but never own the parent's active cursor.
928
- if ((this.config.branchDepth ?? 0) > 0) return;
929
- const last = (await readNode(this.config.logDir, this.state.node)).events.at(-1);
930
- await writeCursor(this.config.logDir, {
931
- node: this.state.node,
932
- next_node: this.state.nextNode,
933
- model,
934
- provider,
935
- thinking,
936
- ...(last ? { at_event: last.id } : {}),
937
- ...(this.config.blobDir === undefined ? {} : { blob_dir: this.config.blobDir }),
938
- updated_at: this.clock.now(),
939
- workspace: this.config.tools.root,
940
- context_ref: contextRef ?? await this.persistContext(),
941
- xeno_version: XENO_VERSION,
942
- });
943
- }
944
-
945
- /** Q24: publish identities only after durable append, including non-model user actions. */
946
- /** Commits inside this session's branches (their node numbers), for a client showing one live. */
947
- subscribeBranchRecords(listener: (item: CommittedItem) => void): () => void {
948
- this.branchRecordListeners.add(listener); return () => { this.branchRecordListeners.delete(listener); };
949
- }
950
- subscribeRecords(listener: (item: CommittedItem) => void): () => void {
951
- this.recordListeners.add(listener); return () => { this.recordListeners.delete(listener); };
952
- }
953
-
954
- private async record(event: LogEvent): Promise<void> {
955
- // D8: runtime callers may retain mutable results. The in-memory projection
956
- // must own the same JSON value that was appended, independent of later edits.
957
- event = JSON.parse(JSON.stringify(event)) as LogEvent;
958
- // S.9: credentials become placeholders before anything is written, so no
959
- // projection, handoff, export or history page can carry the value.
960
- const redacted = await this.secrets.redact(event, this.lastAssistantText());
961
- event = redacted.event;
962
- const saved = this.recordTail.then(async () => {
963
- await appendEvent(this.config.logDir, this.state.node, event);
964
- this.state.events.push(event);
965
- if (this.stall !== null) this.stall = advanceStall(this.stall, [{ node: this.state.node, event }]);
966
- if (this.recovery !== null) this.recovery = advanceRecovery(this.recovery, [event], path => resolve(this.config.tools.root, path));
967
- for (const listener of this.recordListeners) {
968
- try { listener({ node: this.state.node, id: event.id, type: event.t }); } catch { /* observers never own the append */ }
969
- }
970
- });
971
- this.recordTail = saved.catch(() => {});
972
- await saved;
973
- if (redacted.added > 0 && !this.state.events.some(item => item.t === "guidance" && item.reason === "secret")) {
974
- await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "secret", text: SECRET_GUIDANCE, sources: [event.id] });
975
- }
976
- if (event.t === "tool_result" && event.ok && event.effect?.kind === "read") {
977
- const path = resolve(this.config.tools.root, event.effect.path);
978
- const skill = this.config.skills?.find(item => item.filePath === path);
979
- if (skill && !skill.disableModelInvocation) {
980
- await this.recordSkill(skill, "read", "model");
981
- this.config.tools.allow = skillTools(this.allowedNames(), skill);
982
- // Restricted skill scopes cannot grow their capability set by searching a parent directory.
983
- if (skill.allowedTools !== undefined || skill.disallowedTools !== undefined) this.config.tools.deferredTools = [];
984
- }
985
- }
986
- }
987
-
988
- /** The text the user is answering, for recognising a solicited credential. */
989
- private lastAssistantText(): string | undefined {
990
- for (let i = this.state.events.length - 1; i >= 0; i--) {
991
- const event = this.state.events[i]!;
992
- if (event.t === "user") return undefined;
993
- if (event.t === "assistant") {
994
- const text = event.blocks.flatMap(block => block.type === "text" ? [block.text] : []).join("\n");
995
- if (text.trim()) return text;
996
- }
997
- }
998
- return undefined;
999
- }
1000
-
1001
- private allowedNames(): string[] {
1002
- return this.config.frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" &&
1003
- (this.config.tools.allow === undefined || this.config.tools.allow.includes(tool.name)) ? [tool.name] : []);
1004
- }
1005
-
1006
- private adviceNames(frozen = this.config.frozen): string[] {
1007
- const declared = frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" ? [tool.name] : []);
1008
- return adviceTools({ ...this.config.tools, advice: declared, ...(frozen === this.config.frozen ? {} : { allow: declared }) });
1009
- }
1010
-
1011
- private installSkillResources(): void {
1012
- this.config.tools.skillResources = (this.config.skills ?? []).map(skill => ({ path: skill.filePath,
1013
- text: skill.sourceText ?? skill.body, modelInvocable: !skill.disableModelInvocation && skill.context !== "fork" }));
1014
- }
1015
-
1016
- private async recordSkill(skill: Skill, mode: "inline" | "fork" | "read", origin: "user" | "model"): Promise<void> {
1017
- const artifact = await putBlob(join(this.config.logDir, "artifacts"), skill.sourceText ?? skill.body);
1018
- await this.record({ t: "skill_loaded", id: this.clock.nextId(), at: this.clock.now(), name: skill.name, path: skill.filePath,
1019
- sha256: artifact, artifact, mode, origin });
1020
- }
1021
-
1022
- /** O.12: main instructions are durable; permissions apply to this invocation only. */
1023
- async *sendSkill(skill: Skill, prompt: UserInput, signal?: AbortSignal): AsyncGenerator<LoopEvent> {
1024
- if (this.running || this.cleanup || this.closing || this.isHalfRound()) throw new Error("a skill requires an idle paired session");
1025
- skill = this.config.skills?.find(item => item.name === skill.name && item.filePath === skill.filePath) ?? skill;
1026
- if (skill.userInvocable === false) throw new Error("this skill cannot be invoked by users");
1027
- const savedTools = this.config.tools;
1028
- this.config.tools = { ...savedTools, allow: skillTools(this.allowedNames(), skill) };
1029
- if (skill.allowedTools !== undefined || skill.disallowedTools !== undefined) this.config.tools.deferredTools = [];
1030
- try {
1031
- await this.recordSkill(skill, skill.context === "fork" ? "fork" : "inline", "user");
1032
- if (skill.context !== "fork") { yield* this.send(prompt, signal); return; }
1033
- const owned = this.execution.begin(signal);
1034
- try {
1035
- await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), text: `Run isolated skill ${skill.name}; source ${skill.filePath}.` });
1036
- const writes = skill.allowedTools?.some(name => this.allowedNames().includes(name) &&
1037
- WRITING_BRANCH_TOOL_NAMES.includes(name) && !BRANCH_TOOL_NAMES.includes(name)) ?? false;
1038
- const result = yield* this.runBranch({ purpose: `skill ${skill.name}`, prompt: userInputData(prompt).text, writes }, 0, owned.signal, skill);
1039
- await result.accept?.();
1040
- await this.record({ t: "extension", id: this.clock.nextId(), at: this.clock.now(), customType: "skill_result", content: result.content,
1041
- display: true, inContext: true });
1042
- // Evidence stays in the receipt; structured output validates the child's
1043
- // actual final message, never the human-readable receipt wrapper (D11).
1044
- const finalText = this.config.validateOutput ? result.finalText ?? "" : result.content;
1045
- let outcome = result.outcome ?? executionOutcome("execution_error"), reason = "isolated skill finished";
1046
- if (outcome.status === "completed") {
1047
- const error = await this.validateFinalOutput(finalText);
1048
- if (error !== undefined) { outcome = executionOutcome("execution_error"); reason = error; }
1049
- }
1050
- await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), reason, outcome });
1051
- await this.saveCursor();
1052
- yield { kind: "assistant_text", text: finalText };
1053
- yield { kind: "done", outcome, reason };
1054
- } finally { owned.finish(); }
1055
- } finally { this.config.tools = savedTools; this.restoreDeclaredTools(); }
1056
- }
1057
-
1058
- /** Every final publication path records the same validator receipt (D11). */
1059
- private async validateFinalOutput(text: string): Promise<string | undefined> {
1060
- if (!this.config.validateOutput) return undefined;
1061
- const validation = this.config.validateOutput(text);
1062
- await this.appendExtension({ customType: "output_validation", content: validation.ok ? "Final JSON output passed the selected schema." : `Final JSON output failed: ${validation.error ?? "schema mismatch"}`,
1063
- display: false, inContext: false, data: { valid: validation.ok } });
1064
- return validation.ok ? undefined : `output schema validation failed: ${validation.error ?? "schema mismatch"}`;
1065
- }
1066
-
1067
- /** O.8: dispatch and diagnostics share exact source hints, including forks. */
1068
- contextInput(): ProjectInput {
1069
- return { frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events,
1070
- logHint: { manifestPath: manifestPath(this.config.logDir),
1071
- segmentPath: this.state.handoff?.predecessor?.log_ref ?? nodePath(this.config.logDir, this.state.node) } };
1072
- }
1073
-
1074
- private async boundCarried(full: Handoff, tools = this.adviceNames()): Promise<Handoff> {
1075
- return carriedHandoff(this.config.logDir, this.state.node, full, { handoff_tokens: DEFAULT_HANDOFF_TOKENS,
1076
- ...(this.config.workingSet ? { working_set_version: CURRENT_WORKING_SET_VERSION } : {}), guidance_tools: tools });
1077
- }
1078
-
1079
- async acceptanceState(): Promise<AcceptanceState | undefined> {
1080
- if (!this.running) await this.observeEvidence();
1081
- // External editor changes must invalidate proof before the user reads it.
1082
- if (!this.running) await this.refreshRuntimeTargets();
1083
- return (await this.currentHandoff()).acceptance;
1084
- }
1085
-
1086
- /** S.5: the goal in force, derived from the active log ancestry, so it survives restarts and resume. */
1087
- async goal(): Promise<ActiveGoal | null> {
1088
- const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
1089
- if (!history.ok) throw new Error(history.error);
1090
- return activeGoal(history.nodes.flatMap(part => part.events));
1091
- }
1092
-
1093
- /**
1094
- * Records a goal from criteria the user accepted out of a model draft. The
1095
- * draft event must be this session's assistant reply; the contract holds
1096
- * only what was accepted (S.2).
1097
- */
1098
- async setGoal(input: { objective: string; draft: string; criteria: readonly AcceptanceCriterion[]; accepted: readonly string[] }): Promise<ActiveGoal> {
1099
- if (this.running || this.isHalfRound()) throw new Error("a goal can be set only while the session is idle");
1100
- if (!input.objective.trim()) throw new Error("a goal needs an objective");
1101
- const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
1102
- if (!history.ok) throw new Error(history.error);
1103
- if (!history.nodes.some(part => part.events.some(event => event.t === "assistant" && event.id === input.draft))) throw new Error("the goal draft is not in this session");
1104
- const contract = goalContract(input.criteria, input.accepted);
1105
- const accepted = contract.criteria.map(item => item.id);
1106
- const event: ActiveGoal = { t: "goal", id: this.clock.nextId(), at: this.clock.now(), action: "set", objective: input.objective.trim(),
1107
- contract, draft: input.draft, accepted, rejected: input.criteria.map(item => item.id).filter(id => !accepted.includes(id)) };
1108
- await this.record(event);
1109
- await this.saveCursor();
1110
- return event;
1111
- }
1112
-
1113
- /**
1114
- * The discriminating rung: each passing accepted check re-run in a clone with
1115
- * this request's changes undone, recorded as counter_check facts with the
1116
- * role each criterion was declared with. Guards run too: passing there is
1117
- * what makes a guard a guard (S.7). The goal's verdict does not depend on it;
1118
- * it tells the user which checks actually tested the change.
1119
- */
1120
- private async counterCheckRequest(state: RequestState, signal?: AbortSignal): Promise<void> {
1121
- const checks = state.criteria.flatMap(item => item.kind === "command" && item.status === "pass" && item.command
1122
- ? [{ id: item.id, command: item.command, ...(item.guard ? { guard: true as const } : {}) }] : []);
1123
- if (checks.length === 0 || signal?.aborted) return;
1124
- const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
1125
- if (!history.ok) throw new Error(history.error);
1126
- const events = currentRequestRecords(history.nodes).map(record => record.event);
1127
- // A snapshot taken when this request began reverts shell changes too; otherwise the journal does.
1128
- const workspace = this.config.tools.workspace, base = this.goalBaseline?.request === state.request.event ? this.goalBaseline.snapshot : null;
1129
- const now = base && workspace ? await workspace.snapshot(signal) : null;
1130
- const revert = base && now && workspace ? invertFileChanges(await workspace.snapshotChanges(base, now, signal)) : undefined;
1131
- const results = !this.config.blobDir ? checks.map(check => ({ ...check, reason: "this session keeps no file history to undo changes from" }))
1132
- : await counterCheck({ root: this.config.tools.root, blobs: this.config.blobDir, ownedPaths: this.config.tools.ownedStatePaths ?? [],
1133
- events, checks, timeoutMs: this.config.tools.timeoutMs, maxOutputBytes: this.config.tools.maxOutputBytes,
1134
- outputStore: join(this.config.logDir, "artifacts"), ...(revert ? { revert } : {}), ...(signal ? { signal } : {}) });
1135
- for (const result of results) await this.record({ t: "counter_check", id: this.clock.nextId(), at: this.clock.now(), request: state.request.event,
1136
- criterion: result.id, command: result.command, ...(result.guard ? { guard: true as const } : {}),
1137
- ...("exit" in result ? { exit: result.exit } : { reason: result.reason }) });
1138
- }
1139
-
1140
- /**
1141
- * The independent rung: blind checks run on a clone of the final result once
1142
- * the goal's own checks pass. The scan for exposure covers the whole log, not
1143
- * just this request -- a blind file read yesterday is still seen.
1144
- */
1145
- private async blindCheckRequest(state: RequestState, signal?: AbortSignal): Promise<void> {
1146
- const blind = this.config.blindChecks;
1147
- if (!blind || signal?.aborted) return;
1148
- const nodes = await listNodes(this.config.logDir);
1149
- const everything = (await Promise.all(nodes.map(node => readNode(this.config.logDir, node)))).flatMap(read => read.events);
1150
- const exposure = blindExposure(everything, blind);
1151
- const results = await runBlindChecks({ root: this.config.tools.root, blind, timeoutMs: this.config.tools.timeoutMs,
1152
- maxOutputBytes: this.config.tools.maxOutputBytes, outputStore: join(this.config.logDir, "artifacts"), ...(signal ? { signal } : {}) });
1153
- for (const result of results) await this.record({ t: "blind_check", id: this.clock.nextId(), at: this.clock.now(), request: state.request.event,
1154
- check: result.id, exposed: exposure !== null, ...(exposure ? { exposure } : {}), ...("exit" in result ? { exit: result.exit } : { reason: result.reason }) });
1155
- }
1156
-
1157
- async clearGoal(): Promise<boolean> {
1158
- if (this.running) throw new Error("a goal can be cleared only while the session is idle");
1159
- if (!await this.goal()) return false;
1160
- await this.record({ t: "goal", id: this.clock.nextId(), at: this.clock.now(), action: "clear", reason: "user" });
1161
- await this.saveCursor();
1162
- return true;
1163
- }
1164
-
1165
- async requestState(): Promise<RequestState | undefined> {
1166
- // Use the same idle workspace/target observation boundary as /acceptance.
1167
- if (!this.running) await this.acceptanceState();
1168
- const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
1169
- if (!history.ok) throw new Error(history.error);
1170
- return deriveRequestState(history.nodes, { strict: this.config.strictVerification === true, blind: this.config.blindChecks !== undefined });
1171
- }
1172
-
1173
- /** Only called at paired, owned boundaries; probes never enter a pending model tool exchange. */
1174
- private async refreshRuntimeTargets(): Promise<void> {
1175
- const state = (await this.currentHandoff()).acceptance;
1176
- const observations: string[] = [];
1177
- for (const target of state?.targets ?? []) if (target.observation || target.binding) {
1178
- const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
1179
- if (!history.ok) throw Error(history.error);
1180
- const observed = await this.runtimeTargets.perform(this.config.tools, history.nodes, { action: "inspect", target: target.id });
1181
- const id = this.clock.nextId();
1182
- await this.record({ t: "target_observation", id, at: this.clock.now(), observation: observed.observation });
1183
- observations.push(id);
1184
- }
1185
- if (!observations.length) return;
1186
- const current = (await this.currentHandoff()).acceptance;
1187
- if (current?.targets?.some(target => {
1188
- const before = state?.targets?.find(item => item.id === target.id);
1189
- return before?.status !== target.status || before.binding !== target.binding || before.build_event !== target.build_event;
1190
- })) await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "runtime_acceptance", sources: observations,
1191
- text: `Runtime acceptance observations changed. Status ${current.status}; targets ${JSON.stringify(current.targets)}. Only a ready owned instance can receive human acceptance; old observations do not grant current ownership.` });
1192
- }
1193
-
1194
- async workItems(): Promise<readonly WorkItem[]> {
1195
- const replayed = await replayContext(this.config.logDir, this.state.node);
1196
- return deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events, depth: replayed.depth,
1197
- self: await this.nodeRefFor(this.state.node) }).state.work_items ?? [];
1198
- }
1199
-
1200
- /** What the next handoff would carry if the segment were cut now. Pure derivation; nothing is written. */
1201
- async previewHandoff(): Promise<Handoff> { return this.currentHandoff(); }
1202
-
1203
- private async currentHandoff(): Promise<Handoff> {
1204
- const replayed = await replayContext(this.config.logDir, this.state.node);
1205
- return deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events, depth: replayed.depth, self: await this.nodeRefFor(this.state.node) });
1206
- }
1207
- async branchStatuses() {
1208
- return (await this.currentHandoff()).branches.map(item => ({ ...item, status: branchStatus(item, this.jobs.state(item.node)) }));
1209
- }
1210
- async processStatuses() {
1211
- const handles = new Map(runningSessions(this.execOwner).map(session => [session.id, session.exit === null ? "running" as const : "ended_unobserved" as const]));
1212
- const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
1213
- if (!history.ok) throw new Error(history.error);
1214
- let processes: ProcessObservation[] = [];
1215
- for (const part of await hydrateProcessReceipts(history.nodes, this.config.tools.outputStore!)) processes = deriveProcesses(processes, part.events, part.node);
1216
- return processes.map(item => ({ ...item, status: processStatus(item, handles.get(item.session)) }));
1217
- }
1218
- /** User lifecycle commands share the coordinator; no model request is needed. */
1219
- async resolveBranch(node: number, resolution: "wait" | "cancel" | "status", waitMs = 10_000): Promise<ToolResult> {
1220
- if (this.running || this.cleanup || this.closing) throw new Error("branch commands require an idle session");
1221
- const owned = this.execution.begin();
1222
- try {
1223
- const result = await this.runResolve({ node, resolution, wait_ms: waitMs });
1224
- await this.saveCursor(); return finishToolResult(result);
1225
- } finally { owned.finish(); }
1226
- }
1227
-
1228
- /**
1229
- * D14 for a branch the user opened rather than the model. Returns once
1230
- * `spawn` is recorded; the child runs in the background with the caller's
1231
- * explicit config (its own prompt, tools and roots: nothing of the parent's
1232
- * transcript, so a review stays independent of the work it judges). When it
1233
- * ends, `joined` plus one guidance line reach the parent at its next idle,
1234
- * pairable point -- the line is how the parent model learns to read it.
1235
- */
1236
- async startUserBranch(spec: {
1237
- purpose: string; prompt: string; config: SessionConfig; provider?: Provider;
1238
- /** Events the branch records before its first request (a review's premise receipt). */
1239
- prelude?: (branch: Session) => Promise<void>;
1240
- /** The guidance the parent model gets once the branch joined. */
1241
- note: (node: number, joined: Extract<LogEvent, { t: "joined" }>) => string;
1242
- }): Promise<number> {
1243
- if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
1244
- const node = this.state.nextNode++;
1245
- this.startingUserBranches.add(node);
1246
- try { return await this.launchUserBranch(node, spec); } finally { this.startingUserBranches.delete(node); }
1247
- }
1248
- private async launchUserBranch(node: number, spec: Parameters<Session["startUserBranch"]>[0]): Promise<number> {
1249
- const sourceEvent = this.state.events.findLast(event => event.t === "user")?.id;
1250
- const { ref, branch } = await this.openBranch({ node, purpose: spec.purpose, background: true, ...(sourceEvent ? { sourceEvent } : {}),
1251
- inputWorkspace: { kind: "shared_readonly" }, worktree: null, config: spec.config, ...(spec.provider ? { provider: spec.provider } : {}) });
1252
- // Not in `children`: the parent's cancel() disposes those, and an Esc must not stop this one.
1253
- try { await spec.prelude?.(branch); await this.saveCursor(); }
1254
- catch (error) { await branch.dispose(); throw error; }
1255
- const controller = new AbortController();
1256
- this.userJobs.start(node, controller, async () => {
1257
- const { outcome, text } = await this.runChild(node, branch, spec.prompt, true, controller.signal, true);
1258
- const { joined } = await this.closeBranch(ref, branch, outcome, text);
1259
- if (joined.t !== "joined") throw new Error("a closed branch must produce a joined event");
1260
- this.pendingJoins.push({ joined, note: spec.note(node, joined), usage: [...branch.state.usage] });
1261
- await this.flushUserJoins();
1262
- return joined;
1263
- }, () => {});
1264
- return node;
1265
- }
1266
- /** User branches still running, so a client can show and stop them. */
1267
- get userBranches(): readonly number[] {
1268
- return [...this.startingUserBranches, ...this.userJobs.nodes().filter(node => this.userJobs.state(node) === "running")];
1269
- }
1270
- /** Every branch with a live child here: the model's (foreground or background) and the user's. */
1271
- get runningBranches(): readonly number[] { return [...new Set([...this.children.keys(), ...this.userBranches])]; }
1272
- cancelUserBranch(node: number): void { this.userJobs.cancel(node); }
1273
- /**
1274
- * The branch's join once it ended -- recorded, or still waiting for the
1275
- * parent to be idle and pairable. Undefined if its finalization failed.
1276
- */
1277
- async whenUserBranch(node: number): Promise<Extract<LogEvent, { t: "joined" }> | undefined> {
1278
- await this.userJobs.drain(node); return (await this.userJobs.wait(node, 0))?.value;
1279
- }
1280
- /** Appends joins that waited for an idle, pairable parent. Never mid-turn: the guidance enters model context. */
1281
- private async flushUserJoins(): Promise<void> {
1282
- while (this.pendingJoins.length > 0 && !this.running && !this.cleanup && !this.isHalfRound()) {
1283
- const item = this.pendingJoins.shift()!;
1284
- this.state.usage.push(...item.usage);
1285
- await this.record(item.joined);
1286
- await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "branch", text: item.note, sources: [item.joined.id] });
1287
- await this.saveCursor();
1288
- }
1289
- }
1290
-
1291
- private async drainProcesses(): Promise<void> {
1292
- const results = await disposeExecSessions(this.execOwner, this.config.tools.outputStore!, this.config.tools.maxOutputBytes);
1293
- await this.config.tools.workspace?.abandonCommands();
1294
- for (const result of results) {
1295
- await this.record({ t: "process_exit", id: this.clock.nextId(), at: this.clock.now(), ...result, reason: "disposed" });
1296
- }
1297
- if (results.length > 0) await this.saveCursor();
1298
- }
1299
- private async acceptReadyBranches(): Promise<void> {
1300
- for (const node of this.jobs.nodes()) {
1301
- const done = await this.jobs.wait(node, 0);
1302
- if (done?.value?.accept) await done.value.accept();
1303
- this.jobs.forget(node);
1304
- }
1305
- }
1306
- dispose(): Promise<void> {
1307
- if (this.closing) return this.closing;
1308
- this.cancel();
1309
- // User branches outlive interrupts, not the session. Their joins are still recorded.
1310
- this.userJobs.cancel();
1311
- this.closing = this.whenIdle().then(async () => { await this.userJobs.drain(); await this.flushUserJoins(); }).then(() => this.rootTools?.close());
1312
- return this.closing;
1313
- }
1314
-
1315
- /** Only the app's user command calls this; note never grants human acceptance. */
1316
- async acceptCriterion(criterion: string): Promise<AcceptanceState> {
1317
- if (this.running || this.isHalfRound()) throw new Error("acceptance requires an idle, paired request");
1318
- const current = await this.acceptanceState();
1319
- const item = current?.criteria.find(item => item.id === criterion && item.kind === "human");
1320
- if (!current || !item || item.kind !== "human") throw new Error("unknown human acceptance criterion");
1321
- const target = item.target ? current.targets?.find(target => target.id === item.target) : undefined;
1322
- if (item.target && (target?.status !== "ready" || !target.binding || !target.observation)) throw Error("human acceptance requires a current ready target instance");
1323
- await this.record({ t: "acceptance_decision", id: this.clock.nextId(), at: this.clock.now(), request: current.request,
1324
- contract_id: current.contract_id, criterion, actor: "user",
1325
- ...(target?.binding && target.observation ? { target_binding: target.binding, target_observation: target.observation } : {}) });
1326
- await this.saveCursor();
1327
- return (await this.acceptanceState())!;
1328
- }
1329
-
1330
- async appendExtension(entry: Omit<Extract<LogEvent, { t: "extension" }>, "t" | "id" | "at">): Promise<void> {
1331
- // Messages are only pairable between tools. Private extension state may
1332
- // be recorded during execution because it never enters model context.
1333
- if (entry.inContext && (this.running || this.isHalfRound())) throw new Error("Custom messages must be queued until the active turn finishes");
1334
- const event = { ...entry, t: "extension" as const, id: this.clock.nextId(), at: this.clock.now() };
1335
- if (!isLogEvent(event)) throw new Error("Invalid extension entry");
1336
- await this.record(event);
1337
- }
1338
-
1339
- get totals(): UsageTotals {
1340
- return totalUsage(this.state.usage);
1341
- }
1342
-
1343
- cancel(): void {
1344
- this.execution.cancel(); this.jobs.cancel();
1345
- for (const child of this.children.values()) child.cancel();
1346
- if (!this.cleanup) this.cleanup = (async () => {
1347
- await this.execution.whenIdle(); await this.jobs.drain();
1348
- // A consumer can close at the preparation marker before execution is
1349
- // admitted. Those children still have an owner, even without a job.
1350
- for (const [node, child] of this.children) { await child.dispose(); this.children.delete(node); }
1351
- await this.acceptReadyBranches(); await this.drainProcesses();
1352
- })().then(() => { this.cleanup = null; });
1353
- // A void cancellation caller may await later. Preserve rejection for the
1354
- // owner while preventing an unhandled rejection from bypassing that path.
1355
- void this.cleanup.catch(() => {});
1356
- }
1357
- async whenIdle(): Promise<void> { await this.execution.whenIdle(); await this.cleanup; }
1358
- get running(): boolean { return this.execution.running; }
1359
-
1360
- /**
1361
- * Runs one user message to completion: model turns and tool calls until the
1362
- * model stops asking for tools.
1363
- *
1364
- * `signal` is per-send, matching Provider.send. Abort is a decision, not a
1365
- * failure: the generator yields `done` with reason `interrupted` and
1366
- * returns. Any tool_use already on disk is answered with a tool_result
1367
- * that says so -- otherwise the next request is unpairable (D8: the log
1368
- * is append-only, so the alternative of deleting the tool_use is not
1369
- * available, and would also lie about a call that may have had effects).
1370
- */
1371
- async *send(
1372
- userText: UserInput,
1373
- signal?: AbortSignal,
1374
- pull?: (phase: Phase) => Promise<UserInput | null>,
1375
- requestId?: string,
1376
- options: RequestOptions = {},
1377
- ): AsyncGenerator<LoopEvent> {
1378
- if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
1379
- const owned = this.execution.begin(signal);
1380
- this.processWake = new AbortController();
1381
- const savedTools = this.config.tools;
1382
- this.turnTools = savedTools;
1383
- this.config.tools = { ...savedTools };
1384
- this.installSkillResources();
1385
- const events = this.run(userText, owned.signal, pull, requestId, options);
1386
- let terminal = false;
1387
- let exhausted = false;
1388
- try {
1389
- for (;;) {
1390
- const item = await events.next();
1391
- if (item.done) { exhausted = true; break; }
1392
- if (item.value.kind === "done") {
1393
- terminal = true;
1394
- if (item.value.outcome && item.value.outcome.reason !== "model_end") await this.record({
1395
- t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
1396
- reason: item.value.reason ?? item.value.outcome.reason, outcome: item.value.outcome,
1397
- ...(item.value.failure ? { failure: item.value.failure } : {}),
1398
- });
1399
- const request = await this.requestState();
1400
- if (request) item.value.request = request;
1401
- }
1402
- yield item.value;
1403
- }
1404
- if (!terminal) {
1405
- // Every path through run() ends with `done` or throws; this records the impossible case honestly.
1406
- const outcome = executionOutcome("execution_error");
1407
- await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), reason: "request ended without a terminal outcome", outcome });
1408
- const request = await this.requestState();
1409
- yield { kind: "done", reason: "stopped: request ended without a terminal outcome", outcome, ...(request ? { request } : {}) };
1410
- }
1411
- } catch (error) {
1412
- // Raw exceptions can contain credentials; persist the category, never the message.
1413
- // Only the provider request's own exception is a provider error: this catch
1414
- // also sees failures in history reads and in the harness itself.
1415
- const provider = this.providerFailure?.error === error ? this.providerFailure : undefined;
1416
- const failure = failureCause(error, provider);
1417
- await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
1418
- reason: `request failed before completion (${describeFailure(failure)}); no complete response was committed`,
1419
- outcome: executionOutcome(provider ? "provider_error" : "execution_error"), failure });
1420
- throw error;
1421
- } finally {
1422
- // Closing a consumer is not a user cancellation. Preserve the existing
1423
- // crash/recovery points; only an explicit signal can claim cancellation.
1424
- try {
1425
- if (!exhausted) {
1426
- await events.return(undefined);
1427
- }
1428
- } finally {
1429
- while (this.hostPending.size > 0) await Promise.allSettled(this.hostPending);
1430
- this.config.tools = savedTools; this.turnTools = null; this.readOnlyRequest = false;
1431
- if (owned.signal.aborted) this.cancel();
1432
- owned.finish();
1433
- if (owned.signal.aborted) await this.whenIdle();
1434
- // A user branch that ended mid-turn joins now that the parent is idle.
1435
- if (this.pendingJoins.length > 0) await this.flushUserJoins().catch(() => {});
1436
- }
1437
- }
1438
- }
1439
-
1440
- private async *run(
1441
- userText: UserInput,
1442
- signal?: AbortSignal,
1443
- pull?: (phase: Phase) => Promise<UserInput | null>,
1444
- requestId?: string,
1445
- options: RequestOptions = {},
1446
- ): AsyncGenerator<LoopEvent> {
1447
- // A crash after a discovery receipt but before its cut is recovered from that same receipt.
1448
- if (!signal?.aborted) await this.applyDiscoveredTools();
1449
- if (needsReplayCheckpoint(this.state.events, providerReplayKey(this.provider.config.spec), nativeReplay(this.provider.config.spec))) {
1450
- if (this.isHalfRound()) throw new Error("Cannot change a wire profile while tool results are pending");
1451
- await this.performCut("model_checkpoint");
1452
- }
1453
- const blocked = this.config.imageOptions?.blockImages ?? false;
1454
- // A privacy change uses the existing mechanical checkpoint (D5/D8); it never edits old messages.
1455
- if (blocked && this.state.events.some(e => (e.t === "user" || e.t === "tool_result") && e.images?.some(image => !image.blocked))) {
1456
- if (this.isHalfRound()) throw new Error("Cannot change image transmission while tool results are pending");
1457
- await this.performCut("model_checkpoint");
1458
- }
1459
- const request = requestId ?? this.clock.nextId();
1460
- // S.5: a draft request has no goal yet and must not inherit the current one.
1461
- this.readOnlyRequest = options.readOnly === true;
1462
- const goal = options.readOnly ? null : await this.goal();
1463
- const contract = goal?.contract ?? this.config.acceptanceContract;
1464
- await this.record({ t: "user", id: request, at: this.clock.now(), ...userInputData(userText, blocked) });
1465
- if (contract) await this.record({ t: "acceptance_contract", id: this.clock.nextId(), at: this.clock.now(),
1466
- request: this.state.events.at(-1)!.id, contract });
1467
- // The state a counter-check must rebuild before it may run (feat-counter-check).
1468
- if (goal) {
1469
- await this.config.tools.workspace?.baseline(signal);
1470
- const snapshot = await this.config.tools.workspace?.snapshot(signal);
1471
- this.goalBaseline = snapshot ? { request, snapshot } : null;
1472
- }
1473
- // Idle changes belong to the new prompt. Recording them before `user`
1474
- // would silently move the preceding exchange's restore point forward.
1475
-
1476
- const counter = this.config.counter ?? estimateTokens;
1477
-
1478
- try {
1479
- let overflowRetried = false;
1480
- const requestStarted = this.clock.now();
1481
- const requestLimit = options.readOnly ? Math.min(READ_ONLY_REQUEST_LIMIT, this.config.maxTurns) : this.config.maxTurns;
1482
- const report = (cause: string, requests: number) => this.terminationReport(cause, requests, requestLimit);
1483
- for (let turn = 0; turn < requestLimit; turn++) {
1484
- const phase = requestPhase(turn, requestLimit);
1485
- if (signal?.aborted) {
1486
- yield* this.closeInterrupted();
1487
- return;
1488
- }
1489
-
1490
- if (yield* this.checkStall(request, signal)) {
1491
- yield { kind: "done", reason: await report("repeated tool cycle without new evidence", turn), outcome: executionOutcome("stalled") };
1492
- return;
1493
- }
1494
- if (phase === "finalize" && !supportsToolDisabling(this.provider.config.spec.api)) {
1495
- yield { kind: "done", reason: await report("work budget exhausted; protocol cannot disable tools while preserving frozen definitions", turn), outcome: executionOutcome("max_turns") };
1496
- return;
1497
- }
1498
- yield { kind: "turn_start", node: this.state.node };
1499
-
1500
- if (phase === "finalize") {
1501
- await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "budget", sources: [], text: finalizationNotice() });
1502
- } else if (!options.readOnly && this.config.guidance !== false) {
1503
- const names = this.adviceNames();
1504
- const guidance = (this.config.harnessReviews === false ? null : await this.recoveryGuidance(names)) ??
1505
- continuationGuidance({ events: this.state.events, handoff: this.state.handoff,
1506
- tools: names, remainingRequests: this.config.maxTurns - turn,
1507
- ...(this.config.continuationAfter === undefined ? {} : { minAssistantTurns: this.config.continuationAfter }) }) ??
1508
- (this.config.timeBudgetMs === undefined ? null
1509
- : deadlineNotice(requestStarted, this.clock.now(), this.config.timeBudgetMs, this.state.events)) ??
1510
- (this.config.harnessReviews === false ? null : churnReview(this.state.events));
1511
- if (guidance) await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...guidance });
1512
- }
1513
-
1514
- // New user/steering input and guidance can overflow a previously safe view.
1515
- if ((this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY) === "windowed" &&
1516
- (yield* this.maybeWindowCut(counter, true))) {
1517
- yield { kind: "done", reason: "stopped: context cannot fit the model window", outcome: executionOutcome("context_budget") };
1518
- return;
1519
- }
1520
- const projected = projectContext(this.contextInput());
1521
-
1522
- // Pairing violations are a 400 waiting to happen; surface them here,
1523
- // where the cause is still visible, rather than as a request failure.
1524
- if (projected.violations.length > 0) {
1525
- yield {
1526
- kind: "done",
1527
- reason: `projection violations: ${projected.violations.map((v) => v.code).join(", ")}`,
1528
- outcome: executionOutcome("projection_invalid"),
1529
- };
1530
- return;
1531
- }
1532
-
1533
- const window = this.provider.config.spec.contextWindow;
1534
- const estimated = anchoredCount(projected.messages, projected.system, this.state.anchor,
1535
- counter, this.config.frozen.tools).tokens;
1536
- if (window !== undefined && estimated + this.provider.config.maxTokens > window) {
1537
- 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") };
1538
- return;
1539
- }
1540
-
1541
- let result: Turn;
1542
- try {
1543
- result = yield* this.awaitProvider(projected.messages, signal, phase === "finalize" ? { toolUse: "disabled" } : undefined);
1544
- } catch (error) {
1545
- // D11: retain a typed response receipt once, without anchoring rejected content or trusting arbitrary error fields.
1546
- if (error instanceof FailedResponseError && error.usage !== undefined) this.state.usage.push({ ...error.usage });
1547
- if (signal?.aborted) {
1548
- yield* this.closeInterrupted();
1549
- return;
1550
- }
1551
- if (phase === "finalize") {
1552
- yield { kind: "discard" };
1553
- const failure = failureCause(error, this.providerFailure?.error === error ? this.providerFailure : { retries: 0 });
1554
- yield { kind: "done", reason: await report(`final response failed (${describeFailure(failure)}); no additional request was made`, turn + 1),
1555
- outcome: executionOutcome("provider_error"), failure };
1556
- return;
1557
- }
1558
- if (!(error instanceof Error) || !isContextOverflow(error.message, statusOf(error))) throw error;
1559
- // The estimate said this fit and the provider says it does not: the
1560
- // estimate is wrong for this content. The only projection that can
1561
- // shrink is a cut, so force one and send once more; a second
1562
- // overflow means even the retained tail is misjudged, and another
1563
- // attempt would only be paid for.
1564
- if (overflowRetried || this.policyLimits().kind !== "window") {
1565
- yield { kind: "done", reason: `stopped: provider rejected the prompt as too long (${error.message})`, outcome: executionOutcome("context_budget") };
1566
- return;
1567
- }
1568
- overflowRetried = true;
1569
- if (yield* this.maybeWindowCut(counter, true, true)) {
1570
- yield { kind: "done", reason: "stopped: context cannot fit the model window", outcome: executionOutcome("context_budget") };
1571
- return;
1572
- }
1573
- continue;
1574
- }
1575
- overflowRetried = false;
1576
-
1577
- const previous = this.state.usage.at(-1);
1578
- this.state.usage.push(result.usage);
1579
- // Q24: an adapter may finish after abort. Keep measured usage, but cancellation
1580
- // owns termination and a late response cannot enter context or execute tools.
1581
- if (signal?.aborted) {
1582
- yield { kind: "usage", usage: result.usage, ok: true };
1583
- yield { kind: "discard" };
1584
- yield* this.closeInterrupted();
1585
- return;
1586
- }
1587
- const promptTokens =
1588
- result.usage.input_tokens +
1589
- result.usage.cache_creation_input_tokens +
1590
- result.usage.cache_read_input_tokens;
1591
- // A stream that reported no usage at all maps onto zeros in the
1592
- // adapter. Anchoring to that fabrication would make shouldCut compare
1593
- // against a fake small number and never fire. Leave the previous
1594
- // anchor (null before the first measurement); anchoredCount already
1595
- // falls back to a plain estimate when anchor is null. Do not invent
1596
- // a substitute count — diagnosePrefix and /usage must not see a
1597
- // fabricated prompt size.
1598
- if (promptTokens > 0 || result.usage.output_tokens > 0) {
1599
- this.state.anchor = {
1600
- promptTokens,
1601
- outputTokens: result.usage.output_tokens,
1602
- messageCount: projected.messages.length,
1603
- };
1604
- }
1605
- const cache = this.provider.config.spec.cache;
1606
- if (cache === undefined) {
1607
- yield {
1608
- kind: "usage",
1609
- usage: result.usage,
1610
- prefix: "cache terms unknown",
1611
- ok: true,
1612
- };
1613
- } else {
1614
- const verdict = diagnosePrefix(result.usage, previous, cache);
1615
- yield {
1616
- kind: "usage",
1617
- usage: result.usage,
1618
- prefix: verdict.kind === "healthy"
1619
- ? `cache read ${verdict.read}, wrote ${verdict.wrote}`
1620
- : verdict.detail,
1621
- ok: verdict.kind !== "suspicious",
1622
- };
1623
- }
1624
-
1625
- // D5: ambiguous IDs cannot be persisted or paired safely. Keep sampled
1626
- // usage, but stop before observer hooks or any tool effect can run.
1627
- const identityViolations = toolCallIdentityViolations(result.blocks);
1628
- if (identityViolations.length > 0) {
1629
- yield { kind: "done", outcome: executionOutcome("provider_error"),
1630
- reason: `stopped: invalid provider tool call identity (${identityViolations.map(violation => `${violation.code}: ${violation.detail}`).join("; ")})` };
1631
- return;
1632
- }
1633
-
1634
- await this.record({
1635
- t: "assistant", id: this.clock.nextId(), at: this.clock.now(), blocks: result.blocks, stop_reason: result.stopReason,
1636
- replay_key: providerReplayKey(this.provider.config.spec),
1637
- });
1638
-
1639
- for (const b of result.blocks) {
1640
- if (b.type === "text" && b.text.trim()) yield { kind: "assistant_text", text: b.text };
1641
- if (b.type === "thinking" && b.text.trim()) yield { kind: "thinking", text: b.text };
1642
- }
1643
-
1644
- const calls = result.blocks.filter(
1645
- (b): b is Extract<AssistantBlock, { type: "tool_use" }> => b.type === "tool_use",
1646
- );
1647
-
1648
- // Provider policy is a wire instruction, not authority. Pair every
1649
- // refused call, but never admit execution in the reserved final request.
1650
- if (phase === "finalize" && (calls.length > 0 || !isCompleteResponse(result.stopReason) ||
1651
- !result.blocks.some(block => block.type === "text" && block.text.trim()))) {
1652
- for (const item of await this.answerUnpairedTools("not executed: tools are disabled in the final request", "not_started"))
1653
- yield { kind: "tool_result", text: item.content, ok: false };
1654
- yield { kind: "done", reason: await report("request budget exhausted; final response did not provide a complete text answer", turn + 1), outcome: executionOutcome("max_turns") };
1655
- return;
1656
- }
1657
-
1658
- // N.12: syntactically valid arguments in an unfinished response are not
1659
- // authorization to execute a partial batch. Pair without side effects.
1660
- if (!isCompleteResponse(result.stopReason)) {
1661
- for (const item of await this.answerUnpairedTools(incompleteToolText(result.stopReason))) {
1662
- yield { kind: "tool_result", text: item.content, ok: false };
1663
- }
1664
- const extra = await this.pullSteering(pull);
1665
- if (extra !== null) {
1666
- await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), request, ...userInputData(extra, blocked) });
1667
- continue;
1668
- }
1669
- const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
1670
- if (!history.ok) throw new Error(history.error);
1671
- const notice = outputRecoveryNotice(history.nodes.flatMap(node => node.events),
1672
- this.config.outputRecoveryLimit ?? DEFAULT_OUTPUT_RECOVERY_LIMIT, this.config.maxTurns - turn - 1);
1673
- if (notice) {
1674
- await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...notice });
1675
- if (yield* this.maybeCut(counter, true)) {
1676
- yield { kind: "done", reason: "stopped: no verifiable progress", outcome: executionOutcome("no_progress") };
1677
- return;
1678
- }
1679
- continue;
1680
- }
1681
- yield { kind: "done", reason: result.stopReason ?? "unknown model stop", outcome: modelOutcome(result.stopReason) };
1682
- return;
1683
- }
1684
-
1685
- if (calls.length === 0) {
1686
- const extra = await this.pullSteering(pull);
1687
- if (extra !== null) {
1688
- await this.record({
1689
- t: "user", id: this.clock.nextId(), at: this.clock.now(), request, ...userInputData(extra, blocked),
1690
- });
1691
- continue;
1692
- }
1693
- if (contract) await this.refreshRuntimeTargets();
1694
- const state = await this.requestState();
1695
- const remaining = requestLimit - turn - 1;
1696
- // S.5: an active goal replaces the one-time completion check. It keeps
1697
- // the request going until the ledger says the goal is met; a stated
1698
- // blocker, a spent budget or the stall watchdog still end the run.
1699
- const verdict = goal && state ? goalDecision(state, remaining, idleGoalChecks(this.state.events)) : null;
1700
- if (state && (verdict?.action === "met" || verdict?.action === "await_human")) {
1701
- await this.counterCheckRequest(state, signal);
1702
- await this.blindCheckRequest(state, signal);
1703
- }
1704
- if (verdict?.action === "met") await this.record({ t: "goal", id: this.clock.nextId(), at: this.clock.now(), action: "clear", reason: "met" });
1705
- const decision = verdict?.action === "continue" ? { action: "continue" as const, guidance: { reason: "goal" as const, sources: [state!.request.event], text: verdict.text } }
1706
- : verdict || !state || options.readOnly || this.config.guidance === false || this.config.harnessReviews === false ? { action: "finish" as const }
1707
- : requestCompletion(state, remaining);
1708
- if (decision.action === "continue") {
1709
- if (yield* this.maybeCut(counter, true)) {
1710
- yield { kind: "done", reason: "stopped: no verifiable progress", outcome: executionOutcome("no_progress") };
1711
- return;
1712
- }
1713
- // Q23: a notice appended before a cut can disappear from the next
1714
- // projection. Append at the destination so the provider sees it once.
1715
- await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...decision.guidance });
1716
- continue;
1717
- }
1718
- const stopped = yield* this.maybeCut(counter, false);
1719
- if (!stopped) {
1720
- const error = await this.validateFinalOutput(result.blocks.filter(block => block.type === "text").map(block => block.text).join("\n"));
1721
- if (error !== undefined) {
1722
- yield { kind: "done", reason: error, outcome: executionOutcome("execution_error") }; return;
1723
- }
1724
- }
1725
- yield {
1726
- kind: "done",
1727
- reason: stopped ? "stopped: no verifiable progress" : result.stopReason ?? "end_turn",
1728
- outcome: stopped ? executionOutcome("no_progress") : modelOutcome(result.stopReason),
1729
- };
1730
- return;
1731
- }
1732
-
1733
- let branchesThisTurn = 0;
1734
- type Settled = { ok: true; value: SessionBrokerReceipt } | { ok: false; error: unknown };
1735
- const pending = new Map<string, Promise<Settled>>();
1736
- const modes = calls.map(call => this.executionMode(call));
1737
- try { for (let callIndex = 0; callIndex < calls.length; callIndex++) {
1738
- const call = calls[callIndex]!;
1739
- if (signal?.aborted && !pending.has(call.call_id)) {
1740
- yield* this.closeInterrupted();
1741
- return;
1742
- }
1743
-
1744
- if (!pending.has(call.call_id)) {
1745
- const end = schedulingWindow(modes, callIndex, this.config.maxParallelTools ?? 3);
1746
- if (end - callIndex > 1) for (let index = callIndex; index < end; index++) {
1747
- const planned = calls[index]!;
1748
- yield { kind: "tool_call", tool: { name: planned.name, input: planned.input } };
1749
- if (signal?.aborted) break;
1750
- const dispatch = yield* this.prepareBrokerCall(planned, () => branchesThisTurn++, blocked, signal);
1751
- pending.set(planned.call_id, dispatch.run().then(value => ({ ok: true as const, value }), error => ({ ok: false as const, error })));
1752
- }
1753
- }
1754
-
1755
- let receipt: SessionBrokerReceipt;
1756
- const dispatched = pending.get(call.call_id);
1757
- if (dispatched) {
1758
- const settled = await dispatched;
1759
- pending.delete(call.call_id);
1760
- if (!settled.ok) throw settled.error;
1761
- receipt = settled.value;
1762
- } else {
1763
- if (signal?.aborted) { yield* this.closeInterrupted(); return; }
1764
- yield { kind: "tool_call", tool: { name: call.name, input: call.input } };
1765
- if (signal?.aborted) { yield* this.closeInterrupted(); return; }
1766
- const prepared = yield* this.prepareBrokerCall(call, () => branchesThisTurn++, blocked, signal);
1767
- receipt = yield* withProgress<SessionBrokerReceipt, LoopEvent>(emit => prepared.run(emit));
1768
- }
1769
- await receipt.settle();
1770
- for (const event of receipt.events.splice(0)) yield event;
1771
- const out = finishToolResult(receipt.value);
1772
- // Observation still precedes persistence: crashes here remain unknown,
1773
- // while nested receipts have already been committed before their caller resumed.
1774
- yield { kind: "tool_result", text: out.content, ok: out.ok, outcome: out.outcome! };
1775
- await receipt.commit();
1776
-
1777
- if (signal?.aborted && pending.size === 0) {
1778
- yield* this.closeInterrupted();
1779
- return;
1780
- }
1781
- } } finally { await Promise.allSettled(pending.values()); }
1782
-
1783
- // One scan per turn, not per tool call: a turn is the smallest unit
1784
- // that means anything, and per-call would be the character-level
1785
- // granularity the research warns against.
1786
- const declarationNode = this.state.node;
1787
- if (!signal?.aborted && await this.applyDiscoveredTools()) yield { kind: "cut", node: declarationNode, reason: "model_checkpoint" };
1788
- if (this.pendingRoot !== null) await this.applyPendingRoot();
1789
- if (this.pendingAdoption !== null) await this.adoptPendingBranch();
1790
-
1791
- // Between complete exchanges is the only safe place to cut.
1792
- if (yield* this.maybeCut(counter, turn + 1 < this.config.maxTurns)) {
1793
- yield { kind: "done", reason: "stopped: no verifiable progress", outcome: executionOutcome("no_progress") };
1794
- return;
1795
- }
1796
-
1797
- const extra = await this.pullSteering(pull);
1798
- if (extra !== null) {
1799
- await this.record({
1800
- t: "user", id: this.clock.nextId(), at: this.clock.now(), request, ...userInputData(extra, blocked),
1801
- });
1802
- }
1803
- }
1804
-
1805
- yield { kind: "done", reason: await report("request budget exhausted", requestLimit), outcome: executionOutcome("max_turns") };
1806
- } finally {
1807
- // If the consumer aborts and stops pulling, the generator is closed
1808
- // without running the yield* closeInterrupted path. Pairing still
1809
- // has to happen: an unanswered tool_use on disk is a 400 next turn.
1810
- if (signal?.aborted) await this.answerUnpairedTools();
1811
- }
1812
- }
1813
-
1814
- /**
1815
- * Answers every tool_use that has no tool_result yet.
1816
- *
1817
- * Append, never delete: the call was issued, and rolling it back would
1818
- * rewrite the log (D8) and hide a call that may already have touched
1819
- * disk. Same shape as a refused branch -- a result that says why.
1820
- */
1821
- private async answerUnpairedTools(
1822
- content = "interrupted: the user cancelled before this tool completed",
1823
- reason: ExecutionReason = "unknown",
1824
- ): Promise<{ callId: string; content: string }[]> {
1825
- const added: { callId: string; content: string }[] = [];
1826
- for (const block of pendingToolCalls(this.state.events)) {
1827
- const resultText = knownUnexecutedTool(this.state.events, block.call_id) ?? content;
1828
- await this.record({
1829
- t: "tool_result",
1830
- id: this.clock.nextId(),
1831
- at: this.clock.now(),
1832
- call_id: block.call_id,
1833
- ...(block.invocation_id ? { invocation_ref: { node: this.state.node, event: block.invocation_id } } : {}),
1834
- ...(block.parent_call_id ? { parent_call_id: block.parent_call_id } : {}),
1835
- ...(block.origin ? { origin: block.origin } : {}),
1836
- ok: false,
1837
- content: resultText,
1838
- outcome: executionOutcome(reason),
1839
- });
1840
- added.push({ callId: block.call_id, content: resultText });
1841
- }
1842
- return added;
1843
- }
1844
-
1845
- private async *closeInterrupted(): AsyncGenerator<LoopEvent> {
1846
- const added = await this.answerUnpairedTools();
1847
- for (const item of added) {
1848
- yield { kind: "tool_result", text: item.content, ok: false };
1849
- }
1850
- await this.recordInterrupt();
1851
- yield { kind: "done", reason: "interrupted", outcome: executionOutcome("cancelled") };
1852
- }
1853
-
1854
- /**
1855
- * Appends an interrupt event for the user turn this send opened.
1856
- *
1857
- * The cancel happened after that user event, so it is a later record --
1858
- * not a flag written back onto it (D8). Partial assistant text is omitted:
1859
- * a cancelled generation was never delivered (AGENT_PLAYBOOK: a failed
1860
- * request is not a conversation that happened).
1861
- */
1862
- private async recordInterrupt(): Promise<void> {
1863
- for (let i = this.state.events.length - 1; i >= 0; i--) {
1864
- const event = this.state.events[i]!;
1865
- if (event.t !== "user") continue;
1866
- await this.record({
1867
- t: "interrupt",
1868
- id: this.clock.nextId(),
1869
- at: this.clock.now(),
1870
- user_id: event.id,
1871
- });
1872
- return;
1873
- }
1874
- }
1875
-
1876
- /**
1877
- * Runs the provider and yields display deltas as they arrive, so a long
1878
- * reply is visible before `send` resolves. The returned Turn is still the
1879
- * complete message -- that is what gets logged.
1880
- */
1881
- private async *awaitProvider(
1882
- messages: readonly Message[],
1883
- signal?: AbortSignal,
1884
- options?: ProviderSendOptions,
1885
- ): AsyncGenerator<LoopEvent, Turn> {
1886
- const hydrated = await hydrateImages(messages, this.config.blobDir ?? join(this.config.logDir, "blobs"),
1887
- this.provider.config.spec.input?.includes("image") ?? false);
1888
- let retries = 0;
1889
- this.providerFailure = undefined;
1890
- const progress = (emit: (event: LoopEvent) => void) => (delta: StreamDelta): void => {
1891
- switch (delta.kind) {
1892
- case "text":
1893
- emit({ kind: "text_delta", text: delta.text });
1894
- break;
1895
- case "thinking":
1896
- emit({ kind: "thinking_delta", text: delta.text });
1897
- break;
1898
- case "discard":
1899
- emit({ kind: "discard" });
1900
- break;
1901
- case "retry":
1902
- retries = Math.max(retries, delta.attempt);
1903
- emit({ kind: "progress", progress: delta });
1904
- break;
1905
- case "tool":
1906
- emit({ kind: "progress", progress: delta });
1907
- break;
1908
- default:
1909
- return assertNever(delta, "onDelta");
1910
- }
1911
- };
1912
- try {
1913
- return yield* withProgress<Turn, LoopEvent>(emit => this.provider.send(
1914
- this.config.frozen, hydrated, this.state.node, signal, progress(emit), options));
1915
- } catch (error) {
1916
- // The outcome needs to know this exception came from the request, and after how many retries.
1917
- this.providerFailure = { error, retries };
1918
- throw error;
1919
- }
1920
- }
1921
-
1922
- /**
1923
- * Yields any boundary events and returns true when the session should stop.
1924
- *
1925
- * Returning the verdict rather than only announcing it is the point: an
1926
- * advisory the loop ignores looks like a guard while the budget drains.
1927
- */
1928
- private get limits(): BranchLimits {
1929
- return { ...DEFAULT_BRANCH_LIMITS, ...this.config.branches };
1930
- }
1931
-
1932
- private nodeRefFor(node: number): Promise<NodeRef> {
1933
- return digestedRef(this.config.logDir, node);
1934
- }
1935
-
1936
- /**
1937
- * Runs a read-only branch in its own node and returns a summary.
1938
- *
1939
- * The branch is an ordinary Session with a different node, a reduced tool
1940
- * set and no handoff -- the recursion is what keeps a branch and a session
1941
- * the same thing, so nothing here has to reimplement the loop.
1942
- */
1943
- private executionMode(call: ToolCall): "parallel" | "exclusive" {
1944
- if (this.config.tools.extensionHooks?.serial?.()) return "exclusive";
1945
- if (call.name === "read" && typeof call.input === "object" && call.input !== null &&
1946
- this.config.skills?.some(skill => skill.filePath === resolve(this.config.tools.root, String((call.input as Record<string, unknown>)["path"])))) return "exclusive";
1947
- if (call.name === "branch" && typeof call.input === "object" && call.input !== null &&
1948
- (call.input as Record<string, unknown>)["writes"] !== true && (call.input as Record<string, unknown>)["background"] !== true) return "parallel";
1949
- return toolDefinition(call.name, this.config.tools)?.execution ?? "exclusive";
1950
- }
1951
-
1952
- private async *dispatchBranch(input: unknown, index: number, signal?: AbortSignal): AsyncGenerator<LoopEvent, DeferredToolResult> {
1953
- if (typeof input !== "object" || input === null || !("background" in input) || input.background !== true) return yield* this.runBranch(input, index, signal);
1954
- if (this.jobs.active >= (this.config.maxParallelTools ?? 3)) return { ok: false, content: "background branch capacity reached; wait or cancel an owned branch first" };
1955
- const controller = new AbortController();
1956
- const abort = () => controller.abort();
1957
- signal?.addEventListener("abort", abort, { once: true });
1958
- if (signal?.aborted) abort();
1959
- const detach = () => signal?.removeEventListener("abort", abort);
1960
- const child = this.runBranch(input, index, controller.signal);
1961
- let registered = false;
1962
- try {
1963
- for (;;) {
1964
- const step = await child.next();
1965
- if (step.done) return step.value;
1966
- yield step.value;
1967
- if (step.value.kind !== "branch_start") continue;
1968
- const node = step.value.node!;
1969
- this.jobs.start(node, controller, async () => {
1970
- const result = await child.next();
1971
- if (!result.done) throw new Error("unexpected background child yield");
1972
- return result.value;
1973
- }, detach);
1974
- registered = true;
1975
- return { ok: true, content: `branch ${node} running; ` + (this.adviceNames().includes("resolve")
1976
- ? `use resolve(node=${node}, resolution='status'|'wait'|'cancel')` : "branch-observation capability unavailable; completion is unknown"), data: { node, background: true, running: true } };
1977
- }
1978
- } finally { if (!registered) detach(); }
1979
- }
1980
-
1981
- private async *prepareBrokerCall(call: ToolCall, nextBranch: () => number, blockedImages: boolean,
1982
- signal?: AbortSignal, native = false): AsyncGenerator<LoopEvent, PreparedBrokerCall> {
1983
- this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
1984
- const release = await this.rootTools.acquire(this.executionMode(call));
1985
- let transferred = false;
1986
- let scope: BrokerExecution<DeferredToolResult> | undefined;
1987
- try {
1988
- const events: LoopEvent[] = [];
1989
- let emit = (event: LoopEvent) => { events.push(event); };
1990
- let context: ToolContext = { ...this.config.tools, ...(this.readOnlyRequest ? { workflowMode: "plan" as const, planHint: READ_ONLY_HINT } : {}),
1991
- imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"),
1992
- advice: this.allowedNames(),
1993
- imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}),
1994
- // feat-approval-events: the broker has already recorded this call's
1995
- // invocation, and its result comes after execution, so the order is fixed.
1996
- onApproval: approval => this.record({ t: "approval", id: this.clock.nextId(), at: this.clock.now(), ...approval }) };
1997
- if (native) context = nativeCommandContext(context);
1998
- const decoder = new TextDecoder();
1999
- context.onOutput = chunk => emit({ kind: "progress", progress: { kind: "output", name: call.name, text: decoder.decode(chunk, { stream: true }) } });
2000
- context.onProcessWait = (session, active) => emit({ kind: "progress", progress: { kind: "waiting", session, active } });
2001
- const broker = new ToolBroker<DeferredToolResult>({
2002
- parallelLimit: this.config.maxParallelTools ?? 3,
2003
- callId: () => `nested-${randomUUID()}`,
2004
- mode: request => this.executionMode(request),
2005
- invocation: (request, ctx, parent) => this.recordInvocation(request, ctx, parent),
2006
- prepare: (request, ctx) => this.prepareSessionAction(request, ctx, nextBranch, event => emit(event)),
2007
- settle: async result => {
2008
- if (result.accept) events.push(...await result.accept());
2009
- if (!result.content_ref) Object.assign(result, await presentObservation(context, result.content));
2010
- },
2011
- result: async (invocation, result) => {
2012
- if (result.annotation) await this.record(result.annotation);
2013
- await this.recordToolReceipt(invocation, finishToolResult(result), blockedImages);
2014
- },
2015
- });
2016
- const execute = yield* withProgress<BrokerExecution<DeferredToolResult>, LoopEvent>(async output => {
2017
- emit = output;
2018
- scope = await broker.prepare(call, context);
2019
- return scope;
2020
- });
2021
- emit = event => { events.push(event); };
2022
- transferred = true;
2023
- return { run: async output => {
2024
- if (output) emit = output;
2025
- let commitOwnsSlot = false;
2026
- try {
2027
- const receipt = await execute();
2028
- if (native) {
2029
- // An idle host result has no generator consumer to own its commit.
2030
- // Retain the slot so a checkpoint cannot split invocation from receipt.
2031
- commitOwnsSlot = true;
2032
- return { ...receipt, events, commit: async () => { try { await receipt.commit(); } finally { release(); } } };
2033
- }
2034
- return { ...receipt, events };
2035
- } finally { emit = event => { events.push(event); }; if (!commitOwnsSlot) release(); }
2036
- } };
2037
- } finally { if (!transferred) { try { await scope?.close(); } finally { release(); } } }
2038
- }
2039
-
2040
- private async recordInvocation(call: ToolCall, ctx: ToolContext, parent?: ToolInvocationEvent): Promise<ToolInvocationEvent> {
2041
- const store = ctx.outputStore ?? join(this.config.logDir, "artifacts");
2042
- const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));
2043
- const archive = async (bytes: Uint8Array) => {
2044
- async function* source() { yield bytes; }
2045
- return (await archiveObservation(store, source(), 0)).ref;
2046
- };
2047
- let valid = false;
2048
- try { valid = isJsonValue(call.input); } catch { /* Cyclic or excessively deep host input is not a JSON request. */ }
2049
- const bytes = encode(valid ? call.input : { unavailable: "non-JSON arguments", type: typeof call.input });
2050
- const definition = toolDefinition(call.name, ctx);
2051
- const declared = this.config.frozen.tools.find(tool => tool !== null && typeof tool === "object" && "name" in tool && tool.name === call.name &&
2052
- "description" in tool && typeof tool.description === "string" && "input_schema" in tool) as ReturnType<typeof modelToolSchema> | undefined;
2053
- const schema = definition ? declared ?? modelToolSchema(definition, adviceTools(ctx)) : undefined;
2054
- const descriptor = definition ? { version: 1, ...schema,
2055
- ...(declared && JSON.stringify(declared.input_schema) !== JSON.stringify(definition.input_schema) ? { admission_schema: definition.input_schema } : {}),
2056
- access: definition.access, execution: definition.execution, branch: definition.branch, hiddenInputs: definition.hiddenInputs ?? [], result_version: 1 } : null;
2057
- const original = this.state.events.findLast(event => event.t === "assistant" && event.blocks.some(block => block.type === "tool_use" && block.call_id === call.call_id));
2058
- const originalInput = original?.t === "assistant" ? original.blocks.find(block => block.type === "tool_use" && block.call_id === call.call_id) : undefined;
2059
- const changed = originalInput?.type !== "tool_use" || valid && JSON.stringify(originalInput.input) !== JSON.stringify(call.input);
2060
- const event: ToolInvocationEvent = { t: "tool_invocation", id: this.clock.nextId(), at: this.clock.now(), call_id: call.call_id, name: call.name,
2061
- ...(ctx.origin ? { origin: ctx.origin } : {}),
2062
- input_ref: await archive(bytes), schema_ref: descriptor ? await archive(encode(descriptor)) : null,
2063
- ...(parent ? { parent_call_id: parent.call_id, parent_ref: { node: this.state.node, event: parent.id } } : {}),
2064
- ...(valid && bytes.length <= MAX_NESTED_ARGUMENT_BYTES && (parent || changed) ? { input: call.input as JsonValue } : {}) };
2065
- await this.record(event); return event;
2066
- }
2067
-
2068
- private async recordToolReceipt(invocation: ToolInvocationEvent, out: ToolResult, blockedImages: boolean): Promise<void> {
2069
- let data = out.data;
2070
- let dataRef;
2071
- if (data !== undefined) {
2072
- const bytes = new TextEncoder().encode(JSON.stringify(data));
2073
- if (bytes.length > MAX_INLINE_RESULT_DATA_BYTES) {
2074
- async function* source() { yield bytes; }
2075
- dataRef = (await archiveObservation(this.config.tools.outputStore!, source(), 0)).ref;
2076
- data = undefined;
2077
- }
2078
- }
2079
- await this.record({ t: "tool_result", id: this.clock.nextId(), at: this.clock.now(), call_id: invocation.call_id,
2080
- invocation_ref: { node: this.state.node, event: invocation.id },
2081
- ...(invocation.origin ? { origin: invocation.origin } : {}),
2082
- ...(invocation.parent_call_id ? { parent_call_id: invocation.parent_call_id } : {}), ok: out.ok, content: out.content,
2083
- ...(out.outcome ? { outcome: out.outcome } : {}), ...(data !== undefined ? { data } : {}), ...(dataRef ? { data_ref: dataRef } : {}),
2084
- ...(out.observation_sha256 !== undefined ? { observation_sha256: out.observation_sha256 } : {}),
2085
- ...(out.observation ? { observation: out.observation } : {}), ...(out.diagnostic ? { diagnostic: out.diagnostic } : {}),
2086
- ...(out.warnings ? { warnings: out.warnings } : {}), ...(out.instructions ? { instructions: out.instructions } : {}),
2087
- ...(out.content_sha256 ? { content_sha256: out.content_sha256 } : {}),
2088
- ...(out.content_ref ? { content_ref: out.content_ref } : {}),
2089
- ...(out.images?.length ? { images: submissionImages(out.images, blockedImages) } : {}), ...(out.effect ? { effect: out.effect } : {}) });
2090
- }
2091
-
2092
- private async prepareSessionAction(call: ToolCall, ctx: ToolContext, nextBranch: () => number,
2093
- emit: (event: LoopEvent) => void): Promise<DeferredToolResult | (() => Promise<DeferredToolResult>)> {
2094
- // SDK callers may omit allow or preauthorize an optional name. Permission is
2095
- // distinct from a declaration: neither direct nor nested calls may run it early.
2096
- const declared = declaredToolNames(this.config.frozen.tools);
2097
- if (ctx.deferredTools?.some(tool => tool.name === call.name) && !declared.includes(call.name)) {
2098
- return prepareNormalizedTool({ ...ctx, allow: (ctx.allow ?? declared).filter(name => name !== call.name) }, call.name, call.input);
2099
- }
2100
- const forkSkill = call.name === "read" && typeof call.input === "object" && call.input !== null ? this.config.skills?.find(skill =>
2101
- skill.context === "fork" && skill.filePath === resolve(ctx.root, String((call.input as Record<string, unknown>)["path"]))) : undefined;
2102
- const discovery = call.name === DISCOVERY_TOOL_NAME && ctx.extensions?.get(call.name) === DISCOVERY_TOOL;
2103
- if (!forkSkill && !discovery && !["note", "branch", "resolve", "history", "target", "worktree"].includes(call.name)) return prepareNormalizedTool(ctx, call.name, call.input);
2104
- const branchIndex = forkSkill || call.name === "branch" ? nextBranch() : 0;
2105
- return prepareNormalizedTool(ctx, call.name, call.input, async () => {
2106
- if (discovery) return async () => searchDeferredTools(ctx, call.input as Record<string, unknown>, this.allowedNames());
2107
- if (call.name === "history") return () => runHistoryTool(this.config.logDir, this.state.node, ctx, call.input);
2108
- if (call.name === "target") return async () => {
2109
- const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
2110
- if (!history.ok) throw Error(history.error);
2111
- const observed = await this.runtimeTargets.perform(ctx, history.nodes, call.input as Parameters<RuntimeTargets["perform"]>[2]);
2112
- return { ...observed.result, annotation: { t: "target_observation" as const, id: this.clock.nextId(), at: this.clock.now(), observation: observed.observation } };
2113
- };
2114
- if (call.name === "note") return async () => {
2115
- await this.observeEvidence();
2116
- const parsed = parseNote(call.input);
2117
- const prepared = await prepareWorkingNote(this.config.logDir, this.state.node, this.config.workNamespace ?? "main",
2118
- parsed.annotation ? this.toEvent(parsed.annotation) : undefined, parsed.result);
2119
- return { ...prepared.out, ...(prepared.annotation ? { annotation: prepared.annotation } : {}) };
2120
- };
2121
- if (call.name === "resolve") return () => this.runResolve(call.input);
2122
- if (call.name === "worktree") return () => this.runWorktree(call.input, ctx.pathRoot);
2123
- if (forkSkill?.disableModelInvocation) return async () => ({ ok: false, content: "this skill requires explicit user invocation" });
2124
- if (forkSkill) await this.recordSkill(forkSkill, "fork", "model");
2125
- const task = this.state.events.findLast(event => event.t === "user");
2126
- const branch = forkSkill ? this.runBranch({ purpose: `skill ${forkSkill.name}`, prompt: `${forkSkill.body}\n\nCurrent task: ${task?.t === "user" ? task.text : ""}` },
2127
- branchIndex, ctx.signal, forkSkill, skillTools(this.allowedNames(), forkSkill), "model") : this.dispatchBranch(call.input, branchIndex, ctx.signal);
2128
- while (true) {
2129
- const step = await branch.next();
2130
- if (step.done) return async () => step.value;
2131
- emit(step.value);
2132
- if (step.value.kind === "branch_start") return async () => {
2133
- const result = await branch.next();
2134
- if (!result.done) throw new Error(`unexpected child execution event: ${result.value.kind}`);
2135
- return result.value;
2136
- };
2137
- }
2138
- });
2139
- }
2140
-
2141
- private async *runBranch(
2142
- input: unknown,
2143
- indexThisTurn: number,
2144
- signal?: AbortSignal,
2145
- skill?: Skill,
2146
- allowedOverride?: readonly string[],
2147
- skillOrigin: "user" | "model" = "user",
2148
- ): AsyncGenerator<LoopEvent, DeferredToolResult> {
2149
- const limits = this.limits;
2150
- const depth = this.config.branchDepth ?? 0;
2151
- if (this.children.size >= (this.config.maxParallelTools ?? 3)) return { ok: false, content: "branch capacity reached; wait or cancel an owned branch first" };
2152
-
2153
- // Every refusal is a normal tool_result: an unanswered tool_use makes the
2154
- // next request unpairable, and a refusal can say what to do instead.
2155
- if (depth + 1 >= limits.maxBranchDepth) {
2156
- return {
2157
- content:
2158
- `refused: branch depth limit (${limits.maxBranchDepth}) reached. ` +
2159
- "Do this work directly with your own tools.",
2160
- ok: false,
2161
- };
2162
- }
2163
- if (indexThisTurn >= limits.maxBranchesPerTurn) {
2164
- return {
2165
- content:
2166
- `refused: at most ${limits.maxBranchesPerTurn} branches per turn. ` +
2167
- "Run the rest in a later turn, or do them yourself -- branching is " +
2168
- "usually more expensive than working sequentially.",
2169
- ok: false,
2170
- };
2171
- }
2172
- if (this.state.branchesOpened >= limits.maxBranchesPerSession) {
2173
- return {
2174
- content:
2175
- `refused: this session has opened its ${limits.maxBranchesPerSession} branches. ` +
2176
- "Continue directly.",
2177
- ok: false,
2178
- };
2179
- }
2180
-
2181
- const args = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
2182
- const purpose = String(args["purpose"] ?? "").trim();
2183
- const prompt = String(args["prompt"] ?? "").trim();
2184
- const writes = args["writes"] === true;
2185
- if (!purpose || !prompt) {
2186
- return { content: "a branch needs both a purpose and a prompt", ok: false };
2187
- }
2188
- const parentAllow = allowedOverride ?? this.config.tools.allow;
2189
- const branchNames = writes ? WRITING_BRANCH_TOOL_NAMES : BRANCH_TOOL_NAMES;
2190
- const allowed: string[] = parentAllow
2191
- ? branchNames.filter(name => parentAllow.includes(name)) : [...branchNames];
2192
- const sourceEvent = this.state.events.findLast(event => event.t === "assistant")?.id ?? this.state.events.at(-1)!.id;
2193
- let inputWorkspace: { kind: "shared_readonly" } | { kind: "git_tree"; head: string; tree: string } = { kind: "shared_readonly" };
2194
-
2195
- // Siblings are staggered so the second does not race the first for the
2196
- // prompt cache and rewrite the whole prefix.
2197
- if (indexThisTurn > 0 && limits.branchStaggerMs > 0) {
2198
- await new Promise((r) => setTimeout(r, limits.branchStaggerMs));
2199
- }
2200
-
2201
- const node = this.state.nextNode++;
2202
- this.state.branchesOpened += 1;
2203
- // A fork point must include any preceding tool effects in this batch.
2204
-
2205
- // A writing branch needs its own checkout, or parallel branches overwrite
2206
- // each other's files with nothing to show isolation was never in place.
2207
- // Refused rather than downgraded to read-only: the model asked to change
2208
- // files, and quietly not letting it would look like the work was done.
2209
- let worktree: { path: string; branch: string } | null = null;
2210
- if (writes) {
2211
- const created = await createWorktree(this.config.tools.root, `b${node}`);
2212
- if (!created.ok) {
2213
- this.state.branchesOpened -= 1;
2214
- this.state.nextNode -= 1;
2215
- return { content: `refused: ${created.error}`, ok: false };
2216
- }
2217
- worktree = { path: created.path, branch: created.branch };
2218
- inputWorkspace = { kind: "git_tree", ...created.snapshot };
2219
- this.state.worktrees.set(node, worktree);
2220
- }
2221
-
2222
- const { ref, branch } = await this.openBranch({
2223
- node, purpose, background: args["background"] === true, sourceEvent, inputWorkspace, worktree,
2224
- config: {
2225
- ...this.config,
2226
- acceptanceContract: null,
2227
- frozen: { system: this.config.frozen.system, tools: branchTools(writes, allowed) },
2228
- // The same list twice: the schema shapes what the model asks for, the
2229
- // allow-list decides what actually runs. Intersected with the parent
2230
- // so `--exclude-tools bash` cannot be undone by opening a branch.
2231
- tools: {
2232
- ...this.config.tools,
2233
- ...(worktree ? { root: worktree.path } : {}),
2234
- allow: allowed,
2235
- readonlyBranch: !writes,
2236
- },
2237
- branchDepth: depth + 1,
2238
- workNamespace: `branch-${node}`,
2239
- maxTurns: Math.min(this.config.maxTurns, 15),
2240
- },
2241
- });
2242
- if (skill) await branch.recordSkill(skill, "fork", skillOrigin);
2243
- this.children.set(node, branch);
2244
- yield { kind: "branch_start", node, reason: purpose, at: Date.now() };
2245
-
2246
- const { outcome, text } = await this.runChild(node, branch, prompt, args["background"] === true, signal);
2247
- const closed = await this.closeBranch(ref, branch, outcome, text);
2248
- const { tip, summary, receipt, receiptRef, joined } = closed;
2249
-
2250
- // The worktree stays until the branch is resolved: its uncommitted work
2251
- // is the branch's result, and removing it here would discard exactly
2252
- // what the parent is about to decide on.
2253
- const where = worktree
2254
- ? `\n\nIts changes are in ${worktree.path} on branch ${worktree.branch}, ` +
2255
- "not in your files. Adopting continues in that checkout without merging it into this one."
2256
- : "";
2257
-
2258
- // Only the summary crosses back. The branch's own calls stay in its node,
2259
- // which is the entire reason to branch rather than read in place.
2260
- return {
2261
- content:
2262
- `branch ${node} (${purpose}) ended: ${JSON.stringify(outcome)}.\n` +
2263
- `Evidence receipt: ${receipt.commands.length} commands, ${receipt.changes.length} file edits, ${receipt.unfinished.length} unfinished items; verification=${receipt.verification}; task acceptance=not assessed.\n` +
2264
- (this.adviceNames().includes("history") ? `Read history(artifact='${receiptRef}'), then history(branch=${node}, event='source ID') for original evidence.\n`
2265
- : `Evidence receipt artifact=${receiptRef}; original-source recovery capability unavailable.\n`) +
2266
- `Child closing statement (unverified):\n${summary}${where}\n\n` +
2267
- `Its full state is at ${handoffPath(this.config.logDir, tip)}. ` +
2268
- (this.adviceNames().includes("resolve") ? `Resolve it with resolve(node: ${node}, ...).` : "Branch-resolution capability unavailable."),
2269
- ok: outcome.status === "completed",
2270
- outcome,
2271
- finalText: text,
2272
- accept: async () => {
2273
- if ((await this.branchStatuses()).some(item => item.node === node && item.handoff_ref)) return [];
2274
- this.state.usage.push(...branch.state.usage);
2275
- await this.record(joined);
2276
- await this.saveCursor();
2277
- return [{ kind: "branch_done", node, text: summary, outcome, ok: outcome.status === "completed" }];
2278
- },
2279
- data: { node, outcome, handoff_ref: handoffPath(this.config.logDir, tip), requests: branch.state.usage.length,
2280
- receipt_ref: receiptRef,
2281
- input: { source_event: sourceEvent, workspace: inputWorkspace } },
2282
- };
2283
- }
2284
-
2285
- /**
2286
- * D14: the parent records `spawn`, the branch gets its own node file with
2287
- * basis "none" (it starts fresh; the parent pointer is provenance only).
2288
- * Shared by model-spawned branches and user-started ones (/review-code).
2289
- */
2290
- private async openBranch(spec: {
2291
- /** Omitted for a user command in an empty session: the spawn is then its own request. */
2292
- node: number; purpose: string; background: boolean; sourceEvent?: string;
2293
- inputWorkspace: { kind: "shared_readonly" } | { kind: "git_tree"; head: string; tree: string };
2294
- worktree: { path: string; branch: string } | null; config: SessionConfig; provider?: Provider;
2295
- }): Promise<{ ref: NodeRef; branch: Session }> {
2296
- const { node, purpose, worktree } = spec;
2297
- const ref = await this.nodeRefFor(node), id = this.clock.nextId();
2298
- await this.record({
2299
- t: "spawn", id, at: this.clock.now(), branch: ref, purpose,
2300
- ...(spec.background ? { background: true as const } : {}),
2301
- input: { source_event: spec.sourceEvent ?? id, tools: spec.config.tools.allow ?? [], max_turns: spec.config.maxTurns, workspace: spec.inputWorkspace },
2302
- ...(worktree ? { workspace: worktree } : {}),
2303
- });
2304
- await initNode(this.config.logDir, node, {
2305
- ...await this.nodeRefFor(this.state.node), at_event: this.state.events.at(-1)!.id,
2306
- }, this.clock.now(), "none");
2307
- const branch = new Session(spec.config, spec.provider ?? (this.config.branchProvider ? this.config.branchProvider() : this.provider), this.clock, this.allocation);
2308
- branch.state.node = node;
2309
- branch.state.nextNode = this.state.nextNode;
2310
- branch.secrets = this.secrets;
2311
- branch.subscribeRecords(item => { for (const listener of this.branchRecordListeners) { try { listener(item); } catch { /* observers never own the append */ } } });
2312
- return { ref, branch };
2313
- }
2314
-
2315
- /** Runs the child to its end. Its closing text is the last turn's text; a throw becomes a bounded outcome. */
2316
- private async runChild(node: number, branch: Session, prompt: string, background: boolean, signal?: AbortSignal, keepRecorded = false): Promise<{ outcome: ExecutionOutcome; text: string }> {
2317
- let finalParts: string[] = [];
2318
- let outcome = executionOutcome("unknown");
2319
- try {
2320
- for await (const e of branch.send(prompt, signal)) {
2321
- if (e.kind === "turn_start" || e.kind === "discard") finalParts = [];
2322
- if (e.kind === "assistant_text") finalParts.push(e.text ?? "");
2323
- if (e.kind === "done") outcome = e.outcome ?? executionOutcome("unknown");
2324
- }
2325
- } catch {
2326
- // Persist a bounded failure classification, never a provider's raw error body.
2327
- // A model's branch joins as execution_error (its caller sees a failed tool). A user branch keeps the
2328
- // classification its own send recorded (provider_error vs execution_error), as the detached review did.
2329
- const recorded = keepRecorded ? branch.state.events.findLast(event => event.t === "outcome") : undefined;
2330
- outcome = signal?.aborted ? executionOutcome("cancelled") : recorded?.t === "outcome" ? recorded.outcome : executionOutcome("execution_error");
2331
- await branch.answerUnpairedTools("the branch execution failed; this tool's outcome is unknown; inspect before retrying");
2332
- } finally { try { await branch.dispose(); } finally { this.children.delete(node); } }
2333
- if (background) await branch.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), outcome, reason: "background branch execution ended" });
2334
- return { outcome, text: finalParts.join("\n") };
2335
- }
2336
-
2337
- /** Closes the branch's tip with its derived handoff and builds the `joined` the parent will record (D15). */
2338
- private async closeBranch(ref: NodeRef, branch: Session, outcome: ExecutionOutcome, text: string) {
2339
- // Node numbers the branch allocated are taken.
2340
- const tip = branch.state.node;
2341
- const branchContext = await replayContext(this.config.logDir, tip);
2342
- // Re-digest now: `ref` was taken at spawn, before this node had events.
2343
- const handoff = deriveHandoff({
2344
- prev: branchContext.fullHandoff, depth: branchContext.depth, events: branchContext.events, self: await this.nodeRefFor(tip),
2345
- config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity },
2346
- });
2347
- await closeNode(this.config.logDir, tip, boundHandoff(handoff), (await readNode(this.config.logDir, tip)).events.length, this.clock.now());
2348
- const summary = text.trim() || "(the branch produced no closing message)";
2349
- const receiptHistory = await readNodeHistory(this.config.logDir, tip, undefined, "context");
2350
- if (!receiptHistory.ok) throw new Error(receiptHistory.error);
2351
- const receipt = deriveBranchReceipt({ source: await this.nodeRefFor(tip), outcome, handoff, nodes: receiptHistory.nodes });
2352
- const receiptRef = await putBlob(join(this.config.logDir, "artifacts"), JSON.stringify(receipt));
2353
- const joined: LogEvent = {
2354
- t: "joined", id: this.clock.nextId(), at: this.clock.now(),
2355
- branch: ref, tip: await this.nodeRefFor(tip), handoff_ref: handoffPath(this.config.logDir, tip), summary,
2356
- model: branch.provider.config.spec.id,
2357
- outcome,
2358
- receipt_ref: receiptRef,
2359
- };
2360
- return { tip, summary, receipt, receiptRef, joined };
2361
- }
2362
-
2363
- private async runWorktree(input: unknown, pathRoot?: string): Promise<ToolResult> {
2364
- const args = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
2365
- const root = this.config.tools.root;
2366
- // A branch already runs in its own checkout and never owns the parent's root.
2367
- if ((this.config.branchDepth ?? 0) > 0) return { content: "worktree is unavailable inside a branch; the branch already has its own checkout", ok: false };
2368
- if (this.pendingRoot !== null) return { content: "one worktree switch per tool round; the previous one applies after this round", ok: false };
2369
- const main = await mainWorktree(root);
2370
- if (main === null) return { content: `${root} is not inside a git repository, so there is nothing to make a worktree of`, ok: false };
2371
- const outside = (path: string) => pathRoot !== undefined && relative(pathRoot, path).startsWith("..");
2372
- if (args["action"] === "exit") {
2373
- if (main === root) return { content: `already in the main checkout ${main}; nothing to exit`, ok: false };
2374
- if (outside(main)) return { content: `the main checkout ${main} is outside --path-root ${pathRoot}`, ok: false };
2375
- const branch = (await listWorktrees(main)).find(wt => wt.path === root)?.branch ?? null;
2376
- this.pendingRoot = main;
2377
- return { ok: true, content: [`main ${main}`, `kept ${root}${branch ? ` on ${branch.replace(/^refs\/heads\//, "")}` : ""}`,
2378
- "The session root moves to the main checkout after this tool round. Nothing was merged or deleted; " +
2379
- `remove the worktree yourself with \`git worktree remove ${root}\` once its branch is merged or no longer needed.`].join("\n") };
2380
- }
2381
- if (args["action"] !== "enter") return { content: "worktree action must be enter or exit", ok: false };
2382
- const name = typeof args["name"] === "string" ? args["name"] : "";
2383
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name) || name.includes("..")) {
2384
- return { content: "worktree name must be 1-64 letters, digits, dot, dash or underscore, starting with a letter or digit", ok: false };
2385
- }
2386
- const parent = persistentWorktreeParent(configDir(), main);
2387
- if (outside(parent)) return { content: `xeno's worktree directory ${parent} is outside --path-root ${pathRoot}`, ok: false };
2388
- const created = await createWorktree(root, name, { location: { parent, leaf: name }, includeChanges: args["include_changes"] === true });
2389
- if (!created.ok) return { content: `refused: ${created.error}`, ok: false };
2390
- this.pendingRoot = created.path;
2391
- return { ok: true, content: [`worktree ${created.path}`, `branch ${created.branch}`, `from ${created.snapshot.head}`,
2392
- `The session root moves there after this tool round; relative paths, edits and commands then resolve in it. ` +
2393
- `The working directory in the system prompt still names ${root}. ` +
2394
- (args["include_changes"] === true ? "It starts from the current uncommitted files." : "It starts from HEAD; uncommitted files in the main checkout were not copied.")].join("\n"),
2395
- data: { path: created.path, branch: created.branch, head: created.snapshot.head } };
2396
- }
2397
-
2398
- private async applyPendingRoot(): Promise<void> {
2399
- const root = this.pendingRoot;
2400
- this.pendingRoot = null;
2401
- if (root === null) return;
2402
- this.config.tools.root = root;
2403
- this.installWorkspace();
2404
- await this.saveCursor();
2405
- }
2406
-
2407
- private async runResolve(input: unknown): Promise<ToolResult> {
2408
- const args = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
2409
- const node = Number(args["node"]);
2410
- const resolution = String(args["resolution"] ?? "");
2411
- const why = typeof args["why"] === "string" ? args["why"].trim() : "";
2412
-
2413
- if (!Number.isInteger(node)) return { content: "resolve needs a node number", ok: false };
2414
- const history = await readNodeHistory(this.config.logDir, this.state.node);
2415
- if (!history.ok) return { ok: false, content: history.error };
2416
- const events = history.nodes.flatMap((n) => n.events);
2417
- const spawned = events.some(
2418
- (e) => (e.t === "spawn" || e.t === "joined") && e.branch.node === node,
2419
- );
2420
- if (!spawned) {
2421
- return { content: `no branch ${node} was opened from this node`, ok: false };
2422
- }
2423
- if (events.some((e) => (e.t === "adopt" || e.t === "discard") && e.branch.node === node)) {
2424
- return { ok: false, content: `branch ${node} is already resolved` };
2425
- }
2426
-
2427
- const joined = events.find((e) => e.t === "joined" && e.branch.node === node);
2428
- if (resolution === "status") {
2429
- const item = (await this.branchStatuses()).find(branch => branch.node === node)!;
2430
- return { ok: true, content: `branch ${node}: ${item.status}`, data: { node, status: item.status } };
2431
- }
2432
- if (resolution === "wait" || resolution === "cancel") {
2433
- if (joined) return { ok: true, content: `branch ${node} already joined; receipt ${joined.t === "joined" ? joined.receipt_ref ?? "not recorded" : ""}` };
2434
- if (resolution === "cancel" && this.jobs.state(node) === undefined) return { ok: false, content: `branch ${node}: unknown after restart; no owned handle to cancel` };
2435
- if (resolution === "cancel") { this.jobs.cancel(node); await this.jobs.drain(node); }
2436
- const waitMs = args["wait_ms"] === undefined ? 10_000 : args["wait_ms"];
2437
- if (typeof waitMs !== "number" || !Number.isFinite(waitMs) || waitMs < 0 || waitMs > 60_000) return { ok: false, content: "wait_ms must be between 0 and 60000" };
2438
- const result = await this.jobs.wait(node, waitMs);
2439
- if (result?.running) return { ok: true, content: `branch ${node} running; wait reached its deadline`, data: { node, running: true } };
2440
- if (result?.error) return { ok: false, content: result.error };
2441
- const complete = result?.value ?? await this.recoverBranchResult(node);
2442
- if (!complete) return { ok: false, content: `branch ${node}: unknown after restart; no sealed execution outcome; inspect before retrying` };
2443
- if (complete.accept) await complete.accept();
2444
- this.jobs.forget(node);
2445
- return complete;
2446
- }
2447
- if (this.jobs.state(node) === "running") return { ok: false, content: `branch ${node} running; wait or cancel before resolving it` };
2448
- if (!joined && this.jobs.state(node) === "ready") return { ok: false, content: `branch ${node} has not joined; wait before resolving it` };
2449
-
2450
- const ref = await this.nodeRefFor(node);
2451
- if (resolution === "adopt") {
2452
- if ((await this.branchStatuses()).some(item => item.node !== node && item.background && !item.handoff_ref && item.resolution === null)) return { ok: false, content: "wait or cancel other unjoined background branches before adoption" };
2453
- if (this.pendingAdoption !== null) return { content: "resolve one adoption per tool round", ok: false };
2454
- if (!joined) return { content: `branch ${node} has not joined`, ok: false };
2455
- await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: ref });
2456
- this.pendingAdoption = node;
2457
- return { content: `adopted branch ${node}; continuation moves after this tool round is paired`, ok: true };
2458
- }
2459
- if (resolution === "discard") {
2460
- await this.record({
2461
- t: "discard", id: this.clock.nextId(), at: this.clock.now(),
2462
- branch: ref, reason: why || "no reason given",
2463
- });
2464
- // Discarding is the decision that its worktree can go. A dirty one is
2465
- // reported rather than forced: those changes are what was discarded,
2466
- // and silently deleting them is the destructive-restore mistake again.
2467
- const wt = this.state.worktrees.get(node);
2468
- if (wt) {
2469
- const removed = await removeWorktree(this.config.tools.root, wt.path, wt.branch);
2470
- this.state.worktrees.delete(node);
2471
- if (!removed.ok) {
2472
- return {
2473
- content:
2474
- `discarded branch ${node}, but its checkout is still at ${wt.path}: ${removed.error}`,
2475
- ok: true,
2476
- };
2477
- }
2478
- }
2479
- return { content: `discarded branch ${node}`, ok: true };
2480
- }
2481
- return { content: `resolution must be wait, cancel, status, adopt or discard`, ok: false };
2482
- }
2483
-
2484
- private async recoverBranchResult(root: number): Promise<DeferredToolResult | undefined> {
2485
- // Recover only a unique sealed continuation. An open or ambiguous branch
2486
- // cannot be replayed safely because tools may already have had effects.
2487
- const headers = await Promise.all((await listNodes(this.config.logDir)).map(async node => ({ node, header: (await readNode(this.config.logDir, node)).header })));
2488
- let tip = root;
2489
- const seen = new Set<number>();
2490
- for (;;) {
2491
- if (seen.has(tip)) return undefined;
2492
- seen.add(tip);
2493
- const children = headers.filter(item => item.header?.parent?.node === tip && item.header.basis !== "none");
2494
- if (children.length > 1) return undefined;
2495
- if (children.length === 0) break;
2496
- tip = children[0]!.node;
2497
- }
2498
- if (!(await readManifest(this.config.logDir)).nodes.some(item => item.node === tip && item.handoff_ref)) return undefined;
2499
- const history = await readNodeHistory(this.config.logDir, tip, undefined, "context");
2500
- if (!history.ok) throw new Error(history.error);
2501
- const outcomeEvent = history.nodes.flatMap(part => part.events).findLast(event => event.t === "outcome");
2502
- if (outcomeEvent?.t !== "outcome") return undefined;
2503
- const handoff = await replayContext(this.config.logDir, tip);
2504
- const full = deriveHandoff({ prev: handoff.fullHandoff, events: handoff.events, depth: handoff.depth, self: await this.nodeRefFor(tip) });
2505
- const receipt = deriveBranchReceipt({ source: await this.nodeRefFor(tip), outcome: outcomeEvent.outcome, handoff: full, nodes: history.nodes });
2506
- const receiptRef = await putBlob(join(this.config.logDir, "artifacts"), JSON.stringify(receipt));
2507
- const last = history.nodes.flatMap(part => part.events).findLast(event => event.t === "assistant");
2508
- const summary = last?.t === "assistant" ? last.blocks.flatMap(block => block.type === "text" ? [block.text] : []).join("\n") : "(no closing message)";
2509
- return { ok: outcomeEvent.outcome.status === "completed", outcome: outcomeEvent.outcome,
2510
- content: `branch ${root} recovered from sealed node ${tip}; receipt ${receiptRef}; closing statement is unverified: ${summary}`,
2511
- data: { node: root, outcome: outcomeEvent.outcome, receipt_ref: receiptRef, handoff_ref: handoffPath(this.config.logDir, tip) },
2512
- accept: async () => {
2513
- if ((await this.branchStatuses()).some(item => item.node === root && item.handoff_ref)) return [];
2514
- await this.record({ t: "joined", id: this.clock.nextId(), at: this.clock.now(), branch: await this.nodeRefFor(root), tip: await this.nodeRefFor(tip),
2515
- handoff_ref: handoffPath(this.config.logDir, tip), receipt_ref: receiptRef, summary, outcome: outcomeEvent.outcome });
2516
- await this.saveCursor(); return [];
2517
- } };
2518
- }
2519
-
2520
- /** D15: finish the parent's tool round before moving to the chosen branch. */
2521
- private async adoptPendingBranch(): Promise<void> {
2522
- this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
2523
- const release = await this.rootTools.acquire("exclusive");
2524
- let checkpoint = false;
2525
- try {
2526
- const root = this.pendingAdoption;
2527
- if (root === null) return;
2528
- const history = await readNodeHistory(this.config.logDir, this.state.node);
2529
- if (!history.ok) throw new Error(history.error);
2530
- const events = history.nodes.flatMap((n) => n.events);
2531
- const joined = events.find((e) => e.t === "joined" && e.branch.node === root);
2532
- const spawned = events.find((e) => e.t === "spawn" && e.branch.node === root);
2533
- if (joined?.t !== "joined" || spawned?.t !== "spawn") throw new Error(`missing provenance for adoption ${root}`);
2534
- const parent = this.state.node;
2535
- const annotations = carryForward(events, spawned.id, parent);
2536
- const tip = joined.tip ?? joined.branch;
2537
- const intent = events.findLast((e) => e.t === "adopt" && e.branch.node === root && e.next_node !== undefined);
2538
- const node = intent?.t === "adopt" && intent.next_node !== undefined ? intent.next_node : this.state.nextNode++;
2539
- if (!intent) await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: joined.branch, next_node: node });
2540
- const parentRef = await this.nodeRefFor(parent);
2541
- const existing = await readNode(this.config.logDir, node);
2542
- if (existing.header === null) await initNode(this.config.logDir, node, tip, this.clock.now(), "inherit", { adopted_from: parentRef });
2543
- else if (existing.header.adopted_from?.node !== parent || existing.header.parent?.node !== tip.node) {
2544
- throw new Error(`INTEGRITY: adoption successor ${node} belongs to another transition`);
2545
- }
2546
- const rebuilt = await replayContext(this.config.logDir, node);
2547
- this.state.node = node;
2548
- this.state.nextNode = Math.max(this.state.nextNode, node + 1);
2549
- this.state.events = rebuilt.contextEvents;
2550
- this.resetDerivedToolState();
2551
- this.state.handoff = rebuilt.handoff;
2552
- this.state.depth = rebuilt.depth;
2553
- this.state.anchor = null;
2554
- const copied = new Set(existing.events.flatMap((e) => "carried_from" in e && e.carried_from ? [e.carried_from.event] : []));
2555
- for (const event of annotations) if (!copied.has(event.carried_from!.event)) await this.record({ ...event, id: this.clock.nextId() });
2556
- if (!existing.events.some((e) => e.t === "adopt" && e.applied && e.branch.node === root)) {
2557
- await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: joined.branch, applied: true });
2558
- }
2559
- const checkout = this.state.worktrees.get(root);
2560
- if (checkout) this.config.tools.root = checkout.path;
2561
- this.installWorkspace();
2562
- await this.saveCursor();
2563
- this.pendingAdoption = null;
2564
- checkpoint = joined.model !== undefined && joined.model !== this.provider.config.spec.id;
2565
- } finally { release(); }
2566
- if (checkpoint) await this.performCut("model_checkpoint");
2567
- }
2568
-
2569
- private toEvent(a: Annotation): LogEvent {
2570
- const stamp = { id: this.clock.nextId(), at: this.clock.now() };
2571
- switch (a.t) {
2572
- case "claim": {
2573
- // A citation naming a configured gate is recorded as a gate query, so
2574
- // it still resolves when the same suite is invoked differently later.
2575
- // Everything else stays an exact-command query.
2576
- const gate = a.proof ? matchGate(this.config.tools, a.proof) : undefined;
2577
- return {
2578
- t: "claim", ...stamp, what: a.what, ...(a.work_id ? { work_id: a.work_id } : {}),
2579
- evidence: gate !== undefined ? { gate } : a.evidence,
2580
- };
2581
- }
2582
- case "next_action":
2583
- return { t: "next_action", ...stamp, what: a.what, next_action: a.next_action, ...(a.work_id ? { work_id: a.work_id } : {}), ...(a.depends_on ? { depends_on: a.depends_on } : {}) };
2584
- case "work_owner": return { ...a, ...stamp, owner: this.config.workNamespace ?? "main" };
2585
- case "work_dependencies": return { ...a, ...stamp };
2586
- case "decision":
2587
- return {
2588
- t: "decision", ...stamp, chose: a.chose, because: a.because,
2589
- ...(a.rejected ? { rejected: a.rejected } : {}),
2590
- };
2591
- case "pitfall":
2592
- return { t: "pitfall", ...stamp, tried: a.tried, failed_because: a.failed_because };
2593
- case "blocked":
2594
- return { t: "blocked", ...stamp, what: a.what, reason: a.reason, ...(a.work_id ? { work_id: a.work_id } : {}) };
2595
- case "work_status":
2596
- case "evidence_link":
2597
- return { ...a, ...stamp };
2598
- case "question":
2599
- return { t: "question", ...stamp, text: a.text };
2600
- default:
2601
- return assertNever(a, "toEvent");
2602
- }
2603
- }
2604
-
2605
- // ---- rewind ---------------------------------------------------------------
2606
-
2607
- /** A prepared edit can be reconciled by its exact path; unknown commands are never replayed. */
2608
- private async recoverFileOperations(): Promise<void> {
2609
- if (!this.config.blobDir) return;
2610
- // A cut is only legal after paired tools, so pending operations belong to this node.
2611
- const history = (await readNode(this.config.logDir, this.state.node)).events.filter((event): event is CheckpointEvent => event.t === "checkpoint");
2612
- const pending = new Map<string, CheckpointEvent>();
2613
- for (const event of history) {
2614
- if (event.phase === "prepared") pending.set(event.operation, event);
2615
- else pending.delete(event.operation);
2616
- }
2617
- for (const event of pending.values()) {
2618
- let applied = event.source !== "git" && changedPaths(event.changes).length > 0;
2619
- for (const [path, change] of Object.entries(event.changes)) {
2620
- try { if (!sameVersion((await readEditableFile(this.config.tools.root, path)).version, change.after)) applied = false; }
2621
- catch { applied = false; }
2622
- }
2623
- await this.record({ ...event, id: this.clock.nextId(), at: this.clock.now(), phase: applied ? "applied" : "unavailable",
2624
- reason: applied ? "observed the prepared file version after restart; tool outcome remains unknown" : "interrupted checkpoint; inspect files before retrying" });
2625
- }
2626
- }
2627
-
2628
- private async rewindJournal(point: RestorePoint, mode: RewindMode): Promise<RewindResult> {
2629
- const dir = this.config.logDir, root = this.config.tools.root, blobs = this.config.blobDir;
2630
- if (mode !== "context" && !blobs) return { ok: false, error: "this session stores no file checkpoints" };
2631
- try {
2632
- const target = { node: point.node, atEvent: point.atEvent };
2633
- // Reading the log validates the target. Context-only navigation never reads workspace contents.
2634
- const history = await readNodeHistory(dir, point.node);
2635
- if (!history.ok) throw new Error(history.error);
2636
- if (!history.nodes.find(part => part.node === point.node)?.events.some(event => event.id === point.atEvent)) throw new Error(`NO_SUCH_EVENT: ${point.atEvent}`);
2637
- const changes: FileChanges = await planCheckpointRestore(dir, this.state.node, target).catch(error => {
2638
- if (mode !== "context") throw error;
2639
- return {};
2640
- });
2641
- const paths = changedPaths(changes);
2642
- const historical = await readCursorAt(dir, point.node, point.atEvent);
2643
- const from: NodeRef = { ...await digestedRef(dir, point.node), at_event: point.atEvent };
2644
- const latest = (await readNode(dir, this.state.node)).events.at(-1);
2645
- const filesFrom: NodeRef = { ...await digestedRef(dir, this.state.node), ...(latest ? { at_event: latest.id } : {}) };
2646
- if (mode !== "context" && blobs) {
2647
- await preflightChanges(root, blobs, changes);
2648
- // Persist the intention before files move; recovery never invents command completion.
2649
- const operation = this.clock.nextId();
2650
- await this.record({ t: "checkpoint", id: operation, at: this.clock.now(), operation, source: "restore", phase: "prepared", changes });
2651
- await applyFileChanges(root, blobs, changes);
2652
- await this.record({ t: "checkpoint", id: this.clock.nextId(), at: this.clock.now(), operation, source: "restore", phase: "applied", changes });
2653
- }
2654
- const restored = mode === "context" ? [] : paths.filter(path => changes[path]!.after !== null);
2655
- const removed = mode === "context" ? [] : paths.filter(path => changes[path]!.after === null);
2656
- const event: LogEvent = { t: "rewind", id: this.clock.nextId(), at: this.clock.now(), mode, from, prompt: point.prompt,
2657
- restored, removed, unrecoverable: [], divergence: mode === "both" ? [] : paths,
2658
- ...(mode === "context" ? { files_from: filesFrom } : {}) };
2659
- if (mode === "files") await this.record(event);
2660
- else {
2661
- const node = this.state.nextNode++;
2662
- await initNode(dir, node, from, this.clock.now(), "inherit");
2663
- const replayed = await replayContext(dir, node);
2664
- this.state.node = node; this.state.events = [...replayed.contextEvents]; this.state.handoff = replayed.handoff;
2665
- this.state.depth = replayed.depth; this.state.anchor = null; this.resetDerivedToolState();
2666
- await this.record(event);
2667
- if (historical === null || !sameModel(historical, { model: this.provider.config.spec.id, provider: this.provider.config.spec.provider })) {
2668
- const cursor = await readCursor(dir);
2669
- if (cursor) await writeCursor(dir, { ...cursor, node, next_node: this.state.nextNode, needs_model_checkpoint: true });
2670
- await this.performCut("model_checkpoint", this.provider.config.spec.id);
2671
- }
2672
- }
2673
- await this.saveCursor();
2674
- return { ok: true, node: this.state.node, restored, removed, unrecoverable: [], note: mode === "context"
2675
- ? "Only the conversation moved; workspace files were left unchanged."
2676
- : "Restored recorded file changes. Unrelated files, ignored paths and external effects were left unchanged; command recovery requires captured Git checkpoints." };
2677
- } catch (error) { return { ok: false, error: String(error) }; }
2678
- }
2679
-
2680
- /**
2681
- * Every completed exchange, including abandoned branches and conversation-only turns.
2682
- *
2683
- * An exchange runs from one user message to just before the next, so its
2684
- * last event is where a fork belongs: the conversation is whole there, and
2685
- * the files are as the exchange left them.
2686
- *
2687
- * EVERY exchange, including ones that changed no file. Going back to
2688
- * before a badly phrased instruction is the main use of a context-only
2689
- * rewind, and such a turn often only talked -- filtering on "this exchange
2690
- * wrote something" removes exactly those points. Claude Code lists every
2691
- * prompt for the same reason and hides only the code options; whether
2692
- * files would move is a question the preview answers, since what matters
2693
- * is changes AFTER the point, not changes in it.
2694
- */
2695
- async restorePoints(): Promise<RestorePoint[]> {
2696
- const dir = this.config.logDir;
2697
- const history = await readNodeHistory(dir, this.state.node);
2698
- if (!history.ok) return [];
2699
- // Per EVENT, not per node. A rewind usually forks inside the node it is
2700
- // sitting in, so that node is on the path and the abandoned exchange is
2701
- // in the same file, just past the fork point. Marking whole nodes would
2702
- // hide exactly the exchange most worth getting back to.
2703
- const onPath = new Set(history.nodes.flatMap((n) => n.events.map((e) => e.id)));
2704
-
2705
- // Every node, read in full. A node the session rewound away from is
2706
- // still on disk, and listing only the current path would leave it
2707
- // undestroyed and unfindable at the same time.
2708
- const all: { node: number; events: LogEvent[] }[] = [];
2709
- for (const id of await listNodes(dir)) {
2710
- const { header, events } = await readNode(dir, id);
2711
- if (header === null) continue;
2712
- all.push({ node: id, events });
2713
- }
2714
-
2715
- const points: RestorePoint[] = [];
2716
- for (const n of all) {
2717
- let open: { prompt: string; changed: number; last: string; userId: string } | null = null;
2718
- const close = () => {
2719
- if (open) {
2720
- points.push({
2721
- node: n.node, atEvent: open.last, prompt: open.prompt, userId: open.userId,
2722
- changed: open.changed, onPath: onPath.has(open.last),
2723
- });
2724
- }
2725
- };
2726
- for (const e of n.events) {
2727
- if (e.t === "workspace_observation") continue;
2728
- if (e.t === "checkpoint" && e.source === "restore") { close(); open = null; continue; }
2729
- if (e.t === "user") {
2730
- close();
2731
- open = { prompt: e.text, changed: 0, last: e.id, userId: e.id };
2732
- continue;
2733
- }
2734
- if (!open) continue;
2735
- open.last = e.id;
2736
- if (e.t === "checkpoint" && e.phase === "applied") open.changed += changedPaths(e.changes).length;
2737
- }
2738
- close();
2739
- }
2740
- return points;
2741
- }
2742
-
2743
- /**
2744
- * What a rewind would do, without doing it.
2745
- *
2746
- * P.3 previews only logged paths and preflights conflicts when files will move.
2747
- * Context-only navigation consults history without reading workspace contents.
2748
- */
2749
- async previewRewind(point: RestorePoint, mode: RewindMode = "both"): Promise<RewindPreview> {
2750
- if (mode === "context") {
2751
- const history = await readNodeHistory(this.config.logDir, point.node);
2752
- if (!history.ok) return history;
2753
- if (!history.nodes.find(part => part.node === point.node)?.events.some(event => event.id === point.atEvent)) return { ok: false, error: `NO_SUCH_EVENT: ${point.atEvent}` };
2754
- const changes = await planCheckpointRestore(this.config.logDir, this.state.node, { node: point.node, atEvent: point.atEvent }).catch((): FileChanges => ({}));
2755
- return { ok: true, willWrite: changedPaths(changes).filter(path => changes[path]!.after !== null),
2756
- willDelete: changedPaths(changes).filter(path => changes[path]!.after === null) };
2757
- }
2758
- try {
2759
- const changes = await planCheckpointRestore(this.config.logDir, this.state.node, { node: point.node, atEvent: point.atEvent });
2760
- if (this.config.blobDir) await preflightChanges(this.config.tools.root, this.config.blobDir, changes);
2761
- return { ok: true, willWrite: changedPaths(changes).filter(path => changes[path]!.after !== null),
2762
- willDelete: changedPaths(changes).filter(path => changes[path]!.after === null) };
2763
- } catch (error) { return { ok: false, error: String(error) }; }
2764
- }
2765
-
2766
- /**
2767
- * Goes back to a turn: the files and the conversation move together.
2768
- *
2769
- * FORKS, never truncates. claude-code#89646 is the argument for this: their
2770
- * rewind drops the context and starts a new session, so rewinding too far
2771
- * loses the work with no way back -- even though the transcript was
2772
- * append-only on disk the whole time. Here the node rewound away from is
2773
- * still a node, so it is still a restore target, and "rewound too far" is
2774
- * recoverable rather than terminal.
2775
- *
2776
- * P.3 records a separate physical file cursor for context-only navigation.
2777
- * In combined mode, file restoration must succeed before opening the new
2778
- * conversation node. File-only restoration appends its observed transition.
2779
- */
2780
- async rewind(point: RestorePoint, mode: RewindMode = "both"): Promise<RewindResult> {
2781
- return this.rewindJournal(point, mode);
2782
- }
2783
-
2784
-
2785
- private resetDerivedToolState(): void {
2786
- this.stall = null;
2787
- this.recovery = null;
2788
- }
2789
-
2790
- private async recoveryGuidance(tools: readonly string[]): Promise<ReturnType<typeof recoveryReview>> {
2791
- if (this.recovery === null) {
2792
- const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
2793
- if (!history.ok) throw new Error(history.error);
2794
- this.recovery = advanceRecovery(emptyRecoveryState(), history.nodes.flatMap(node => node.events), path => resolve(this.config.tools.root, path));
2795
- }
2796
- return recoveryReview(this.recovery, tools);
2797
- }
2798
-
2799
- /**
2800
- * S.8: a repeated cycle is pointed out to the model once; the next one is
2801
- * the user's call when a question client can answer -- continue, stop, or
2802
- * new instructions -- and ends the request only without one (headless) or
2803
- * when the user stops it. The answer is recorded as an event, never kept
2804
- * as loop state: a continue is a `stall` guidance, instructions are steering.
2805
- */
2806
- private async *checkStall(request: string, signal?: AbortSignal): AsyncGenerator<LoopEvent, boolean> {
2807
- const limit = this.config.stallLimit ?? DEFAULT_STALL_LIMIT;
2808
- if (limit === 0) return false;
2809
- if (this.stall === null) {
2810
- const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
2811
- if (!history.ok) throw new Error(history.error);
2812
- this.stall = advanceStall(emptyStallState(), history.nodes.flatMap(({ node, events }) => events.map((event) => ({ node, event }))));
2813
- }
2814
- const decision = stallDecision(this.stall, limit);
2815
- if (decision.cycle === null) return false;
2816
- const pattern = decision.cycle === 1 ? "the same tool call" : `a cycle of ${decision.cycle} tool calls`;
2817
- const facts = `${pattern} repeated ${decision.repeats} times without new evidence (${decision.failures} failed receipts; ${decision.emptyWaits} empty waits)`;
2818
- if (decision.warn) {
2819
- await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "stall", sources: [],
2820
- text: `Harness: ${facts}. Change the method, run a meaningful check, or give the final result with unresolved limits. If the pattern repeats again, the user decides whether the request continues.` });
2821
- return false;
2822
- }
2823
- const answer = await this.askAboutCycle(facts, signal);
2824
- if (answer?.kind === "continue") {
2825
- await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "stall", sources: [],
2826
- text: `Harness: the user reviewed this (${facts}) and chose to continue. Do not repeat the same calls expecting a different result; change the method or finish with the result and its limits.` });
2827
- return false;
2828
- }
2829
- if (answer?.kind === "steer") {
2830
- await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), request, text: answer.text });
2831
- return false;
2832
- }
2833
- yield { kind: "stop_and_ask", reason: `${facts}; change the approach or provide new input` };
2834
- return true;
2835
- }
2836
-
2837
- /** Undefined means nobody answered: no question client (headless), cancelled, or an invalid reply. */
2838
- private async askAboutCycle(facts: string, signal?: AbortSignal): Promise<{ kind: "continue" } | { kind: "stop" } | { kind: "steer"; text: string } | undefined> {
2839
- const slot = this.config.tools.questionSlot, adapter = slot?.current, turn = slot?.activeTurn;
2840
- if (!adapter || !turn || signal?.aborted) return undefined;
2841
- const request = questionRequest(turn, `stall-${this.clock.nextId()}`, [{ id: "stall", header: "Repeated tool calls",
2842
- question: `The agent ${facts}. Continue, stop, or type instructions for it?`, multiple: false, custom: true,
2843
- options: [{ id: "continue", label: "Continue", description: "Let the agent keep working; it is told to change its method." },
2844
- { id: "stop", label: "Stop", description: "End this request; the log and evidence stay." }] }]);
2845
- try {
2846
- const reply = validateQuestionReply(request, await adapter(structuredClone(request), signal ?? new AbortController().signal));
2847
- const [answer] = reply.answers, text = answer?.text?.trim();
2848
- if (text) return { kind: "steer", text };
2849
- return answer?.choices[0] === "continue" ? { kind: "continue" } : answer?.choices[0] === "stop" ? { kind: "stop" } : undefined;
2850
- } catch { return undefined; }
2851
- }
2852
-
2853
- /** A steering signal releases only the wait; cancellation owns process termination. */
2854
- wakeProcessWaits(): void { this.processWake.abort(); }
2855
-
2856
- private async terminationReport(cause: string, requests: number, limit = this.config.maxTurns): Promise<string> {
2857
- const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
2858
- if (!history.ok) throw new Error(history.error);
2859
- return stopReport(history.nodes.flatMap(part => part.events), requests, limit, cause);
2860
- }
2861
-
2862
- /** N.8: build and check the replacement before committing a durable boundary. */
2863
- /**
2864
- * `overflowed` is the provider's own verdict that the current projection
2865
- * does not fit, which outranks any estimate: the cut is taken as a hard
2866
- * limit, and retention is sized against the estimate scaled so the rejected
2867
- * prompt reads as exactly over the usable window.
2868
- */
2869
- private async *maybeWindowCut(counter: TokenCounter, willContinue: boolean, overflowed = false): AsyncGenerator<LoopEvent, boolean> {
2870
- const budget = this.policyLimits();
2871
- if (!willContinue || budget.kind !== "window" || pendingToolCalls(this.state.events).length > 0) return false;
2872
- const project = () => projectContext({ frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events });
2873
- let projected = project();
2874
- const measured = anchoredCount(projected.messages, projected.system, this.state.anchor, counter, this.config.frozen.tools).tokens;
2875
- const scale = overflowed ? Math.max(1, (budget.usable + 1) / Math.max(1, measured)) : 1;
2876
- if (overflowed) counter = scaledCounter(counter, scale);
2877
- let tokens = Math.ceil(measured * scale);
2878
- if (tokens < budget.prepare) return false;
2879
- const replayed = await replayContext(this.config.logDir, this.state.node);
2880
- let draft: Handoff;
2881
- try {
2882
- draft = await this.boundCarried(deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events,
2883
- depth: replayed.depth, self: await digestedRef(this.config.logDir, this.state.node),
2884
- config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity } }));
2885
- } catch (error) {
2886
- if (!(error instanceof RangeError)) throw error;
2887
- // An objective too large for a carried handoff cannot be repaired by another cut.
2888
- if (tokens <= budget.usable) return false;
2889
- yield { kind: "stop_and_ask", reason: `context cannot fit the model window: ${error instanceof Error ? error.message : "handoff construction failed"}` };
2890
- return true;
2891
- }
2892
- const noteEnabled = this.config.frozen.tools.some((tool) => tool !== null && typeof tool === "object" && "name" in tool && tool.name === "note") &&
2893
- (this.config.tools.allow === undefined || this.config.tools.allow.includes("note"));
2894
- if (this.config.guidance !== false && noteEnabled && tokens < budget.trigger && budget.trigger > 1) {
2895
- const notice = boundaryPreparation({ events: this.state.events, tokens, inheritedTokens: 0,
2896
- continuationReady: draft.continuation_valid,
2897
- config: { triggerTokens: budget.trigger, hardTokens: budget.trigger + (budget.trigger - budget.prepare), minNodeWorkTokens: 0 } });
2898
- if (notice) {
2899
- await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...notice });
2900
- projected = project();
2901
- tokens = anchoredCount(projected.messages, projected.system, this.state.anchor, counter, this.config.frozen.tools).tokens;
2902
- }
2903
- }
2904
- if (tokens < budget.trigger) return false;
2905
- const lastAssistant = this.state.events.findLastIndex((event) => event.t === "assistant");
2906
- const pendingUser = this.state.events.slice(lastAssistant + 1).find((event) => event.t === "user");
2907
- const retained = selectRecentContext({ events: this.state.events, frozen: this.config.frozen, handoff: draft,
2908
- counter, targetTokens: budget.target,
2909
- ...(pendingUser === undefined ? {} : { requiredUserId: pendingUser.id }) });
2910
- if (retained === null || retained.tokens >= tokens) {
2911
- // A large fresh prompt may still fit; let the model answer once. Never discard
2912
- // it to manufacture space, or record repeated cuts that cannot shrink it.
2913
- if (tokens <= budget.usable) return false;
2914
- 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" };
2915
- return true;
2916
- }
2917
- const node = this.state.node;
2918
- const reason = tokens > budget.usable ? "hard_limit" : "trigger";
2919
- await this.performCut(reason, this.provider.config.spec.id, retained.from);
2920
- yield { kind: "cut", node, reason };
2921
- return false;
2922
- }
2923
-
2924
- /** Every threshold the loop acts at. The footer and banner read the same function. */
2925
- private policyLimits(): ContextLimits {
2926
- return contextLimits({
2927
- policy: this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
2928
- contextWindow: this.provider.config.spec.contextWindow,
2929
- outputReserve: this.provider.config.maxTokens,
2930
- ...(this.config.boundary ? { boundary: this.config.boundary } : {}),
2931
- });
2932
- }
2933
-
2934
- private async *maybeCut(counter: TokenCounter, canPrepare: boolean): AsyncGenerator<LoopEvent, boolean> {
2935
- const limits = this.policyLimits();
2936
- if (limits.kind === "window") {
2937
- return yield* this.maybeWindowCut(counter, canPrepare);
2938
- }
2939
- const projected = projectContext({
2940
- frozen: this.config.frozen,
2941
- handoff: this.state.handoff,
2942
- events: this.state.events,
2943
- });
2944
- let { tokens } = anchoredCount(
2945
- projected.messages, projected.system, this.state.anchor, counter,
2946
- this.config.frozen.tools,
2947
- );
2948
-
2949
- // What this node would cost having done nothing: the frozen prefix plus
2950
- // the rendered handoff. Cutting cannot reduce it, so it does not count
2951
- // toward the node having earned a boundary.
2952
- const floor = projectContext({
2953
- frozen: this.config.frozen,
2954
- handoff: this.state.handoff,
2955
- events: [],
2956
- });
2957
- // Counted with the tools, because `tokens` above came from the anchor and
2958
- // the provider bills schemas on every request. Subtracting a figure that
2959
- // omitted them would read the difference as work the node had done.
2960
- const inheritedTokens = counter.count(floor.messages, floor.system, this.config.frozen.tools);
2961
-
2962
- const policy = this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY;
2963
- let draft: Handoff | null = null;
2964
- if (limits.kind === "segment" && tokens >= limits.prepare) {
2965
- // Read the same full ancestry used by the actual cut. A bounded carried
2966
- // page is insufficient to decide whether all active work is completed.
2967
- const replayed = await replayContext(this.config.logDir, this.state.node);
2968
- draft = await this.boundCarried(deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events,
2969
- depth: replayed.depth, self: await digestedRef(this.config.logDir, this.state.node),
2970
- config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity } }));
2971
- const noteEnabled = this.config.frozen.tools.some((tool) => tool !== null && typeof tool === "object" && "name" in tool && tool.name === "note") &&
2972
- (this.config.tools.allow === undefined || this.config.tools.allow.includes("note"));
2973
- if (canPrepare && this.config.guidance !== false && noteEnabled && tokens < limits.hard) {
2974
- const notice = boundaryPreparation({ events: this.state.events, tokens, inheritedTokens,
2975
- continuationReady: draft.continuation_valid, config: limits.config });
2976
- if (notice) {
2977
- await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...notice });
2978
- const updated = projectContext({ frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events });
2979
- tokens = anchoredCount(updated.messages, updated.system, this.state.anchor, counter, this.config.frozen.tools).tokens;
2980
- }
2981
- }
2982
- }
2983
- const price = this.provider.config.spec.price;
2984
- const usage = this.state.usage.at(-1);
2985
- const replacement = draft ? projectContext({ frozen: this.config.frozen, handoff: draft, events: [] }) : null;
2986
- const cost = replacement && price?.cacheRead !== undefined && price.cacheWrite !== undefined && usage &&
2987
- usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens > 0
2988
- ? estimateBoundaryCost({ retainedTokens: tokens,
2989
- rebuiltTokens: counter.count(replacement.messages, replacement.system, this.config.frozen.tools),
2990
- observedCachedTokens: usage.node === this.state.node ? usage.cache_read_input_tokens : 0,
2991
- inputPrice: price.input, cacheReadPrice: price.cacheRead, cacheWritePrice: price.cacheWrite }) : null;
2992
- const decision = decideContext({
2993
- policy, continuationReady: draft?.continuation_valid ?? false, cost,
2994
- tokens,
2995
- inheritedTokens,
2996
- atTurnBoundary: true,
2997
- ...(this.config.boundary ? { config: this.config.boundary } : {}),
2998
- });
2999
-
3000
- if (!decision.cut) {
3001
- // A floor past the hard limit cannot be worked around by cutting; the
3002
- // caller has to widen the thresholds or shrink the frozen prefix.
3003
- if (decision.reason === "inherited_floor_exceeds_limit") {
3004
- yield {
3005
- kind: "stop_and_ask",
3006
- reason:
3007
- `the inherited context alone is ${inheritedTokens} tokens, at or past the hard ` +
3008
- `limit -- widen the thresholds or shrink the system prompt`,
3009
- };
3010
- return true;
3011
- }
3012
- return false;
3013
- }
3014
-
3015
- const node = this.state.node;
3016
- await this.performCut(decision.reason);
3017
- yield { kind: "cut", node, reason: decision.reason };
3018
-
3019
- // D9 uses observations and fresh successful checks, not model assertions.
3020
- // The closed node's handoff is now this.state.handoff.
3021
- const handoff = this.state.handoff;
3022
- if (handoff !== null && shouldStopAndAsk(handoff.budget.nodes_without_progress)) {
3023
- yield {
3024
- kind: "stop_and_ask",
3025
- reason:
3026
- `${handoff.budget.nodes_without_progress} nodes with no verifiable progress. ` +
3027
- `blocked: ${handoff.state.blocked.length}, open questions: ${handoff.open_questions.length}`,
3028
- };
3029
- return true;
3030
- }
3031
- return false;
3032
- }
3033
-
3034
- /**
3035
- * Closes the current node and opens the next with a handoff basis.
3036
- *
3037
- * Shared by the automatic boundary and by switchModel. The caller decides
3038
- * WHETHER to cut -- this only performs it. Putting the work floor in here
3039
- * would make a model switch wait for minNodeWorkTokens, and the new model
3040
- * would see the old thinking blocks in the meantime.
3041
- */
3042
- private async performCut(reason: CutReason, nextModel = this.provider.config.spec.id, retainedFrom?: string,
3043
- nextProvider = this.provider.config.spec.provider, nextThinking = this.provider.config.effort, nextContextRef?: string): Promise<void> {
3044
- this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
3045
- const release = await this.rootTools.acquire("exclusive");
3046
- try {
3047
- const dir = this.config.logDir;
3048
- const node = this.state.node;
3049
-
3050
- const replayed = await replayContext(dir, node);
3051
- const fromDisk = replayed.events;
3052
- const retained = retainedFrom === undefined ? [] : retainedContext(replayed.contextEvents, retainedFrom);
3053
- let guidanceTools = this.adviceNames();
3054
- if (nextContextRef !== undefined) {
3055
- const saved = await getBlob(join(dir, "artifacts"), nextContextRef);
3056
- if (saved === null) throw new Error("missing successor guidance context");
3057
- guidanceTools = this.adviceNames(parseSessionContext(JSON.parse(saved)).frozen);
3058
- }
3059
-
3060
- // The cut event is part of the closed file. Digest after appending it
3061
- // so predecessor.sha256 matches what closeNode writes into the manifest.
3062
- await this.record({
3063
- t: "cut",
3064
- id: this.clock.nextId(),
3065
- at: this.clock.now(),
3066
- handoff_ref: handoffPath(dir, node),
3067
- predecessor: this.state.handoff?.predecessor ?? null,
3068
- reason,
3069
- next_node: this.state.nextNode,
3070
- next_model: nextModel,
3071
- next_provider: nextProvider,
3072
- next_thinking: nextThinking,
3073
- ...(nextContextRef === undefined ? {} : { next_context_ref: nextContextRef }),
3074
- handoff_tokens: DEFAULT_HANDOFF_TOKENS,
3075
- ...(this.config.workingSet ? { working_set_version: CURRENT_WORKING_SET_VERSION } : {}),
3076
- guidance_tools: guidanceTools,
3077
- ...(retainedFrom === undefined ? {} : { retained_from: retainedFrom }),
3078
- });
3079
- const self = await digestedRef(dir, node);
3080
- const handoff = await this.boundCarried(deriveHandoff({
3081
- prev: replayed.fullHandoff,
3082
- depth: replayed.depth,
3083
- events: fromDisk,
3084
- self,
3085
- config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity },
3086
- }), guidanceTools);
3087
- await closeNode(dir, node, handoff, (await readNode(dir, node)).events.length, this.clock.now());
3088
-
3089
- // One allocator for the whole session. A cut used to take node + 1 while
3090
- // a branch took nextNode++, so a cut after a branch reached the branch's
3091
- // id and overwrote its log.
3092
- this.state.node = this.state.nextNode++;
3093
- this.state.depth += 1;
3094
- this.state.handoff = handoff;
3095
- this.state.events = retained;
3096
- // The new node's prefix has never been sent, so there is nothing to anchor
3097
- // to until its first response comes back.
3098
- this.state.anchor = null;
3099
- // A cut declares a handoff basis: the closed node's events do not replay,
3100
- // its derived state does. Relying on the default here would silently make
3101
- // every boundary a fork and replay the whole history.
3102
- await initNode(dir, this.state.node, self, this.clock.now(), "handoff");
3103
- // The durable successor already belongs to the target provider, even if
3104
- // the process exits before switchModel installs its in-memory adapter.
3105
- await this.saveCursor(nextModel, nextProvider, nextThinking, nextContextRef);
3106
- } finally { release(); }
3107
- }
3108
- }