@visual-z/xeno 0.2.1 → 0.4.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 (282) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +126 -34
  3. package/docs/app-server.md +84 -0
  4. package/docs/install.md +51 -13
  5. package/docs/providers.md +24 -3
  6. package/package.json +7 -5
  7. package/src/apps/app-server.ts +28 -0
  8. package/src/apps/approve.ts +3 -3
  9. package/src/apps/cli.ts +25 -10
  10. package/src/apps/commands.ts +45 -8
  11. package/src/apps/completions.ts +78 -22
  12. package/src/apps/control-transport.ts +89 -0
  13. package/src/apps/image-draft.ts +46 -0
  14. package/src/apps/model-catalog.ts +2 -1
  15. package/src/apps/project-trust.ts +2 -42
  16. package/src/apps/provider-connect.ts +346 -0
  17. package/src/apps/render.ts +22 -47
  18. package/src/apps/resources.ts +5 -2
  19. package/src/apps/shell.ts +333 -1542
  20. package/src/apps/startup-input.ts +45 -9
  21. package/src/apps/startup.ts +149 -94
  22. package/src/apps/ui/app.ts +1553 -0
  23. package/src/apps/ui/control-model.ts +26 -0
  24. package/src/apps/ui/extension-host.ts +194 -0
  25. package/src/apps/ui/image-cache.ts +78 -0
  26. package/src/apps/ui/palette.ts +88 -0
  27. package/src/apps/ui/project.ts +127 -0
  28. package/src/apps/ui/standalone.ts +146 -0
  29. package/src/apps/ui/status-model.ts +68 -0
  30. package/src/apps/ui/surface.ts +86 -0
  31. package/src/apps/ui/theme.ts +86 -0
  32. package/src/core/acceptance.ts +107 -0
  33. package/src/core/action-defaults.ts +47 -295
  34. package/src/core/benchmark.ts +2 -2
  35. package/src/core/branch-receipt.ts +24 -0
  36. package/src/core/checkpoints.ts +64 -0
  37. package/src/core/churn.ts +2 -0
  38. package/src/core/command-artifacts.ts +94 -0
  39. package/src/core/command-contract.ts +35 -0
  40. package/src/core/completion-action.ts +20 -0
  41. package/src/core/completion-review.ts +37 -21
  42. package/src/core/context-detail.ts +37 -0
  43. package/src/core/context-policy.ts +74 -1
  44. package/src/core/continuation.ts +17 -2
  45. package/src/core/control-protocol.ts +62 -0
  46. package/src/core/control-view.ts +42 -0
  47. package/src/core/derive.ts +29 -3
  48. package/src/core/draft-images.ts +56 -0
  49. package/src/core/environment.ts +145 -0
  50. package/src/core/evaluation-coverage.ts +74 -0
  51. package/src/core/event-validation.ts +81 -6
  52. package/src/core/events.ts +99 -3
  53. package/src/core/evidence-links.ts +7 -0
  54. package/src/core/evidence.ts +13 -3
  55. package/src/core/execution.ts +8 -2
  56. package/src/core/experiment.ts +133 -0
  57. package/src/core/export.ts +9 -0
  58. package/src/core/failure-recovery.ts +173 -0
  59. package/src/core/file-precondition.ts +72 -0
  60. package/src/core/handoff.ts +65 -4
  61. package/src/core/harness-diagnostics.ts +206 -0
  62. package/src/core/history-query.ts +38 -2
  63. package/src/core/history.ts +38 -1
  64. package/src/core/integration-health.ts +34 -0
  65. package/src/core/keymap.ts +8 -20
  66. package/src/core/model-picker.ts +64 -0
  67. package/src/core/native-eval.ts +25 -0
  68. package/src/core/note-guidance.ts +34 -0
  69. package/src/core/oauth-fields.ts +44 -0
  70. package/src/core/observation-view.ts +49 -8
  71. package/src/core/permissions.ts +24 -2
  72. package/src/core/process-receipt.ts +105 -0
  73. package/src/core/process-state.ts +37 -0
  74. package/src/core/program-status.ts +31 -0
  75. package/src/core/project.ts +70 -19
  76. package/src/core/prompt-policy.ts +37 -0
  77. package/src/core/query-eval-contract.ts +14 -0
  78. package/src/core/read-observation.ts +82 -0
  79. package/src/core/recovery-corpus.ts +17 -0
  80. package/src/core/recovery-evaluation.ts +60 -0
  81. package/src/core/recovery-sources.ts +74 -0
  82. package/src/core/request-progress.ts +87 -0
  83. package/src/core/request-state.ts +114 -0
  84. package/src/core/runtime-target.ts +94 -0
  85. package/src/core/search-observation.ts +97 -0
  86. package/src/core/session-tree.ts +147 -0
  87. package/src/core/settings.ts +33 -10
  88. package/src/core/skills.ts +44 -0
  89. package/src/core/sql-query.ts +211 -0
  90. package/src/core/theme.ts +36 -58
  91. package/src/core/tool-contract.ts +51 -2
  92. package/src/core/tool-discovery.ts +87 -0
  93. package/src/core/tool-failure.ts +60 -0
  94. package/src/core/tool-input.ts +6 -0
  95. package/src/core/tool-mode-protocol.ts +25 -0
  96. package/src/core/transcript.ts +19 -4
  97. package/src/core/trial-lifecycle.ts +14 -0
  98. package/src/core/tui-mode.ts +29 -0
  99. package/src/core/update-check.ts +97 -0
  100. package/src/core/version.ts +55 -0
  101. package/src/core/work-state.ts +49 -3
  102. package/src/core/working-set.ts +91 -0
  103. package/src/runtime/auth.ts +11 -4
  104. package/src/runtime/branch-jobs.ts +36 -0
  105. package/src/runtime/catalog.ts +13 -3
  106. package/src/runtime/clipboard.ts +15 -4
  107. package/src/runtime/command-artifacts.ts +64 -0
  108. package/src/runtime/control-connection.ts +104 -0
  109. package/src/runtime/environment-tools.ts +60 -0
  110. package/src/runtime/evidence-notes.ts +4 -2
  111. package/src/runtime/exec-sessions.ts +206 -61
  112. package/src/runtime/execution-control.ts +1 -0
  113. package/src/runtime/extension-ui.ts +22 -30
  114. package/src/runtime/extensions.ts +95 -16
  115. package/src/runtime/harness-comparison.ts +27 -7
  116. package/src/runtime/harness-eval.ts +462 -0
  117. package/src/runtime/history-cache.ts +57 -0
  118. package/src/runtime/history-tools.ts +62 -12
  119. package/src/runtime/integrations.ts +210 -0
  120. package/src/runtime/loop.ts +1088 -282
  121. package/src/runtime/model-discovery.ts +208 -0
  122. package/src/runtime/model.ts +9 -0
  123. package/src/runtime/native-eval-fixtures.ts +33 -0
  124. package/src/runtime/native-eval.ts +146 -0
  125. package/src/runtime/oauth.ts +80 -3
  126. package/src/runtime/pi-provider.ts +169 -12
  127. package/src/runtime/program-status.ts +38 -0
  128. package/src/runtime/provider-connect.ts +109 -0
  129. package/src/runtime/provider-presets.ts +87 -0
  130. package/src/runtime/provider-request.ts +13 -2
  131. package/src/runtime/provider.ts +23 -9
  132. package/src/runtime/query-eval-verifier.ts +28 -0
  133. package/src/runtime/recovery-coverage.ts +71 -0
  134. package/src/runtime/recovery-evidence.ts +76 -0
  135. package/src/runtime/recovery-fixtures.ts +116 -0
  136. package/src/runtime/runtime-target.ts +149 -0
  137. package/src/runtime/session-control.ts +198 -0
  138. package/src/runtime/tool-broker.ts +137 -0
  139. package/src/runtime/tool-discovery.ts +57 -0
  140. package/src/runtime/tool-mode-eval.ts +146 -0
  141. package/src/runtime/tool-queue.ts +77 -0
  142. package/src/runtime/tools.ts +628 -183
  143. package/src/runtime/trial-control.ts +45 -0
  144. package/src/runtime/update-check.ts +47 -0
  145. package/src/runtime/window-trial.ts +2 -1
  146. package/src/runtime/working-notes.ts +33 -3
  147. package/src/runtime/workspace-lifecycle.ts +97 -0
  148. package/src/store/acceptance.ts +15 -0
  149. package/src/store/checkpoint-history.ts +43 -0
  150. package/src/store/command-artifacts.ts +96 -0
  151. package/src/store/command.ts +65 -37
  152. package/src/store/file-checkpoints.ts +104 -0
  153. package/src/store/file-index.ts +13 -1
  154. package/src/store/git-checkpoints.ts +67 -0
  155. package/src/store/observations.ts +61 -20
  156. package/src/store/process-receipts.ts +34 -0
  157. package/src/store/query-source.ts +13 -8
  158. package/src/store/recovery.ts +2 -3
  159. package/src/store/replay.ts +16 -4
  160. package/src/store/runtime-target.ts +64 -0
  161. package/src/store/search.ts +171 -132
  162. package/src/store/session-archive.ts +4 -0
  163. package/src/store/source-cancellation.ts +6 -0
  164. package/src/store/update-cache.ts +36 -0
  165. package/src/store/workspace-observation.ts +113 -0
  166. package/src/tui/ansi-spans.ts +126 -0
  167. package/src/tui/style.ts +19 -16
  168. package/src/tui/ui/atomic-draft.ts +121 -0
  169. package/src/tui/ui/canvas.ts +96 -0
  170. package/src/tui/ui/colors.ts +70 -0
  171. package/src/tui/ui/composer.ts +200 -0
  172. package/src/tui/ui/control-panel.ts +23 -0
  173. package/src/tui/ui/dialog.ts +379 -0
  174. package/src/tui/ui/draft.ts +23 -0
  175. package/src/tui/ui/edge.ts +186 -0
  176. package/src/tui/ui/image-size.ts +40 -0
  177. package/src/tui/ui/ink.ts +210 -0
  178. package/src/tui/ui/items.ts +40 -0
  179. package/src/tui/ui/keys.ts +40 -0
  180. package/src/tui/ui/layout.ts +74 -0
  181. package/src/tui/ui/list-model.ts +86 -0
  182. package/src/tui/ui/list-view.ts +172 -0
  183. package/src/tui/ui/mascot.ts +67 -0
  184. package/src/tui/ui/messages.ts +518 -0
  185. package/src/tui/ui/motion.ts +28 -0
  186. package/src/tui/ui/paths.ts +10 -0
  187. package/src/tui/ui/pictures.ts +78 -0
  188. package/src/tui/ui/screen.ts +421 -0
  189. package/src/tui/ui/status.ts +279 -0
  190. package/src/tui/ui/styled.ts +85 -0
  191. package/src/tui/ui/testing.ts +5 -0
  192. package/src/tui/ui/tool-card.ts +150 -0
  193. package/src/apps/extension-ui.ts +0 -330
  194. package/src/apps/resource-browser.ts +0 -75
  195. package/src/apps/session-browser.ts +0 -113
  196. package/src/apps/session-picker.ts +0 -55
  197. package/src/tui/ansi.ts +0 -67
  198. package/src/tui/autocomplete.ts +0 -29
  199. package/src/tui/chat-viewport.ts +0 -47
  200. package/src/tui/complete.ts +0 -87
  201. package/src/tui/editor.ts +0 -940
  202. package/src/tui/host.ts +0 -264
  203. package/src/tui/input.ts +0 -75
  204. package/src/tui/keys.ts +0 -378
  205. package/src/tui/markdown.ts +0 -17
  206. package/src/tui/render.ts +0 -153
  207. package/src/tui/select.ts +0 -258
  208. package/src/tui/settings.ts +0 -44
  209. package/src/tui/statusline.ts +0 -162
  210. package/src/tui/system-theme.ts +0 -700
  211. package/src/tui/terminal.ts +0 -30
  212. package/src/tui/text.ts +0 -52
  213. package/src/tui/toolview.ts +0 -386
  214. package/src/tui/transcript.ts +0 -123
  215. package/src/tui/tree.ts +0 -471
  216. package/src/vendor/pi-tui/LICENSE.upstream +0 -21
  217. package/src/vendor/pi-tui/UPSTREAM.json +0 -50
  218. package/src/vendor/pi-tui/VENDOR.md +0 -25
  219. package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
  220. package/src/vendor/pi-tui/autocomplete.ts +0 -860
  221. package/src/vendor/pi-tui/colors.ts +0 -367
  222. package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
  223. package/src/vendor/pi-tui/components/box.ts +0 -166
  224. package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
  225. package/src/vendor/pi-tui/components/editor.ts +0 -2472
  226. package/src/vendor/pi-tui/components/h-stack.ts +0 -44
  227. package/src/vendor/pi-tui/components/image.ts +0 -127
  228. package/src/vendor/pi-tui/components/input.ts +0 -494
  229. package/src/vendor/pi-tui/components/loader.ts +0 -101
  230. package/src/vendor/pi-tui/components/markdown.ts +0 -1021
  231. package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
  232. package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
  233. package/src/vendor/pi-tui/components/select-list.ts +0 -273
  234. package/src/vendor/pi-tui/components/settings-list.ts +0 -328
  235. package/src/vendor/pi-tui/components/spacer.ts +0 -28
  236. package/src/vendor/pi-tui/components/stack.ts +0 -154
  237. package/src/vendor/pi-tui/components/text.ts +0 -107
  238. package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
  239. package/src/vendor/pi-tui/components/v-stack.ts +0 -33
  240. package/src/vendor/pi-tui/editor-component.ts +0 -74
  241. package/src/vendor/pi-tui/fuzzy.ts +0 -138
  242. package/src/vendor/pi-tui/index.ts +0 -186
  243. package/src/vendor/pi-tui/keybindings.ts +0 -320
  244. package/src/vendor/pi-tui/keys.ts +0 -1401
  245. package/src/vendor/pi-tui/kill-ring.ts +0 -46
  246. package/src/vendor/pi-tui/latex.ts +0 -1506
  247. package/src/vendor/pi-tui/layout-node.ts +0 -51
  248. package/src/vendor/pi-tui/layout.ts +0 -449
  249. package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
  250. package/src/vendor/pi-tui/native/clipboard.h +0 -208
  251. package/src/vendor/pi-tui/native/darwin/README.md +0 -22
  252. package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
  253. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  254. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  255. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
  256. package/src/vendor/pi-tui/native/linux/README.md +0 -23
  257. package/src/vendor/pi-tui/native/linux/build.sh +0 -65
  258. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  259. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  260. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
  261. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
  262. package/src/vendor/pi-tui/native/napi.h +0 -91
  263. package/src/vendor/pi-tui/native/win32/README.md +0 -31
  264. package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
  265. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  266. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  267. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
  268. package/src/vendor/pi-tui/native-modifiers.ts +0 -13
  269. package/src/vendor/pi-tui/native-module-path.ts +0 -31
  270. package/src/vendor/pi-tui/native-platform.ts +0 -65
  271. package/src/vendor/pi-tui/oklab.ts +0 -233
  272. package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
  273. package/src/vendor/pi-tui/terminal-colors.ts +0 -91
  274. package/src/vendor/pi-tui/terminal-image.ts +0 -731
  275. package/src/vendor/pi-tui/terminal.ts +0 -554
  276. package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
  277. package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
  278. package/src/vendor/pi-tui/tui.ts +0 -1493
  279. package/src/vendor/pi-tui/undo-stack.ts +0 -28
  280. package/src/vendor/pi-tui/utils.ts +0 -1385
  281. package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
  282. package/src/vendor/pi-tui/word-navigation.ts +0 -117
@@ -18,53 +18,78 @@
18
18
  * request.
19
19
  */
20
20
 
21
- import type { LogEvent, AssistantBlock, NodeRef, CutReason } from "../core/events.ts";
21
+ import type { LogEvent, AssistantBlock, NodeRef, CutReason, ToolInvocationEvent } from "../core/events.ts";
22
22
  import { userInputData, submissionImages, type UserInput, type ImageRef } from "../core/images.ts";
23
23
  import { hydrateImages } from "./images.ts";
24
24
  import type { Handoff } from "../core/handoff.ts";
25
25
  import { boundHandoff, DEFAULT_HANDOFF_TOKENS } from "../core/history.ts";
26
26
  import { boundaryPreparation, continuationGuidance } from "../core/continuation.ts";
27
- import type { FrozenPrefix, Message } from "../core/project.ts";
28
- import { projectContext } from "../core/project.ts";
27
+ import type { FrozenPrefix, Message, ProjectInput } from "../core/project.ts";
28
+ import { projectContext, PROJECTION_VERSION, toolCallIdentityViolations } from "../core/project.ts";
29
29
  import { deriveHandoff, carryForward } from "../core/derive.ts";
30
- import { DEFAULT_BOUNDARY, shouldStopAndAsk, type BoundaryConfig } from "../core/boundary.ts";
31
- import { decideContext, DEFAULT_CONTEXT_POLICY, estimateBoundaryCost, isContextPolicy, type ContextPolicy } from "../core/context-policy.ts";
32
- import { retainedContext, selectRecentContext, windowBudget } from "../core/context-window.ts";
30
+ import { latestObservedFileVersion } from "../core/file-precondition.ts";
31
+ import { latestProcessReceipt } from "../core/process-receipt.ts";
32
+ import { advanceRecovery, emptyRecoveryState, recoveryReview, type RecoveryState } from "../core/failure-recovery.ts";
33
+ import { shouldStopAndAsk, type BoundaryConfig } from "../core/boundary.ts";
34
+ import {
35
+ contextLimits, decideContext, DEFAULT_CONTEXT_POLICY, estimateBoundaryCost, isContextPolicy,
36
+ type ContextLimits, type ContextPolicy,
37
+ } from "../core/context-policy.ts";
38
+ import { retainedContext, selectRecentContext } from "../core/context-window.ts";
33
39
  import { advanceStall, emptyStallState, stallDecision, DEFAULT_STALL_LIMIT, type StallState } from "../core/stall.ts";
34
40
  import { DEFAULT_OUTPUT_RECOVERY_LIMIT, isCompleteResponse, incompleteToolText, knownUnexecutedTool, outputRecoveryNotice, validateOutputRecoveryLimit } from "../core/response-recovery.ts";
