@visual-z/xeno 0.2.0 → 0.3.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 (259) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +106 -26
  3. package/docs/install.md +52 -9
  4. package/docs/providers.md +11 -0
  5. package/package.json +5 -4
  6. package/src/apps/approve.ts +3 -3
  7. package/src/apps/cli.ts +10 -11
  8. package/src/apps/commands.ts +38 -5
  9. package/src/apps/completions.ts +73 -19
  10. package/src/apps/image-draft.ts +46 -0
  11. package/src/apps/project-trust.ts +2 -42
  12. package/src/apps/provider-connect.ts +346 -0
  13. package/src/apps/render.ts +22 -47
  14. package/src/apps/resources.ts +5 -2
  15. package/src/apps/shell.ts +298 -1526
  16. package/src/apps/startup-input.ts +130 -0
  17. package/src/apps/startup.ts +143 -115
  18. package/src/apps/ui/app.ts +1383 -0
  19. package/src/apps/ui/extension-host.ts +194 -0
  20. package/src/apps/ui/image-cache.ts +78 -0
  21. package/src/apps/ui/palette.ts +88 -0
  22. package/src/apps/ui/project.ts +127 -0
  23. package/src/apps/ui/standalone.ts +146 -0
  24. package/src/apps/ui/status-model.ts +68 -0
  25. package/src/apps/ui/surface.ts +84 -0
  26. package/src/apps/ui/theme.ts +86 -0
  27. package/src/core/acceptance.ts +107 -0
  28. package/src/core/action-defaults.ts +47 -295
  29. package/src/core/benchmark.ts +2 -2
  30. package/src/core/branch-receipt.ts +24 -0
  31. package/src/core/checkpoints.ts +64 -0
  32. package/src/core/churn.ts +2 -0
  33. package/src/core/command-artifacts.ts +94 -0
  34. package/src/core/command-contract.ts +35 -0
  35. package/src/core/completion-review.ts +21 -5
  36. package/src/core/context-detail.ts +37 -0
  37. package/src/core/context-policy.ts +74 -1
  38. package/src/core/continuation.ts +17 -2
  39. package/src/core/derive.ts +29 -3
  40. package/src/core/draft-images.ts +56 -0
  41. package/src/core/environment.ts +145 -0
  42. package/src/core/evaluation-coverage.ts +74 -0
  43. package/src/core/event-validation.ts +80 -5
  44. package/src/core/events.ts +97 -3
  45. package/src/core/evidence-links.ts +7 -0
  46. package/src/core/evidence.ts +13 -3
  47. package/src/core/execution.ts +8 -2
  48. package/src/core/experiment.ts +133 -0
  49. package/src/core/export.ts +9 -0
  50. package/src/core/failure-recovery.ts +173 -0
  51. package/src/core/file-precondition.ts +72 -0
  52. package/src/core/handoff.ts +65 -4
  53. package/src/core/history-query.ts +38 -2
  54. package/src/core/history.ts +38 -1
  55. package/src/core/integration-health.ts +34 -0
  56. package/src/core/keymap.ts +8 -20
  57. package/src/core/model-picker.ts +64 -0
  58. package/src/core/native-eval.ts +25 -0
  59. package/src/core/note-guidance.ts +34 -0
  60. package/src/core/observation-view.ts +49 -8
  61. package/src/core/permissions.ts +24 -2
  62. package/src/core/process-receipt.ts +105 -0
  63. package/src/core/process-state.ts +37 -0
  64. package/src/core/project.ts +45 -18
  65. package/src/core/prompt-policy.ts +37 -0
  66. package/src/core/query-eval-contract.ts +14 -0
  67. package/src/core/read-observation.ts +82 -0
  68. package/src/core/recovery-corpus.ts +17 -0
  69. package/src/core/recovery-evaluation.ts +60 -0
  70. package/src/core/recovery-sources.ts +74 -0
  71. package/src/core/runtime-target.ts +94 -0
  72. package/src/core/search-observation.ts +97 -0
  73. package/src/core/session-tree.ts +147 -0
  74. package/src/core/settings.ts +26 -10
  75. package/src/core/skills.ts +44 -0
  76. package/src/core/sql-query.ts +211 -0
  77. package/src/core/theme.ts +36 -58
  78. package/src/core/tool-contract.ts +51 -2
  79. package/src/core/tool-discovery.ts +87 -0
  80. package/src/core/tool-failure.ts +60 -0
  81. package/src/core/tool-mode-protocol.ts +25 -0
  82. package/src/core/transcript.ts +19 -4
  83. package/src/core/trial-lifecycle.ts +14 -0
  84. package/src/core/tui-mode.ts +29 -0
  85. package/src/core/update-check.ts +97 -0
  86. package/src/core/version.ts +55 -0
  87. package/src/core/work-state.ts +49 -3
  88. package/src/core/working-set.ts +91 -0
  89. package/src/runtime/auth.ts +2 -2
  90. package/src/runtime/branch-jobs.ts +36 -0
  91. package/src/runtime/clipboard.ts +15 -4
  92. package/src/runtime/command-artifacts.ts +64 -0
  93. package/src/runtime/environment-tools.ts +60 -0
  94. package/src/runtime/evidence-notes.ts +4 -2
  95. package/src/runtime/exec-sessions.ts +206 -61
  96. package/src/runtime/execution-control.ts +1 -0
  97. package/src/runtime/extension-ui.ts +22 -30
  98. package/src/runtime/extensions.ts +95 -16
  99. package/src/runtime/harness-comparison.ts +27 -7
  100. package/src/runtime/harness-eval.ts +460 -0
  101. package/src/runtime/history-cache.ts +57 -0
  102. package/src/runtime/history-tools.ts +62 -12
  103. package/src/runtime/integrations.ts +210 -0
  104. package/src/runtime/loop.ts +1021 -268
  105. package/src/runtime/model-discovery.ts +208 -0
  106. package/src/runtime/native-eval-fixtures.ts +33 -0
  107. package/src/runtime/native-eval.ts +143 -0
  108. package/src/runtime/oauth.ts +1 -1
  109. package/src/runtime/provider-connect.ts +109 -0
  110. package/src/runtime/provider-presets.ts +87 -0
  111. package/src/runtime/query-eval-verifier.ts +28 -0
  112. package/src/runtime/recovery-coverage.ts +71 -0
  113. package/src/runtime/recovery-evidence.ts +76 -0
  114. package/src/runtime/recovery-fixtures.ts +116 -0
  115. package/src/runtime/runtime-target.ts +149 -0
  116. package/src/runtime/tool-broker.ts +137 -0
  117. package/src/runtime/tool-discovery.ts +57 -0
  118. package/src/runtime/tool-mode-eval.ts +146 -0
  119. package/src/runtime/tool-queue.ts +77 -0
  120. package/src/runtime/tools.ts +628 -183
  121. package/src/runtime/trial-control.ts +45 -0
  122. package/src/runtime/update-check.ts +47 -0
  123. package/src/runtime/window-trial.ts +2 -1
  124. package/src/runtime/working-notes.ts +33 -3
  125. package/src/runtime/workspace-lifecycle.ts +97 -0
  126. package/src/store/acceptance.ts +15 -0
  127. package/src/store/checkpoint-history.ts +43 -0
  128. package/src/store/command-artifacts.ts +96 -0
  129. package/src/store/command.ts +65 -37
  130. package/src/store/file-checkpoints.ts +104 -0
  131. package/src/store/file-index.ts +13 -1
  132. package/src/store/git-checkpoints.ts +67 -0
  133. package/src/store/observations.ts +61 -20
  134. package/src/store/process-receipts.ts +34 -0
  135. package/src/store/query-source.ts +13 -8
  136. package/src/store/recovery.ts +2 -3
  137. package/src/store/replay.ts +16 -4
  138. package/src/store/runtime-target.ts +64 -0
  139. package/src/store/search.ts +171 -132
  140. package/src/store/session-archive.ts +4 -0
  141. package/src/store/source-cancellation.ts +6 -0
  142. package/src/store/update-cache.ts +36 -0
  143. package/src/store/workspace-observation.ts +113 -0
  144. package/src/tui/ansi-spans.ts +126 -0
  145. package/src/tui/style.ts +19 -16
  146. package/src/tui/ui/atomic-draft.ts +121 -0
  147. package/src/tui/ui/canvas.ts +96 -0
  148. package/src/tui/ui/colors.ts +70 -0
  149. package/src/tui/ui/composer.ts +199 -0
  150. package/src/tui/ui/dialog.ts +379 -0
  151. package/src/tui/ui/draft.ts +23 -0
  152. package/src/tui/ui/edge.ts +186 -0
  153. package/src/tui/ui/image-size.ts +40 -0
  154. package/src/tui/ui/ink.ts +210 -0
  155. package/src/tui/ui/items.ts +40 -0
  156. package/src/tui/ui/keys.ts +40 -0
  157. package/src/tui/ui/layout.ts +74 -0
  158. package/src/tui/ui/list-model.ts +86 -0
  159. package/src/tui/ui/list-view.ts +172 -0
  160. package/src/tui/ui/mascot.ts +67 -0
  161. package/src/tui/ui/messages.ts +518 -0
  162. package/src/tui/ui/motion.ts +28 -0
  163. package/src/tui/ui/paths.ts +10 -0
  164. package/src/tui/ui/pictures.ts +78 -0
  165. package/src/tui/ui/screen.ts +421 -0
  166. package/src/tui/ui/status.ts +271 -0
  167. package/src/tui/ui/styled.ts +85 -0
  168. package/src/tui/ui/testing.ts +5 -0
  169. package/src/tui/ui/tool-card.ts +150 -0
  170. package/src/apps/extension-ui.ts +0 -330
  171. package/src/apps/resource-browser.ts +0 -75
  172. package/src/apps/session-browser.ts +0 -113
  173. package/src/apps/session-picker.ts +0 -55
  174. package/src/tui/ansi.ts +0 -67
  175. package/src/tui/autocomplete.ts +0 -29
  176. package/src/tui/chat-viewport.ts +0 -47
  177. package/src/tui/complete.ts +0 -87
  178. package/src/tui/editor.ts +0 -940
  179. package/src/tui/host.ts +0 -264
  180. package/src/tui/input.ts +0 -75
  181. package/src/tui/keys.ts +0 -378
  182. package/src/tui/markdown.ts +0 -17
  183. package/src/tui/render.ts +0 -153
  184. package/src/tui/select.ts +0 -258
  185. package/src/tui/settings.ts +0 -44
  186. package/src/tui/statusline.ts +0 -162
  187. package/src/tui/system-theme.ts +0 -700
  188. package/src/tui/terminal.ts +0 -30
  189. package/src/tui/text.ts +0 -52
  190. package/src/tui/toolview.ts +0 -386
  191. package/src/tui/transcript.ts +0 -123
  192. package/src/tui/tree.ts +0 -471
  193. package/src/vendor/pi-tui/LICENSE.upstream +0 -21
  194. package/src/vendor/pi-tui/UPSTREAM.json +0 -50
  195. package/src/vendor/pi-tui/VENDOR.md +0 -25
  196. package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
  197. package/src/vendor/pi-tui/autocomplete.ts +0 -860
  198. package/src/vendor/pi-tui/colors.ts +0 -367
  199. package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
  200. package/src/vendor/pi-tui/components/box.ts +0 -166
  201. package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
  202. package/src/vendor/pi-tui/components/editor.ts +0 -2472
  203. package/src/vendor/pi-tui/components/h-stack.ts +0 -44
  204. package/src/vendor/pi-tui/components/image.ts +0 -127
  205. package/src/vendor/pi-tui/components/input.ts +0 -494
  206. package/src/vendor/pi-tui/components/loader.ts +0 -101
  207. package/src/vendor/pi-tui/components/markdown.ts +0 -1021
  208. package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
  209. package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
  210. package/src/vendor/pi-tui/components/select-list.ts +0 -273
  211. package/src/vendor/pi-tui/components/settings-list.ts +0 -328
  212. package/src/vendor/pi-tui/components/spacer.ts +0 -28
  213. package/src/vendor/pi-tui/components/stack.ts +0 -154
  214. package/src/vendor/pi-tui/components/text.ts +0 -107
  215. package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
  216. package/src/vendor/pi-tui/components/v-stack.ts +0 -33
  217. package/src/vendor/pi-tui/editor-component.ts +0 -74
  218. package/src/vendor/pi-tui/fuzzy.ts +0 -138
  219. package/src/vendor/pi-tui/index.ts +0 -186
  220. package/src/vendor/pi-tui/keybindings.ts +0 -320
  221. package/src/vendor/pi-tui/keys.ts +0 -1401
  222. package/src/vendor/pi-tui/kill-ring.ts +0 -46
  223. package/src/vendor/pi-tui/latex.ts +0 -1506
  224. package/src/vendor/pi-tui/layout-node.ts +0 -51
  225. package/src/vendor/pi-tui/layout.ts +0 -449
  226. package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
  227. package/src/vendor/pi-tui/native/clipboard.h +0 -208
  228. package/src/vendor/pi-tui/native/darwin/README.md +0 -22
  229. package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
  230. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  231. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  232. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
  233. package/src/vendor/pi-tui/native/linux/README.md +0 -23
  234. package/src/vendor/pi-tui/native/linux/build.sh +0 -65
  235. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  236. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  237. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
  238. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
  239. package/src/vendor/pi-tui/native/napi.h +0 -91
  240. package/src/vendor/pi-tui/native/win32/README.md +0 -31
  241. package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
  242. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  243. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  244. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
  245. package/src/vendor/pi-tui/native-modifiers.ts +0 -13
  246. package/src/vendor/pi-tui/native-module-path.ts +0 -31
  247. package/src/vendor/pi-tui/native-platform.ts +0 -65
  248. package/src/vendor/pi-tui/oklab.ts +0 -233
  249. package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
  250. package/src/vendor/pi-tui/terminal-colors.ts +0 -91
  251. package/src/vendor/pi-tui/terminal-image.ts +0 -731
  252. package/src/vendor/pi-tui/terminal.ts +0 -554
  253. package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
  254. package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
  255. package/src/vendor/pi-tui/tui.ts +0 -1493
  256. package/src/vendor/pi-tui/undo-stack.ts +0 -28
  257. package/src/vendor/pi-tui/utils.ts +0 -1385
  258. package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
  259. package/src/vendor/pi-tui/word-navigation.ts +0 -117
@@ -18,39 +18,62 @@
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 } 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
62
  import { completionReview } from "../core/completion-review.ts";
44
63
  import { churnReview } from "../core/churn.ts";