35
- import { join } from "node:path";
41
+ import { join, resolve } from "node:path";
36
42
  import { randomUUID } from "node:crypto";
37
43
  import { pendingToolCalls } from "../core/execution.ts";
44
+ import { declaredToolNames, recordedDeclarations } from "../core/tool-discovery.ts";
45
+ import { DISCOVERY_TOOL, DISCOVERY_TOOL_NAME, currentDeferredTools, discoveryFingerprint, discoveryPermitted, searchDeferredTools } from "./tool-discovery.ts";
38
46
  import { executionOutcome, modelOutcome, type ExecutionOutcome } from "../core/outcome.ts";
47
+ import { commandCompletion } from "../core/command-contract.ts";
39
48
  import { ExecutionControl } from "./execution-control.ts";
49
+ import { BranchJobs } from "./branch-jobs.ts";
50
+ import { HistoryCache } from "./history-cache.ts";
51
+ import { RuntimeTargets } from "./runtime-target.ts";
52
+ import { ToolBroker, MAX_NESTED_ARGUMENT_BYTES, MAX_INLINE_RESULT_DATA_BYTES, type BrokerReceipt, type BrokerExecution } from "./tool-broker.ts";
53
+ import { ToolQueue } from "./tool-queue.ts";
54
+ import { NATIVE_COMMAND_NAME, nativeCommandContext, nativeCommandResult, type NativeCommandInput, type NativeCommandResult } from "./tools.ts";
55
+ import { isJsonValue, type JsonValue } from "../core/tool-contract.ts";
56
+ import { archiveObservation } from "../store/observations.ts";
57
+ import { disposeExecSessions, runningSessions } from "./exec-sessions.ts";
58
+ import { branchStatus, processStatus, deriveProcesses, type ProcessObservation } from "../core/process-state.ts";
40
59
  import { schedulingWindow } from "../core/scheduling.ts";
41
60
  import { estimateTokens, anchoredCount, scaledCounter, type TokenCounter, type Anchor } from "../core/tokens.ts";
42
61
  import { isContextOverflow } from "../core/overflow.ts";
43
- import { completionReview } from "../core/completion-review.ts";
62
+ import { requestCompletion } from "../core/completion-review.ts";
63
+ import { deriveRequestState, type RequestState } from "../core/request-state.ts";
44
64
  import { churnReview } from "../core/churn.ts";
65
+ import { deriveBranchReceipt } from "../core/branch-receipt.ts";
66
+ import { skillTools, type Skill } from "../core/skills.ts";
67
+ import type { WorkItem } from "../core/work-state.ts";
68
+ import { parseAcceptanceContract, type AcceptanceContract, type AcceptanceState } from "../core/acceptance.ts";
45
69
  import { deadlineNotice } from "../core/deadline.ts";
46
70
  import { statusOf } from "./retry.ts";
47
71
  import { assertNever } from "../core/exhaustive.ts";
48
72
  import { isLogEvent } from "../core/event-validation.ts";
49
73
  import {
50
74
  initNode, appendEvent, readNode, closeNode, digestNode, handoffPath, nodePath, manifestPath,
51
- listNodes, readCursor, readCursorAt, writeCursor,
75
+ listNodes, readManifest, readCursor, readCursorAt, writeCursor,
52
76
  } from "../store/log.ts";
53
- import { replayContext } from "../store/replay.ts";
77
+ import { replayContext, carriedHandoff } from "../store/replay.ts";
54
78
  import { recoverCut } from "../store/recovery.ts";
55
79
  import { runHistoryTool } from "./history-tools.ts";
56
80
  import { prepareWorkingNote } from "./working-notes.ts";
57
81
  import { copyAncestryTo } from "../store/sessions.ts";
58
- import type { Provider, Turn, StreamDelta } from "./provider.ts";
82
+ import { FailedResponseError, type Provider, type Turn, type StreamDelta } from "./provider.ts";
59
83
  import {
60
84
  scanWorkspace, diffScans, reconcile, DEFAULT_IGNORE, workspaceVersion,
61
85
  type FileSet, type RestoreResult,
62
86
  } from "../store/workspace.ts";
63
87
  import { putBlob, getBlob, putFileBlob } from "../store/blobs.ts";
88
+ import { hydrateProcessReceipts } from "../store/process-receipts.ts";
64
89
  import { readNodeHistory, planRestoreToNode, applyRestorePlan } from "../store/restore.ts";
65
90
  import { createWorktree, removeWorktree } from "../store/worktree.ts";
66
91
  import {
67
- runTool, parseNote, matchGate, branchTools, gateTool, finishToolResult, prepareTool, toolDefinition,
92
+ parseNote, matchGate, branchTools, finishToolResult, prepareNormalizedTool, presentObservation, toolDefinition, modelToolSchema, adviceTools,
68
93
  BRANCH_TOOL_NAMES, WRITING_BRANCH_TOOL_NAMES,
69
94
  type ToolContext, type Annotation, type ToolResult,
70
95
  } from "./tools.ts";
@@ -79,6 +104,10 @@ import { resolveHistoryPosition } from "../store/session-position.ts";
79
104
  import { observeCommand } from "../store/command.ts";
80
105
  import { headTailView } from "../core/observation-view.ts";
81
106
  import { withProgress } from "./progress.ts";
107
+ import { WorkspaceLifecycle } from "./workspace-lifecycle.ts";
108
+ import { changedPaths, sameVersion, type CheckpointEvent, type FileChanges } from "../core/checkpoints.ts";
109
+ import { readEditableFile, preflightChanges, applyFileChanges } from "../store/file-checkpoints.ts";
110
+ import { planCheckpointRestore } from "../store/checkpoint-history.ts";
82
111
 
83
112
  /** Ids and timestamps come from here so the loop stays the only impure part. */
84
113
  export interface Clock {
@@ -122,6 +151,8 @@ export interface LoopEvent {
122
151
  /** branch_start: wall-clock milliseconds, for asserting the stagger. */
123
152
  at?: number;
124
153
  outcome?: ExecutionOutcome;
154
+ /** Q23: task evidence is separate from the execution outcome; rebuilt from original receipts. */
155
+ request?: RequestState;
125
156
  progress?: Extract<StreamDelta, { kind: "tool" | "retry" }> | { kind: "output"; text: string; name: string };
126
157
  }
127
158
 
@@ -156,6 +187,18 @@ export const DEFAULT_BRANCH_LIMITS: BranchLimits = {
156
187
  };
157
188
 
158
189
  export interface SessionConfig {
190
+ /** P.3: legacy full snapshots are an explicit compatibility mode, never the CLI default. */
191
+ workspaceTracking?: "journal" | "legacy";
192
+ shellCheckpoints?: boolean;
193
+ skills?: readonly Skill[];
194
+ acceptanceContract?: AcceptanceContract | null;
195
+ strictVerification?: boolean;
196
+ /** O.10: opt-in carried view; legacy sessions and evaluations retain D20. */
197
+ workingSet?: boolean;
198
+ /** R9: CLI opts into source-priority views; absent preserves v1 experiment/old-session behavior. */
199
+ workingSetVersion?: 1 | 2;
200
+ /** R10: new CLI guidance; absence retains old carried/prefix wording on resume. */
201
+ guidanceVersion?: 1;
159
202
  logDir: string;
160
203
  frozen: FrozenPrefix;
161
204
  tools: ToolContext;
@@ -287,14 +330,30 @@ export type RewindResult =
287
330
  | { ok: false; error: string };
288
331
 
289
332
  /** O.5: child execution returns facts; only the parent coordinator accepts them. */
290
- type DeferredToolResult = ToolResult & { accept?: () => Promise<LoopEvent[]> };
333
+ type DeferredToolResult = ToolResult & { accept?: () => Promise<LoopEvent[]>; annotation?: LogEvent };
291
334
  type ToolCall = Extract<AssistantBlock, { type: "tool_use" }>;
335
+ type SessionBrokerReceipt = BrokerReceipt<DeferredToolResult> & { events: LoopEvent[] };
336
+ type PreparedBrokerCall = { run(emit?: (event: LoopEvent) => void): Promise<SessionBrokerReceipt> };
337
+ export type CommittedItem = { node: number; id: string; type: LogEvent["t"] };
292
338
 
293
339
  export class Session {
340
+ private readonly execOwner = randomUUID();
341
+ private readonly jobs = new BranchJobs<DeferredToolResult>();
342
+ private readonly children = new Map<number, Session>();
343
+ private cleanup: Promise<void> | null = null;
344
+ private closing: Promise<void> | null = null;
345
+ private turnTools: ToolContext | null = null;
294
346
  private readonly execution = new ExecutionControl();
347
+ private readonly runtimeTargets = new RuntimeTargets();
348
+ private rootTools: ToolQueue | undefined;
349
+ private hostOwner: ReturnType<ExecutionControl["begin"]> | undefined;
350
+ private readonly hostPending = new Set<Promise<NativeCommandResult>>();
295
351
  private stall: StallState | null = null;
352
+ private recovery: RecoveryState | null = null;
296
353
  private pendingAdoption: number | null = null;
297
354
  private readonly allocatedEventIds = new Set<string>();
355
+ private recordTail: Promise<void> = Promise.resolve();
356
+ private readonly recordListeners = new Set<(item: CommittedItem) => void>();
298
357
  readonly config: SessionConfig;
299
358
  /**
300
359
  * Replaced by switchModel. Not readonly: a live session can change which
@@ -307,13 +366,36 @@ export class Session {
307
366
  private readonly allocation: { next: number };
308
367
 
309
368
  constructor(config: SessionConfig, provider: Provider, clock: Clock = systemClock(), allocation = { next: 1 }) {
369
+ if (config.acceptanceContract) parseAcceptanceContract(config.acceptanceContract);
310
370
  if (config.contextPolicy !== undefined && !isContextPolicy(config.contextPolicy)) throw new Error("unknown context policy");
311
371
  stallDecision(emptyStallState(), config.stallLimit ?? DEFAULT_STALL_LIMIT);
312
372
  validateOutputRecoveryLimit(config.outputRecoveryLimit ?? DEFAULT_OUTPUT_RECOVERY_LIMIT);
313
373
  schedulingWindow([], 0, config.maxParallelTools ?? 3);
314
374
  this.allocation = allocation;
315
- this.config = config;
375
+ this.config = { ...config, ...(config.acceptanceContract ? { acceptanceContract: parseAcceptanceContract(config.acceptanceContract) } : {}), tools: { ...config.tools, execOwner: this.execOwner, historyCache: new HistoryCache() } };
376
+ // A child or restored Session must never inherit another live invocation scope.
377
+ delete this.config.tools.invoke;
378
+ delete this.config.tools.invokeHost;
379
+ delete this.config.tools.runOwnedCommand;
380
+ delete this.config.tools.drainNested;
381
+ delete this.config.tools.origin;
382
+ delete this.config.tools.parentCallId;
383
+ delete this.config.tools.callId;
384
+ // Branch capability sets are owned by branchTools; a parent's optional catalog is not a grant.
385
+ if ((config.branchDepth ?? 0) > 0) delete this.config.tools.deferredTools;
316
386
  this.config.tools.outputStore ??= join(config.logDir, "artifacts");
387
+ // R4 / D8: full ancestry, not the bounded model view or a second mutable ledger.
388
+ // Each child Session installs its own callback even when contexts are copied.
389
+ this.config.tools.observedFileVersion = async absolute => {
390
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
391
+ if (!history.ok) throw new Error(history.error);
392
+ return latestObservedFileVersion(history.nodes.flatMap(node => node.events), absolute, path => resolve(this.config.tools.root, path));
393
+ };
394
+ this.config.tools.observedProcess = async session => {
395
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
396
+ if (!history.ok) throw new Error(history.error);
397
+ return latestProcessReceipt(await hydrateProcessReceipts(history.nodes, this.config.tools.outputStore!), session);
398
+ };
317
399
  this.provider = provider;
318
400
  this.clock = {
319
401
  now: () => clock.now(),
@@ -333,6 +415,34 @@ export class Session {
333
415
  set nextNode(value: number) { allocation.next = value; },
334
416
  worktrees: new Map(),
335
417
  };
418
+ this.installWorkspace();
419
+ }
420
+
421
+ private installWorkspace(): void {
422
+ delete this.config.tools.workspace;
423
+ this.config.tools.ownedStatePaths = [this.config.logDir, ...(this.config.blobDir ? [this.config.blobDir] : [])];
424
+ this.config.tools.legacyWorkspace = this.config.workspaceTracking === "legacy";
425
+ if (this.config.blobDir && this.config.workspaceTracking !== "legacy") this.config.tools.workspace = new WorkspaceLifecycle(
426
+ this.config.tools.root, this.config.blobDir, this.config.shellCheckpoints === true,
427
+ event => this.record({ ...event, id: this.clock.nextId(), at: this.clock.now() }),
428
+ join(this.config.logDir, "checkpoints"),
429
+ this.config.tools.ownedStatePaths,
430
+ );
431
+ }
432
+
433
+ private async observeEvidence(): Promise<void> {
434
+ let proof = this.state.events.findLast(event => event.t === "tool_result" && event.effect?.kind === "command");
435
+ let prior = proof?.t === "tool_result" && proof.effect?.kind === "command" ? proof.effect.workspace_sha256 :
436
+ this.state.handoff?.state.completed.at(-1)?.evidence.workspace_sha256;
437
+ // A short task need not create a completed note. Its last command still
438
+ // establishes the comparison version when the current segment is empty.
439
+ if (!proof && this.config.tools.workspace) {
440
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
441
+ if (!history.ok) throw new Error(history.error);
442
+ proof = history.nodes.flatMap(part => part.events).findLast(event => event.t === "tool_result" && event.effect?.kind === "command");
443
+ if (proof?.t === "tool_result" && proof.effect?.kind === "command") prior = proof.effect.workspace_sha256;
444
+ }
445
+ if (proof || prior || this.config.acceptanceContract) await this.config.tools.workspace?.observe(prior);
336
446
  }
337
447
 
338
448
  /**
@@ -345,7 +455,7 @@ export class Session {
345
455
  * session quietly different from a continuous one.
346
456
  */
347
457
  async start(): Promise<void> {
348
- this.stall = null;
458
+ this.resetDerivedToolState();
349
459
  const dir = this.config.logDir;
350
460
  const model = this.provider.config.spec.id;
351
461
  const existing = await listNodes(dir);
@@ -368,6 +478,11 @@ export class Session {
368
478
  break;
369
479
 
370
480
  case "resume": {
481
+ this.config.acceptanceContract = null;
482
+ this.config.strictVerification = false;
483
+ this.config.workingSet = false;
484
+ this.config.workingSetVersion = 1;
485
+ delete this.config.guidanceVersion;
371
486
  // Logs predating explicit policies meant segmented, regardless of today's default.
372
487
  this.config.contextPolicy = "segmented";
373
488
  this.config.maxParallelTools = 1;
@@ -377,7 +492,19 @@ export class Session {
377
492
  if (plan.cursor.context_ref) {
378
493
  const saved = await getBlob(join(dir, "artifacts"), plan.cursor.context_ref);
379
494
  if (saved === null) throw new Error("missing frozen session context");
380
- const context = JSON.parse(saved) as { frozen: FrozenPrefix; boundary?: Partial<BoundaryConfig>; contextPolicy?: unknown; stallLimit?: number; outputRecoveryLimit?: number; guidance?: boolean; gates: Record<string, string>; snapshotIgnore?: readonly string[]; workNamespace?: string; maxParallelTools?: number };
495
+ const context = JSON.parse(saved) as { skills?: Skill[]; acceptanceContract?: AcceptanceContract; strictVerification?: boolean; workingSet?: boolean; workingSetVersion?: 1 | 2; guidanceVersion?: 1; projectionVersion?: number; frozen: FrozenPrefix; boundary?: Partial<BoundaryConfig>; contextPolicy?: unknown; stallLimit?: number; outputRecoveryLimit?: number; guidance?: boolean; gates: Record<string, string>; snapshotIgnore?: readonly string[]; workspaceTracking?: "journal" | "legacy"; shellCheckpoints?: boolean; workNamespace?: string; maxParallelTools?: number };
496
+ if (context.projectionVersion !== undefined && context.projectionVersion !== PROJECTION_VERSION) throw new Error("unsupported saved projection version");
497
+ this.config.workingSet = context.workingSet === true;
498
+ if (context.workingSetVersion !== undefined && ![1, 2].includes(context.workingSetVersion)) throw new Error("unsupported saved working-set version");
499
+ this.config.workingSetVersion = context.workingSetVersion ?? 1;
500
+ if (context.guidanceVersion !== undefined && context.guidanceVersion !== 1) throw new Error("unsupported saved guidance version");
501
+ if (context.guidanceVersion === 1) this.config.guidanceVersion = 1;
502
+ this.config.workspaceTracking = context.workspaceTracking ?? "journal";
503
+ this.config.shellCheckpoints = context.shellCheckpoints === true;
504
+ if (context.acceptanceContract) { parseAcceptanceContract(context.acceptanceContract); this.config.acceptanceContract = context.acceptanceContract; }
505
+ this.config.strictVerification = context.strictVerification === true;
506
+ this.config.skills = context.skills ?? [];
507
+ this.installSkillResources();
381
508
  this.config.workNamespace = context.workNamespace ?? "main";
382
509
  this.config.maxParallelTools = context.maxParallelTools ?? 1;
383
510
  schedulingWindow([], 0, this.config.maxParallelTools);
@@ -427,7 +554,7 @@ export class Session {
427
554
  this.state.anchor = null;
428
555
  // Compare disk against the durable file projection. Treating current
429
556
  // disk as the baseline would erase unlogged effects of a crashed tool.
430
- if (this.config.blobDir) {
557
+ if (this.config.blobDir && this.config.workspaceTracking === "legacy") {
431
558
  const files = await planRestoreToNode(dir, this.state.node);
432
559
  if (!files.ok) throw new Error(files.error);
433
560
  this.state.lastScan = files.files;
@@ -447,7 +574,13 @@ export class Session {
447
574
  return assertNever(plan, "Session.start");
448
575
  }
449
576
 
450
- if (cursor?.needs_model_checkpoint || needsReplayCheckpoint(this.state.events, providerReplayKey(this.provider.config.spec), nativeReplay(this.provider.config.spec))) {
577
+ this.installWorkspace();
578
+ if (this.config.workspaceTracking !== "legacy") await this.recoverFileOperations();
579
+ this.restoreDeclaredTools();
580
+ // Consume the original pending receipt before another checkpoint can replace this projection.
581
+ const declarationCut = await this.applyDiscoveredTools();
582
+ await this.refreshRuntimeTargets();
583
+ if (!declarationCut && (cursor?.needs_model_checkpoint || needsReplayCheckpoint(this.state.events, providerReplayKey(this.provider.config.spec), nativeReplay(this.provider.config.spec)))) {
451
584
  await this.performCut("model_checkpoint", model);
452
585
  }
453
586
  await this.saveCursor();
@@ -461,6 +594,17 @@ export class Session {
461
594
  */
462
595
  async fork(destLogDir: string, untilEvent?: string, position: "at" | "before" = "at"): Promise<void> {
463
596
  await copyAncestryTo(this.config.logDir, destLogDir, this.clock.now(), untilEvent, position);
597
+ if (untilEvent !== undefined && this.config.workspaceTracking !== "legacy") {
598
+ const cursor = await readCursor(destLogDir);
599
+ if (!cursor) throw new Error("missing fork cursor");
600
+ const tip = (await readNode(this.config.logDir, this.state.node)).events.at(-1);
601
+ const filesFrom = { ...await digestedRef(this.config.logDir, this.state.node), ...(tip ? { at_event: tip.id } : {}) };
602
+ const header = (await readNode(destLogDir, cursor.node)).header;
603
+ const event: LogEvent = { t: "rewind", id: this.clock.nextId(), at: this.clock.now(), mode: "context", from: header?.parent ?? filesFrom,
604
+ files_from: filesFrom, prompt: "Forked conversation", restored: [], removed: [], unrecoverable: [], divergence: [] };
605
+ await appendEvent(destLogDir, cursor.node, event);
606
+ await writeCursor(destLogDir, { ...cursor, at_event: event.id, updated_at: event.at });
607
+ }
464
608
  }
465
609
 
466
610
  async setThinking(effort: Effort): Promise<void> {
@@ -468,25 +612,60 @@ export class Session {
468
612
  await this.saveCursor();
469
613
  }
470
614
 
471
- /** Pi user bash shares process ownership and archival with tools, never their authority or model dispatch. */
615
+ /** Trusted extension commands share the Session owner without inventing a user turn. */
616
+ runExtensionCommand(command: string, args: string[], options?: { cwd?: string; timeout?: number; signal?: AbortSignal }): Promise<NativeCommandResult> {
617
+ if (this.cleanup || this.closing) return Promise.reject(new Error("session is draining or disposed"));
618
+ const input: NativeCommandInput = { argv: [command, ...args], ...(options?.cwd !== undefined ? { cwd: options.cwd } : {}),
619
+ ...(options?.timeout !== undefined ? { timeout: options.timeout } : {}) };
620
+ if (!this.execution.running) this.hostOwner = this.execution.begin();
621
+ const signals = [this.execution.signal, options?.signal].filter((signal): signal is AbortSignal => signal !== undefined);
622
+ const signal = AbortSignal.any(signals);
623
+ const result = (async () => {
624
+ const call: ToolCall = { type: "tool_use", call_id: `host-${randomUUID()}`, name: NATIVE_COMMAND_NAME, input };
625
+ const preparation = this.prepareBrokerCall(call, () => 0, false, signal, true);
626
+ let prepared: PreparedBrokerCall;
627
+ for (;;) { const step = await preparation.next(); if (step.done) { prepared = step.value; break; } }
628
+ const receipt = await prepared.run();
629
+ await receipt.commit();
630
+ await this.saveCursor();
631
+ return nativeCommandResult(receipt.value, this.config.tools.outputStore!);
632
+ })();
633
+ this.hostPending.add(result);
634
+ const finished = () => {
635
+ this.hostPending.delete(result);
636
+ if (this.hostPending.size === 0 && this.hostOwner) { this.hostOwner.finish(); this.hostOwner = undefined; }
637
+ };
638
+ void result.then(finished, finished);
639
+ return result;
640
+ }
641
+
472
642
  async runUserBash(command: string, excludeFromContext: boolean, signal?: AbortSignal,
473
643
  onOutput?: (chunk: Uint8Array) => void): Promise<Extract<LogEvent, { t: "user_bash" }>> {
644
+ if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
474
645
  const owned = this.execution.begin(signal);
646
+ let release: (() => void) | undefined;
475
647
  try {
648
+ this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
649
+ release = await this.rootTools.acquire("exclusive");
476
650
  if (!command.trim()) throw new Error("empty user command");
477
651
  for await (const event of this.snapshotTurn([], false, "idle")) {
478
652
  if (event.kind === "snapshot_failed") throw new Error(event.reason);
479
653
  }
480
- const captured = await observeCommand({ command, cwd: this.config.tools.root,
481
- outputStore: join(this.config.logDir, "artifacts"), maxOutputBytes: this.config.tools.maxOutputBytes,
482
- timeoutMs: this.config.tools.timeoutMs, signal: owned.signal, ...(onOutput ? { onOutput } : {}) });
483
- const outcome = executionOutcome(captured.stopped === "cancelled" ? "cancelled" : captured.stopped === "timeout" ? "timeout"
484
- : captured.exit === 0 ? "tool_completed" : "tool_failed");
485
- const preview = headTailView(captured.head, captured.tail, captured.ref.bytes, this.config.tools.maxOutputBytes,
486
- omitted => `\n[${omitted} bytes omitted; complete output: ${captured.ref.path}]\n`);
487
- const content = `${preview}\nexit ${captured.exit} · ${outcome.status} · ${outcome.reason}`;
654
+ const execute = async (): Promise<ToolResult> => {
655
+ const captured = await observeCommand({ command, cwd: this.config.tools.root,
656
+ outputStore: join(this.config.logDir, "artifacts"), maxOutputBytes: this.config.tools.maxOutputBytes,
657
+ timeoutMs: this.config.tools.timeoutMs, signal: owned.signal, ...(onOutput ? { onOutput } : {}) });
658
+ const completion = commandCompletion(captured.exit, captured.stopped, captured.cleanup);
659
+ const preview = headTailView(captured.head, captured.tail, captured.ref.bytes, this.config.tools.maxOutputBytes,
660
+ omitted => `\n[${omitted} bytes omitted; complete output: ${captured.ref.path}]\n`);
661
+ return { ...completion, content: `${preview}\nexit ${captured.exit} · ${completion.outcome.status} · ${completion.outcome.reason}` +
662
+ (captured.cleanup ? `\n${captured.cleanup.message}` : ""),
663
+ content_ref: captured.ref, effect: { kind: "command", cmd: command, exit: captured.exit } };
664
+ };
665
+ const result = this.config.tools.workspace ? await this.config.tools.workspace.command("bash", { command }, execute, owned.signal) : await execute();
666
+ if (result.effect?.kind !== "command" || !result.content_ref || !result.outcome) throw new Error("missing user command observation");
488
667
  const event: Extract<LogEvent, { t: "user_bash" }> = { t: "user_bash", id: this.clock.nextId(), at: this.clock.now(),
489
- command, content, exit: captured.exit, excludeFromContext, outcome, content_ref: captured.ref };
668
+ command, content: result.content, exit: result.effect.exit, excludeFromContext, outcome: result.outcome, content_ref: result.content_ref };
490
669
  await this.record(event);
491
670
  for await (const snapshot of this.snapshotTurn([])) {
492
671
  if (snapshot.kind === "snapshot_failed") throw new Error(snapshot.reason);
@@ -497,7 +676,11 @@ export class Session {
497
676
  await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
498
677
  reason: "user command failed before completion; inspect the workspace before retrying", outcome: executionOutcome("execution_error") });
499
678
  throw error;
500
- } finally { owned.finish(); }
679
+ } finally {
680
+ release?.();
681
+ while (this.hostPending.size > 0) await Promise.allSettled(this.hostPending);
682
+ owned.finish();
683
+ }
501
684
  }
502
685
 
503
686
  async userMessages(): Promise<{ id: string; node: number; text: string; images?: readonly ImageRef[] }[]> {
@@ -515,11 +698,17 @@ export class Session {
515
698
  const dir = this.config.logDir;
516
699
  const users = await this.userMessages();
517
700
  const target = await resolveHistoryPosition(dir, eventId, users.some(user => user.id === eventId) ? "before" : "at");
701
+ const previous = (await readNode(dir, this.state.node)).events.at(-1);
702
+ const filesFrom = { ...await digestedRef(dir, this.state.node), ...(previous ? { at_event: previous.id } : {}) };
518
703
  for await (const event of this.snapshotTurn([], false, "idle")) {
519
704
  if (event.kind === "snapshot_failed") throw new Error(event.reason);
520
705
  }
521
706
  const node = this.state.nextNode++;
522
707
  await initNode(dir, node, target.parent, this.clock.now(), target.basis);
708
+ if (this.config.workspaceTracking !== "legacy") await appendEvent(dir, node, {
709
+ t: "rewind", id: this.clock.nextId(), at: this.clock.now(), mode: "context", from: target.parent ?? filesFrom,
710
+ files_from: filesFrom, prompt: target.editorText ?? "Selected conversation position", restored: [], removed: [], unrecoverable: [], divergence: [],
711
+ });
523
712
  const cursor = await readCursor(dir);
524
713
  if (!cursor) throw new Error("missing current session cursor");
525
714
  const next = { ...cursor, node, next_node: this.state.nextNode, updated_at: this.clock.now(),
@@ -571,18 +760,47 @@ export class Session {
571
760
  }
572
761
 
573
762
  /** D6: changing the frozen prefix is an explicit checkpoint, never in-place prompt surgery. */
574
- async reloadResources(frozen: FrozenPrefix): Promise<boolean> {
575
- if (JSON.stringify(frozen) === JSON.stringify(this.config.frozen)) return false;
763
+ async reloadResources(frozen: FrozenPrefix, skills = this.config.skills): Promise<boolean> {
764
+ if (JSON.stringify(frozen) === JSON.stringify(this.config.frozen) && JSON.stringify(skills) === JSON.stringify(this.config.skills)) return false;
576
765
  if (this.running || this.isHalfRound()) throw new Error("cannot reload resources while a turn is active or a tool call is unpaired");
577
- const contextRef = await this.persistContext(frozen);
766
+ const contextRef = await this.persistContext(frozen, skills);
578
767
  await this.performCut("model_checkpoint", this.provider.config.spec.id, undefined,
579
768
  this.provider.config.spec.provider, this.provider.config.effort, contextRef);
580
769
  this.config.frozen = frozen;
770
+ if (skills !== undefined) this.config.skills = skills;
771
+ this.installSkillResources();
581
772
  this.config.tools.allow = frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" ? [tool.name] : []);
582
773
  await this.saveCursor();
583
774
  return true;
584
775
  }
585
776
 
777
+ /** Restoring a declaration never installs code: only the current selected local catalog can supply it. */
778
+ private restoreDeclaredTools(): void {
779
+ const declared = new Set(declaredToolNames(this.config.frozen.tools));
780
+ const loaded = currentDeferredTools(this.config.tools).filter(tool => declared.has(tool.name)).map(tool => tool.name);
781
+ if (loaded.length && this.config.tools.allow !== undefined) this.config.tools.allow = [...new Set([...this.config.tools.allow, ...loaded])];
782
+ }
783
+
784
+ /** D5/D6: consume original discovery receipts only after every invocation has its result. */
785
+ private async applyDiscoveredTools(): Promise<boolean> {
786
+ if (this.isHalfRound() || (this.config.branchDepth ?? 0) > 0 || this.config.tools.extensions?.get(DISCOVERY_TOOL_NAME) !== DISCOVERY_TOOL) return false;
787
+ const declared = new Set(declaredToolNames(this.config.frozen.tools));
788
+ const requested = new Map(recordedDeclarations(this.state.events).map(item => [item.name, item.fingerprint]));
789
+ const additions = currentDeferredTools(this.config.tools).filter(tool => !declared.has(tool.name) &&
790
+ requested.get(tool.name) === discoveryFingerprint(tool) && discoveryPermitted(this.config.tools, tool.name));
791
+ if (additions.length === 0) return false;
792
+ const frozen = { ...this.config.frozen, tools: [...this.config.frozen.tools, ...additions] };
793
+ const contextRef = await this.persistContext(frozen);
794
+ await this.performCut("model_checkpoint", this.provider.config.spec.id, undefined,
795
+ this.provider.config.spec.provider, this.provider.config.effort, contextRef);
796
+ this.config.frozen = frozen;
797
+ this.restoreDeclaredTools();
798
+ // send() restores its base tool scope at the end; keep accepted declarations there too.
799
+ if (this.turnTools?.allow !== undefined) this.turnTools.allow = [...new Set([...this.turnTools.allow, ...additions.map(tool => tool.name)])];
800
+ await this.saveCursor();
801
+ return true;
802
+ }
803
+
586
804
  /**
587
805
  * The same pairing check switchModel already used. A user message
588
806
  * between tool_use and tool_result is the same unpairable request a
@@ -609,6 +827,7 @@ export class Session {
609
827
  if (pull === undefined || this.isHalfRound()) return null;
610
828
  const text = await pull("after_tools");
611
829
  if (text === null || typeof text === "string" && text.length === 0) return null;
830
+ if (this.turnTools) this.config.tools = { ...this.turnTools };
612
831
  return text;
613
832
  }
614
833
 
@@ -619,8 +838,17 @@ export class Session {
619
838
  * resuming from -- a crash, a kill, a closed laptop -- do not run exit
620
839
  * handlers.
621
840
  */
622
- private async persistContext(frozen = this.config.frozen): Promise<string> {
841
+ private async persistContext(frozen = this.config.frozen, skills = this.config.skills): Promise<string> {
623
842
  return putBlob(join(this.config.logDir, "artifacts"), JSON.stringify({
843
+ projectionVersion: PROJECTION_VERSION,
844
+ workspaceTracking: this.config.workspaceTracking ?? "journal",
845
+ shellCheckpoints: this.config.shellCheckpoints === true,
846
+ ...(skills?.length ? { skills } : {}),
847
+ ...(this.config.acceptanceContract ? { acceptanceContract: this.config.acceptanceContract } : {}),
848
+ ...(this.config.strictVerification ? { strictVerification: true } : {}),
849
+ ...(this.config.workingSet ? { workingSet: true } : {}),
850
+ ...(this.config.workingSet && this.config.workingSetVersion === 2 ? { workingSetVersion: 2 } : {}),
851
+ ...(this.config.guidanceVersion === 1 ? { guidanceVersion: 1 } : {}),
624
852
  frozen, gates: this.config.tools.gates,
625
853
  workNamespace: this.config.workNamespace ?? "main",
626
854
  maxParallelTools: this.config.maxParallelTools ?? 3,
@@ -652,10 +880,211 @@ export class Session {
652
880
  });
653
881
  }
654
882
 
883
+ /** Q24: publish identities only after durable append, including non-model user actions. */
884
+ subscribeRecords(listener: (item: CommittedItem) => void): () => void {
885
+ this.recordListeners.add(listener); return () => { this.recordListeners.delete(listener); };
886
+ }
887
+
655
888
  private async record(event: LogEvent): Promise<void> {
656
- await appendEvent(this.config.logDir, this.state.node, event);
657
- this.state.events.push(event);
658
- if (this.stall !== null) this.stall = advanceStall(this.stall, [{ node: this.state.node, event }]);
889
+ // D8: runtime callers may retain mutable results. The in-memory projection
890
+ // must own the same JSON value that was appended, independent of later edits.
891
+ event = JSON.parse(JSON.stringify(event)) as LogEvent;
892
+ const saved = this.recordTail.then(async () => {
893
+ await appendEvent(this.config.logDir, this.state.node, event);
894
+ this.state.events.push(event);
895
+ if (this.stall !== null) this.stall = advanceStall(this.stall, [{ node: this.state.node, event }]);
896
+ if (this.recovery !== null) this.recovery = advanceRecovery(this.recovery, [event], path => resolve(this.config.tools.root, path));
897
+ for (const listener of this.recordListeners) {
898
+ try { listener({ node: this.state.node, id: event.id, type: event.t }); } catch { /* observers never own the append */ }
899
+ }
900
+ });
901
+ this.recordTail = saved.catch(() => {});
902
+ await saved;
903
+ if (event.t === "tool_result" && event.ok && event.effect?.kind === "read") {
904
+ const path = resolve(this.config.tools.root, event.effect.path);
905
+ const skill = this.config.skills?.find(item => item.filePath === path);
906
+ if (skill && !skill.disableModelInvocation) {
907
+ await this.recordSkill(skill, "read", "model");
908
+ this.config.tools.allow = skillTools(this.allowedNames(), skill);
909
+ // Restricted skill scopes cannot grow their capability set by searching a parent directory.
910
+ if (skill.allowedTools !== undefined || skill.disallowedTools !== undefined) this.config.tools.deferredTools = [];
911
+ }
912
+ }
913
+ }
914
+
915
+ private allowedNames(): string[] {
916
+ return this.config.frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" &&
917
+ (this.config.tools.allow === undefined || this.config.tools.allow.includes(tool.name)) ? [tool.name] : []);
918
+ }
919
+
920
+ private adviceNames(frozen = this.config.frozen): string[] {
921
+ const declared = frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" ? [tool.name] : []);
922
+ return adviceTools({ ...this.config.tools, advice: declared, ...(frozen === this.config.frozen ? {} : { allow: declared }) });
923
+ }
924
+
925
+ private installSkillResources(): void {
926
+ this.config.tools.skillResources = (this.config.skills ?? []).map(skill => ({ path: skill.filePath,
927
+ text: skill.sourceText ?? skill.body, modelInvocable: !skill.disableModelInvocation && skill.context !== "fork" }));
928
+ }
929
+
930
+ private async recordSkill(skill: Skill, mode: "inline" | "fork" | "read", origin: "user" | "model"): Promise<void> {
931
+ const artifact = await putBlob(join(this.config.logDir, "artifacts"), skill.sourceText ?? skill.body);
932
+ await this.record({ t: "skill_loaded", id: this.clock.nextId(), at: this.clock.now(), name: skill.name, path: skill.filePath,
933
+ sha256: artifact, artifact, mode, origin });
934
+ }
935
+
936
+ /** O.12: main instructions are durable; permissions apply to this invocation only. */
937
+ async *sendSkill(skill: Skill, prompt: UserInput, signal?: AbortSignal): AsyncGenerator<LoopEvent> {
938
+ if (this.running || this.cleanup || this.closing || this.isHalfRound()) throw new Error("a skill requires an idle paired session");
939
+ skill = this.config.skills?.find(item => item.name === skill.name && item.filePath === skill.filePath) ?? skill;
940
+ if (skill.userInvocable === false) throw new Error("this skill cannot be invoked by users");
941
+ const savedTools = this.config.tools;
942
+ this.config.tools = { ...savedTools, allow: skillTools(this.allowedNames(), skill) };
943
+ if (skill.allowedTools !== undefined || skill.disallowedTools !== undefined) this.config.tools.deferredTools = [];
944
+ try {
945
+ await this.recordSkill(skill, skill.context === "fork" ? "fork" : "inline", "user");
946
+ if (skill.context !== "fork") { yield* this.send(prompt, signal); return; }
947
+ const owned = this.execution.begin(signal);
948
+ try {
949
+ await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), text: `Run isolated skill ${skill.name}; source ${skill.filePath}.` });
950
+ const writes = skill.allowedTools?.some(name => this.allowedNames().includes(name) &&
951
+ WRITING_BRANCH_TOOL_NAMES.includes(name) && !BRANCH_TOOL_NAMES.includes(name)) ?? false;
952
+ const result = yield* this.runBranch({ purpose: `skill ${skill.name}`, prompt: userInputData(prompt).text, writes }, 0, owned.signal, skill);
953
+ await result.accept?.();
954
+ await this.record({ t: "extension", id: this.clock.nextId(), at: this.clock.now(), customType: "skill_result", content: result.content,
955
+ display: true, inContext: true });
956
+ await this.saveCursor();
957
+ yield { kind: "assistant_text", text: result.content };
958
+ yield { kind: "done", outcome: result.outcome ?? executionOutcome("execution_error"), reason: "isolated skill finished" };
959
+ } finally { owned.finish(); }
960
+ } finally { this.config.tools = savedTools; this.restoreDeclaredTools(); }
961
+ }
962
+
963
+ /** O.8: dispatch and diagnostics share exact source hints, including forks. */
964
+ contextInput(): ProjectInput {
965
+ return { frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events,
966
+ logHint: { manifestPath: manifestPath(this.config.logDir),
967
+ ...(this.config.guidanceVersion === 1 ? { scopedGuidance: true } : {}),
968
+ segmentPath: this.state.handoff?.predecessor?.log_ref ?? nodePath(this.config.logDir, this.state.node) } };
969
+ }
970
+
971
+ private async boundCarried(full: Handoff, tools = this.adviceNames()): Promise<Handoff> {
972
+ return carriedHandoff(this.config.logDir, this.state.node, full, { handoff_tokens: DEFAULT_HANDOFF_TOKENS,
973
+ ...(this.config.workingSet ? { working_set_version: this.config.workingSetVersion ?? 1 } : {}),
974
+ ...(this.config.guidanceVersion === 1 ? { guidance_tools: tools } : {}) });
975
+ }
976
+
977
+ async acceptanceState(): Promise<AcceptanceState | undefined> {
978
+ if (!this.running) await this.observeEvidence();
979
+ // External editor changes must invalidate proof before the user reads it.
980
+ if (!this.running) for await (const result of this.snapshotTurn([], false, "idle")) {
981
+ if (result.kind === "snapshot_failed") throw new Error(`cannot inspect an unobserved workspace: ${result.reason}`);
982
+ }
983
+ if (!this.running) await this.refreshRuntimeTargets();
984
+ return (await this.currentHandoff()).acceptance;
985
+ }
986
+
987
+ async requestState(): Promise<RequestState | undefined> {
988
+ // Use the same idle workspace/target observation boundary as /acceptance.
989
+ if (!this.running) await this.acceptanceState();
990
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
991
+ if (!history.ok) throw new Error(history.error);
992
+ return deriveRequestState(history.nodes);
993
+ }
994
+
995
+ /** Only called at paired, owned boundaries; probes never enter a pending model tool exchange. */
996
+ private async refreshRuntimeTargets(): Promise<void> {
997
+ const state = (await this.currentHandoff()).acceptance;
998
+ const observations: string[] = [];
999
+ for (const target of state?.targets ?? []) if (target.observation || target.binding) {
1000
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
1001
+ if (!history.ok) throw Error(history.error);
1002
+ const observed = await this.runtimeTargets.perform(this.config.tools, history.nodes, { action: "inspect", target: target.id });
1003
+ const id = this.clock.nextId();
1004
+ await this.record({ t: "target_observation", id, at: this.clock.now(), observation: observed.observation });
1005
+ observations.push(id);
1006
+ }
1007
+ if (!observations.length) return;
1008
+ const current = (await this.currentHandoff()).acceptance;
1009
+ if (current?.targets?.some(target => {
1010
+ const before = state?.targets?.find(item => item.id === target.id);
1011
+ return before?.status !== target.status || before.binding !== target.binding || before.build_event !== target.build_event;
1012
+ })) await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "runtime_acceptance", sources: observations,
1013
+ 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.` });
1014
+ }
1015
+
1016
+ async workItems(): Promise<readonly WorkItem[]> {
1017
+ const replayed = await replayContext(this.config.logDir, this.state.node);
1018
+ return deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events, depth: replayed.depth,
1019
+ self: await this.nodeRefFor(this.state.node) }).state.work_items ?? [];
1020
+ }
1021
+
1022
+ /** What the next handoff would carry if the segment were cut now. Pure derivation; nothing is written. */
1023
+ async previewHandoff(): Promise<Handoff> { return this.currentHandoff(); }
1024
+
1025
+ private async currentHandoff(): Promise<Handoff> {
1026
+ const replayed = await replayContext(this.config.logDir, this.state.node);
1027
+ return deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events, depth: replayed.depth, self: await this.nodeRefFor(this.state.node) });
1028
+ }
1029
+ async branchStatuses() {
1030
+ return (await this.currentHandoff()).branches.map(item => ({ ...item, status: branchStatus(item, this.jobs.state(item.node)) }));
1031
+ }
1032
+ async processStatuses() {
1033
+ const handles = new Map(runningSessions(this.execOwner).map(session => [session.id, session.exit === null ? "running" as const : "ended_unobserved" as const]));
1034
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
1035
+ if (!history.ok) throw new Error(history.error);
1036
+ let processes: ProcessObservation[] = [];
1037
+ for (const part of await hydrateProcessReceipts(history.nodes, this.config.tools.outputStore!)) processes = deriveProcesses(processes, part.events, part.node);
1038
+ return processes.map(item => ({ ...item, status: processStatus(item, handles.get(item.session)) }));
1039
+ }
1040
+ /** User lifecycle commands share the coordinator; no model request is needed. */
1041
+ async resolveBranch(node: number, resolution: "wait" | "cancel" | "status", waitMs = 10_000): Promise<ToolResult> {
1042
+ if (this.running || this.cleanup || this.closing) throw new Error("branch commands require an idle session");
1043
+ const owned = this.execution.begin();
1044
+ try {
1045
+ const result = await this.runResolve({ node, resolution, wait_ms: waitMs });
1046
+ await this.saveCursor(); return finishToolResult(result);
1047
+ } finally { owned.finish(); }
1048
+ }
1049
+
1050
+ private async drainProcesses(): Promise<void> {
1051
+ const results = await disposeExecSessions(this.execOwner, this.config.tools.outputStore!, this.config.tools.maxOutputBytes);
1052
+ await this.config.tools.workspace?.abandonCommands();
1053
+ for (const result of results) {
1054
+ await this.record({ t: "process_exit", id: this.clock.nextId(), at: this.clock.now(), ...result, reason: "disposed" });
1055
+ }
1056
+ if (results.length > 0) await this.saveCursor();
1057
+ }
1058
+ private async acceptReadyBranches(): Promise<void> {
1059
+ for (const node of this.jobs.nodes()) {
1060
+ const done = await this.jobs.wait(node, 0);
1061
+ if (done?.value?.accept) await done.value.accept();
1062
+ this.jobs.forget(node);
1063
+ }
1064
+ }
1065
+ dispose(): Promise<void> {
1066
+ if (this.closing) return this.closing;
1067
+ this.cancel();
1068
+ this.closing = this.whenIdle().then(() => this.rootTools?.close());
1069
+ return this.closing;
1070
+ }
1071
+
1072
+ /** Only the app's user command calls this; note never grants human acceptance. */
1073
+ async acceptCriterion(criterion: string): Promise<AcceptanceState> {
1074
+ if (this.running || this.isHalfRound()) throw new Error("acceptance requires an idle, paired request");
1075
+ for await (const result of this.snapshotTurn([], true)) {
1076
+ if (result.kind === "snapshot_failed") throw new Error(`cannot accept an unobserved workspace: ${result.reason}`);
1077
+ }
1078
+ const current = await this.acceptanceState();
1079
+ const item = current?.criteria.find(item => item.id === criterion && item.kind === "human");
1080
+ if (!current || !item || item.kind !== "human") throw new Error("unknown human acceptance criterion");
1081
+ const target = item.target ? current.targets?.find(target => target.id === item.target) : undefined;
1082
+ if (item.target && (target?.status !== "ready" || !target.binding || !target.observation)) throw Error("human acceptance requires a current ready target instance");
1083
+ await this.record({ t: "acceptance_decision", id: this.clock.nextId(), at: this.clock.now(), request: current.request,
1084
+ contract_id: current.contract_id, criterion, actor: "user",
1085
+ ...(target?.binding && target.observation ? { target_binding: target.binding, target_observation: target.observation } : {}) });
1086
+ await this.saveCursor();
1087
+ return (await this.acceptanceState())!;
659
1088
  }
660
1089
 
661
1090
  async appendExtension(entry: Omit<Extract<LogEvent, { t: "extension" }>, "t" | "id" | "at">): Promise<void> {
@@ -671,8 +1100,21 @@ export class Session {
671
1100
  return totalUsage(this.state.usage);
672
1101
  }
673
1102
 
674
- cancel(): void { this.execution.cancel(); }
675
- whenIdle(): Promise<void> { return this.execution.whenIdle(); }
1103
+ cancel(): void {
1104
+ this.execution.cancel(); this.jobs.cancel();
1105
+ for (const child of this.children.values()) child.cancel();
1106
+ if (!this.cleanup) this.cleanup = (async () => {
1107
+ await this.execution.whenIdle(); await this.jobs.drain();
1108
+ // A consumer can close at the preparation marker before execution is
1109
+ // admitted. Those children still have an owner, even without a job.
1110
+ for (const [node, child] of this.children) { await child.dispose(); this.children.delete(node); }
1111
+ await this.acceptReadyBranches(); await this.drainProcesses();
1112
+ })().then(() => { this.cleanup = null; });
1113
+ // A void cancellation caller may await later. Preserve rejection for the
1114
+ // owner while preventing an unhandled rejection from bypassing that path.
1115
+ void this.cleanup.catch(() => {});
1116
+ }
1117
+ async whenIdle(): Promise<void> { await this.execution.whenIdle(); await this.cleanup; }
676
1118
  get running(): boolean { return this.execution.running; }
677
1119
 
678
1120
  /**
@@ -690,9 +1132,15 @@ export class Session {
690
1132
  userText: UserInput,
691
1133
  signal?: AbortSignal,
692
1134
  pull?: (phase: Phase) => Promise<UserInput | null>,
1135
+ requestId?: string,
693
1136
  ): AsyncGenerator<LoopEvent> {
1137
+ if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
694
1138
  const owned = this.execution.begin(signal);
695
- const events = this.run(userText, owned.signal, pull);
1139
+ const savedTools = this.config.tools;
1140
+ this.turnTools = savedTools;
1141
+ this.config.tools = { ...savedTools };
1142
+ this.installSkillResources();
1143
+ const events = this.run(userText, owned.signal, pull, requestId);
696
1144
  let terminal = false;
697
1145
  let exhausted = false;
698
1146
  try {
@@ -705,13 +1153,16 @@ export class Session {
705
1153
  t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
706
1154
  reason: item.value.reason ?? item.value.outcome.reason, outcome: item.value.outcome,
707
1155
  });
1156
+ const request = await this.requestState();
1157
+ if (request) item.value.request = request;
708
1158
  }
709
1159
  yield item.value;
710
1160
  }
711
1161
  if (!terminal) {
712
1162
  const outcome = executionOutcome("snapshot_failed");
713
1163
  await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), reason: "workspace snapshot failed", outcome });
714
- yield { kind: "done", reason: "stopped: workspace snapshot failed", outcome };
1164
+ const request = await this.requestState();
1165
+ yield { kind: "done", reason: "stopped: workspace snapshot failed", outcome, ...(request ? { request } : {}) };
715
1166
  }
716
1167
  } catch (error) {
717
1168
  // Raw provider exceptions can contain credentials; persist only the execution fact.
@@ -725,7 +1176,13 @@ export class Session {
725
1176
  if (!exhausted) {
726
1177
  await events.return(undefined);
727
1178
  }
728
- } finally { owned.finish(); }
1179
+ } finally {
1180
+ while (this.hostPending.size > 0) await Promise.allSettled(this.hostPending);
1181
+ this.config.tools = savedTools; this.turnTools = null;
1182
+ if (owned.signal.aborted) this.cancel();
1183
+ owned.finish();
1184
+ if (owned.signal.aborted) await this.whenIdle();
1185
+ }
729
1186
  }
730
1187
  }
731
1188
 
@@ -733,7 +1190,10 @@ export class Session {
733
1190
  userText: UserInput,
734
1191
  signal?: AbortSignal,
735
1192
  pull?: (phase: Phase) => Promise<UserInput | null>,
1193
+ requestId?: string,
736
1194
  ): AsyncGenerator<LoopEvent> {
1195
+ // A crash after a discovery receipt but before its cut is recovered from that same receipt.
1196
+ if (!signal?.aborted) await this.applyDiscoveredTools();
737
1197
  if (needsReplayCheckpoint(this.state.events, providerReplayKey(this.provider.config.spec), nativeReplay(this.provider.config.spec))) {
738
1198
  if (this.isHalfRound()) throw new Error("Cannot change a wire profile while tool results are pending");
739
1199
  await this.performCut("model_checkpoint");
@@ -744,9 +1204,10 @@ export class Session {
744
1204
  if (this.isHalfRound()) throw new Error("Cannot change image transmission while tool results are pending");
745
1205
  await this.performCut("model_checkpoint");
746
1206
  }
747
- await this.record({
748
- t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(userText, blocked),
749
- });
1207
+ const request = requestId ?? this.clock.nextId();
1208
+ await this.record({ t: "user", id: request, at: this.clock.now(), ...userInputData(userText, blocked) });
1209
+ if (this.config.acceptanceContract) await this.record({ t: "acceptance_contract", id: this.clock.nextId(), at: this.clock.now(),
1210
+ request: this.state.events.at(-1)!.id, contract: this.config.acceptanceContract });
750
1211
  // Idle changes belong to the new prompt. Recording them before `user`
751
1212
  // would silently move the preceding exchange's restore point forward.
752
1213
  for await (const event of this.snapshotTurn([])) {
@@ -774,12 +1235,11 @@ export class Session {
774
1235
  yield { kind: "turn_start", node: this.state.node };
775
1236
 
776
1237
  if (this.config.guidance !== false) {
777
- const names = this.config.frozen.tools.flatMap((tool) => {
778
- if (tool === null || typeof tool !== "object" || !("name" in tool) || typeof tool.name !== "string") return [];
779
- return this.config.tools.allow === undefined || this.config.tools.allow.includes(tool.name) ? [tool.name] : [];
780
- });
781
- const guidance = continuationGuidance({ events: this.state.events, handoff: this.state.handoff,
1238
+ const names = this.adviceNames();
1239
+ const guidance = (this.config.harnessReviews === false ? null : await this.recoveryGuidance(names)) ??
1240
+ continuationGuidance({ events: this.state.events, handoff: this.state.handoff,
782
1241
  tools: names, remainingRequests: this.config.maxTurns - turn,
1242
+ ...(this.config.guidanceVersion === 1 ? { taskAware: true } : {}),
783
1243
  ...(this.config.continuationAfter === undefined ? {} : { minAssistantTurns: this.config.continuationAfter }) }) ??
784
1244
  (this.config.timeBudgetMs === undefined ? null
785
1245
  : deadlineNotice(requestStarted, this.clock.now(), this.config.timeBudgetMs, this.state.events)) ??
@@ -793,15 +1253,7 @@ export class Session {
793
1253
  yield { kind: "done", reason: "stopped: context cannot fit the model window", outcome: executionOutcome("context_budget") };
794
1254
  return;
795
1255
  }
796
- const projected = projectContext({
797
- frozen: this.config.frozen,
798
- handoff: this.state.handoff,
799
- events: this.state.events,
800
- logHint: {
801
- manifestPath: manifestPath(this.config.logDir),
802
- segmentPath: nodePath(this.config.logDir, Math.max(0, this.state.node - 1)),
803
- },
804
- });
1256
+ const projected = projectContext(this.contextInput());
805
1257
 
806
1258
  // Pairing violations are a 400 waiting to happen; surface them here,
807
1259
  // where the cause is still visible, rather than as a request failure.
@@ -826,6 +1278,8 @@ export class Session {
826
1278
  try {
827
1279
  result = yield* this.awaitProvider(projected.messages, signal);
828
1280
  } catch (error) {
1281
+ // D11: retain a typed response receipt once, without anchoring rejected content or trusting arbitrary error fields.
1282
+ if (error instanceof FailedResponseError && error.usage !== undefined) this.state.usage.push({ ...error.usage });
829
1283
  if (signal?.aborted) {
830
1284
  yield* this.closeInterrupted(completedThisTurn);
831
1285
  return;
@@ -836,8 +1290,7 @@ export class Session {
836
1290
  // shrink is a cut, so force one and send once more; a second
837
1291
  // overflow means even the retained tail is misjudged, and another
838
1292
  // attempt would only be paid for.
839
- if (overflowRetried || (this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY) !== "windowed" ||
840
- windowBudget(this.provider.config.spec.contextWindow, this.provider.config.maxTokens) === null) {
1293
+ if (overflowRetried || this.policyLimits().kind !== "window") {
841
1294
  yield { kind: "done", reason: `stopped: provider rejected the prompt as too long (${error.message})`, outcome: executionOutcome("context_budget") };
842
1295
  return;
843
1296
  }
@@ -852,6 +1305,14 @@ export class Session {
852
1305
 
853
1306
  const previous = this.state.usage.at(-1);
854
1307
  this.state.usage.push(result.usage);
1308
+ // Q24: an adapter may finish after abort. Keep measured usage, but cancellation
1309
+ // owns termination and a late response cannot enter context or execute tools.
1310
+ if (signal?.aborted) {
1311
+ yield { kind: "usage", usage: result.usage, ok: true };
1312
+ yield { kind: "discard" };
1313
+ yield* this.closeInterrupted(completedThisTurn);
1314
+ return;
1315
+ }
855
1316
  const promptTokens =
856
1317
  result.usage.input_tokens +
857
1318
  result.usage.cache_creation_input_tokens +
@@ -890,6 +1351,15 @@ export class Session {
890
1351
  };
891
1352
  }
892
1353
 
1354
+ // D5: ambiguous IDs cannot be persisted or paired safely. Keep sampled
1355
+ // usage, but stop before observer hooks or any tool effect can run.
1356
+ const identityViolations = toolCallIdentityViolations(result.blocks);
1357
+ if (identityViolations.length > 0) {
1358
+ yield { kind: "done", outcome: executionOutcome("provider_error"),
1359
+ reason: `stopped: invalid provider tool call identity (${identityViolations.map(violation => `${violation.code}: ${violation.detail}`).join("; ")})` };
1360
+ return;
1361
+ }
1362
+
893
1363
  await this.record({
894
1364
  t: "assistant", id: this.clock.nextId(), at: this.clock.now(), blocks: result.blocks, stop_reason: result.stopReason,
895
1365
  replay_key: providerReplayKey(this.provider.config.spec),
@@ -912,7 +1382,7 @@ export class Session {
912
1382
  }
913
1383
  const extra = await this.pullSteering(pull);
914
1384
  if (extra !== null) {
915
- await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(extra, blocked) });
1385
+ await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), request, ...userInputData(extra, blocked) });
916
1386
  continue;
917
1387
  }
918
1388
  const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
@@ -935,18 +1405,25 @@ export class Session {
935
1405
  const extra = await this.pullSteering(pull);
936
1406
  if (extra !== null) {
937
1407
  await this.record({
938
- t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(extra, blocked),
1408
+ t: "user", id: this.clock.nextId(), at: this.clock.now(), request, ...userInputData(extra, blocked),
939
1409
  });
940
1410
  continue;
941
1411
  }
942
- const review = this.config.guidance === false || this.config.harnessReviews === false ? null
943
- : completionReview(this.state.events, this.config.maxTurns - turn - 1);
944
- if (review) {
945
- await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...review });
1412
+ if (this.config.acceptanceContract) await this.refreshRuntimeTargets();
1413
+ const state = await this.requestState();
1414
+ const decision = !state || this.config.guidance === false || this.config.harnessReviews === false ? { action: "finish" as const }
1415
+ : requestCompletion(state, this.config.maxTurns - turn - 1, {
1416
+ ...(this.config.guidanceVersion === 1 ? { taskAware: true } : {}),
1417
+ ...(this.config.strictVerification ? { strictVerification: true } : {}),
1418
+ });
1419
+ if (decision.action === "continue") {
946
1420
  if (yield* this.maybeCut(counter, true)) {
947
1421
  yield { kind: "done", reason: "stopped: no verifiable progress", outcome: executionOutcome("no_progress") };
948
1422
  return;
949
1423
  }
1424
+ // Q23: a notice appended before a cut can disappear from the next
1425
+ // projection. Append at the destination so the provider sees it once.
1426
+ await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...decision.guidance });
950
1427
  continue;
951
1428
  }
952
1429
  const stopped = yield* this.maybeCut(counter, false);
@@ -959,7 +1436,7 @@ export class Session {
959
1436
  }
960
1437
 
961
1438
  let branchesThisTurn = 0;
962
- type Settled = { ok: true; value: DeferredToolResult } | { ok: false; error: unknown };
1439
+ type Settled = { ok: true; value: SessionBrokerReceipt } | { ok: false; error: unknown };
963
1440
  const pending = new Map<string, Promise<Settled>>();
964
1441
  const modes = calls.map(call => this.executionMode(call));
965
1442
  try { for (let callIndex = 0; callIndex < calls.length; callIndex++) {
@@ -975,88 +1452,33 @@ export class Session {
975
1452
  const planned = calls[index]!;
976
1453
  yield { kind: "tool_call", tool: { name: planned.name, input: planned.input } };
977
1454
  if (signal?.aborted) break;
978
- const dispatch = yield* this.prepareConcurrentCall(planned, planned.name === "branch" ? branchesThisTurn++ : 0, signal);
979
- pending.set(planned.call_id, dispatch().then(value => ({ ok: true as const, value }), error => ({ ok: false as const, error })));
1455
+ const dispatch = yield* this.prepareBrokerCall(planned, () => branchesThisTurn++, blocked, signal);
1456
+ pending.set(planned.call_id, dispatch.run().then(value => ({ ok: true as const, value }), error => ({ ok: false as const, error })));
980
1457
  }
981
1458
  }
982
1459
 
983
- let out: DeferredToolResult;
984
- let annotation: LogEvent | undefined;
1460
+ let receipt: SessionBrokerReceipt;
985
1461
  const dispatched = pending.get(call.call_id);
986
1462
  if (dispatched) {
987
1463
  const settled = await dispatched;
988
1464
  pending.delete(call.call_id);
989
1465
  if (!settled.ok) throw settled.error;
990
- out = settled.value;
1466
+ receipt = settled.value;
991
1467
  } else {
992
1468
  if (signal?.aborted) { yield* this.closeInterrupted(completedThisTurn); return; }
993
-
994
1469
  yield { kind: "tool_call", tool: { name: call.name, input: call.input } };
995
-
996
- // Checked again after the yield: that is the window a consumer has
997
- // after seeing the call and before it runs. The first check above
998
- // covers abort arriving between calls.
999
- if (signal?.aborted) {
1000
- yield* this.closeInterrupted(completedThisTurn);
1001
- return;
1002
- }
1003
-
1004
- // Three tools are not workspace actions and are handled here:
1005
- // `note` writes an annotation the handoff is built from, and
1006
- // `branch`/`resolve` act on the tree. Each still produces an ordinary
1007
- // tool_result so the turn stays pairable. They go through the same
1008
- // gate as read/write/edit/bash, which runTool applies itself.
1009
- const toolContext = { ...this.config.tools, imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"), imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}) };
1010
- if (call.name === "note" || call.name === "branch" || call.name === "resolve" || call.name === "history") {
1011
- const gated = await gateTool(toolContext, call.name, call.input);
1012
- if (gated) {
1013
- out = gated;
1014
- } else if (call.name === "history") {
1015
- out = await runHistoryTool(this.config.logDir, this.state.node, toolContext, call.input);
1016
- } else if (call.name === "note") {
1017
- const parsed = parseNote(call.input);
1018
- const prepared = await prepareWorkingNote(this.config.logDir, this.state.node, this.config.workNamespace ?? "main",
1019
- parsed.annotation ? this.toEvent(parsed.annotation) : undefined, parsed.result);
1020
- out = prepared.out; annotation = prepared.annotation;
1021
- } else if (call.name === "branch") {
1022
- out = yield* this.runBranch(call.input, branchesThisTurn++, signal);
1023
- } else {
1024
- out = await this.runResolve(call.input);
1025
- }
1026
- } else {
1027
- const decoder = new TextDecoder();
1028
- out = yield* withProgress<ToolResult, LoopEvent>(emit => runTool({ ...toolContext, callId: call.call_id,
1029
- onOutput: chunk => emit({ kind: "progress", progress: { kind: "output", name: call.name, text: decoder.decode(chunk, { stream: true }) } }),
1030
- }, call.name, call.input));
1031
- }
1470
+ if (signal?.aborted) { yield* this.closeInterrupted(completedThisTurn); return; }
1471
+ const prepared = yield* this.prepareBrokerCall(call, () => branchesThisTurn++, blocked, signal);
1472
+ receipt = yield* withProgress<SessionBrokerReceipt, LoopEvent>(emit => prepared.run(emit));
1032
1473
  }
1033
- if (out.accept) for (const event of await out.accept()) yield event;
1034
- out = finishToolResult(out);
1035
- // Observation precedes persistence, preserving the after-effect crash
1036
- // boundary. A lost process here recovers as unknown, never as success.
1474
+ await receipt.settle();
1475
+ for (const event of receipt.events.splice(0)) yield event;
1476
+ const out = finishToolResult(receipt.value);
1477
+ // Observation still precedes persistence: crashes here remain unknown,
1478
+ // while nested receipts have already been committed before their caller resumed.
1037
1479
  yield { kind: "tool_result", text: out.content, ok: out.ok, outcome: out.outcome! };
1480
+ await receipt.commit();
1038
1481
 
1039
- // Recorded before the tool_result so a claim can never precede the
1040
- // evidence it cites -- deriveHandoff resolves a claim against runs at
1041
- // or before it, and an annotation is about work already done.
1042
- if (annotation) await this.record(annotation);
1043
-
1044
- await this.record({
1045
- t: "tool_result",
1046
- id: this.clock.nextId(),
1047
- at: this.clock.now(),
1048
- call_id: call.call_id,
1049
- ok: out.ok,
1050
- content: out.content,
1051
- outcome: out.outcome!,
1052
- ...(out.data !== undefined ? { data: out.data } : {}),
1053
- ...(out.observation_sha256 !== undefined ? { observation_sha256: out.observation_sha256 } : {}),
1054
- ...(out.diagnostic !== undefined ? { diagnostic: out.diagnostic } : {}),
1055
- ...(out.content_sha256 ? { content_sha256: out.content_sha256 } : {}),
1056
- ...(out.content_ref ? { content_ref: out.content_ref } : {}),
1057
- ...(out.images?.length ? { images: submissionImages(out.images, blocked) } : {}),
1058
- ...(out.effect ? { effect: out.effect } : {}),
1059
- });
1060
1482
  completedThisTurn.push(call.call_id);
1061
1483
  if (signal?.aborted && pending.size === 0) {
1062
1484
  yield* this.closeInterrupted(completedThisTurn);
@@ -1068,6 +1490,8 @@ export class Session {
1068
1490
  // that means anything, and per-call would be the character-level
1069
1491
  // granularity the research warns against.
1070
1492
  yield* this.snapshotTurn(calls.map((c) => c.call_id));
1493
+ const declarationNode = this.state.node;
1494
+ if (!signal?.aborted && await this.applyDiscoveredTools()) yield { kind: "cut", node: declarationNode, reason: "model_checkpoint" };
1071
1495
  if (this.pendingAdoption !== null) await this.adoptPendingBranch();
1072
1496
 
1073
1497
  // Between complete exchanges is the only safe place to cut.
@@ -1079,7 +1503,7 @@ export class Session {
1079
1503
  const extra = await this.pullSteering(pull);
1080
1504
  if (extra !== null) {
1081
1505
  await this.record({
1082
- t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(extra, blocked),
1506
+ t: "user", id: this.clock.nextId(), at: this.clock.now(), request, ...userInputData(extra, blocked),
1083
1507
  });
1084
1508
  }
1085
1509
  }
@@ -1111,6 +1535,9 @@ export class Session {
1111
1535
  id: this.clock.nextId(),
1112
1536
  at: this.clock.now(),
1113
1537
  call_id: block.call_id,
1538
+ ...(block.invocation_id ? { invocation_ref: { node: this.state.node, event: block.invocation_id } } : {}),
1539
+ ...(block.parent_call_id ? { parent_call_id: block.parent_call_id } : {}),
1540
+ ...(block.origin ? { origin: block.origin } : {}),
1114
1541
  ok: false,
1115
1542
  content: resultText,
1116
1543
  outcome: executionOutcome("unknown"),
@@ -1200,6 +1627,8 @@ export class Session {
1200
1627
  * delta rather than a copy of the workspace.
1201
1628
  */
1202
1629
  private async *snapshotTurn(callIds: readonly string[], baseline = false, phase: "turn" | "idle" = "turn"): AsyncGenerator<LoopEvent> {
1630
+ // P.3: retained only for explicit historical compatibility. New sessions never scan here.
1631
+ if (this.config.workspaceTracking !== "legacy") return;
1203
1632
  const blobDir = this.config.blobDir;
1204
1633
  if (!blobDir) return;
1205
1634
 
@@ -1278,41 +1707,207 @@ export class Session {
1278
1707
  * the same thing, so nothing here has to reimplement the loop.
1279
1708
  */
1280
1709
  private executionMode(call: ToolCall): "parallel" | "exclusive" {
1710
+ if (this.config.tools.extensionHooks?.serial?.()) return "exclusive";
1711
+ if (call.name === "read" && typeof call.input === "object" && call.input !== null &&
1712
+ this.config.skills?.some(skill => skill.filePath === resolve(this.config.tools.root, String((call.input as Record<string, unknown>)["path"])))) return "exclusive";
1281
1713
  if (call.name === "branch" && typeof call.input === "object" && call.input !== null &&
1282
- (call.input as Record<string, unknown>)["writes"] !== true) return "parallel";
1714
+ (call.input as Record<string, unknown>)["writes"] !== true && (call.input as Record<string, unknown>)["background"] !== true) return "parallel";
1283
1715
  return toolDefinition(call.name, this.config.tools)?.execution ?? "exclusive";
1284
1716
  }
1285
1717
 
1286
- private async *prepareConcurrentCall(call: ToolCall, branchIndex: number, signal?: AbortSignal): AsyncGenerator<LoopEvent, () => Promise<DeferredToolResult>> {
1287
- const ctx = { ...this.config.tools, callId: call.call_id, imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"), imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}) };
1288
- if (call.name !== "branch") {
1289
- const prepared = await prepareTool(ctx, call.name, call.input);
1290
- return typeof prepared === "function" ? prepared : async () => prepared;
1291
- }
1292
- const gated = await gateTool(ctx, call.name, call.input);
1293
- if (gated) return async () => gated;
1294
- const branch = this.runBranch(call.input, branchIndex, signal);
1295
- while (true) {
1296
- const step = await branch.next();
1297
- if (step.done) return async () => step.value;
1298
- yield step.value;
1299
- // Spawn and snapshot preparation remain ordered. Advancing past this
1300
- // marker starts only the child's execution, which may overlap siblings.
1301
- if (step.value.kind === "branch_start") return async () => {
1302
- const result = await branch.next();
1303
- if (result.done) return result.value;
1304
- throw new Error(`unexpected child execution event: ${result.value.kind}`);
1305
- };
1718
+ private async *dispatchBranch(input: unknown, index: number, signal?: AbortSignal): AsyncGenerator<LoopEvent, DeferredToolResult> {
1719
+ if (typeof input !== "object" || input === null || !("background" in input) || input.background !== true) return yield* this.runBranch(input, index, signal);
1720
+ if (this.jobs.active >= (this.config.maxParallelTools ?? 3)) return { ok: false, content: "background branch capacity reached; wait or cancel an owned branch first" };
1721
+ const controller = new AbortController();
1722
+ const abort = () => controller.abort();
1723
+ signal?.addEventListener("abort", abort, { once: true });
1724
+ if (signal?.aborted) abort();
1725
+ const detach = () => signal?.removeEventListener("abort", abort);
1726
+ const child = this.runBranch(input, index, controller.signal);
1727
+ let registered = false;
1728
+ try {
1729
+ for (;;) {
1730
+ const step = await child.next();
1731
+ if (step.done) return step.value;
1732
+ yield step.value;
1733
+ if (step.value.kind !== "branch_start") continue;
1734
+ const node = step.value.node!;
1735
+ this.jobs.start(node, controller, async () => {
1736
+ const result = await child.next();
1737
+ if (!result.done) throw new Error("unexpected background child yield");
1738
+ return result.value;
1739
+ }, detach);
1740
+ registered = true;
1741
+ return { ok: true, content: `branch ${node} running; ` + (this.adviceNames().includes("resolve")
1742
+ ? `use resolve(node=${node}, resolution='status'|'wait'|'cancel')` : "branch-observation capability unavailable; completion is unknown"), data: { node, background: true, running: true } };
1743
+ }
1744
+ } finally { if (!registered) detach(); }
1745
+ }
1746
+
1747
+ private async *prepareBrokerCall(call: ToolCall, nextBranch: () => number, blockedImages: boolean,
1748
+ signal?: AbortSignal, native = false): AsyncGenerator<LoopEvent, PreparedBrokerCall> {
1749
+ this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
1750
+ const release = await this.rootTools.acquire(this.executionMode(call));
1751
+ let transferred = false;
1752
+ let scope: BrokerExecution<DeferredToolResult> | undefined;
1753
+ try {
1754
+ const events: LoopEvent[] = [];
1755
+ let emit = (event: LoopEvent) => { events.push(event); };
1756
+ let context: ToolContext = { ...this.config.tools, imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"),
1757
+ advice: this.allowedNames(),
1758
+ imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}) };
1759
+ if (native) context = nativeCommandContext(context);
1760
+ const decoder = new TextDecoder();
1761
+ context.onOutput = chunk => emit({ kind: "progress", progress: { kind: "output", name: call.name, text: decoder.decode(chunk, { stream: true }) } });
1762
+ const broker = new ToolBroker<DeferredToolResult>({
1763
+ parallelLimit: this.config.maxParallelTools ?? 3,
1764
+ callId: () => `nested-${randomUUID()}`,
1765
+ mode: request => this.executionMode(request),
1766
+ invocation: (request, ctx, parent) => this.recordInvocation(request, ctx, parent),
1767
+ prepare: (request, ctx) => this.prepareSessionAction(request, ctx, nextBranch, event => emit(event)),
1768
+ settle: async result => {
1769
+ if (result.accept) events.push(...await result.accept());
1770
+ if (!result.content_ref) Object.assign(result, await presentObservation(context, result.content));
1771
+ },
1772
+ result: async (invocation, result) => {
1773
+ if (result.annotation) await this.record(result.annotation);
1774
+ await this.recordToolReceipt(invocation, finishToolResult(result), blockedImages);
1775
+ },
1776
+ });
1777
+ const execute = yield* withProgress<BrokerExecution<DeferredToolResult>, LoopEvent>(async output => {
1778
+ emit = output;
1779
+ scope = await broker.prepare(call, context);
1780
+ return scope;
1781
+ });
1782
+ emit = event => { events.push(event); };
1783
+ transferred = true;
1784
+ return { run: async output => {
1785
+ if (output) emit = output;
1786
+ let commitOwnsSlot = false;
1787
+ try {
1788
+ const receipt = await execute();
1789
+ if (native) {
1790
+ // An idle host result has no generator consumer to own its commit.
1791
+ // Retain the slot so a checkpoint cannot split invocation from receipt.
1792
+ commitOwnsSlot = true;
1793
+ return { ...receipt, events, commit: async () => { try { await receipt.commit(); } finally { release(); } } };
1794
+ }
1795
+ return { ...receipt, events };
1796
+ } finally { emit = event => { events.push(event); }; if (!commitOwnsSlot) release(); }
1797
+ } };
1798
+ } finally { if (!transferred) { try { await scope?.close(); } finally { release(); } } }
1799
+ }
1800
+
1801
+ private async recordInvocation(call: ToolCall, ctx: ToolContext, parent?: ToolInvocationEvent): Promise<ToolInvocationEvent> {
1802
+ const store = ctx.outputStore ?? join(this.config.logDir, "artifacts");
1803
+ const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));
1804
+ const archive = async (bytes: Uint8Array) => {
1805
+ async function* source() { yield bytes; }
1806
+ return (await archiveObservation(store, source(), 0)).ref;
1807
+ };
1808
+ let valid = false;
1809
+ try { valid = isJsonValue(call.input); } catch { /* Cyclic or excessively deep host input is not a JSON request. */ }
1810
+ const bytes = encode(valid ? call.input : { unavailable: "non-JSON arguments", type: typeof call.input });
1811
+ const definition = toolDefinition(call.name, ctx);
1812
+ const declared = this.config.frozen.tools.find(tool => tool !== null && typeof tool === "object" && "name" in tool && tool.name === call.name &&
1813
+ "description" in tool && typeof tool.description === "string" && "input_schema" in tool) as ReturnType<typeof modelToolSchema> | undefined;
1814
+ const schema = definition ? declared ?? modelToolSchema(definition, adviceTools(ctx)) : undefined;
1815
+ const descriptor = definition ? { version: 1, ...schema,
1816
+ ...(declared && JSON.stringify(declared.input_schema) !== JSON.stringify(definition.input_schema) ? { admission_schema: definition.input_schema } : {}),
1817
+ access: definition.access, execution: definition.execution, branch: definition.branch, hiddenInputs: definition.hiddenInputs ?? [], result_version: 1 } : null;
1818
+ const original = this.state.events.findLast(event => event.t === "assistant" && event.blocks.some(block => block.type === "tool_use" && block.call_id === call.call_id));
1819
+ const originalInput = original?.t === "assistant" ? original.blocks.find(block => block.type === "tool_use" && block.call_id === call.call_id) : undefined;
1820
+ const changed = originalInput?.type !== "tool_use" || valid && JSON.stringify(originalInput.input) !== JSON.stringify(call.input);
1821
+ const event: ToolInvocationEvent = { t: "tool_invocation", id: this.clock.nextId(), at: this.clock.now(), call_id: call.call_id, name: call.name,
1822
+ ...(ctx.origin ? { origin: ctx.origin } : {}),
1823
+ input_ref: await archive(bytes), schema_ref: descriptor ? await archive(encode(descriptor)) : null,
1824
+ ...(parent ? { parent_call_id: parent.call_id, parent_ref: { node: this.state.node, event: parent.id } } : {}),
1825
+ ...(valid && bytes.length <= MAX_NESTED_ARGUMENT_BYTES && (parent || changed) ? { input: call.input as JsonValue } : {}) };
1826
+ await this.record(event); return event;
1827
+ }
1828
+
1829
+ private async recordToolReceipt(invocation: ToolInvocationEvent, out: ToolResult, blockedImages: boolean): Promise<void> {
1830
+ let data = out.data;
1831
+ let dataRef;
1832
+ if (data !== undefined) {
1833
+ const bytes = new TextEncoder().encode(JSON.stringify(data));
1834
+ if (bytes.length > MAX_INLINE_RESULT_DATA_BYTES) {
1835
+ async function* source() { yield bytes; }
1836
+ dataRef = (await archiveObservation(this.config.tools.outputStore!, source(), 0)).ref;
1837
+ data = undefined;
1838
+ }
1306
1839
  }
1840
+ await this.record({ t: "tool_result", id: this.clock.nextId(), at: this.clock.now(), call_id: invocation.call_id,
1841
+ invocation_ref: { node: this.state.node, event: invocation.id },
1842
+ ...(invocation.origin ? { origin: invocation.origin } : {}),
1843
+ ...(invocation.parent_call_id ? { parent_call_id: invocation.parent_call_id } : {}), ok: out.ok, content: out.content,
1844
+ ...(out.outcome ? { outcome: out.outcome } : {}), ...(data !== undefined ? { data } : {}), ...(dataRef ? { data_ref: dataRef } : {}),
1845
+ ...(out.observation_sha256 !== undefined ? { observation_sha256: out.observation_sha256 } : {}),
1846
+ ...(out.observation ? { observation: out.observation } : {}), ...(out.diagnostic ? { diagnostic: out.diagnostic } : {}),
1847
+ ...(out.warnings ? { warnings: out.warnings } : {}), ...(out.content_sha256 ? { content_sha256: out.content_sha256 } : {}),
1848
+ ...(out.content_ref ? { content_ref: out.content_ref } : {}),
1849
+ ...(out.images?.length ? { images: submissionImages(out.images, blockedImages) } : {}), ...(out.effect ? { effect: out.effect } : {}) });
1850
+ }
1851
+
1852
+ private async prepareSessionAction(call: ToolCall, ctx: ToolContext, nextBranch: () => number,
1853
+ emit: (event: LoopEvent) => void): Promise<DeferredToolResult | (() => Promise<DeferredToolResult>)> {
1854
+ // SDK callers may omit allow or preauthorize an optional name. Permission is
1855
+ // distinct from a declaration: neither direct nor nested calls may run it early.
1856
+ const declared = declaredToolNames(this.config.frozen.tools);
1857
+ if (ctx.deferredTools?.some(tool => tool.name === call.name) && !declared.includes(call.name)) {
1858
+ return prepareNormalizedTool({ ...ctx, allow: (ctx.allow ?? declared).filter(name => name !== call.name) }, call.name, call.input);
1859
+ }
1860
+ const forkSkill = call.name === "read" && typeof call.input === "object" && call.input !== null ? this.config.skills?.find(skill =>
1861
+ skill.context === "fork" && skill.filePath === resolve(ctx.root, String((call.input as Record<string, unknown>)["path"]))) : undefined;
1862
+ const discovery = call.name === DISCOVERY_TOOL_NAME && ctx.extensions?.get(call.name) === DISCOVERY_TOOL;
1863
+ if (!forkSkill && !discovery && !["note", "branch", "resolve", "history", "target"].includes(call.name)) return prepareNormalizedTool(ctx, call.name, call.input);
1864
+ const branchIndex = forkSkill || call.name === "branch" ? nextBranch() : 0;
1865
+ return prepareNormalizedTool(ctx, call.name, call.input, async () => {
1866
+ if (discovery) return async () => searchDeferredTools(ctx, call.input as Record<string, unknown>, this.allowedNames());
1867
+ if (call.name === "history") return () => runHistoryTool(this.config.logDir, this.state.node, ctx, call.input);
1868
+ if (call.name === "target") return async () => {
1869
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
1870
+ if (!history.ok) throw Error(history.error);
1871
+ const observed = await this.runtimeTargets.perform(ctx, history.nodes, call.input as Parameters<RuntimeTargets["perform"]>[2]);
1872
+ return { ...observed.result, annotation: { t: "target_observation" as const, id: this.clock.nextId(), at: this.clock.now(), observation: observed.observation } };
1873
+ };
1874
+ if (call.name === "note") return async () => {
1875
+ await this.observeEvidence();
1876
+ const parsed = parseNote(call.input);
1877
+ const prepared = await prepareWorkingNote(this.config.logDir, this.state.node, this.config.workNamespace ?? "main",
1878
+ parsed.annotation ? this.toEvent(parsed.annotation) : undefined, parsed.result);
1879
+ return { ...prepared.out, ...(prepared.annotation ? { annotation: prepared.annotation } : {}) };
1880
+ };
1881
+ if (call.name === "resolve") return () => this.runResolve(call.input);
1882
+ if (forkSkill?.disableModelInvocation) return async () => ({ ok: false, content: "this skill requires explicit user invocation" });
1883
+ if (forkSkill) await this.recordSkill(forkSkill, "fork", "model");
1884
+ const task = this.state.events.findLast(event => event.t === "user");
1885
+ const branch = forkSkill ? this.runBranch({ purpose: `skill ${forkSkill.name}`, prompt: `${forkSkill.body}\n\nCurrent task: ${task?.t === "user" ? task.text : ""}` },
1886
+ branchIndex, ctx.signal, forkSkill, skillTools(this.allowedNames(), forkSkill), "model") : this.dispatchBranch(call.input, branchIndex, ctx.signal);
1887
+ while (true) {
1888
+ const step = await branch.next();
1889
+ if (step.done) return async () => step.value;
1890
+ emit(step.value);
1891
+ if (step.value.kind === "branch_start") return async () => {
1892
+ const result = await branch.next();
1893
+ if (!result.done) throw new Error(`unexpected child execution event: ${result.value.kind}`);
1894
+ return result.value;
1895
+ };
1896
+ }
1897
+ });
1307
1898
  }
1308
1899
 
1309
1900
  private async *runBranch(
1310
1901
  input: unknown,
1311
1902
  indexThisTurn: number,
1312
1903
  signal?: AbortSignal,
1904
+ skill?: Skill,
1905
+ allowedOverride?: readonly string[],
1906
+ skillOrigin: "user" | "model" = "user",
1313
1907
  ): AsyncGenerator<LoopEvent, DeferredToolResult> {
1314
1908
  const limits = this.limits;
1315
1909
  const depth = this.config.branchDepth ?? 0;
1910
+ if (this.children.size >= (this.config.maxParallelTools ?? 3)) return { ok: false, content: "branch capacity reached; wait or cancel an owned branch first" };
1316
1911
 
1317
1912
  // Every refusal is a normal tool_result: an unanswered tool_use makes the
1318
1913
  // next request unpairable, and a refusal can say what to do instead.
@@ -1349,7 +1944,7 @@ export class Session {
1349
1944
  if (!purpose || !prompt) {
1350
1945
  return { content: "a branch needs both a purpose and a prompt", ok: false };
1351
1946
  }
1352
- const parentAllow = this.config.tools.allow;
1947
+ const parentAllow = allowedOverride ?? this.config.tools.allow;
1353
1948
  const branchNames = writes ? WRITING_BRANCH_TOOL_NAMES : BRANCH_TOOL_NAMES;
1354
1949
  const allowed: string[] = parentAllow
1355
1950
  ? branchNames.filter(name => parentAllow.includes(name)) : [...branchNames];
@@ -1387,6 +1982,7 @@ export class Session {
1387
1982
 
1388
1983
  await this.record({
1389
1984
  t: "spawn", id: this.clock.nextId(), at: this.clock.now(), branch: ref, purpose,
1985
+ ...(args["background"] === true ? { background: true as const } : {}),
1390
1986
  input: { source_event: sourceEvent, tools: allowed, max_turns: Math.min(this.config.maxTurns, 15), workspace: inputWorkspace },
1391
1987
  ...(worktree ? { workspace: worktree } : {}),
1392
1988
  });
@@ -1399,7 +1995,8 @@ export class Session {
1399
1995
  const branch = new Session(
1400
1996
  {
1401
1997
  ...this.config,
1402
- frozen: { system: this.config.frozen.system, tools: branchTools(writes).filter((t) => allowed.includes(t.name)) },
1998
+ acceptanceContract: null,
1999
+ frozen: { system: this.config.frozen.system, tools: branchTools(writes, allowed) },
1403
2000
  // The same list twice: the schema shapes what the model asks for, the
1404
2001
  // allow-list decides what actually runs. Intersected with the parent
1405
2002
  // so `--exclude-tools bash` cannot be undone by opening a branch.
@@ -1407,6 +2004,7 @@ export class Session {
1407
2004
  ...this.config.tools,
1408
2005
  ...(worktree ? { root: worktree.path } : {}),
1409
2006
  allow: allowed,
2007
+ readonlyBranch: !writes,
1410
2008
  },
1411
2009
  branchDepth: depth + 1,
1412
2010
  workNamespace: `branch-${node}`,
@@ -1419,14 +2017,16 @@ export class Session {
1419
2017
  branch.state.node = node;
1420
2018
  branch.state.nextNode = this.state.nextNode;
1421
2019
  branch.state.lastScan = { ...this.state.lastScan };
2020
+ if (skill) await branch.recordSkill(skill, "fork", skillOrigin);
2021
+ this.children.set(node, branch);
1422
2022
  yield { kind: "branch_start", node, reason: purpose, at: Date.now() };
1423
- for await (const event of branch.snapshotTurn([], true)) {
1424
- if (event.kind === "snapshot_failed") throw new Error(`cannot establish branch baseline: ${event.reason}`);
1425
- }
1426
2023
 
1427
2024
  let text = "";
1428
2025
  let outcome = executionOutcome("unknown");
1429
2026
  try {
2027
+ for await (const event of branch.snapshotTurn([], true)) {
2028
+ if (event.kind === "snapshot_failed") throw new Error(`cannot establish branch baseline: ${event.reason}`);
2029
+ }
1430
2030
  for await (const e of branch.send(prompt, signal)) {
1431
2031
  if (e.kind === "assistant_text" && e.text) text = e.text;
1432
2032
  if (e.kind === "done") outcome = e.outcome ?? executionOutcome("unknown");
@@ -1435,7 +2035,8 @@ export class Session {
1435
2035
  // Persist a bounded failure classification, never a provider's raw error body.
1436
2036
  outcome = executionOutcome(signal?.aborted ? "cancelled" : "execution_error");
1437
2037
  await branch.answerUnpairedTools("the branch execution failed; this tool's outcome is unknown; inspect before retrying");
1438
- }
2038
+ } finally { try { await branch.dispose(); } finally { this.children.delete(node); } }
2039
+ if (args["background"] === true) await branch.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), outcome, reason: "background branch execution ended" });
1439
2040
  // Node numbers the branch allocated are taken.
1440
2041
 
1441
2042
  const tip = branch.state.node;
@@ -1455,29 +2056,40 @@ export class Session {
1455
2056
  "not in your files. Adopting continues in that checkout without merging it into this one."
1456
2057
  : "";
1457
2058
  const summary = text.trim() || "(the branch produced no closing message)";
2059
+ const receiptHistory = await readNodeHistory(this.config.logDir, tip, undefined, "context");
2060
+ if (!receiptHistory.ok) throw new Error(receiptHistory.error);
2061
+ const receipt = deriveBranchReceipt({ source: await this.nodeRefFor(tip), outcome, handoff, nodes: receiptHistory.nodes });
2062
+ const receiptRef = await putBlob(join(this.config.logDir, "artifacts"), JSON.stringify(receipt));
1458
2063
  const joined: LogEvent = {
1459
2064
  t: "joined", id: this.clock.nextId(), at: this.clock.now(),
1460
2065
  branch: ref, tip: await this.nodeRefFor(tip), handoff_ref: handoffPath(this.config.logDir, tip), summary,
1461
2066
  model: branch.provider.config.spec.id,
1462
2067
  outcome,
2068
+ receipt_ref: receiptRef,
1463
2069
  };
1464
2070
 
1465
2071
  // Only the summary crosses back. The branch's own calls stay in its node,
1466
2072
  // which is the entire reason to branch rather than read in place.
1467
2073
  return {
1468
2074
  content:
1469
- `branch ${node} (${purpose}) ended: ${JSON.stringify(outcome)}.\n\n${summary}${where}\n\n` +
2075
+ `branch ${node} (${purpose}) ended: ${JSON.stringify(outcome)}.\n` +
2076
+ `Evidence receipt: ${receipt.commands.length} commands, ${receipt.changes.length} file edits, ${receipt.unfinished.length} unfinished items; verification=${receipt.verification}; task acceptance=not assessed.\n` +
2077
+ (this.adviceNames().includes("history") ? `Read history(artifact='${receiptRef}'), then history(branch=${node}, event='source ID') for original evidence.\n`
2078
+ : `Evidence receipt artifact=${receiptRef}; original-source recovery capability unavailable.\n`) +
2079
+ `Child closing statement (unverified):\n${summary}${where}\n\n` +
1470
2080
  `Its full state is at ${handoffPath(this.config.logDir, tip)}. ` +
1471
- `Resolve it with resolve(node: ${node}, ...).`,
2081
+ (this.adviceNames().includes("resolve") ? `Resolve it with resolve(node: ${node}, ...).` : "Branch-resolution capability unavailable."),
1472
2082
  ok: outcome.status === "completed",
1473
2083
  outcome,
1474
2084
  accept: async () => {
2085
+ if ((await this.branchStatuses()).some(item => item.node === node && item.handoff_ref)) return [];
1475
2086
  this.state.usage.push(...branch.state.usage);
1476
- await this.saveCursor();
1477
2087
  await this.record(joined);
2088
+ await this.saveCursor();
1478
2089
  return [{ kind: "branch_done", node, text: summary, outcome, ok: outcome.status === "completed" }];
1479
2090
  },
1480
2091
  data: { node, outcome, handoff_ref: handoffPath(this.config.logDir, tip), requests: branch.state.usage.length,
2092
+ receipt_ref: receiptRef,
1481
2093
  input: { source_event: sourceEvent, workspace: inputWorkspace } },
1482
2094
  };
1483
2095
  }
@@ -1502,10 +2114,33 @@ export class Session {
1502
2114
  return { ok: false, content: `branch ${node} is already resolved` };
1503
2115
  }
1504
2116
 
2117
+ const joined = events.find((e) => e.t === "joined" && e.branch.node === node);
2118
+ if (resolution === "status") {
2119
+ const item = (await this.branchStatuses()).find(branch => branch.node === node)!;
2120
+ return { ok: true, content: `branch ${node}: ${item.status}`, data: { node, status: item.status } };
2121
+ }
2122
+ if (resolution === "wait" || resolution === "cancel") {
2123
+ if (joined) return { ok: true, content: `branch ${node} already joined; receipt ${joined.t === "joined" ? joined.receipt_ref ?? "not recorded" : ""}` };
2124
+ if (resolution === "cancel" && this.jobs.state(node) === undefined) return { ok: false, content: `branch ${node}: unknown after restart; no owned handle to cancel` };
2125
+ if (resolution === "cancel") { this.jobs.cancel(node); await this.jobs.drain(node); }
2126
+ const waitMs = args["wait_ms"] === undefined ? 10_000 : args["wait_ms"];
2127
+ if (typeof waitMs !== "number" || !Number.isFinite(waitMs) || waitMs < 0 || waitMs > 60_000) return { ok: false, content: "wait_ms must be between 0 and 60000" };
2128
+ const result = await this.jobs.wait(node, waitMs);
2129
+ if (result?.running) return { ok: true, content: `branch ${node} running; wait reached its deadline`, data: { node, running: true } };
2130
+ if (result?.error) return { ok: false, content: result.error };
2131
+ const complete = result?.value ?? await this.recoverBranchResult(node);
2132
+ if (!complete) return { ok: false, content: `branch ${node}: unknown after restart; no sealed execution outcome; inspect before retrying` };
2133
+ if (complete.accept) await complete.accept();
2134
+ this.jobs.forget(node);
2135
+ return complete;
2136
+ }
2137
+ if (this.jobs.state(node) === "running") return { ok: false, content: `branch ${node} running; wait or cancel before resolving it` };
2138
+ if (!joined && this.jobs.state(node) === "ready") return { ok: false, content: `branch ${node} has not joined; wait before resolving it` };
2139
+
1505
2140
  const ref = await this.nodeRefFor(node);
1506
2141
  if (resolution === "adopt") {
2142
+ 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" };
1507
2143
  if (this.pendingAdoption !== null) return { content: "resolve one adoption per tool round", ok: false };
1508
- const joined = events.find((e) => e.t === "joined" && e.branch.node === node);
1509
2144
  if (!joined) return { content: `branch ${node} has not joined`, ok: false };
1510
2145
  await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: ref });
1511
2146
  this.pendingAdoption = node;
@@ -1533,52 +2168,97 @@ export class Session {
1533
2168
  }
1534
2169
  return { content: `discarded branch ${node}`, ok: true };
1535
2170
  }
1536
- return { content: `resolution must be "adopt" or "discard"`, ok: false };
2171
+ return { content: `resolution must be wait, cancel, status, adopt or discard`, ok: false };
2172
+ }
2173
+
2174
+ private async recoverBranchResult(root: number): Promise<DeferredToolResult | undefined> {
2175
+ // Recover only a unique sealed continuation. An open or ambiguous branch
2176
+ // cannot be replayed safely because tools may already have had effects.
2177
+ const headers = await Promise.all((await listNodes(this.config.logDir)).map(async node => ({ node, header: (await readNode(this.config.logDir, node)).header })));
2178
+ let tip = root;
2179
+ const seen = new Set<number>();
2180
+ for (;;) {
2181
+ if (seen.has(tip)) return undefined;
2182
+ seen.add(tip);
2183
+ const children = headers.filter(item => item.header?.parent?.node === tip && item.header.basis !== "none");
2184
+ if (children.length > 1) return undefined;
2185
+ if (children.length === 0) break;
2186
+ tip = children[0]!.node;
2187
+ }
2188
+ if (!(await readManifest(this.config.logDir)).nodes.some(item => item.node === tip && item.handoff_ref)) return undefined;
2189
+ const history = await readNodeHistory(this.config.logDir, tip, undefined, "context");
2190
+ if (!history.ok) throw new Error(history.error);
2191
+ const outcomeEvent = history.nodes.flatMap(part => part.events).findLast(event => event.t === "outcome");
2192
+ if (outcomeEvent?.t !== "outcome") return undefined;
2193
+ const handoff = await replayContext(this.config.logDir, tip);
2194
+ const full = deriveHandoff({ prev: handoff.fullHandoff, events: handoff.events, depth: handoff.depth, self: await this.nodeRefFor(tip) });
2195
+ const receipt = deriveBranchReceipt({ source: await this.nodeRefFor(tip), outcome: outcomeEvent.outcome, handoff: full, nodes: history.nodes });
2196
+ const receiptRef = await putBlob(join(this.config.logDir, "artifacts"), JSON.stringify(receipt));
2197
+ const last = history.nodes.flatMap(part => part.events).findLast(event => event.t === "assistant");
2198
+ const summary = last?.t === "assistant" ? last.blocks.flatMap(block => block.type === "text" ? [block.text] : []).join("\n") : "(no closing message)";
2199
+ return { ok: outcomeEvent.outcome.status === "completed", outcome: outcomeEvent.outcome,
2200
+ content: `branch ${root} recovered from sealed node ${tip}; receipt ${receiptRef}; closing statement is unverified: ${summary}`,
2201
+ data: { node: root, outcome: outcomeEvent.outcome, receipt_ref: receiptRef, handoff_ref: handoffPath(this.config.logDir, tip) },
2202
+ accept: async () => {
2203
+ if ((await this.branchStatuses()).some(item => item.node === root && item.handoff_ref)) return [];
2204
+ await this.record({ t: "joined", id: this.clock.nextId(), at: this.clock.now(), branch: await this.nodeRefFor(root), tip: await this.nodeRefFor(tip),
2205
+ handoff_ref: handoffPath(this.config.logDir, tip), receipt_ref: receiptRef, summary, outcome: outcomeEvent.outcome });
2206
+ await this.saveCursor(); return [];
2207
+ } };
1537
2208
  }
1538
2209
 
1539
2210
  /** D15: finish the parent's tool round before moving to the chosen branch. */
1540
2211
  private async adoptPendingBranch(): Promise<void> {
1541
- const root = this.pendingAdoption;
1542
- if (root === null) return;
1543
- const history = await readNodeHistory(this.config.logDir, this.state.node);
1544
- if (!history.ok) throw new Error(history.error);
1545
- const events = history.nodes.flatMap((n) => n.events);
1546
- const joined = events.find((e) => e.t === "joined" && e.branch.node === root);
1547
- const spawned = events.find((e) => e.t === "spawn" && e.branch.node === root);
1548
- if (joined?.t !== "joined" || spawned?.t !== "spawn") throw new Error(`missing provenance for adoption ${root}`);
1549
- const parent = this.state.node;
1550
- const annotations = carryForward(events, spawned.id, parent);
1551
- const tip = joined.tip ?? joined.branch;
1552
- const intent = events.findLast((e) => e.t === "adopt" && e.branch.node === root && e.next_node !== undefined);
1553
- const node = intent?.t === "adopt" && intent.next_node !== undefined ? intent.next_node : this.state.nextNode++;
1554
- if (!intent) await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: joined.branch, next_node: node });
1555
- const parentRef = await this.nodeRefFor(parent);
1556
- const existing = await readNode(this.config.logDir, node);
1557
- if (existing.header === null) await initNode(this.config.logDir, node, tip, this.clock.now(), "inherit", { adopted_from: parentRef });
1558
- else if (existing.header.adopted_from?.node !== parent || existing.header.parent?.node !== tip.node) {
1559
- throw new Error(`INTEGRITY: adoption successor ${node} belongs to another transition`);
1560
- }
1561
- const rebuilt = await replayContext(this.config.logDir, node);
1562
- this.state.node = node;
1563
- this.state.nextNode = Math.max(this.state.nextNode, node + 1);
1564
- this.state.events = rebuilt.contextEvents;
1565
- this.stall = null;
1566
- this.state.handoff = rebuilt.handoff;
1567
- this.state.depth = rebuilt.depth;
1568
- this.state.anchor = null;
1569
- const copied = new Set(existing.events.flatMap((e) => "carried_from" in e && e.carried_from ? [e.carried_from.event] : []));
1570
- for (const event of annotations) if (!copied.has(event.carried_from!.event)) await this.record({ ...event, id: this.clock.nextId() });
1571
- if (!existing.events.some((e) => e.t === "adopt" && e.applied && e.branch.node === root)) {
1572
- await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: joined.branch, applied: true });
1573
- }
1574
- const checkout = this.state.worktrees.get(root);
1575
- if (checkout) this.config.tools.root = checkout.path;
1576
- const scan = await scanWorkspace(this.config.tools.root, this.ignore);
1577
- if (!scan.ok) throw new Error(`cannot adopt workspace: ${scan.error}`);
1578
- this.state.lastScan = scan.files;
1579
- await this.saveCursor();
1580
- this.pendingAdoption = null;
1581
- if (joined.model !== undefined && joined.model !== this.provider.config.spec.id) await this.performCut("model_checkpoint");
2212
+ this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
2213
+ const release = await this.rootTools.acquire("exclusive");
2214
+ let checkpoint = false;
2215
+ try {
2216
+ const root = this.pendingAdoption;
2217
+ if (root === null) return;
2218
+ const history = await readNodeHistory(this.config.logDir, this.state.node);
2219
+ if (!history.ok) throw new Error(history.error);
2220
+ const events = history.nodes.flatMap((n) => n.events);
2221
+ const joined = events.find((e) => e.t === "joined" && e.branch.node === root);
2222
+ const spawned = events.find((e) => e.t === "spawn" && e.branch.node === root);
2223
+ if (joined?.t !== "joined" || spawned?.t !== "spawn") throw new Error(`missing provenance for adoption ${root}`);
2224
+ const parent = this.state.node;
2225
+ const annotations = carryForward(events, spawned.id, parent);
2226
+ const tip = joined.tip ?? joined.branch;
2227
+ const intent = events.findLast((e) => e.t === "adopt" && e.branch.node === root && e.next_node !== undefined);
2228
+ const node = intent?.t === "adopt" && intent.next_node !== undefined ? intent.next_node : this.state.nextNode++;
2229
+ if (!intent) await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: joined.branch, next_node: node });
2230
+ const parentRef = await this.nodeRefFor(parent);
2231
+ const existing = await readNode(this.config.logDir, node);
2232
+ if (existing.header === null) await initNode(this.config.logDir, node, tip, this.clock.now(), "inherit", { adopted_from: parentRef });
2233
+ else if (existing.header.adopted_from?.node !== parent || existing.header.parent?.node !== tip.node) {
2234
+ throw new Error(`INTEGRITY: adoption successor ${node} belongs to another transition`);
2235
+ }
2236
+ const rebuilt = await replayContext(this.config.logDir, node);
2237
+ this.state.node = node;
2238
+ this.state.nextNode = Math.max(this.state.nextNode, node + 1);
2239
+ this.state.events = rebuilt.contextEvents;
2240
+ this.resetDerivedToolState();
2241
+ this.state.handoff = rebuilt.handoff;
2242
+ this.state.depth = rebuilt.depth;
2243
+ this.state.anchor = null;
2244
+ const copied = new Set(existing.events.flatMap((e) => "carried_from" in e && e.carried_from ? [e.carried_from.event] : []));
2245
+ for (const event of annotations) if (!copied.has(event.carried_from!.event)) await this.record({ ...event, id: this.clock.nextId() });
2246
+ if (!existing.events.some((e) => e.t === "adopt" && e.applied && e.branch.node === root)) {
2247
+ await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: joined.branch, applied: true });
2248
+ }
2249
+ const checkout = this.state.worktrees.get(root);
2250
+ if (checkout) this.config.tools.root = checkout.path;
2251
+ if (this.config.workspaceTracking === "legacy") {
2252
+ const scan = await scanWorkspace(this.config.tools.root, this.ignore);
2253
+ if (!scan.ok) throw new Error(`cannot adopt workspace: ${scan.error}`);
2254
+ this.state.lastScan = scan.files;
2255
+ }
2256
+ this.installWorkspace();
2257
+ await this.saveCursor();
2258
+ this.pendingAdoption = null;
2259
+ checkpoint = joined.model !== undefined && joined.model !== this.provider.config.spec.id;
2260
+ } finally { release(); }
2261
+ if (checkpoint) await this.performCut("model_checkpoint");
1582
2262
  }
1583
2263
 
1584
2264
  private toEvent(a: Annotation): LogEvent {
@@ -1595,7 +2275,9 @@ export class Session {
1595
2275
  };
1596
2276
  }
1597
2277
  case "next_action":
1598
- return { t: "next_action", ...stamp, what: a.what, next_action: a.next_action, ...(a.work_id ? { work_id: a.work_id } : {}) };
2278
+ 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 } : {}) };
2279
+ case "work_owner": return { ...a, ...stamp, owner: this.config.workNamespace ?? "main" };
2280
+ case "work_dependencies": return { ...a, ...stamp };
1599
2281
  case "decision":
1600
2282
  return {
1601
2283
  t: "decision", ...stamp, chose: a.chose, because: a.because,
@@ -1617,12 +2299,85 @@ export class Session {
1617
2299
 
1618
2300
  // ---- rewind ---------------------------------------------------------------
1619
2301
 
2302
+ /** A prepared edit can be reconciled by its exact path; unknown commands are never replayed. */
2303
+ private async recoverFileOperations(): Promise<void> {
2304
+ if (!this.config.blobDir) return;
2305
+ // A cut is only legal after paired tools, so pending operations belong to this node.
2306
+ const history = (await readNode(this.config.logDir, this.state.node)).events.filter((event): event is CheckpointEvent => event.t === "checkpoint");
2307
+ const pending = new Map<string, CheckpointEvent>();
2308
+ for (const event of history) {
2309
+ if (event.phase === "prepared") pending.set(event.operation, event);
2310
+ else pending.delete(event.operation);
2311
+ }
2312
+ for (const event of pending.values()) {
2313
+ let applied = event.source !== "git" && changedPaths(event.changes).length > 0;
2314
+ for (const [path, change] of Object.entries(event.changes)) {
2315
+ try { if (!sameVersion((await readEditableFile(this.config.tools.root, path)).version, change.after)) applied = false; }
2316
+ catch { applied = false; }
2317
+ }
2318
+ await this.record({ ...event, id: this.clock.nextId(), at: this.clock.now(), phase: applied ? "applied" : "unavailable",
2319
+ reason: applied ? "observed the prepared file version after restart; tool outcome remains unknown" : "interrupted checkpoint; inspect files before retrying" });
2320
+ }
2321
+ }
2322
+
2323
+ private async rewindJournal(point: RestorePoint, mode: RewindMode): Promise<RewindResult> {
2324
+ const dir = this.config.logDir, root = this.config.tools.root, blobs = this.config.blobDir;
2325
+ if (mode !== "context" && !blobs) return { ok: false, error: "this session stores no file checkpoints" };
2326
+ try {
2327
+ const target = { node: point.node, atEvent: point.atEvent };
2328
+ // Reading the log validates the target. Context-only navigation never reads workspace contents.
2329
+ const history = await readNodeHistory(dir, point.node);
2330
+ if (!history.ok) throw new Error(history.error);
2331
+ if (!history.nodes.find(part => part.node === point.node)?.events.some(event => event.id === point.atEvent)) throw new Error(`NO_SUCH_EVENT: ${point.atEvent}`);
2332
+ const changes: FileChanges = await planCheckpointRestore(dir, this.state.node, target).catch(error => {
2333
+ if (mode !== "context") throw error;
2334
+ return {};
2335
+ });
2336
+ const paths = changedPaths(changes);
2337
+ const historical = await readCursorAt(dir, point.node, point.atEvent);
2338
+ const from: NodeRef = { ...await digestedRef(dir, point.node), at_event: point.atEvent };
2339
+ const latest = (await readNode(dir, this.state.node)).events.at(-1);
2340
+ const filesFrom: NodeRef = { ...await digestedRef(dir, this.state.node), ...(latest ? { at_event: latest.id } : {}) };
2341
+ if (mode !== "context" && blobs) {
2342
+ await preflightChanges(root, blobs, changes);
2343
+ // Persist the intention before files move; recovery never invents command completion.
2344
+ const operation = this.clock.nextId();
2345
+ await this.record({ t: "checkpoint", id: operation, at: this.clock.now(), operation, source: "restore", phase: "prepared", changes });
2346
+ await applyFileChanges(root, blobs, changes);
2347
+ await this.record({ t: "checkpoint", id: this.clock.nextId(), at: this.clock.now(), operation, source: "restore", phase: "applied", changes });
2348
+ }
2349
+ const restored = mode === "context" ? [] : paths.filter(path => changes[path]!.after !== null);
2350
+ const removed = mode === "context" ? [] : paths.filter(path => changes[path]!.after === null);
2351
+ const event: LogEvent = { t: "rewind", id: this.clock.nextId(), at: this.clock.now(), mode, from, prompt: point.prompt,
2352
+ restored, removed, unrecoverable: [], divergence: mode === "both" ? [] : paths,
2353
+ ...(mode === "context" ? { files_from: filesFrom } : {}) };
2354
+ if (mode === "files") await this.record(event);
2355
+ else {
2356
+ const node = this.state.nextNode++;
2357
+ await initNode(dir, node, from, this.clock.now(), "inherit");
2358
+ const replayed = await replayContext(dir, node);
2359
+ this.state.node = node; this.state.events = [...replayed.contextEvents]; this.state.handoff = replayed.handoff;
2360
+ this.state.depth = replayed.depth; this.state.anchor = null; this.resetDerivedToolState();
2361
+ await this.record(event);
2362
+ if (historical === null || !sameModel(historical, { model: this.provider.config.spec.id, provider: this.provider.config.spec.provider })) {
2363
+ const cursor = await readCursor(dir);
2364
+ if (cursor) await writeCursor(dir, { ...cursor, node, next_node: this.state.nextNode, needs_model_checkpoint: true });
2365
+ await this.performCut("model_checkpoint", this.provider.config.spec.id);
2366
+ }
2367
+ }
2368
+ await this.saveCursor();
2369
+ return { ok: true, node: this.state.node, restored, removed, unrecoverable: [], note: mode === "context"
2370
+ ? "Only the conversation moved; workspace files were left unchanged."
2371
+ : "Restored recorded file changes. Unrelated files, ignored paths and external effects were left unchanged; command recovery requires captured Git checkpoints." };
2372
+ } catch (error) { return { ok: false, error: String(error) }; }
2373
+ }
2374
+
1620
2375
  private get ignore(): readonly string[] {
1621
2376
  return this.config.snapshotIgnore ?? DEFAULT_IGNORE;
1622
2377
  }
1623
2378
 
1624
2379
  /**
1625
- * Every completed exchange on the current ancestry that changed a file.
2380
+ * Every completed exchange, including abandoned branches and conversation-only turns.
1626
2381
  *
1627
2382
  * An exchange runs from one user message to just before the next, so its
1628
2383
  * last event is where a fork belongs: the conversation is whole there, and
@@ -1668,6 +2423,8 @@ export class Session {
1668
2423
  }
1669
2424
  };
1670
2425
  for (const e of n.events) {
2426
+ if (e.t === "workspace_observation") continue;
2427
+ if (e.t === "checkpoint" && e.source === "restore") { close(); open = null; continue; }
1671
2428
  if (e.t === "snapshot" && e.phase === "idle") {
1672
2429
  close();
1673
2430
  open = null;
@@ -1683,6 +2440,7 @@ export class Session {
1683
2440
  }
1684
2441
  if (!open) continue;
1685
2442
  open.last = e.id;
2443
+ if (e.t === "checkpoint" && e.phase === "applied") open.changed += changedPaths(e.changes).length;
1686
2444
  if (e.t === "snapshot") {
1687
2445
  open.changed +=
1688
2446
  Object.keys(e.added).length + Object.keys(e.changed).length + e.removed.length;
@@ -1696,12 +2454,26 @@ export class Session {
1696
2454
  /**
1697
2455
  * What a rewind would do, without doing it.
1698
2456
  *
1699
- * Going back deletes whatever the target did not have, which necessarily
1700
- * includes a file a person wrote by hand while the session was idle. That
1701
- * is the right behaviour -- a half-restored workspace is worse -- but it is
1702
- * the half of the operation nobody asked for, so it is shown first.
2457
+ * P.3 previews only logged paths and preflights conflicts when files will move.
2458
+ * Context-only navigation consults history without reading workspace contents.
1703
2459
  */
1704
- async previewRewind(point: RestorePoint): Promise<RewindPreview> {
2460
+ async previewRewind(point: RestorePoint, mode: RewindMode = "both"): Promise<RewindPreview> {
2461
+ if (mode === "context") {
2462
+ const history = await readNodeHistory(this.config.logDir, point.node);
2463
+ if (!history.ok) return history;
2464
+ 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}` };
2465
+ const changes = await planCheckpointRestore(this.config.logDir, this.state.node, { node: point.node, atEvent: point.atEvent }).catch((): FileChanges => ({}));
2466
+ return { ok: true, willWrite: changedPaths(changes).filter(path => changes[path]!.after !== null),
2467
+ willDelete: changedPaths(changes).filter(path => changes[path]!.after === null) };
2468
+ }
2469
+ if (this.config.workspaceTracking !== "legacy") {
2470
+ try {
2471
+ const changes = await planCheckpointRestore(this.config.logDir, this.state.node, { node: point.node, atEvent: point.atEvent });
2472
+ if (this.config.blobDir) await preflightChanges(this.config.tools.root, this.config.blobDir, changes);
2473
+ return { ok: true, willWrite: changedPaths(changes).filter(path => changes[path]!.after !== null),
2474
+ willDelete: changedPaths(changes).filter(path => changes[path]!.after === null) };
2475
+ } catch (error) { return { ok: false, error: String(error) }; }
2476
+ }
1705
2477
  const plan = await planRestoreToNode(
1706
2478
  this.config.logDir, { node: point.node, atEvent: point.atEvent },
1707
2479
  undefined, this.config.tools.root, this.ignore,
@@ -1721,14 +2493,12 @@ export class Session {
1721
2493
  * still a node, so it is still a restore target, and "rewound too far" is
1722
2494
  * recoverable rather than terminal.
1723
2495
  *
1724
- * BOTH HALVES OR NEITHER. Moving the cursor without the files leaves the
1725
- * session on a node whose context describes a workspace that is not on
1726
- * disk. Claude Code's own docs warn that this divergence causes unexpected
1727
- * behaviour; we would additionally flag the whole restore as undeclared
1728
- * damage at the next turn boundary. So the files move first and the node is
1729
- * opened only if that worked.
2496
+ * P.3 records a separate physical file cursor for context-only navigation.
2497
+ * In combined mode, file restoration must succeed before opening the new
2498
+ * conversation node. File-only restoration appends its observed transition.
1730
2499
  */
1731
2500
  async rewind(point: RestorePoint, mode: RewindMode = "both"): Promise<RewindResult> {
2501
+ if (this.config.workspaceTracking !== "legacy") return this.rewindJournal(point, mode);
1732
2502
  const dir = this.config.logDir;
1733
2503
  const root = this.config.tools.root;
1734
2504
  const target = { node: point.node, atEvent: point.atEvent };
@@ -1817,7 +2587,7 @@ export class Session {
1817
2587
  this.state.node = node;
1818
2588
  this.state.events = [];
1819
2589
  this.state.handoff = replayed.handoff;
1820
- this.stall = null;
2590
+ this.resetDerivedToolState();
1821
2591
  this.state.depth = replayed.depth;
1822
2592
  // The new node's prefix has never been sent, so nothing to anchor to.
1823
2593
  this.state.anchor = null;
@@ -1868,6 +2638,20 @@ export class Session {
1868
2638
  ].sort();
1869
2639
  }
1870
2640
 
2641
+ private resetDerivedToolState(): void {
2642
+ this.stall = null;
2643
+ this.recovery = null;
2644
+ }
2645
+
2646
+ private async recoveryGuidance(tools: readonly string[]): Promise<ReturnType<typeof recoveryReview>> {
2647
+ if (this.recovery === null) {
2648
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
2649
+ if (!history.ok) throw new Error(history.error);
2650
+ this.recovery = advanceRecovery(emptyRecoveryState(), history.nodes.flatMap(node => node.events), path => resolve(this.config.tools.root, path));
2651
+ }
2652
+ return recoveryReview(this.recovery, tools);
2653
+ }
2654
+
1871
2655
  private async *checkStall(): AsyncGenerator<LoopEvent, boolean> {
1872
2656
  const limit = this.config.stallLimit ?? DEFAULT_STALL_LIMIT;
1873
2657
  if (limit === 0) return false;
@@ -1894,8 +2678,8 @@ export class Session {
1894
2678
  * prompt reads as exactly over the usable window.
1895
2679
  */
1896
2680
  private async *maybeWindowCut(counter: TokenCounter, willContinue: boolean, overflowed = false): AsyncGenerator<LoopEvent, boolean> {
1897
- const budget = windowBudget(this.provider.config.spec.contextWindow, this.provider.config.maxTokens);
1898
- if (!willContinue || budget === null || pendingToolCalls(this.state.events).length > 0) return false;
2681
+ const budget = this.policyLimits();
2682
+ if (!willContinue || budget.kind !== "window" || pendingToolCalls(this.state.events).length > 0) return false;
1899
2683
  const project = () => projectContext({ frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events });
1900
2684
  let projected = project();
1901
2685
  const measured = anchoredCount(projected.messages, projected.system, this.state.anchor, counter, this.config.frozen.tools).tokens;
@@ -1906,7 +2690,7 @@ export class Session {
1906
2690
  const replayed = await replayContext(this.config.logDir, this.state.node);
1907
2691
  let draft: Handoff;
1908
2692
  try {
1909
- draft = boundHandoff(deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events,
2693
+ draft = await this.boundCarried(deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events,
1910
2694
  depth: replayed.depth, self: await digestedRef(this.config.logDir, this.state.node),
1911
2695
  config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity } }));
1912
2696
  } catch (error) {
@@ -1948,8 +2732,19 @@ export class Session {
1948
2732
  return false;
1949
2733
  }
1950
2734
 
2735
+ /** Every threshold the loop acts at. The footer and banner read the same function. */
2736
+ private policyLimits(): ContextLimits {
2737
+ return contextLimits({
2738
+ policy: this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
2739
+ contextWindow: this.provider.config.spec.contextWindow,
2740
+ outputReserve: this.provider.config.maxTokens,
2741
+ ...(this.config.boundary ? { boundary: this.config.boundary } : {}),
2742
+ });
2743
+ }
2744
+
1951
2745
  private async *maybeCut(counter: TokenCounter, canPrepare: boolean): AsyncGenerator<LoopEvent, boolean> {
1952
- if ((this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY) === "windowed" && this.provider.config.spec.contextWindow !== undefined) {
2746
+ const limits = this.policyLimits();
2747
+ if (limits.kind === "window") {
1953
2748
  return yield* this.maybeWindowCut(counter, canPrepare);
1954
2749
  }
1955
2750
  const projected = projectContext({
@@ -1976,20 +2771,19 @@ export class Session {
1976
2771
  const inheritedTokens = counter.count(floor.messages, floor.system, this.config.frozen.tools);
1977
2772
 
1978
2773
  const policy = this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY;
1979
- const cfg = { ...DEFAULT_BOUNDARY, ...this.config.boundary };
1980
2774
  let draft: Handoff | null = null;
1981
- if (policy !== "continuous" && tokens >= cfg.triggerTokens - (cfg.hardTokens - cfg.triggerTokens)) {
2775
+ if (limits.kind === "segment" && tokens >= limits.prepare) {
1982
2776
  // Read the same full ancestry used by the actual cut. A bounded carried
1983
2777
  // page is insufficient to decide whether all active work is completed.
1984
2778
  const replayed = await replayContext(this.config.logDir, this.state.node);
1985
- draft = boundHandoff(deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events,
2779
+ draft = await this.boundCarried(deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events,
1986
2780
  depth: replayed.depth, self: await digestedRef(this.config.logDir, this.state.node),
1987
2781
  config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity } }));
1988
2782
  const noteEnabled = this.config.frozen.tools.some((tool) => tool !== null && typeof tool === "object" && "name" in tool && tool.name === "note") &&
1989
2783
  (this.config.tools.allow === undefined || this.config.tools.allow.includes("note"));
1990
- if (canPrepare && this.config.guidance !== false && noteEnabled && tokens < cfg.hardTokens) {
2784
+ if (canPrepare && this.config.guidance !== false && noteEnabled && tokens < limits.hard) {
1991
2785
  const notice = boundaryPreparation({ events: this.state.events, tokens, inheritedTokens,
1992
- continuationReady: draft.continuation_valid, config: cfg });
2786
+ continuationReady: draft.continuation_valid, config: limits.config });
1993
2787
  if (notice) {
1994
2788
  await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...notice });
1995
2789
  const updated = projectContext({ frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events });
@@ -2058,56 +2852,68 @@ export class Session {
2058
2852
  */
2059
2853
  private async performCut(reason: CutReason, nextModel = this.provider.config.spec.id, retainedFrom?: string,
2060
2854
  nextProvider = this.provider.config.spec.provider, nextThinking = this.provider.config.effort, nextContextRef?: string): Promise<void> {
2061
- const dir = this.config.logDir;
2062
- const node = this.state.node;
2063
-
2064
- const replayed = await replayContext(dir, node);
2065
- const fromDisk = replayed.events;
2066
- const retained = retainedFrom === undefined ? [] : retainedContext(replayed.contextEvents, retainedFrom);
2855
+ this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
2856
+ const release = await this.rootTools.acquire("exclusive");
2857
+ try {
2858
+ const dir = this.config.logDir;
2859
+ const node = this.state.node;
2860
+
2861
+ const replayed = await replayContext(dir, node);
2862
+ const fromDisk = replayed.events;
2863
+ const retained = retainedFrom === undefined ? [] : retainedContext(replayed.contextEvents, retainedFrom);
2864
+ let guidanceTools = this.adviceNames();
2865
+ if (this.config.guidanceVersion === 1 && nextContextRef !== undefined) {
2866
+ const saved = await getBlob(join(dir, "artifacts"), nextContextRef);
2867
+ if (saved === null) throw new Error("missing successor guidance context");
2868
+ guidanceTools = this.adviceNames((JSON.parse(saved) as { frozen: FrozenPrefix }).frozen);
2869
+ }
2067
2870
 
2068
- // The cut event is part of the closed file. Digest after appending it
2069
- // so predecessor.sha256 matches what closeNode writes into the manifest.
2070
- await this.record({
2071
- t: "cut",
2072
- id: this.clock.nextId(),
2073
- at: this.clock.now(),
2074
- handoff_ref: handoffPath(dir, node),
2075
- predecessor: this.state.handoff?.predecessor ?? null,
2076
- reason,
2077
- next_node: this.state.nextNode,
2078
- next_model: nextModel,
2079
- next_provider: nextProvider,
2080
- next_thinking: nextThinking,
2081
- ...(nextContextRef === undefined ? {} : { next_context_ref: nextContextRef }),
2082
- handoff_tokens: DEFAULT_HANDOFF_TOKENS,
2083
- ...(retainedFrom === undefined ? {} : { retained_from: retainedFrom }),
2084
- });
2085
- const self = await digestedRef(dir, node);
2086
- const handoff = boundHandoff(deriveHandoff({
2087
- prev: replayed.fullHandoff,
2088
- depth: replayed.depth,
2089
- events: fromDisk,
2090
- self,
2091
- config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity },
2092
- }));
2093
- await closeNode(dir, node, handoff, (await readNode(dir, node)).events.length, this.clock.now());
2094
-
2095
- // One allocator for the whole session. A cut used to take node + 1 while
2096
- // a branch took nextNode++, so a cut after a branch reached the branch's
2097
- // id and overwrote its log.
2098
- this.state.node = this.state.nextNode++;
2099
- this.state.depth += 1;
2100
- this.state.handoff = handoff;
2101
- this.state.events = retained;
2102
- // The new node's prefix has never been sent, so there is nothing to anchor
2103
- // to until its first response comes back.
2104
- this.state.anchor = null;
2105
- // A cut declares a handoff basis: the closed node's events do not replay,
2106
- // its derived state does. Relying on the default here would silently make
2107
- // every boundary a fork and replay the whole history.
2108
- await initNode(dir, this.state.node, self, this.clock.now(), "handoff");
2109
- // The durable successor already belongs to the target provider, even if
2110
- // the process exits before switchModel installs its in-memory adapter.
2111
- await this.saveCursor(nextModel, nextProvider, nextThinking, nextContextRef);
2871
+ // The cut event is part of the closed file. Digest after appending it
2872
+ // so predecessor.sha256 matches what closeNode writes into the manifest.
2873
+ await this.record({
2874
+ t: "cut",
2875
+ id: this.clock.nextId(),
2876
+ at: this.clock.now(),
2877
+ handoff_ref: handoffPath(dir, node),
2878
+ predecessor: this.state.handoff?.predecessor ?? null,
2879
+ reason,
2880
+ next_node: this.state.nextNode,
2881
+ next_model: nextModel,
2882
+ next_provider: nextProvider,
2883
+ next_thinking: nextThinking,
2884
+ ...(nextContextRef === undefined ? {} : { next_context_ref: nextContextRef }),
2885
+ handoff_tokens: DEFAULT_HANDOFF_TOKENS,
2886
+ ...(this.config.workingSet ? { working_set_version: this.config.workingSetVersion ?? 1 } : {}),
2887
+ ...(this.config.guidanceVersion === 1 ? { guidance_tools: guidanceTools } : {}),
2888
+ ...(retainedFrom === undefined ? {} : { retained_from: retainedFrom }),
2889
+ });
2890
+ const self = await digestedRef(dir, node);
2891
+ const handoff = await this.boundCarried(deriveHandoff({
2892
+ prev: replayed.fullHandoff,
2893
+ depth: replayed.depth,
2894
+ events: fromDisk,
2895
+ self,
2896
+ config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity },
2897
+ }), guidanceTools);
2898
+ await closeNode(dir, node, handoff, (await readNode(dir, node)).events.length, this.clock.now());
2899
+
2900
+ // One allocator for the whole session. A cut used to take node + 1 while
2901
+ // a branch took nextNode++, so a cut after a branch reached the branch's
2902
+ // id and overwrote its log.
2903
+ this.state.node = this.state.nextNode++;
2904
+ this.state.depth += 1;
2905
+ this.state.handoff = handoff;
2906
+ this.state.events = retained;
2907
+ // The new node's prefix has never been sent, so there is nothing to anchor
2908
+ // to until its first response comes back.
2909
+ this.state.anchor = null;
2910
+ // A cut declares a handoff basis: the closed node's events do not replay,
2911
+ // its derived state does. Relying on the default here would silently make
2912
+ // every boundary a fork and replay the whole history.
2913
+ await initNode(dir, this.state.node, self, this.clock.now(), "handoff");
2914
+ // The durable successor already belongs to the target provider, even if
2915
+ // the process exits before switchModel installs its in-memory adapter.
2916
+ await this.saveCursor(nextModel, nextProvider, nextThinking, nextContextRef);
2917
+ } finally { release(); }
2112
2918
  }
2113
2919
  }