64
+ import { deriveBranchReceipt } from "../core/branch-receipt.ts";
65
+ import { skillTools, type Skill } from "../core/skills.ts";
66
+ import type { WorkItem } from "../core/work-state.ts";
67
+ import { parseAcceptanceContract, type AcceptanceContract, type AcceptanceState } from "../core/acceptance.ts";
45
68
  import { deadlineNotice } from "../core/deadline.ts";
46
69
  import { statusOf } from "./retry.ts";
47
70
  import { assertNever } from "../core/exhaustive.ts";
48
71
  import { isLogEvent } from "../core/event-validation.ts";
49
72
  import {
50
73
  initNode, appendEvent, readNode, closeNode, digestNode, handoffPath, nodePath, manifestPath,
51
- listNodes, readCursor, readCursorAt, writeCursor,
74
+ listNodes, readManifest, readCursor, readCursorAt, writeCursor,
52
75
  } from "../store/log.ts";
53
- import { replayContext } from "../store/replay.ts";
76
+ import { replayContext, carriedHandoff } from "../store/replay.ts";
54
77
  import { recoverCut } from "../store/recovery.ts";
55
78
  import { runHistoryTool } from "./history-tools.ts";
56
79
  import { prepareWorkingNote } from "./working-notes.ts";
@@ -61,10 +84,11 @@ import {
61
84
  type FileSet, type RestoreResult,
62
85
  } from "../store/workspace.ts";
63
86
  import { putBlob, getBlob, putFileBlob } from "../store/blobs.ts";
87
+ import { hydrateProcessReceipts } from "../store/process-receipts.ts";
64
88
  import { readNodeHistory, planRestoreToNode, applyRestorePlan } from "../store/restore.ts";
65
89
  import { createWorktree, removeWorktree } from "../store/worktree.ts";
66
90
  import {
67
- runTool, parseNote, matchGate, branchTools, gateTool, finishToolResult, prepareTool, toolDefinition,
91
+ parseNote, matchGate, branchTools, finishToolResult, prepareNormalizedTool, presentObservation, toolDefinition, modelToolSchema, adviceTools,
68
92
  BRANCH_TOOL_NAMES, WRITING_BRANCH_TOOL_NAMES,
69
93
  type ToolContext, type Annotation, type ToolResult,
70
94
  } from "./tools.ts";
@@ -79,6 +103,10 @@ import { resolveHistoryPosition } from "../store/session-position.ts";
79
103
  import { observeCommand } from "../store/command.ts";
80
104
  import { headTailView } from "../core/observation-view.ts";
81
105
  import { withProgress } from "./progress.ts";
106
+ import { WorkspaceLifecycle } from "./workspace-lifecycle.ts";
107
+ import { changedPaths, sameVersion, type CheckpointEvent, type FileChanges } from "../core/checkpoints.ts";
108
+ import { readEditableFile, preflightChanges, applyFileChanges } from "../store/file-checkpoints.ts";
109
+ import { planCheckpointRestore } from "../store/checkpoint-history.ts";
82
110
 
83
111
  /** Ids and timestamps come from here so the loop stays the only impure part. */
84
112
  export interface Clock {
@@ -156,6 +184,18 @@ export const DEFAULT_BRANCH_LIMITS: BranchLimits = {
156
184
  };
157
185
 
158
186
  export interface SessionConfig {
187
+ /** P.3: legacy full snapshots are an explicit compatibility mode, never the CLI default. */
188
+ workspaceTracking?: "journal" | "legacy";
189
+ shellCheckpoints?: boolean;
190
+ skills?: readonly Skill[];
191
+ acceptanceContract?: AcceptanceContract | null;
192
+ strictVerification?: boolean;
193
+ /** O.10: opt-in carried view; legacy sessions and evaluations retain D20. */
194
+ workingSet?: boolean;
195
+ /** R9: CLI opts into source-priority views; absent preserves v1 experiment/old-session behavior. */
196
+ workingSetVersion?: 1 | 2;
197
+ /** R10: new CLI guidance; absence retains old carried/prefix wording on resume. */
198
+ guidanceVersion?: 1;
159
199
  logDir: string;
160
200
  frozen: FrozenPrefix;
161
201
  tools: ToolContext;
@@ -287,14 +327,28 @@ export type RewindResult =
287
327
  | { ok: false; error: string };
288
328
 
289
329
  /** O.5: child execution returns facts; only the parent coordinator accepts them. */
290
- type DeferredToolResult = ToolResult & { accept?: () => Promise<LoopEvent[]> };
330
+ type DeferredToolResult = ToolResult & { accept?: () => Promise<LoopEvent[]>; annotation?: LogEvent };
291
331
  type ToolCall = Extract<AssistantBlock, { type: "tool_use" }>;
332
+ type SessionBrokerReceipt = BrokerReceipt<DeferredToolResult> & { events: LoopEvent[] };
333
+ type PreparedBrokerCall = { run(emit?: (event: LoopEvent) => void): Promise<SessionBrokerReceipt> };
292
334
 
293
335
  export class Session {
336
+ private readonly execOwner = randomUUID();
337
+ private readonly jobs = new BranchJobs<DeferredToolResult>();
338
+ private readonly children = new Map<number, Session>();
339
+ private cleanup: Promise<void> | null = null;
340
+ private closing: Promise<void> | null = null;
341
+ private turnTools: ToolContext | null = null;
294
342
  private readonly execution = new ExecutionControl();
343
+ private readonly runtimeTargets = new RuntimeTargets();
344
+ private rootTools: ToolQueue | undefined;
345
+ private hostOwner: ReturnType<ExecutionControl["begin"]> | undefined;
346
+ private readonly hostPending = new Set<Promise<NativeCommandResult>>();
295
347
  private stall: StallState | null = null;
348
+ private recovery: RecoveryState | null = null;
296
349
  private pendingAdoption: number | null = null;
297
350
  private readonly allocatedEventIds = new Set<string>();
351
+ private recordTail: Promise<void> = Promise.resolve();
298
352
  readonly config: SessionConfig;
299
353
  /**
300
354
  * Replaced by switchModel. Not readonly: a live session can change which
@@ -307,13 +361,36 @@ export class Session {
307
361
  private readonly allocation: { next: number };
308
362
 
309
363
  constructor(config: SessionConfig, provider: Provider, clock: Clock = systemClock(), allocation = { next: 1 }) {
364
+ if (config.acceptanceContract) parseAcceptanceContract(config.acceptanceContract);
310
365
  if (config.contextPolicy !== undefined && !isContextPolicy(config.contextPolicy)) throw new Error("unknown context policy");
311
366
  stallDecision(emptyStallState(), config.stallLimit ?? DEFAULT_STALL_LIMIT);
312
367
  validateOutputRecoveryLimit(config.outputRecoveryLimit ?? DEFAULT_OUTPUT_RECOVERY_LIMIT);
313
368
  schedulingWindow([], 0, config.maxParallelTools ?? 3);
314
369
  this.allocation = allocation;
315
- this.config = config;
370
+ this.config = { ...config, ...(config.acceptanceContract ? { acceptanceContract: parseAcceptanceContract(config.acceptanceContract) } : {}), tools: { ...config.tools, execOwner: this.execOwner, historyCache: new HistoryCache() } };
371
+ // A child or restored Session must never inherit another live invocation scope.
372
+ delete this.config.tools.invoke;
373
+ delete this.config.tools.invokeHost;
374
+ delete this.config.tools.runOwnedCommand;
375
+ delete this.config.tools.drainNested;
376
+ delete this.config.tools.origin;
377
+ delete this.config.tools.parentCallId;
378
+ delete this.config.tools.callId;
379
+ // Branch capability sets are owned by branchTools; a parent's optional catalog is not a grant.
380
+ if ((config.branchDepth ?? 0) > 0) delete this.config.tools.deferredTools;
316
381
  this.config.tools.outputStore ??= join(config.logDir, "artifacts");
382
+ // R4 / D8: full ancestry, not the bounded model view or a second mutable ledger.
383
+ // Each child Session installs its own callback even when contexts are copied.
384
+ this.config.tools.observedFileVersion = async absolute => {
385
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
386
+ if (!history.ok) throw new Error(history.error);
387
+ return latestObservedFileVersion(history.nodes.flatMap(node => node.events), absolute, path => resolve(this.config.tools.root, path));
388
+ };
389
+ this.config.tools.observedProcess = async session => {
390
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
391
+ if (!history.ok) throw new Error(history.error);
392
+ return latestProcessReceipt(await hydrateProcessReceipts(history.nodes, this.config.tools.outputStore!), session);
393
+ };
317
394
  this.provider = provider;
318
395
  this.clock = {
319
396
  now: () => clock.now(),
@@ -333,6 +410,26 @@ export class Session {
333
410
  set nextNode(value: number) { allocation.next = value; },
334
411
  worktrees: new Map(),
335
412
  };
413
+ this.installWorkspace();
414
+ }
415
+
416
+ private installWorkspace(): void {
417
+ delete this.config.tools.workspace;
418
+ this.config.tools.ownedStatePaths = [this.config.logDir, ...(this.config.blobDir ? [this.config.blobDir] : [])];
419
+ this.config.tools.legacyWorkspace = this.config.workspaceTracking === "legacy";
420
+ if (this.config.blobDir && this.config.workspaceTracking !== "legacy") this.config.tools.workspace = new WorkspaceLifecycle(
421
+ this.config.tools.root, this.config.blobDir, this.config.shellCheckpoints === true,
422
+ event => this.record({ ...event, id: this.clock.nextId(), at: this.clock.now() }),
423
+ join(this.config.logDir, "checkpoints"),
424
+ this.config.tools.ownedStatePaths,
425
+ );
426
+ }
427
+
428
+ private async observeEvidence(): Promise<void> {
429
+ const proof = this.state.events.findLast(event => event.t === "tool_result" && event.effect?.kind === "command");
430
+ const prior = proof?.t === "tool_result" && proof.effect?.kind === "command" ? proof.effect.workspace_sha256 :
431
+ this.state.handoff?.state.completed.at(-1)?.evidence.workspace_sha256;
432
+ if (proof || prior || this.config.acceptanceContract) await this.config.tools.workspace?.observe(prior);
336
433
  }
337
434
 
338
435
  /**
@@ -345,7 +442,7 @@ export class Session {
345
442
  * session quietly different from a continuous one.
346
443
  */
347
444
  async start(): Promise<void> {
348
- this.stall = null;
445
+ this.resetDerivedToolState();
349
446
  const dir = this.config.logDir;
350
447
  const model = this.provider.config.spec.id;
351
448
  const existing = await listNodes(dir);
@@ -368,6 +465,11 @@ export class Session {
368
465
  break;
369
466
 
370
467
  case "resume": {
468
+ this.config.acceptanceContract = null;
469
+ this.config.strictVerification = false;
470
+ this.config.workingSet = false;
471
+ this.config.workingSetVersion = 1;
472
+ delete this.config.guidanceVersion;
371
473
  // Logs predating explicit policies meant segmented, regardless of today's default.
372
474
  this.config.contextPolicy = "segmented";
373
475
  this.config.maxParallelTools = 1;
@@ -377,7 +479,19 @@ export class Session {
377
479
  if (plan.cursor.context_ref) {
378
480
  const saved = await getBlob(join(dir, "artifacts"), plan.cursor.context_ref);
379
481
  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 };
482
+ 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 };
483
+ if (context.projectionVersion !== undefined && context.projectionVersion !== PROJECTION_VERSION) throw new Error("unsupported saved projection version");
484
+ this.config.workingSet = context.workingSet === true;
485
+ if (context.workingSetVersion !== undefined && ![1, 2].includes(context.workingSetVersion)) throw new Error("unsupported saved working-set version");
486
+ this.config.workingSetVersion = context.workingSetVersion ?? 1;
487
+ if (context.guidanceVersion !== undefined && context.guidanceVersion !== 1) throw new Error("unsupported saved guidance version");
488
+ if (context.guidanceVersion === 1) this.config.guidanceVersion = 1;
489
+ this.config.workspaceTracking = context.workspaceTracking ?? "journal";
490
+ this.config.shellCheckpoints = context.shellCheckpoints === true;
491
+ if (context.acceptanceContract) { parseAcceptanceContract(context.acceptanceContract); this.config.acceptanceContract = context.acceptanceContract; }
492
+ this.config.strictVerification = context.strictVerification === true;
493
+ this.config.skills = context.skills ?? [];
494
+ this.installSkillResources();
381
495
  this.config.workNamespace = context.workNamespace ?? "main";
382
496
  this.config.maxParallelTools = context.maxParallelTools ?? 1;
383
497
  schedulingWindow([], 0, this.config.maxParallelTools);
@@ -427,7 +541,7 @@ export class Session {
427
541
  this.state.anchor = null;
428
542
  // Compare disk against the durable file projection. Treating current
429
543
  // disk as the baseline would erase unlogged effects of a crashed tool.
430
- if (this.config.blobDir) {
544
+ if (this.config.blobDir && this.config.workspaceTracking === "legacy") {
431
545
  const files = await planRestoreToNode(dir, this.state.node);
432
546
  if (!files.ok) throw new Error(files.error);
433
547
  this.state.lastScan = files.files;
@@ -447,7 +561,13 @@ export class Session {
447
561
  return assertNever(plan, "Session.start");
448
562
  }
449
563
 
450
- if (cursor?.needs_model_checkpoint || needsReplayCheckpoint(this.state.events, providerReplayKey(this.provider.config.spec), nativeReplay(this.provider.config.spec))) {
564
+ this.installWorkspace();
565
+ if (this.config.workspaceTracking !== "legacy") await this.recoverFileOperations();
566
+ this.restoreDeclaredTools();
567
+ // Consume the original pending receipt before another checkpoint can replace this projection.
568
+ const declarationCut = await this.applyDiscoveredTools();
569
+ await this.refreshRuntimeTargets();
570
+ if (!declarationCut && (cursor?.needs_model_checkpoint || needsReplayCheckpoint(this.state.events, providerReplayKey(this.provider.config.spec), nativeReplay(this.provider.config.spec)))) {
451
571
  await this.performCut("model_checkpoint", model);
452
572
  }
453
573
  await this.saveCursor();
@@ -461,6 +581,17 @@ export class Session {
461
581
  */
462
582
  async fork(destLogDir: string, untilEvent?: string, position: "at" | "before" = "at"): Promise<void> {
463
583
  await copyAncestryTo(this.config.logDir, destLogDir, this.clock.now(), untilEvent, position);
584
+ if (untilEvent !== undefined && this.config.workspaceTracking !== "legacy") {
585
+ const cursor = await readCursor(destLogDir);
586
+ if (!cursor) throw new Error("missing fork cursor");
587
+ const tip = (await readNode(this.config.logDir, this.state.node)).events.at(-1);
588
+ const filesFrom = { ...await digestedRef(this.config.logDir, this.state.node), ...(tip ? { at_event: tip.id } : {}) };
589
+ const header = (await readNode(destLogDir, cursor.node)).header;
590
+ const event: LogEvent = { t: "rewind", id: this.clock.nextId(), at: this.clock.now(), mode: "context", from: header?.parent ?? filesFrom,
591
+ files_from: filesFrom, prompt: "Forked conversation", restored: [], removed: [], unrecoverable: [], divergence: [] };
592
+ await appendEvent(destLogDir, cursor.node, event);
593
+ await writeCursor(destLogDir, { ...cursor, at_event: event.id, updated_at: event.at });
594
+ }
464
595
  }
465
596
 
466
597
  async setThinking(effort: Effort): Promise<void> {
@@ -468,25 +599,60 @@ export class Session {
468
599
  await this.saveCursor();
469
600
  }
470
601
 
471
- /** Pi user bash shares process ownership and archival with tools, never their authority or model dispatch. */
602
+ /** Trusted extension commands share the Session owner without inventing a user turn. */
603
+ runExtensionCommand(command: string, args: string[], options?: { cwd?: string; timeout?: number; signal?: AbortSignal }): Promise<NativeCommandResult> {
604
+ if (this.cleanup || this.closing) return Promise.reject(new Error("session is draining or disposed"));
605
+ const input: NativeCommandInput = { argv: [command, ...args], ...(options?.cwd !== undefined ? { cwd: options.cwd } : {}),
606
+ ...(options?.timeout !== undefined ? { timeout: options.timeout } : {}) };
607
+ if (!this.execution.running) this.hostOwner = this.execution.begin();
608
+ const signals = [this.execution.signal, options?.signal].filter((signal): signal is AbortSignal => signal !== undefined);
609
+ const signal = AbortSignal.any(signals);
610
+ const result = (async () => {
611
+ const call: ToolCall = { type: "tool_use", call_id: `host-${randomUUID()}`, name: NATIVE_COMMAND_NAME, input };
612
+ const preparation = this.prepareBrokerCall(call, () => 0, false, signal, true);
613
+ let prepared: PreparedBrokerCall;
614
+ for (;;) { const step = await preparation.next(); if (step.done) { prepared = step.value; break; } }
615
+ const receipt = await prepared.run();
616
+ await receipt.commit();
617
+ await this.saveCursor();
618
+ return nativeCommandResult(receipt.value, this.config.tools.outputStore!);
619
+ })();
620
+ this.hostPending.add(result);
621
+ const finished = () => {
622
+ this.hostPending.delete(result);
623
+ if (this.hostPending.size === 0 && this.hostOwner) { this.hostOwner.finish(); this.hostOwner = undefined; }
624
+ };
625
+ void result.then(finished, finished);
626
+ return result;
627
+ }
628
+
472
629
  async runUserBash(command: string, excludeFromContext: boolean, signal?: AbortSignal,
473
630
  onOutput?: (chunk: Uint8Array) => void): Promise<Extract<LogEvent, { t: "user_bash" }>> {
631
+ if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
474
632
  const owned = this.execution.begin(signal);
633
+ let release: (() => void) | undefined;
475
634
  try {
635
+ this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
636
+ release = await this.rootTools.acquire("exclusive");
476
637
  if (!command.trim()) throw new Error("empty user command");
477
638
  for await (const event of this.snapshotTurn([], false, "idle")) {
478
639
  if (event.kind === "snapshot_failed") throw new Error(event.reason);
479
640
  }
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}`;
641
+ const execute = async (): Promise<ToolResult> => {
642
+ const captured = await observeCommand({ command, cwd: this.config.tools.root,
643
+ outputStore: join(this.config.logDir, "artifacts"), maxOutputBytes: this.config.tools.maxOutputBytes,
644
+ timeoutMs: this.config.tools.timeoutMs, signal: owned.signal, ...(onOutput ? { onOutput } : {}) });
645
+ const completion = commandCompletion(captured.exit, captured.stopped, captured.cleanup);
646
+ const preview = headTailView(captured.head, captured.tail, captured.ref.bytes, this.config.tools.maxOutputBytes,
647
+ omitted => `\n[${omitted} bytes omitted; complete output: ${captured.ref.path}]\n`);
648
+ return { ...completion, content: `${preview}\nexit ${captured.exit} · ${completion.outcome.status} · ${completion.outcome.reason}` +
649
+ (captured.cleanup ? `\n${captured.cleanup.message}` : ""),
650
+ content_ref: captured.ref, effect: { kind: "command", cmd: command, exit: captured.exit } };
651
+ };
652
+ const result = this.config.tools.workspace ? await this.config.tools.workspace.command("bash", { command }, execute, owned.signal) : await execute();
653
+ if (result.effect?.kind !== "command" || !result.content_ref || !result.outcome) throw new Error("missing user command observation");
488
654
  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 };
655
+ command, content: result.content, exit: result.effect.exit, excludeFromContext, outcome: result.outcome, content_ref: result.content_ref };
490
656
  await this.record(event);
491
657
  for await (const snapshot of this.snapshotTurn([])) {
492
658
  if (snapshot.kind === "snapshot_failed") throw new Error(snapshot.reason);
@@ -497,7 +663,11 @@ export class Session {
497
663
  await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
498
664
  reason: "user command failed before completion; inspect the workspace before retrying", outcome: executionOutcome("execution_error") });
499
665
  throw error;
500
- } finally { owned.finish(); }
666
+ } finally {
667
+ release?.();
668
+ while (this.hostPending.size > 0) await Promise.allSettled(this.hostPending);
669
+ owned.finish();
670
+ }
501
671
  }
502
672
 
503
673
  async userMessages(): Promise<{ id: string; node: number; text: string; images?: readonly ImageRef[] }[]> {
@@ -515,11 +685,17 @@ export class Session {
515
685
  const dir = this.config.logDir;
516
686
  const users = await this.userMessages();
517
687
  const target = await resolveHistoryPosition(dir, eventId, users.some(user => user.id === eventId) ? "before" : "at");
688
+ const previous = (await readNode(dir, this.state.node)).events.at(-1);
689
+ const filesFrom = { ...await digestedRef(dir, this.state.node), ...(previous ? { at_event: previous.id } : {}) };
518
690
  for await (const event of this.snapshotTurn([], false, "idle")) {
519
691
  if (event.kind === "snapshot_failed") throw new Error(event.reason);
520
692
  }
521
693
  const node = this.state.nextNode++;
522
694
  await initNode(dir, node, target.parent, this.clock.now(), target.basis);
695
+ if (this.config.workspaceTracking !== "legacy") await appendEvent(dir, node, {
696
+ t: "rewind", id: this.clock.nextId(), at: this.clock.now(), mode: "context", from: target.parent ?? filesFrom,
697
+ files_from: filesFrom, prompt: target.editorText ?? "Selected conversation position", restored: [], removed: [], unrecoverable: [], divergence: [],
698
+ });
523
699
  const cursor = await readCursor(dir);
524
700
  if (!cursor) throw new Error("missing current session cursor");
525
701
  const next = { ...cursor, node, next_node: this.state.nextNode, updated_at: this.clock.now(),
@@ -571,18 +747,47 @@ export class Session {
571
747
  }
572
748
 
573
749
  /** 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;
750
+ async reloadResources(frozen: FrozenPrefix, skills = this.config.skills): Promise<boolean> {
751
+ if (JSON.stringify(frozen) === JSON.stringify(this.config.frozen) && JSON.stringify(skills) === JSON.stringify(this.config.skills)) return false;
576
752
  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);
753
+ const contextRef = await this.persistContext(frozen, skills);
578
754
  await this.performCut("model_checkpoint", this.provider.config.spec.id, undefined,
579
755
  this.provider.config.spec.provider, this.provider.config.effort, contextRef);
580
756
  this.config.frozen = frozen;
757
+ if (skills !== undefined) this.config.skills = skills;
758
+ this.installSkillResources();
581
759
  this.config.tools.allow = frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" ? [tool.name] : []);
582
760
  await this.saveCursor();
583
761
  return true;
584
762
  }
585
763
 
764
+ /** Restoring a declaration never installs code: only the current selected local catalog can supply it. */
765
+ private restoreDeclaredTools(): void {
766
+ const declared = new Set(declaredToolNames(this.config.frozen.tools));
767
+ const loaded = currentDeferredTools(this.config.tools).filter(tool => declared.has(tool.name)).map(tool => tool.name);
768
+ if (loaded.length && this.config.tools.allow !== undefined) this.config.tools.allow = [...new Set([...this.config.tools.allow, ...loaded])];
769
+ }
770
+
771
+ /** D5/D6: consume original discovery receipts only after every invocation has its result. */
772
+ private async applyDiscoveredTools(): Promise<boolean> {
773
+ if (this.isHalfRound() || (this.config.branchDepth ?? 0) > 0 || this.config.tools.extensions?.get(DISCOVERY_TOOL_NAME) !== DISCOVERY_TOOL) return false;
774
+ const declared = new Set(declaredToolNames(this.config.frozen.tools));
775
+ const requested = new Map(recordedDeclarations(this.state.events).map(item => [item.name, item.fingerprint]));
776
+ const additions = currentDeferredTools(this.config.tools).filter(tool => !declared.has(tool.name) &&
777
+ requested.get(tool.name) === discoveryFingerprint(tool) && discoveryPermitted(this.config.tools, tool.name));
778
+ if (additions.length === 0) return false;
779
+ const frozen = { ...this.config.frozen, tools: [...this.config.frozen.tools, ...additions] };
780
+ const contextRef = await this.persistContext(frozen);
781
+ await this.performCut("model_checkpoint", this.provider.config.spec.id, undefined,
782
+ this.provider.config.spec.provider, this.provider.config.effort, contextRef);
783
+ this.config.frozen = frozen;
784
+ this.restoreDeclaredTools();
785
+ // send() restores its base tool scope at the end; keep accepted declarations there too.
786
+ if (this.turnTools?.allow !== undefined) this.turnTools.allow = [...new Set([...this.turnTools.allow, ...additions.map(tool => tool.name)])];
787
+ await this.saveCursor();
788
+ return true;
789
+ }
790
+
586
791
  /**
587
792
  * The same pairing check switchModel already used. A user message
588
793
  * between tool_use and tool_result is the same unpairable request a
@@ -609,6 +814,7 @@ export class Session {
609
814
  if (pull === undefined || this.isHalfRound()) return null;
610
815
  const text = await pull("after_tools");
611
816
  if (text === null || typeof text === "string" && text.length === 0) return null;
817
+ if (this.turnTools) this.config.tools = { ...this.turnTools };
612
818
  return text;
613
819
  }
614
820
 
@@ -619,8 +825,17 @@ export class Session {
619
825
  * resuming from -- a crash, a kill, a closed laptop -- do not run exit
620
826
  * handlers.
621
827
  */
622
- private async persistContext(frozen = this.config.frozen): Promise<string> {
828
+ private async persistContext(frozen = this.config.frozen, skills = this.config.skills): Promise<string> {
623
829
  return putBlob(join(this.config.logDir, "artifacts"), JSON.stringify({
830
+ projectionVersion: PROJECTION_VERSION,
831
+ workspaceTracking: this.config.workspaceTracking ?? "journal",
832
+ shellCheckpoints: this.config.shellCheckpoints === true,
833
+ ...(skills?.length ? { skills } : {}),
834
+ ...(this.config.acceptanceContract ? { acceptanceContract: this.config.acceptanceContract } : {}),
835
+ ...(this.config.strictVerification ? { strictVerification: true } : {}),
836
+ ...(this.config.workingSet ? { workingSet: true } : {}),
837
+ ...(this.config.workingSet && this.config.workingSetVersion === 2 ? { workingSetVersion: 2 } : {}),
838
+ ...(this.config.guidanceVersion === 1 ? { guidanceVersion: 1 } : {}),
624
839
  frozen, gates: this.config.tools.gates,
625
840
  workNamespace: this.config.workNamespace ?? "main",
626
841
  maxParallelTools: this.config.maxParallelTools ?? 3,
@@ -653,9 +868,194 @@ export class Session {
653
868
  }
654
869
 
655
870
  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 }]);
871
+ // D8: runtime callers may retain mutable results. The in-memory projection
872
+ // must own the same JSON value that was appended, independent of later edits.
873
+ event = JSON.parse(JSON.stringify(event)) as LogEvent;
874
+ const saved = this.recordTail.then(async () => {
875
+ await appendEvent(this.config.logDir, this.state.node, event);
876
+ this.state.events.push(event);
877
+ if (this.stall !== null) this.stall = advanceStall(this.stall, [{ node: this.state.node, event }]);
878
+ if (this.recovery !== null) this.recovery = advanceRecovery(this.recovery, [event], path => resolve(this.config.tools.root, path));
879
+ });
880
+ this.recordTail = saved.catch(() => {});
881
+ await saved;
882
+ if (event.t === "tool_result" && event.ok && event.effect?.kind === "read") {
883
+ const path = resolve(this.config.tools.root, event.effect.path);
884
+ const skill = this.config.skills?.find(item => item.filePath === path);
885
+ if (skill && !skill.disableModelInvocation) {
886
+ await this.recordSkill(skill, "read", "model");
887
+ this.config.tools.allow = skillTools(this.allowedNames(), skill);
888
+ // Restricted skill scopes cannot grow their capability set by searching a parent directory.
889
+ if (skill.allowedTools !== undefined || skill.disallowedTools !== undefined) this.config.tools.deferredTools = [];
890
+ }
891
+ }
892
+ }
893
+
894
+ private allowedNames(): string[] {
895
+ return this.config.frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" &&
896
+ (this.config.tools.allow === undefined || this.config.tools.allow.includes(tool.name)) ? [tool.name] : []);
897
+ }
898
+
899
+ private adviceNames(frozen = this.config.frozen): string[] {
900
+ const declared = frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" ? [tool.name] : []);
901
+ return adviceTools({ ...this.config.tools, advice: declared, ...(frozen === this.config.frozen ? {} : { allow: declared }) });
902
+ }
903
+
904
+ private installSkillResources(): void {
905
+ this.config.tools.skillResources = (this.config.skills ?? []).map(skill => ({ path: skill.filePath,
906
+ text: skill.sourceText ?? skill.body, modelInvocable: !skill.disableModelInvocation && skill.context !== "fork" }));
907
+ }
908
+
909
+ private async recordSkill(skill: Skill, mode: "inline" | "fork" | "read", origin: "user" | "model"): Promise<void> {
910
+ const artifact = await putBlob(join(this.config.logDir, "artifacts"), skill.sourceText ?? skill.body);
911
+ await this.record({ t: "skill_loaded", id: this.clock.nextId(), at: this.clock.now(), name: skill.name, path: skill.filePath,
912
+ sha256: artifact, artifact, mode, origin });
913
+ }
914
+
915
+ /** O.12: main instructions are durable; permissions apply to this invocation only. */
916
+ async *sendSkill(skill: Skill, prompt: UserInput, signal?: AbortSignal): AsyncGenerator<LoopEvent> {
917
+ if (this.running || this.cleanup || this.closing || this.isHalfRound()) throw new Error("a skill requires an idle paired session");
918
+ skill = this.config.skills?.find(item => item.name === skill.name && item.filePath === skill.filePath) ?? skill;
919
+ if (skill.userInvocable === false) throw new Error("this skill cannot be invoked by users");
920
+ const savedTools = this.config.tools;
921
+ this.config.tools = { ...savedTools, allow: skillTools(this.allowedNames(), skill) };
922
+ if (skill.allowedTools !== undefined || skill.disallowedTools !== undefined) this.config.tools.deferredTools = [];
923
+ try {
924
+ await this.recordSkill(skill, skill.context === "fork" ? "fork" : "inline", "user");
925
+ if (skill.context !== "fork") { yield* this.send(prompt, signal); return; }
926
+ const owned = this.execution.begin(signal);
927
+ try {
928
+ await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), text: `Run isolated skill ${skill.name}; source ${skill.filePath}.` });
929
+ const writes = skill.allowedTools?.some(name => this.allowedNames().includes(name) &&
930
+ WRITING_BRANCH_TOOL_NAMES.includes(name) && !BRANCH_TOOL_NAMES.includes(name)) ?? false;
931
+ const result = yield* this.runBranch({ purpose: `skill ${skill.name}`, prompt: userInputData(prompt).text, writes }, 0, owned.signal, skill);
932
+ await result.accept?.();
933
+ await this.record({ t: "extension", id: this.clock.nextId(), at: this.clock.now(), customType: "skill_result", content: result.content,
934
+ display: true, inContext: true });
935
+ await this.saveCursor();
936
+ yield { kind: "assistant_text", text: result.content };
937
+ yield { kind: "done", outcome: result.outcome ?? executionOutcome("execution_error"), reason: "isolated skill finished" };
938
+ } finally { owned.finish(); }
939
+ } finally { this.config.tools = savedTools; this.restoreDeclaredTools(); }
940
+ }
941
+
942
+ /** O.8: dispatch and diagnostics share exact source hints, including forks. */
943
+ contextInput(): ProjectInput {
944
+ return { frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events,
945
+ logHint: { manifestPath: manifestPath(this.config.logDir),
946
+ ...(this.config.guidanceVersion === 1 ? { scopedGuidance: true } : {}),
947
+ segmentPath: this.state.handoff?.predecessor?.log_ref ?? nodePath(this.config.logDir, this.state.node) } };
948
+ }
949
+
950
+ private async boundCarried(full: Handoff, tools = this.adviceNames()): Promise<Handoff> {
951
+ return carriedHandoff(this.config.logDir, this.state.node, full, { handoff_tokens: DEFAULT_HANDOFF_TOKENS,
952
+ ...(this.config.workingSet ? { working_set_version: this.config.workingSetVersion ?? 1 } : {}),
953
+ ...(this.config.guidanceVersion === 1 ? { guidance_tools: tools } : {}) });
954
+ }
955
+
956
+ async acceptanceState(): Promise<AcceptanceState | undefined> {
957
+ if (!this.running) await this.observeEvidence();
958
+ // External editor changes must invalidate proof before the user reads it.
959
+ if (!this.running) for await (const result of this.snapshotTurn([], false, "idle")) {
960
+ if (result.kind === "snapshot_failed") throw new Error(`cannot inspect an unobserved workspace: ${result.reason}`);
961
+ }
962
+ if (!this.running) await this.refreshRuntimeTargets();
963
+ return (await this.currentHandoff()).acceptance;
964
+ }
965
+
966
+ /** Only called at paired, owned boundaries; probes never enter a pending model tool exchange. */
967
+ private async refreshRuntimeTargets(): Promise<void> {
968
+ const state = (await this.currentHandoff()).acceptance;
969
+ const observations: string[] = [];
970
+ for (const target of state?.targets ?? []) if (target.observation || target.binding) {
971
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
972
+ if (!history.ok) throw Error(history.error);
973
+ const observed = await this.runtimeTargets.perform(this.config.tools, history.nodes, { action: "inspect", target: target.id });
974
+ const id = this.clock.nextId();
975
+ await this.record({ t: "target_observation", id, at: this.clock.now(), observation: observed.observation });
976
+ observations.push(id);
977
+ }
978
+ if (!observations.length) return;
979
+ const current = (await this.currentHandoff()).acceptance;
980
+ if (current?.targets?.some(target => {
981
+ const before = state?.targets?.find(item => item.id === target.id);
982
+ return before?.status !== target.status || before.binding !== target.binding || before.build_event !== target.build_event;
983
+ })) await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "runtime_acceptance", sources: observations,
984
+ 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.` });
985
+ }
986
+
987
+ async workItems(): Promise<readonly WorkItem[]> {
988
+ const replayed = await replayContext(this.config.logDir, this.state.node);
989
+ return deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events, depth: replayed.depth,
990
+ self: await this.nodeRefFor(this.state.node) }).state.work_items ?? [];
991
+ }
992
+
993
+ /** What the next handoff would carry if the segment were cut now. Pure derivation; nothing is written. */
994
+ async previewHandoff(): Promise<Handoff> { return this.currentHandoff(); }
995
+
996
+ private async currentHandoff(): Promise<Handoff> {
997
+ const replayed = await replayContext(this.config.logDir, this.state.node);
998
+ return deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events, depth: replayed.depth, self: await this.nodeRefFor(this.state.node) });
999
+ }
1000
+ async branchStatuses() {
1001
+ return (await this.currentHandoff()).branches.map(item => ({ ...item, status: branchStatus(item, this.jobs.state(item.node)) }));
1002
+ }
1003
+ async processStatuses() {
1004
+ const handles = new Map(runningSessions(this.execOwner).map(session => [session.id, session.exit === null ? "running" as const : "ended_unobserved" as const]));
1005
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
1006
+ if (!history.ok) throw new Error(history.error);
1007
+ let processes: ProcessObservation[] = [];
1008
+ for (const part of await hydrateProcessReceipts(history.nodes, this.config.tools.outputStore!)) processes = deriveProcesses(processes, part.events, part.node);
1009
+ return processes.map(item => ({ ...item, status: processStatus(item, handles.get(item.session)) }));
1010
+ }
1011
+ /** User lifecycle commands share the coordinator; no model request is needed. */
1012
+ async resolveBranch(node: number, resolution: "wait" | "cancel" | "status", waitMs = 10_000): Promise<ToolResult> {
1013
+ if (this.running || this.cleanup || this.closing) throw new Error("branch commands require an idle session");
1014
+ const owned = this.execution.begin();
1015
+ try {
1016
+ const result = await this.runResolve({ node, resolution, wait_ms: waitMs });
1017
+ await this.saveCursor(); return finishToolResult(result);
1018
+ } finally { owned.finish(); }
1019
+ }
1020
+
1021
+ private async drainProcesses(): Promise<void> {
1022
+ const results = await disposeExecSessions(this.execOwner, this.config.tools.outputStore!, this.config.tools.maxOutputBytes);
1023
+ await this.config.tools.workspace?.abandonCommands();
1024
+ for (const result of results) {
1025
+ await this.record({ t: "process_exit", id: this.clock.nextId(), at: this.clock.now(), ...result, reason: "disposed" });
1026
+ }
1027
+ if (results.length > 0) await this.saveCursor();
1028
+ }
1029
+ private async acceptReadyBranches(): Promise<void> {
1030
+ for (const node of this.jobs.nodes()) {
1031
+ const done = await this.jobs.wait(node, 0);
1032
+ if (done?.value?.accept) await done.value.accept();
1033
+ this.jobs.forget(node);
1034
+ }
1035
+ }
1036
+ dispose(): Promise<void> {
1037
+ if (this.closing) return this.closing;
1038
+ this.cancel();
1039
+ this.closing = this.whenIdle().then(() => this.rootTools?.close());
1040
+ return this.closing;
1041
+ }
1042
+
1043
+ /** Only the app's user command calls this; note never grants human acceptance. */
1044
+ async acceptCriterion(criterion: string): Promise<AcceptanceState> {
1045
+ if (this.running || this.isHalfRound()) throw new Error("acceptance requires an idle, paired request");
1046
+ for await (const result of this.snapshotTurn([], true)) {
1047
+ if (result.kind === "snapshot_failed") throw new Error(`cannot accept an unobserved workspace: ${result.reason}`);
1048
+ }
1049
+ const current = await this.acceptanceState();
1050
+ const item = current?.criteria.find(item => item.id === criterion && item.kind === "human");
1051
+ if (!current || !item || item.kind !== "human") throw new Error("unknown human acceptance criterion");
1052
+ const target = item.target ? current.targets?.find(target => target.id === item.target) : undefined;
1053
+ if (item.target && (target?.status !== "ready" || !target.binding || !target.observation)) throw Error("human acceptance requires a current ready target instance");
1054
+ await this.record({ t: "acceptance_decision", id: this.clock.nextId(), at: this.clock.now(), request: current.request,
1055
+ contract_id: current.contract_id, criterion, actor: "user",
1056
+ ...(target?.binding && target.observation ? { target_binding: target.binding, target_observation: target.observation } : {}) });
1057
+ await this.saveCursor();
1058
+ return (await this.acceptanceState())!;
659
1059
  }
660
1060
 
661
1061
  async appendExtension(entry: Omit<Extract<LogEvent, { t: "extension" }>, "t" | "id" | "at">): Promise<void> {
@@ -671,8 +1071,21 @@ export class Session {
671
1071
  return totalUsage(this.state.usage);
672
1072
  }
673
1073
 
674
- cancel(): void { this.execution.cancel(); }
675
- whenIdle(): Promise<void> { return this.execution.whenIdle(); }
1074
+ cancel(): void {
1075
+ this.execution.cancel(); this.jobs.cancel();
1076
+ for (const child of this.children.values()) child.cancel();
1077
+ if (!this.cleanup) this.cleanup = (async () => {
1078
+ await this.execution.whenIdle(); await this.jobs.drain();
1079
+ // A consumer can close at the preparation marker before execution is
1080
+ // admitted. Those children still have an owner, even without a job.
1081
+ for (const [node, child] of this.children) { await child.dispose(); this.children.delete(node); }
1082
+ await this.acceptReadyBranches(); await this.drainProcesses();
1083
+ })().then(() => { this.cleanup = null; });
1084
+ // A void cancellation caller may await later. Preserve rejection for the
1085
+ // owner while preventing an unhandled rejection from bypassing that path.
1086
+ void this.cleanup.catch(() => {});
1087
+ }
1088
+ async whenIdle(): Promise<void> { await this.execution.whenIdle(); await this.cleanup; }
676
1089
  get running(): boolean { return this.execution.running; }
677
1090
 
678
1091
  /**
@@ -691,7 +1104,12 @@ export class Session {
691
1104
  signal?: AbortSignal,
692
1105
  pull?: (phase: Phase) => Promise<UserInput | null>,
693
1106
  ): AsyncGenerator<LoopEvent> {
1107
+ if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
694
1108
  const owned = this.execution.begin(signal);
1109
+ const savedTools = this.config.tools;
1110
+ this.turnTools = savedTools;
1111
+ this.config.tools = { ...savedTools };
1112
+ this.installSkillResources();
695
1113
  const events = this.run(userText, owned.signal, pull);
696
1114
  let terminal = false;
697
1115
  let exhausted = false;
@@ -725,7 +1143,13 @@ export class Session {
725
1143
  if (!exhausted) {
726
1144
  await events.return(undefined);
727
1145
  }
728
- } finally { owned.finish(); }
1146
+ } finally {
1147
+ while (this.hostPending.size > 0) await Promise.allSettled(this.hostPending);
1148
+ this.config.tools = savedTools; this.turnTools = null;
1149
+ if (owned.signal.aborted) this.cancel();
1150
+ owned.finish();
1151
+ if (owned.signal.aborted) await this.whenIdle();
1152
+ }
729
1153
  }
730
1154
  }
731
1155
 
@@ -734,6 +1158,8 @@ export class Session {
734
1158
  signal?: AbortSignal,
735
1159
  pull?: (phase: Phase) => Promise<UserInput | null>,
736
1160
  ): AsyncGenerator<LoopEvent> {
1161
+ // A crash after a discovery receipt but before its cut is recovered from that same receipt.
1162
+ if (!signal?.aborted) await this.applyDiscoveredTools();
737
1163
  if (needsReplayCheckpoint(this.state.events, providerReplayKey(this.provider.config.spec), nativeReplay(this.provider.config.spec))) {
738
1164
  if (this.isHalfRound()) throw new Error("Cannot change a wire profile while tool results are pending");
739
1165
  await this.performCut("model_checkpoint");
@@ -747,6 +1173,8 @@ export class Session {
747
1173
  await this.record({
748
1174
  t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(userText, blocked),
749
1175
  });
1176
+ if (this.config.acceptanceContract) await this.record({ t: "acceptance_contract", id: this.clock.nextId(), at: this.clock.now(),
1177
+ request: this.state.events.at(-1)!.id, contract: this.config.acceptanceContract });
750
1178
  // Idle changes belong to the new prompt. Recording them before `user`
751
1179
  // would silently move the preceding exchange's restore point forward.
752
1180
  for await (const event of this.snapshotTurn([])) {
@@ -774,12 +1202,11 @@ export class Session {
774
1202
  yield { kind: "turn_start", node: this.state.node };
775
1203
 
776
1204
  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,
1205
+ const names = this.adviceNames();
1206
+ const guidance = (this.config.harnessReviews === false ? null : await this.recoveryGuidance(names)) ??
1207
+ continuationGuidance({ events: this.state.events, handoff: this.state.handoff,
782
1208
  tools: names, remainingRequests: this.config.maxTurns - turn,
1209
+ ...(this.config.guidanceVersion === 1 ? { taskAware: true } : {}),
783
1210
  ...(this.config.continuationAfter === undefined ? {} : { minAssistantTurns: this.config.continuationAfter }) }) ??
784
1211
  (this.config.timeBudgetMs === undefined ? null
785
1212
  : deadlineNotice(requestStarted, this.clock.now(), this.config.timeBudgetMs, this.state.events)) ??
@@ -793,15 +1220,7 @@ export class Session {
793
1220
  yield { kind: "done", reason: "stopped: context cannot fit the model window", outcome: executionOutcome("context_budget") };
794
1221
  return;
795
1222
  }
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
- });
1223
+ const projected = projectContext(this.contextInput());
805
1224
 
806
1225
  // Pairing violations are a 400 waiting to happen; surface them here,
807
1226
  // where the cause is still visible, rather than as a request failure.
@@ -836,8 +1255,7 @@ export class Session {
836
1255
  // shrink is a cut, so force one and send once more; a second
837
1256
  // overflow means even the retained tail is misjudged, and another
838
1257
  // 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) {
1258
+ if (overflowRetried || this.policyLimits().kind !== "window") {
841
1259
  yield { kind: "done", reason: `stopped: provider rejected the prompt as too long (${error.message})`, outcome: executionOutcome("context_budget") };
842
1260
  return;
843
1261
  }
@@ -939,8 +1357,14 @@ export class Session {
939
1357
  });
940
1358
  continue;
941
1359
  }
1360
+ if (this.config.acceptanceContract) await this.refreshRuntimeTargets();
1361
+ const acceptance = this.config.acceptanceContract ? await this.acceptanceState() : undefined;
942
1362
  const review = this.config.guidance === false || this.config.harnessReviews === false ? null
943
- : completionReview(this.state.events, this.config.maxTurns - turn - 1);
1363
+ : completionReview(this.state.events, this.config.maxTurns - turn - 1, {
1364
+ ...(this.config.guidanceVersion === 1 ? { taskAware: true } : {}),
1365
+ ...(this.config.strictVerification ? { strictVerification: true } : {}),
1366
+ ...(acceptance ? { acceptance } : {}),
1367
+ });
944
1368
  if (review) {
945
1369
  await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...review });
946
1370
  if (yield* this.maybeCut(counter, true)) {
@@ -959,7 +1383,7 @@ export class Session {
959
1383
  }
960
1384
 
961
1385
  let branchesThisTurn = 0;
962
- type Settled = { ok: true; value: DeferredToolResult } | { ok: false; error: unknown };
1386
+ type Settled = { ok: true; value: SessionBrokerReceipt } | { ok: false; error: unknown };
963
1387
  const pending = new Map<string, Promise<Settled>>();
964
1388
  const modes = calls.map(call => this.executionMode(call));
965
1389
  try { for (let callIndex = 0; callIndex < calls.length; callIndex++) {
@@ -975,88 +1399,33 @@ export class Session {
975
1399
  const planned = calls[index]!;
976
1400
  yield { kind: "tool_call", tool: { name: planned.name, input: planned.input } };
977
1401
  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 })));
1402
+ const dispatch = yield* this.prepareBrokerCall(planned, () => branchesThisTurn++, blocked, signal);
1403
+ pending.set(planned.call_id, dispatch.run().then(value => ({ ok: true as const, value }), error => ({ ok: false as const, error })));
980
1404
  }
981
1405
  }
982
1406
 
983
- let out: DeferredToolResult;
984
- let annotation: LogEvent | undefined;
1407
+ let receipt: SessionBrokerReceipt;
985
1408
  const dispatched = pending.get(call.call_id);
986
1409
  if (dispatched) {
987
1410
  const settled = await dispatched;
988
1411
  pending.delete(call.call_id);
989
1412
  if (!settled.ok) throw settled.error;
990
- out = settled.value;
1413
+ receipt = settled.value;
991
1414
  } else {
992
1415
  if (signal?.aborted) { yield* this.closeInterrupted(completedThisTurn); return; }
993
-
994
1416
  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
- }
1417
+ if (signal?.aborted) { yield* this.closeInterrupted(completedThisTurn); return; }
1418
+ const prepared = yield* this.prepareBrokerCall(call, () => branchesThisTurn++, blocked, signal);
1419
+ receipt = yield* withProgress<SessionBrokerReceipt, LoopEvent>(emit => prepared.run(emit));
1032
1420
  }
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.
1421
+ await receipt.settle();
1422
+ for (const event of receipt.events.splice(0)) yield event;
1423
+ const out = finishToolResult(receipt.value);
1424
+ // Observation still precedes persistence: crashes here remain unknown,
1425
+ // while nested receipts have already been committed before their caller resumed.
1037
1426
  yield { kind: "tool_result", text: out.content, ok: out.ok, outcome: out.outcome! };
1427
+ await receipt.commit();
1038
1428
 
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
1429
  completedThisTurn.push(call.call_id);
1061
1430
  if (signal?.aborted && pending.size === 0) {
1062
1431
  yield* this.closeInterrupted(completedThisTurn);
@@ -1068,6 +1437,8 @@ export class Session {
1068
1437
  // that means anything, and per-call would be the character-level
1069
1438
  // granularity the research warns against.
1070
1439
  yield* this.snapshotTurn(calls.map((c) => c.call_id));
1440
+ const declarationNode = this.state.node;
1441
+ if (!signal?.aborted && await this.applyDiscoveredTools()) yield { kind: "cut", node: declarationNode, reason: "model_checkpoint" };
1071
1442
  if (this.pendingAdoption !== null) await this.adoptPendingBranch();
1072
1443
 
1073
1444
  // Between complete exchanges is the only safe place to cut.
@@ -1111,6 +1482,9 @@ export class Session {
1111
1482
  id: this.clock.nextId(),
1112
1483
  at: this.clock.now(),
1113
1484
  call_id: block.call_id,
1485
+ ...(block.invocation_id ? { invocation_ref: { node: this.state.node, event: block.invocation_id } } : {}),
1486
+ ...(block.parent_call_id ? { parent_call_id: block.parent_call_id } : {}),
1487
+ ...(block.origin ? { origin: block.origin } : {}),
1114
1488
  ok: false,
1115
1489
  content: resultText,
1116
1490
  outcome: executionOutcome("unknown"),
@@ -1200,6 +1574,8 @@ export class Session {
1200
1574
  * delta rather than a copy of the workspace.
1201
1575
  */
1202
1576
  private async *snapshotTurn(callIds: readonly string[], baseline = false, phase: "turn" | "idle" = "turn"): AsyncGenerator<LoopEvent> {
1577
+ // P.3: retained only for explicit historical compatibility. New sessions never scan here.
1578
+ if (this.config.workspaceTracking !== "legacy") return;
1203
1579
  const blobDir = this.config.blobDir;
1204
1580
  if (!blobDir) return;
1205
1581
 
@@ -1278,41 +1654,207 @@ export class Session {
1278
1654
  * the same thing, so nothing here has to reimplement the loop.
1279
1655
  */
1280
1656
  private executionMode(call: ToolCall): "parallel" | "exclusive" {
1657
+ if (this.config.tools.extensionHooks?.serial?.()) return "exclusive";
1658
+ if (call.name === "read" && typeof call.input === "object" && call.input !== null &&
1659
+ this.config.skills?.some(skill => skill.filePath === resolve(this.config.tools.root, String((call.input as Record<string, unknown>)["path"])))) return "exclusive";
1281
1660
  if (call.name === "branch" && typeof call.input === "object" && call.input !== null &&
1282
- (call.input as Record<string, unknown>)["writes"] !== true) return "parallel";
1661
+ (call.input as Record<string, unknown>)["writes"] !== true && (call.input as Record<string, unknown>)["background"] !== true) return "parallel";
1283
1662
  return toolDefinition(call.name, this.config.tools)?.execution ?? "exclusive";
1284
1663
  }
1285
1664
 
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;
1665
+ private async *dispatchBranch(input: unknown, index: number, signal?: AbortSignal): AsyncGenerator<LoopEvent, DeferredToolResult> {
1666
+ if (typeof input !== "object" || input === null || !("background" in input) || input.background !== true) return yield* this.runBranch(input, index, signal);
1667
+ if (this.jobs.active >= (this.config.maxParallelTools ?? 3)) return { ok: false, content: "background branch capacity reached; wait or cancel an owned branch first" };
1668
+ const controller = new AbortController();
1669
+ const abort = () => controller.abort();
1670
+ signal?.addEventListener("abort", abort, { once: true });
1671
+ if (signal?.aborted) abort();
1672
+ const detach = () => signal?.removeEventListener("abort", abort);
1673
+ const child = this.runBranch(input, index, controller.signal);
1674
+ let registered = false;
1675
+ try {
1676
+ for (;;) {
1677
+ const step = await child.next();
1678
+ if (step.done) return step.value;
1679
+ yield step.value;
1680
+ if (step.value.kind !== "branch_start") continue;
1681
+ const node = step.value.node!;
1682
+ this.jobs.start(node, controller, async () => {
1683
+ const result = await child.next();
1684
+ if (!result.done) throw new Error("unexpected background child yield");
1685
+ return result.value;
1686
+ }, detach);
1687
+ registered = true;
1688
+ return { ok: true, content: `branch ${node} running; ` + (this.adviceNames().includes("resolve")
1689
+ ? `use resolve(node=${node}, resolution='status'|'wait'|'cancel')` : "branch-observation capability unavailable; completion is unknown"), data: { node, background: true, running: true } };
1690
+ }
1691
+ } finally { if (!registered) detach(); }
1692
+ }
1693
+
1694
+ private async *prepareBrokerCall(call: ToolCall, nextBranch: () => number, blockedImages: boolean,
1695
+ signal?: AbortSignal, native = false): AsyncGenerator<LoopEvent, PreparedBrokerCall> {
1696
+ this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
1697
+ const release = await this.rootTools.acquire(this.executionMode(call));
1698
+ let transferred = false;
1699
+ let scope: BrokerExecution<DeferredToolResult> | undefined;
1700
+ try {
1701
+ const events: LoopEvent[] = [];
1702
+ let emit = (event: LoopEvent) => { events.push(event); };
1703
+ let context: ToolContext = { ...this.config.tools, imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"),
1704
+ advice: this.allowedNames(),
1705
+ imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}) };
1706
+ if (native) context = nativeCommandContext(context);
1707
+ const decoder = new TextDecoder();
1708
+ context.onOutput = chunk => emit({ kind: "progress", progress: { kind: "output", name: call.name, text: decoder.decode(chunk, { stream: true }) } });
1709
+ const broker = new ToolBroker<DeferredToolResult>({
1710
+ parallelLimit: this.config.maxParallelTools ?? 3,
1711
+ callId: () => `nested-${randomUUID()}`,
1712
+ mode: request => this.executionMode(request),
1713
+ invocation: (request, ctx, parent) => this.recordInvocation(request, ctx, parent),
1714
+ prepare: (request, ctx) => this.prepareSessionAction(request, ctx, nextBranch, event => emit(event)),
1715
+ settle: async result => {
1716
+ if (result.accept) events.push(...await result.accept());
1717
+ if (!result.content_ref) Object.assign(result, await presentObservation(context, result.content));
1718
+ },
1719
+ result: async (invocation, result) => {
1720
+ if (result.annotation) await this.record(result.annotation);
1721
+ await this.recordToolReceipt(invocation, finishToolResult(result), blockedImages);
1722
+ },
1723
+ });
1724
+ const execute = yield* withProgress<BrokerExecution<DeferredToolResult>, LoopEvent>(async output => {
1725
+ emit = output;
1726
+ scope = await broker.prepare(call, context);
1727
+ return scope;
1728
+ });
1729
+ emit = event => { events.push(event); };
1730
+ transferred = true;
1731
+ return { run: async output => {
1732
+ if (output) emit = output;
1733
+ let commitOwnsSlot = false;
1734
+ try {
1735
+ const receipt = await execute();
1736
+ if (native) {
1737
+ // An idle host result has no generator consumer to own its commit.
1738
+ // Retain the slot so a checkpoint cannot split invocation from receipt.
1739
+ commitOwnsSlot = true;
1740
+ return { ...receipt, events, commit: async () => { try { await receipt.commit(); } finally { release(); } } };
1741
+ }
1742
+ return { ...receipt, events };
1743
+ } finally { emit = event => { events.push(event); }; if (!commitOwnsSlot) release(); }
1744
+ } };
1745
+ } finally { if (!transferred) { try { await scope?.close(); } finally { release(); } } }
1746
+ }
1747
+
1748
+ private async recordInvocation(call: ToolCall, ctx: ToolContext, parent?: ToolInvocationEvent): Promise<ToolInvocationEvent> {
1749
+ const store = ctx.outputStore ?? join(this.config.logDir, "artifacts");
1750
+ const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));
1751
+ const archive = async (bytes: Uint8Array) => {
1752
+ async function* source() { yield bytes; }
1753
+ return (await archiveObservation(store, source(), 0)).ref;
1754
+ };
1755
+ let valid = false;
1756
+ try { valid = isJsonValue(call.input); } catch { /* Cyclic or excessively deep host input is not a JSON request. */ }
1757
+ const bytes = encode(valid ? call.input : { unavailable: "non-JSON arguments", type: typeof call.input });
1758
+ const definition = toolDefinition(call.name, ctx);
1759
+ const declared = this.config.frozen.tools.find(tool => tool !== null && typeof tool === "object" && "name" in tool && tool.name === call.name &&
1760
+ "description" in tool && typeof tool.description === "string" && "input_schema" in tool) as ReturnType<typeof modelToolSchema> | undefined;
1761
+ const schema = definition ? declared ?? modelToolSchema(definition, adviceTools(ctx)) : undefined;
1762
+ const descriptor = definition ? { version: 1, ...schema,
1763
+ ...(declared && JSON.stringify(declared.input_schema) !== JSON.stringify(definition.input_schema) ? { admission_schema: definition.input_schema } : {}),
1764
+ access: definition.access, execution: definition.execution, branch: definition.branch, hiddenInputs: definition.hiddenInputs ?? [], result_version: 1 } : null;
1765
+ const original = this.state.events.findLast(event => event.t === "assistant" && event.blocks.some(block => block.type === "tool_use" && block.call_id === call.call_id));
1766
+ const originalInput = original?.t === "assistant" ? original.blocks.find(block => block.type === "tool_use" && block.call_id === call.call_id) : undefined;
1767
+ const changed = originalInput?.type !== "tool_use" || valid && JSON.stringify(originalInput.input) !== JSON.stringify(call.input);
1768
+ const event: ToolInvocationEvent = { t: "tool_invocation", id: this.clock.nextId(), at: this.clock.now(), call_id: call.call_id, name: call.name,
1769
+ ...(ctx.origin ? { origin: ctx.origin } : {}),
1770
+ input_ref: await archive(bytes), schema_ref: descriptor ? await archive(encode(descriptor)) : null,
1771
+ ...(parent ? { parent_call_id: parent.call_id, parent_ref: { node: this.state.node, event: parent.id } } : {}),
1772
+ ...(valid && bytes.length <= MAX_NESTED_ARGUMENT_BYTES && (parent || changed) ? { input: call.input as JsonValue } : {}) };
1773
+ await this.record(event); return event;
1774
+ }
1775
+
1776
+ private async recordToolReceipt(invocation: ToolInvocationEvent, out: ToolResult, blockedImages: boolean): Promise<void> {
1777
+ let data = out.data;
1778
+ let dataRef;
1779
+ if (data !== undefined) {
1780
+ const bytes = new TextEncoder().encode(JSON.stringify(data));
1781
+ if (bytes.length > MAX_INLINE_RESULT_DATA_BYTES) {
1782
+ async function* source() { yield bytes; }
1783
+ dataRef = (await archiveObservation(this.config.tools.outputStore!, source(), 0)).ref;
1784
+ data = undefined;
1785
+ }
1291
1786
  }
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
- };
1787
+ await this.record({ t: "tool_result", id: this.clock.nextId(), at: this.clock.now(), call_id: invocation.call_id,
1788
+ invocation_ref: { node: this.state.node, event: invocation.id },
1789
+ ...(invocation.origin ? { origin: invocation.origin } : {}),
1790
+ ...(invocation.parent_call_id ? { parent_call_id: invocation.parent_call_id } : {}), ok: out.ok, content: out.content,
1791
+ ...(out.outcome ? { outcome: out.outcome } : {}), ...(data !== undefined ? { data } : {}), ...(dataRef ? { data_ref: dataRef } : {}),
1792
+ ...(out.observation_sha256 !== undefined ? { observation_sha256: out.observation_sha256 } : {}),
1793
+ ...(out.observation ? { observation: out.observation } : {}), ...(out.diagnostic ? { diagnostic: out.diagnostic } : {}),
1794
+ ...(out.warnings ? { warnings: out.warnings } : {}), ...(out.content_sha256 ? { content_sha256: out.content_sha256 } : {}),
1795
+ ...(out.content_ref ? { content_ref: out.content_ref } : {}),
1796
+ ...(out.images?.length ? { images: submissionImages(out.images, blockedImages) } : {}), ...(out.effect ? { effect: out.effect } : {}) });
1797
+ }
1798
+
1799
+ private async prepareSessionAction(call: ToolCall, ctx: ToolContext, nextBranch: () => number,
1800
+ emit: (event: LoopEvent) => void): Promise<DeferredToolResult | (() => Promise<DeferredToolResult>)> {
1801
+ // SDK callers may omit allow or preauthorize an optional name. Permission is
1802
+ // distinct from a declaration: neither direct nor nested calls may run it early.
1803
+ const declared = declaredToolNames(this.config.frozen.tools);
1804
+ if (ctx.deferredTools?.some(tool => tool.name === call.name) && !declared.includes(call.name)) {
1805
+ return prepareNormalizedTool({ ...ctx, allow: (ctx.allow ?? declared).filter(name => name !== call.name) }, call.name, call.input);
1306
1806
  }
1807
+ const forkSkill = call.name === "read" && typeof call.input === "object" && call.input !== null ? this.config.skills?.find(skill =>
1808
+ skill.context === "fork" && skill.filePath === resolve(ctx.root, String((call.input as Record<string, unknown>)["path"]))) : undefined;
1809
+ const discovery = call.name === DISCOVERY_TOOL_NAME && ctx.extensions?.get(call.name) === DISCOVERY_TOOL;
1810
+ if (!forkSkill && !discovery && !["note", "branch", "resolve", "history", "target"].includes(call.name)) return prepareNormalizedTool(ctx, call.name, call.input);
1811
+ const branchIndex = forkSkill || call.name === "branch" ? nextBranch() : 0;
1812
+ return prepareNormalizedTool(ctx, call.name, call.input, async () => {
1813
+ if (discovery) return async () => searchDeferredTools(ctx, call.input as Record<string, unknown>, this.allowedNames());
1814
+ if (call.name === "history") return () => runHistoryTool(this.config.logDir, this.state.node, ctx, call.input);
1815
+ if (call.name === "target") return async () => {
1816
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
1817
+ if (!history.ok) throw Error(history.error);
1818
+ const observed = await this.runtimeTargets.perform(ctx, history.nodes, call.input as Parameters<RuntimeTargets["perform"]>[2]);
1819
+ return { ...observed.result, annotation: { t: "target_observation" as const, id: this.clock.nextId(), at: this.clock.now(), observation: observed.observation } };
1820
+ };
1821
+ if (call.name === "note") return async () => {
1822
+ await this.observeEvidence();
1823
+ const parsed = parseNote(call.input);
1824
+ const prepared = await prepareWorkingNote(this.config.logDir, this.state.node, this.config.workNamespace ?? "main",
1825
+ parsed.annotation ? this.toEvent(parsed.annotation) : undefined, parsed.result);
1826
+ return { ...prepared.out, ...(prepared.annotation ? { annotation: prepared.annotation } : {}) };
1827
+ };
1828
+ if (call.name === "resolve") return () => this.runResolve(call.input);
1829
+ if (forkSkill?.disableModelInvocation) return async () => ({ ok: false, content: "this skill requires explicit user invocation" });
1830
+ if (forkSkill) await this.recordSkill(forkSkill, "fork", "model");
1831
+ const task = this.state.events.findLast(event => event.t === "user");
1832
+ const branch = forkSkill ? this.runBranch({ purpose: `skill ${forkSkill.name}`, prompt: `${forkSkill.body}\n\nCurrent task: ${task?.t === "user" ? task.text : ""}` },
1833
+ branchIndex, ctx.signal, forkSkill, skillTools(this.allowedNames(), forkSkill), "model") : this.dispatchBranch(call.input, branchIndex, ctx.signal);
1834
+ while (true) {
1835
+ const step = await branch.next();
1836
+ if (step.done) return async () => step.value;
1837
+ emit(step.value);
1838
+ if (step.value.kind === "branch_start") return async () => {
1839
+ const result = await branch.next();
1840
+ if (!result.done) throw new Error(`unexpected child execution event: ${result.value.kind}`);
1841
+ return result.value;
1842
+ };
1843
+ }
1844
+ });
1307
1845
  }
1308
1846
 
1309
1847
  private async *runBranch(
1310
1848
  input: unknown,
1311
1849
  indexThisTurn: number,
1312
1850
  signal?: AbortSignal,
1851
+ skill?: Skill,
1852
+ allowedOverride?: readonly string[],
1853
+ skillOrigin: "user" | "model" = "user",
1313
1854
  ): AsyncGenerator<LoopEvent, DeferredToolResult> {
1314
1855
  const limits = this.limits;
1315
1856
  const depth = this.config.branchDepth ?? 0;
1857
+ if (this.children.size >= (this.config.maxParallelTools ?? 3)) return { ok: false, content: "branch capacity reached; wait or cancel an owned branch first" };
1316
1858
 
1317
1859
  // Every refusal is a normal tool_result: an unanswered tool_use makes the
1318
1860
  // next request unpairable, and a refusal can say what to do instead.
@@ -1349,7 +1891,7 @@ export class Session {
1349
1891
  if (!purpose || !prompt) {
1350
1892
  return { content: "a branch needs both a purpose and a prompt", ok: false };
1351
1893
  }
1352
- const parentAllow = this.config.tools.allow;
1894
+ const parentAllow = allowedOverride ?? this.config.tools.allow;
1353
1895
  const branchNames = writes ? WRITING_BRANCH_TOOL_NAMES : BRANCH_TOOL_NAMES;
1354
1896
  const allowed: string[] = parentAllow
1355
1897
  ? branchNames.filter(name => parentAllow.includes(name)) : [...branchNames];
@@ -1387,6 +1929,7 @@ export class Session {
1387
1929
 
1388
1930
  await this.record({
1389
1931
  t: "spawn", id: this.clock.nextId(), at: this.clock.now(), branch: ref, purpose,
1932
+ ...(args["background"] === true ? { background: true as const } : {}),
1390
1933
  input: { source_event: sourceEvent, tools: allowed, max_turns: Math.min(this.config.maxTurns, 15), workspace: inputWorkspace },
1391
1934
  ...(worktree ? { workspace: worktree } : {}),
1392
1935
  });
@@ -1399,7 +1942,8 @@ export class Session {
1399
1942
  const branch = new Session(
1400
1943
  {
1401
1944
  ...this.config,
1402
- frozen: { system: this.config.frozen.system, tools: branchTools(writes).filter((t) => allowed.includes(t.name)) },
1945
+ acceptanceContract: null,
1946
+ frozen: { system: this.config.frozen.system, tools: branchTools(writes, allowed) },
1403
1947
  // The same list twice: the schema shapes what the model asks for, the
1404
1948
  // allow-list decides what actually runs. Intersected with the parent
1405
1949
  // so `--exclude-tools bash` cannot be undone by opening a branch.
@@ -1407,6 +1951,7 @@ export class Session {
1407
1951
  ...this.config.tools,
1408
1952
  ...(worktree ? { root: worktree.path } : {}),
1409
1953
  allow: allowed,
1954
+ readonlyBranch: !writes,
1410
1955
  },
1411
1956
  branchDepth: depth + 1,
1412
1957
  workNamespace: `branch-${node}`,
@@ -1419,14 +1964,16 @@ export class Session {
1419
1964
  branch.state.node = node;
1420
1965
  branch.state.nextNode = this.state.nextNode;
1421
1966
  branch.state.lastScan = { ...this.state.lastScan };
1967
+ if (skill) await branch.recordSkill(skill, "fork", skillOrigin);
1968
+ this.children.set(node, branch);
1422
1969
  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
1970
 
1427
1971
  let text = "";
1428
1972
  let outcome = executionOutcome("unknown");
1429
1973
  try {
1974
+ for await (const event of branch.snapshotTurn([], true)) {
1975
+ if (event.kind === "snapshot_failed") throw new Error(`cannot establish branch baseline: ${event.reason}`);
1976
+ }
1430
1977
  for await (const e of branch.send(prompt, signal)) {
1431
1978
  if (e.kind === "assistant_text" && e.text) text = e.text;
1432
1979
  if (e.kind === "done") outcome = e.outcome ?? executionOutcome("unknown");
@@ -1435,7 +1982,8 @@ export class Session {
1435
1982
  // Persist a bounded failure classification, never a provider's raw error body.
1436
1983
  outcome = executionOutcome(signal?.aborted ? "cancelled" : "execution_error");
1437
1984
  await branch.answerUnpairedTools("the branch execution failed; this tool's outcome is unknown; inspect before retrying");
1438
- }
1985
+ } finally { try { await branch.dispose(); } finally { this.children.delete(node); } }
1986
+ if (args["background"] === true) await branch.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), outcome, reason: "background branch execution ended" });
1439
1987
  // Node numbers the branch allocated are taken.
1440
1988
 
1441
1989
  const tip = branch.state.node;
@@ -1455,29 +2003,40 @@ export class Session {
1455
2003
  "not in your files. Adopting continues in that checkout without merging it into this one."
1456
2004
  : "";
1457
2005
  const summary = text.trim() || "(the branch produced no closing message)";
2006
+ const receiptHistory = await readNodeHistory(this.config.logDir, tip, undefined, "context");
2007
+ if (!receiptHistory.ok) throw new Error(receiptHistory.error);
2008
+ const receipt = deriveBranchReceipt({ source: await this.nodeRefFor(tip), outcome, handoff, nodes: receiptHistory.nodes });
2009
+ const receiptRef = await putBlob(join(this.config.logDir, "artifacts"), JSON.stringify(receipt));
1458
2010
  const joined: LogEvent = {
1459
2011
  t: "joined", id: this.clock.nextId(), at: this.clock.now(),
1460
2012
  branch: ref, tip: await this.nodeRefFor(tip), handoff_ref: handoffPath(this.config.logDir, tip), summary,
1461
2013
  model: branch.provider.config.spec.id,
1462
2014
  outcome,
2015
+ receipt_ref: receiptRef,
1463
2016
  };
1464
2017
 
1465
2018
  // Only the summary crosses back. The branch's own calls stay in its node,
1466
2019
  // which is the entire reason to branch rather than read in place.
1467
2020
  return {
1468
2021
  content:
1469
- `branch ${node} (${purpose}) ended: ${JSON.stringify(outcome)}.\n\n${summary}${where}\n\n` +
2022
+ `branch ${node} (${purpose}) ended: ${JSON.stringify(outcome)}.\n` +
2023
+ `Evidence receipt: ${receipt.commands.length} commands, ${receipt.changes.length} file edits, ${receipt.unfinished.length} unfinished items; verification=${receipt.verification}; task acceptance=not assessed.\n` +
2024
+ (this.adviceNames().includes("history") ? `Read history(artifact='${receiptRef}'), then history(branch=${node}, event='source ID') for original evidence.\n`
2025
+ : `Evidence receipt artifact=${receiptRef}; original-source recovery capability unavailable.\n`) +
2026
+ `Child closing statement (unverified):\n${summary}${where}\n\n` +
1470
2027
  `Its full state is at ${handoffPath(this.config.logDir, tip)}. ` +
1471
- `Resolve it with resolve(node: ${node}, ...).`,
2028
+ (this.adviceNames().includes("resolve") ? `Resolve it with resolve(node: ${node}, ...).` : "Branch-resolution capability unavailable."),
1472
2029
  ok: outcome.status === "completed",
1473
2030
  outcome,
1474
2031
  accept: async () => {
2032
+ if ((await this.branchStatuses()).some(item => item.node === node && item.handoff_ref)) return [];
1475
2033
  this.state.usage.push(...branch.state.usage);
1476
- await this.saveCursor();
1477
2034
  await this.record(joined);
2035
+ await this.saveCursor();
1478
2036
  return [{ kind: "branch_done", node, text: summary, outcome, ok: outcome.status === "completed" }];
1479
2037
  },
1480
2038
  data: { node, outcome, handoff_ref: handoffPath(this.config.logDir, tip), requests: branch.state.usage.length,
2039
+ receipt_ref: receiptRef,
1481
2040
  input: { source_event: sourceEvent, workspace: inputWorkspace } },
1482
2041
  };
1483
2042
  }
@@ -1502,10 +2061,33 @@ export class Session {
1502
2061
  return { ok: false, content: `branch ${node} is already resolved` };
1503
2062
  }
1504
2063
 
2064
+ const joined = events.find((e) => e.t === "joined" && e.branch.node === node);
2065
+ if (resolution === "status") {
2066
+ const item = (await this.branchStatuses()).find(branch => branch.node === node)!;
2067
+ return { ok: true, content: `branch ${node}: ${item.status}`, data: { node, status: item.status } };
2068
+ }
2069
+ if (resolution === "wait" || resolution === "cancel") {
2070
+ if (joined) return { ok: true, content: `branch ${node} already joined; receipt ${joined.t === "joined" ? joined.receipt_ref ?? "not recorded" : ""}` };
2071
+ if (resolution === "cancel" && this.jobs.state(node) === undefined) return { ok: false, content: `branch ${node}: unknown after restart; no owned handle to cancel` };
2072
+ if (resolution === "cancel") { this.jobs.cancel(node); await this.jobs.drain(node); }
2073
+ const waitMs = args["wait_ms"] === undefined ? 10_000 : args["wait_ms"];
2074
+ if (typeof waitMs !== "number" || !Number.isFinite(waitMs) || waitMs < 0 || waitMs > 60_000) return { ok: false, content: "wait_ms must be between 0 and 60000" };
2075
+ const result = await this.jobs.wait(node, waitMs);
2076
+ if (result?.running) return { ok: true, content: `branch ${node} running; wait reached its deadline`, data: { node, running: true } };
2077
+ if (result?.error) return { ok: false, content: result.error };
2078
+ const complete = result?.value ?? await this.recoverBranchResult(node);
2079
+ if (!complete) return { ok: false, content: `branch ${node}: unknown after restart; no sealed execution outcome; inspect before retrying` };
2080
+ if (complete.accept) await complete.accept();
2081
+ this.jobs.forget(node);
2082
+ return complete;
2083
+ }
2084
+ if (this.jobs.state(node) === "running") return { ok: false, content: `branch ${node} running; wait or cancel before resolving it` };
2085
+ if (!joined && this.jobs.state(node) === "ready") return { ok: false, content: `branch ${node} has not joined; wait before resolving it` };
2086
+
1505
2087
  const ref = await this.nodeRefFor(node);
1506
2088
  if (resolution === "adopt") {
2089
+ 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
2090
  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
2091
  if (!joined) return { content: `branch ${node} has not joined`, ok: false };
1510
2092
  await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: ref });
1511
2093
  this.pendingAdoption = node;
@@ -1533,52 +2115,97 @@ export class Session {
1533
2115
  }
1534
2116
  return { content: `discarded branch ${node}`, ok: true };
1535
2117
  }
1536
- return { content: `resolution must be "adopt" or "discard"`, ok: false };
2118
+ return { content: `resolution must be wait, cancel, status, adopt or discard`, ok: false };
2119
+ }
2120
+
2121
+ private async recoverBranchResult(root: number): Promise<DeferredToolResult | undefined> {
2122
+ // Recover only a unique sealed continuation. An open or ambiguous branch
2123
+ // cannot be replayed safely because tools may already have had effects.
2124
+ const headers = await Promise.all((await listNodes(this.config.logDir)).map(async node => ({ node, header: (await readNode(this.config.logDir, node)).header })));
2125
+ let tip = root;
2126
+ const seen = new Set<number>();
2127
+ for (;;) {
2128
+ if (seen.has(tip)) return undefined;
2129
+ seen.add(tip);
2130
+ const children = headers.filter(item => item.header?.parent?.node === tip && item.header.basis !== "none");
2131
+ if (children.length > 1) return undefined;
2132
+ if (children.length === 0) break;
2133
+ tip = children[0]!.node;
2134
+ }
2135
+ if (!(await readManifest(this.config.logDir)).nodes.some(item => item.node === tip && item.handoff_ref)) return undefined;
2136
+ const history = await readNodeHistory(this.config.logDir, tip, undefined, "context");
2137
+ if (!history.ok) throw new Error(history.error);
2138
+ const outcomeEvent = history.nodes.flatMap(part => part.events).findLast(event => event.t === "outcome");
2139
+ if (outcomeEvent?.t !== "outcome") return undefined;
2140
+ const handoff = await replayContext(this.config.logDir, tip);
2141
+ const full = deriveHandoff({ prev: handoff.fullHandoff, events: handoff.events, depth: handoff.depth, self: await this.nodeRefFor(tip) });
2142
+ const receipt = deriveBranchReceipt({ source: await this.nodeRefFor(tip), outcome: outcomeEvent.outcome, handoff: full, nodes: history.nodes });
2143
+ const receiptRef = await putBlob(join(this.config.logDir, "artifacts"), JSON.stringify(receipt));
2144
+ const last = history.nodes.flatMap(part => part.events).findLast(event => event.t === "assistant");
2145
+ const summary = last?.t === "assistant" ? last.blocks.flatMap(block => block.type === "text" ? [block.text] : []).join("\n") : "(no closing message)";
2146
+ return { ok: outcomeEvent.outcome.status === "completed", outcome: outcomeEvent.outcome,
2147
+ content: `branch ${root} recovered from sealed node ${tip}; receipt ${receiptRef}; closing statement is unverified: ${summary}`,
2148
+ data: { node: root, outcome: outcomeEvent.outcome, receipt_ref: receiptRef, handoff_ref: handoffPath(this.config.logDir, tip) },
2149
+ accept: async () => {
2150
+ if ((await this.branchStatuses()).some(item => item.node === root && item.handoff_ref)) return [];
2151
+ await this.record({ t: "joined", id: this.clock.nextId(), at: this.clock.now(), branch: await this.nodeRefFor(root), tip: await this.nodeRefFor(tip),
2152
+ handoff_ref: handoffPath(this.config.logDir, tip), receipt_ref: receiptRef, summary, outcome: outcomeEvent.outcome });
2153
+ await this.saveCursor(); return [];
2154
+ } };
1537
2155
  }
1538
2156
 
1539
2157
  /** D15: finish the parent's tool round before moving to the chosen branch. */
1540
2158
  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");
2159
+ this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
2160
+ const release = await this.rootTools.acquire("exclusive");
2161
+ let checkpoint = false;
2162
+ try {
2163
+ const root = this.pendingAdoption;
2164
+ if (root === null) return;
2165
+ const history = await readNodeHistory(this.config.logDir, this.state.node);
2166
+ if (!history.ok) throw new Error(history.error);
2167
+ const events = history.nodes.flatMap((n) => n.events);
2168
+ const joined = events.find((e) => e.t === "joined" && e.branch.node === root);
2169
+ const spawned = events.find((e) => e.t === "spawn" && e.branch.node === root);
2170
+ if (joined?.t !== "joined" || spawned?.t !== "spawn") throw new Error(`missing provenance for adoption ${root}`);
2171
+ const parent = this.state.node;
2172
+ const annotations = carryForward(events, spawned.id, parent);
2173
+ const tip = joined.tip ?? joined.branch;
2174
+ const intent = events.findLast((e) => e.t === "adopt" && e.branch.node === root && e.next_node !== undefined);
2175
+ const node = intent?.t === "adopt" && intent.next_node !== undefined ? intent.next_node : this.state.nextNode++;
2176
+ if (!intent) await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: joined.branch, next_node: node });
2177
+ const parentRef = await this.nodeRefFor(parent);
2178
+ const existing = await readNode(this.config.logDir, node);
2179
+ if (existing.header === null) await initNode(this.config.logDir, node, tip, this.clock.now(), "inherit", { adopted_from: parentRef });
2180
+ else if (existing.header.adopted_from?.node !== parent || existing.header.parent?.node !== tip.node) {
2181
+ throw new Error(`INTEGRITY: adoption successor ${node} belongs to another transition`);
2182
+ }
2183
+ const rebuilt = await replayContext(this.config.logDir, node);
2184
+ this.state.node = node;
2185
+ this.state.nextNode = Math.max(this.state.nextNode, node + 1);
2186
+ this.state.events = rebuilt.contextEvents;
2187
+ this.resetDerivedToolState();
2188
+ this.state.handoff = rebuilt.handoff;
2189
+ this.state.depth = rebuilt.depth;
2190
+ this.state.anchor = null;
2191
+ const copied = new Set(existing.events.flatMap((e) => "carried_from" in e && e.carried_from ? [e.carried_from.event] : []));
2192
+ for (const event of annotations) if (!copied.has(event.carried_from!.event)) await this.record({ ...event, id: this.clock.nextId() });
2193
+ if (!existing.events.some((e) => e.t === "adopt" && e.applied && e.branch.node === root)) {
2194
+ await this.record({ t: "adopt", id: this.clock.nextId(), at: this.clock.now(), branch: joined.branch, applied: true });
2195
+ }
2196
+ const checkout = this.state.worktrees.get(root);
2197
+ if (checkout) this.config.tools.root = checkout.path;
2198
+ if (this.config.workspaceTracking === "legacy") {
2199
+ const scan = await scanWorkspace(this.config.tools.root, this.ignore);
2200
+ if (!scan.ok) throw new Error(`cannot adopt workspace: ${scan.error}`);
2201
+ this.state.lastScan = scan.files;
2202
+ }
2203
+ this.installWorkspace();
2204
+ await this.saveCursor();
2205
+ this.pendingAdoption = null;
2206
+ checkpoint = joined.model !== undefined && joined.model !== this.provider.config.spec.id;
2207
+ } finally { release(); }
2208
+ if (checkpoint) await this.performCut("model_checkpoint");
1582
2209
  }
1583
2210
 
1584
2211
  private toEvent(a: Annotation): LogEvent {
@@ -1595,7 +2222,9 @@ export class Session {
1595
2222
  };
1596
2223
  }
1597
2224
  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 } : {}) };
2225
+ 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 } : {}) };
2226
+ case "work_owner": return { ...a, ...stamp, owner: this.config.workNamespace ?? "main" };
2227
+ case "work_dependencies": return { ...a, ...stamp };
1599
2228
  case "decision":
1600
2229
  return {
1601
2230
  t: "decision", ...stamp, chose: a.chose, because: a.because,
@@ -1617,12 +2246,85 @@ export class Session {
1617
2246
 
1618
2247
  // ---- rewind ---------------------------------------------------------------
1619
2248
 
2249
+ /** A prepared edit can be reconciled by its exact path; unknown commands are never replayed. */
2250
+ private async recoverFileOperations(): Promise<void> {
2251
+ if (!this.config.blobDir) return;
2252
+ // A cut is only legal after paired tools, so pending operations belong to this node.
2253
+ const history = (await readNode(this.config.logDir, this.state.node)).events.filter((event): event is CheckpointEvent => event.t === "checkpoint");
2254
+ const pending = new Map<string, CheckpointEvent>();
2255
+ for (const event of history) {
2256
+ if (event.phase === "prepared") pending.set(event.operation, event);
2257
+ else pending.delete(event.operation);
2258
+ }
2259
+ for (const event of pending.values()) {
2260
+ let applied = event.source !== "git" && changedPaths(event.changes).length > 0;
2261
+ for (const [path, change] of Object.entries(event.changes)) {
2262
+ try { if (!sameVersion((await readEditableFile(this.config.tools.root, path)).version, change.after)) applied = false; }
2263
+ catch { applied = false; }
2264
+ }
2265
+ await this.record({ ...event, id: this.clock.nextId(), at: this.clock.now(), phase: applied ? "applied" : "unavailable",
2266
+ reason: applied ? "observed the prepared file version after restart; tool outcome remains unknown" : "interrupted checkpoint; inspect files before retrying" });
2267
+ }
2268
+ }
2269
+
2270
+ private async rewindJournal(point: RestorePoint, mode: RewindMode): Promise<RewindResult> {
2271
+ const dir = this.config.logDir, root = this.config.tools.root, blobs = this.config.blobDir;
2272
+ if (mode !== "context" && !blobs) return { ok: false, error: "this session stores no file checkpoints" };
2273
+ try {
2274
+ const target = { node: point.node, atEvent: point.atEvent };
2275
+ // Reading the log validates the target. Context-only navigation never reads workspace contents.
2276
+ const history = await readNodeHistory(dir, point.node);
2277
+ if (!history.ok) throw new Error(history.error);
2278
+ if (!history.nodes.find(part => part.node === point.node)?.events.some(event => event.id === point.atEvent)) throw new Error(`NO_SUCH_EVENT: ${point.atEvent}`);
2279
+ const changes: FileChanges = await planCheckpointRestore(dir, this.state.node, target).catch(error => {
2280
+ if (mode !== "context") throw error;
2281
+ return {};
2282
+ });
2283
+ const paths = changedPaths(changes);
2284
+ const historical = await readCursorAt(dir, point.node, point.atEvent);
2285
+ const from: NodeRef = { ...await digestedRef(dir, point.node), at_event: point.atEvent };
2286
+ const latest = (await readNode(dir, this.state.node)).events.at(-1);
2287
+ const filesFrom: NodeRef = { ...await digestedRef(dir, this.state.node), ...(latest ? { at_event: latest.id } : {}) };
2288
+ if (mode !== "context" && blobs) {
2289
+ await preflightChanges(root, blobs, changes);
2290
+ // Persist the intention before files move; recovery never invents command completion.
2291
+ const operation = this.clock.nextId();
2292
+ await this.record({ t: "checkpoint", id: operation, at: this.clock.now(), operation, source: "restore", phase: "prepared", changes });
2293
+ await applyFileChanges(root, blobs, changes);
2294
+ await this.record({ t: "checkpoint", id: this.clock.nextId(), at: this.clock.now(), operation, source: "restore", phase: "applied", changes });
2295
+ }
2296
+ const restored = mode === "context" ? [] : paths.filter(path => changes[path]!.after !== null);
2297
+ const removed = mode === "context" ? [] : paths.filter(path => changes[path]!.after === null);
2298
+ const event: LogEvent = { t: "rewind", id: this.clock.nextId(), at: this.clock.now(), mode, from, prompt: point.prompt,
2299
+ restored, removed, unrecoverable: [], divergence: mode === "both" ? [] : paths,
2300
+ ...(mode === "context" ? { files_from: filesFrom } : {}) };
2301
+ if (mode === "files") await this.record(event);
2302
+ else {
2303
+ const node = this.state.nextNode++;
2304
+ await initNode(dir, node, from, this.clock.now(), "inherit");
2305
+ const replayed = await replayContext(dir, node);
2306
+ this.state.node = node; this.state.events = [...replayed.contextEvents]; this.state.handoff = replayed.handoff;
2307
+ this.state.depth = replayed.depth; this.state.anchor = null; this.resetDerivedToolState();
2308
+ await this.record(event);
2309
+ if (historical === null || !sameModel(historical, { model: this.provider.config.spec.id, provider: this.provider.config.spec.provider })) {
2310
+ const cursor = await readCursor(dir);
2311
+ if (cursor) await writeCursor(dir, { ...cursor, node, next_node: this.state.nextNode, needs_model_checkpoint: true });
2312
+ await this.performCut("model_checkpoint", this.provider.config.spec.id);
2313
+ }
2314
+ }
2315
+ await this.saveCursor();
2316
+ return { ok: true, node: this.state.node, restored, removed, unrecoverable: [], note: mode === "context"
2317
+ ? "Only the conversation moved; workspace files were left unchanged."
2318
+ : "Restored recorded file changes. Unrelated files, ignored paths and external effects were left unchanged; command recovery requires captured Git checkpoints." };
2319
+ } catch (error) { return { ok: false, error: String(error) }; }
2320
+ }
2321
+
1620
2322
  private get ignore(): readonly string[] {
1621
2323
  return this.config.snapshotIgnore ?? DEFAULT_IGNORE;
1622
2324
  }
1623
2325
 
1624
2326
  /**
1625
- * Every completed exchange on the current ancestry that changed a file.
2327
+ * Every completed exchange, including abandoned branches and conversation-only turns.
1626
2328
  *
1627
2329
  * An exchange runs from one user message to just before the next, so its
1628
2330
  * last event is where a fork belongs: the conversation is whole there, and
@@ -1668,6 +2370,8 @@ export class Session {
1668
2370
  }
1669
2371
  };
1670
2372
  for (const e of n.events) {
2373
+ if (e.t === "workspace_observation") continue;
2374
+ if (e.t === "checkpoint" && e.source === "restore") { close(); open = null; continue; }
1671
2375
  if (e.t === "snapshot" && e.phase === "idle") {
1672
2376
  close();
1673
2377
  open = null;
@@ -1683,6 +2387,7 @@ export class Session {
1683
2387
  }
1684
2388
  if (!open) continue;
1685
2389
  open.last = e.id;
2390
+ if (e.t === "checkpoint" && e.phase === "applied") open.changed += changedPaths(e.changes).length;
1686
2391
  if (e.t === "snapshot") {
1687
2392
  open.changed +=
1688
2393
  Object.keys(e.added).length + Object.keys(e.changed).length + e.removed.length;
@@ -1696,12 +2401,26 @@ export class Session {
1696
2401
  /**
1697
2402
  * What a rewind would do, without doing it.
1698
2403
  *
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.
2404
+ * P.3 previews only logged paths and preflights conflicts when files will move.
2405
+ * Context-only navigation consults history without reading workspace contents.
1703
2406
  */
1704
- async previewRewind(point: RestorePoint): Promise<RewindPreview> {
2407
+ async previewRewind(point: RestorePoint, mode: RewindMode = "both"): Promise<RewindPreview> {
2408
+ if (mode === "context") {
2409
+ const history = await readNodeHistory(this.config.logDir, point.node);
2410
+ if (!history.ok) return history;
2411
+ 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}` };
2412
+ const changes = await planCheckpointRestore(this.config.logDir, this.state.node, { node: point.node, atEvent: point.atEvent }).catch((): FileChanges => ({}));
2413
+ return { ok: true, willWrite: changedPaths(changes).filter(path => changes[path]!.after !== null),
2414
+ willDelete: changedPaths(changes).filter(path => changes[path]!.after === null) };
2415
+ }
2416
+ if (this.config.workspaceTracking !== "legacy") {
2417
+ try {
2418
+ const changes = await planCheckpointRestore(this.config.logDir, this.state.node, { node: point.node, atEvent: point.atEvent });
2419
+ if (this.config.blobDir) await preflightChanges(this.config.tools.root, this.config.blobDir, changes);
2420
+ return { ok: true, willWrite: changedPaths(changes).filter(path => changes[path]!.after !== null),
2421
+ willDelete: changedPaths(changes).filter(path => changes[path]!.after === null) };
2422
+ } catch (error) { return { ok: false, error: String(error) }; }
2423
+ }
1705
2424
  const plan = await planRestoreToNode(
1706
2425
  this.config.logDir, { node: point.node, atEvent: point.atEvent },
1707
2426
  undefined, this.config.tools.root, this.ignore,
@@ -1721,14 +2440,12 @@ export class Session {
1721
2440
  * still a node, so it is still a restore target, and "rewound too far" is
1722
2441
  * recoverable rather than terminal.
1723
2442
  *
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.
2443
+ * P.3 records a separate physical file cursor for context-only navigation.
2444
+ * In combined mode, file restoration must succeed before opening the new
2445
+ * conversation node. File-only restoration appends its observed transition.
1730
2446
  */
1731
2447
  async rewind(point: RestorePoint, mode: RewindMode = "both"): Promise<RewindResult> {
2448
+ if (this.config.workspaceTracking !== "legacy") return this.rewindJournal(point, mode);
1732
2449
  const dir = this.config.logDir;
1733
2450
  const root = this.config.tools.root;
1734
2451
  const target = { node: point.node, atEvent: point.atEvent };
@@ -1817,7 +2534,7 @@ export class Session {
1817
2534
  this.state.node = node;
1818
2535
  this.state.events = [];
1819
2536
  this.state.handoff = replayed.handoff;
1820
- this.stall = null;
2537
+ this.resetDerivedToolState();
1821
2538
  this.state.depth = replayed.depth;
1822
2539
  // The new node's prefix has never been sent, so nothing to anchor to.
1823
2540
  this.state.anchor = null;
@@ -1868,6 +2585,20 @@ export class Session {
1868
2585
  ].sort();
1869
2586
  }
1870
2587
 
2588
+ private resetDerivedToolState(): void {
2589
+ this.stall = null;
2590
+ this.recovery = null;
2591
+ }
2592
+
2593
+ private async recoveryGuidance(tools: readonly string[]): Promise<ReturnType<typeof recoveryReview>> {
2594
+ if (this.recovery === null) {
2595
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
2596
+ if (!history.ok) throw new Error(history.error);
2597
+ this.recovery = advanceRecovery(emptyRecoveryState(), history.nodes.flatMap(node => node.events), path => resolve(this.config.tools.root, path));
2598
+ }
2599
+ return recoveryReview(this.recovery, tools);
2600
+ }
2601
+
1871
2602
  private async *checkStall(): AsyncGenerator<LoopEvent, boolean> {
1872
2603
  const limit = this.config.stallLimit ?? DEFAULT_STALL_LIMIT;
1873
2604
  if (limit === 0) return false;
@@ -1894,8 +2625,8 @@ export class Session {
1894
2625
  * prompt reads as exactly over the usable window.
1895
2626
  */
1896
2627
  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;
2628
+ const budget = this.policyLimits();
2629
+ if (!willContinue || budget.kind !== "window" || pendingToolCalls(this.state.events).length > 0) return false;
1899
2630
  const project = () => projectContext({ frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events });
1900
2631
  let projected = project();
1901
2632
  const measured = anchoredCount(projected.messages, projected.system, this.state.anchor, counter, this.config.frozen.tools).tokens;
@@ -1906,7 +2637,7 @@ export class Session {
1906
2637
  const replayed = await replayContext(this.config.logDir, this.state.node);
1907
2638
  let draft: Handoff;
1908
2639
  try {
1909
- draft = boundHandoff(deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events,
2640
+ draft = await this.boundCarried(deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events,
1910
2641
  depth: replayed.depth, self: await digestedRef(this.config.logDir, this.state.node),
1911
2642
  config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity } }));
1912
2643
  } catch (error) {
@@ -1948,8 +2679,19 @@ export class Session {
1948
2679
  return false;
1949
2680
  }
1950
2681
 
2682
+ /** Every threshold the loop acts at. The footer and banner read the same function. */
2683
+ private policyLimits(): ContextLimits {
2684
+ return contextLimits({
2685
+ policy: this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
2686
+ contextWindow: this.provider.config.spec.contextWindow,
2687
+ outputReserve: this.provider.config.maxTokens,
2688
+ ...(this.config.boundary ? { boundary: this.config.boundary } : {}),
2689
+ });
2690
+ }
2691
+
1951
2692
  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) {
2693
+ const limits = this.policyLimits();
2694
+ if (limits.kind === "window") {
1953
2695
  return yield* this.maybeWindowCut(counter, canPrepare);
1954
2696
  }
1955
2697
  const projected = projectContext({
@@ -1976,20 +2718,19 @@ export class Session {
1976
2718
  const inheritedTokens = counter.count(floor.messages, floor.system, this.config.frozen.tools);
1977
2719
 
1978
2720
  const policy = this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY;
1979
- const cfg = { ...DEFAULT_BOUNDARY, ...this.config.boundary };
1980
2721
  let draft: Handoff | null = null;
1981
- if (policy !== "continuous" && tokens >= cfg.triggerTokens - (cfg.hardTokens - cfg.triggerTokens)) {
2722
+ if (limits.kind === "segment" && tokens >= limits.prepare) {
1982
2723
  // Read the same full ancestry used by the actual cut. A bounded carried
1983
2724
  // page is insufficient to decide whether all active work is completed.
1984
2725
  const replayed = await replayContext(this.config.logDir, this.state.node);
1985
- draft = boundHandoff(deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events,
2726
+ draft = await this.boundCarried(deriveHandoff({ prev: replayed.fullHandoff, events: replayed.events,
1986
2727
  depth: replayed.depth, self: await digestedRef(this.config.logDir, this.state.node),
1987
2728
  config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity } }));
1988
2729
  const noteEnabled = this.config.frozen.tools.some((tool) => tool !== null && typeof tool === "object" && "name" in tool && tool.name === "note") &&
1989
2730
  (this.config.tools.allow === undefined || this.config.tools.allow.includes("note"));
1990
- if (canPrepare && this.config.guidance !== false && noteEnabled && tokens < cfg.hardTokens) {
2731
+ if (canPrepare && this.config.guidance !== false && noteEnabled && tokens < limits.hard) {
1991
2732
  const notice = boundaryPreparation({ events: this.state.events, tokens, inheritedTokens,
1992
- continuationReady: draft.continuation_valid, config: cfg });
2733
+ continuationReady: draft.continuation_valid, config: limits.config });
1993
2734
  if (notice) {
1994
2735
  await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...notice });
1995
2736
  const updated = projectContext({ frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events });
@@ -2058,56 +2799,68 @@ export class Session {
2058
2799
  */
2059
2800
  private async performCut(reason: CutReason, nextModel = this.provider.config.spec.id, retainedFrom?: string,
2060
2801
  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);
2802
+ this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
2803
+ const release = await this.rootTools.acquire("exclusive");
2804
+ try {
2805
+ const dir = this.config.logDir;
2806
+ const node = this.state.node;
2807
+
2808
+ const replayed = await replayContext(dir, node);
2809
+ const fromDisk = replayed.events;
2810
+ const retained = retainedFrom === undefined ? [] : retainedContext(replayed.contextEvents, retainedFrom);
2811
+ let guidanceTools = this.adviceNames();
2812
+ if (this.config.guidanceVersion === 1 && nextContextRef !== undefined) {
2813
+ const saved = await getBlob(join(dir, "artifacts"), nextContextRef);
2814
+ if (saved === null) throw new Error("missing successor guidance context");
2815
+ guidanceTools = this.adviceNames((JSON.parse(saved) as { frozen: FrozenPrefix }).frozen);
2816
+ }
2067
2817
 
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);
2818
+ // The cut event is part of the closed file. Digest after appending it
2819
+ // so predecessor.sha256 matches what closeNode writes into the manifest.
2820
+ await this.record({
2821
+ t: "cut",
2822
+ id: this.clock.nextId(),
2823
+ at: this.clock.now(),
2824
+ handoff_ref: handoffPath(dir, node),
2825
+ predecessor: this.state.handoff?.predecessor ?? null,
2826
+ reason,
2827
+ next_node: this.state.nextNode,
2828
+ next_model: nextModel,
2829
+ next_provider: nextProvider,
2830
+ next_thinking: nextThinking,
2831
+ ...(nextContextRef === undefined ? {} : { next_context_ref: nextContextRef }),
2832
+ handoff_tokens: DEFAULT_HANDOFF_TOKENS,
2833
+ ...(this.config.workingSet ? { working_set_version: this.config.workingSetVersion ?? 1 } : {}),
2834
+ ...(this.config.guidanceVersion === 1 ? { guidance_tools: guidanceTools } : {}),
2835
+ ...(retainedFrom === undefined ? {} : { retained_from: retainedFrom }),
2836
+ });
2837
+ const self = await digestedRef(dir, node);
2838
+ const handoff = await this.boundCarried(deriveHandoff({
2839
+ prev: replayed.fullHandoff,
2840
+ depth: replayed.depth,
2841
+ events: fromDisk,
2842
+ self,
2843
+ config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity },
2844
+ }), guidanceTools);
2845
+ await closeNode(dir, node, handoff, (await readNode(dir, node)).events.length, this.clock.now());
2846
+
2847
+ // One allocator for the whole session. A cut used to take node + 1 while
2848
+ // a branch took nextNode++, so a cut after a branch reached the branch's
2849
+ // id and overwrote its log.
2850
+ this.state.node = this.state.nextNode++;
2851
+ this.state.depth += 1;
2852
+ this.state.handoff = handoff;
2853
+ this.state.events = retained;
2854
+ // The new node's prefix has never been sent, so there is nothing to anchor
2855
+ // to until its first response comes back.
2856
+ this.state.anchor = null;
2857
+ // A cut declares a handoff basis: the closed node's events do not replay,
2858
+ // its derived state does. Relying on the default here would silently make
2859
+ // every boundary a fork and replay the whole history.
2860
+ await initNode(dir, this.state.node, self, this.clock.now(), "handoff");
2861
+ // The durable successor already belongs to the target provider, even if
2862
+ // the process exits before switchModel installs its in-memory adapter.
2863
+ await this.saveCursor(nextModel, nextProvider, nextThinking, nextContextRef);
2864
+ } finally { release(); }
2112
2865
  }
2113
2866
  }