@visual-z/xeno 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +126 -34
  3. package/docs/app-server.md +84 -0
  4. package/docs/install.md +51 -13
  5. package/docs/providers.md +24 -3
  6. package/package.json +7 -5
  7. package/src/apps/app-server.ts +28 -0
  8. package/src/apps/approve.ts +3 -3
  9. package/src/apps/cli.ts +25 -10
  10. package/src/apps/commands.ts +45 -8
  11. package/src/apps/completions.ts +78 -22
  12. package/src/apps/control-transport.ts +89 -0
  13. package/src/apps/image-draft.ts +46 -0
  14. package/src/apps/model-catalog.ts +2 -1
  15. package/src/apps/project-trust.ts +2 -42
  16. package/src/apps/provider-connect.ts +346 -0
  17. package/src/apps/render.ts +22 -47
  18. package/src/apps/resources.ts +5 -2
  19. package/src/apps/shell.ts +333 -1542
  20. package/src/apps/startup-input.ts +45 -9
  21. package/src/apps/startup.ts +149 -94
  22. package/src/apps/ui/app.ts +1553 -0
  23. package/src/apps/ui/control-model.ts +26 -0
  24. package/src/apps/ui/extension-host.ts +194 -0
  25. package/src/apps/ui/image-cache.ts +78 -0
  26. package/src/apps/ui/palette.ts +88 -0
  27. package/src/apps/ui/project.ts +127 -0
  28. package/src/apps/ui/standalone.ts +146 -0
  29. package/src/apps/ui/status-model.ts +68 -0
  30. package/src/apps/ui/surface.ts +86 -0
  31. package/src/apps/ui/theme.ts +86 -0
  32. package/src/core/acceptance.ts +107 -0
  33. package/src/core/action-defaults.ts +47 -295
  34. package/src/core/benchmark.ts +2 -2
  35. package/src/core/branch-receipt.ts +24 -0
  36. package/src/core/checkpoints.ts +64 -0
  37. package/src/core/churn.ts +2 -0
  38. package/src/core/command-artifacts.ts +94 -0
  39. package/src/core/command-contract.ts +35 -0
  40. package/src/core/completion-action.ts +20 -0
  41. package/src/core/completion-review.ts +37 -21
  42. package/src/core/context-detail.ts +37 -0
  43. package/src/core/context-policy.ts +74 -1
  44. package/src/core/continuation.ts +17 -2
  45. package/src/core/control-protocol.ts +62 -0
  46. package/src/core/control-view.ts +42 -0
  47. package/src/core/derive.ts +29 -3
  48. package/src/core/draft-images.ts +56 -0
  49. package/src/core/environment.ts +145 -0
  50. package/src/core/evaluation-coverage.ts +74 -0
  51. package/src/core/event-validation.ts +81 -6
  52. package/src/core/events.ts +99 -3
  53. package/src/core/evidence-links.ts +7 -0
  54. package/src/core/evidence.ts +13 -3
  55. package/src/core/execution.ts +8 -2
  56. package/src/core/experiment.ts +133 -0
  57. package/src/core/export.ts +9 -0
  58. package/src/core/failure-recovery.ts +173 -0
  59. package/src/core/file-precondition.ts +72 -0
  60. package/src/core/handoff.ts +65 -4
  61. package/src/core/harness-diagnostics.ts +206 -0
  62. package/src/core/history-query.ts +38 -2
  63. package/src/core/history.ts +38 -1
  64. package/src/core/integration-health.ts +34 -0
  65. package/src/core/keymap.ts +8 -20
  66. package/src/core/model-picker.ts +64 -0
  67. package/src/core/native-eval.ts +25 -0
  68. package/src/core/note-guidance.ts +34 -0
  69. package/src/core/oauth-fields.ts +44 -0
  70. package/src/core/observation-view.ts +49 -8
  71. package/src/core/permissions.ts +24 -2
  72. package/src/core/process-receipt.ts +105 -0
  73. package/src/core/process-state.ts +37 -0
  74. package/src/core/program-status.ts +31 -0
  75. package/src/core/project.ts +70 -19
  76. package/src/core/prompt-policy.ts +37 -0
  77. package/src/core/query-eval-contract.ts +14 -0
  78. package/src/core/read-observation.ts +82 -0
  79. package/src/core/recovery-corpus.ts +17 -0
  80. package/src/core/recovery-evaluation.ts +60 -0
  81. package/src/core/recovery-sources.ts +74 -0
  82. package/src/core/request-progress.ts +87 -0
  83. package/src/core/request-state.ts +114 -0
  84. package/src/core/runtime-target.ts +94 -0
  85. package/src/core/search-observation.ts +97 -0
  86. package/src/core/session-tree.ts +147 -0
  87. package/src/core/settings.ts +33 -10
  88. package/src/core/skills.ts +44 -0
  89. package/src/core/sql-query.ts +211 -0
  90. package/src/core/theme.ts +36 -58
  91. package/src/core/tool-contract.ts +51 -2
  92. package/src/core/tool-discovery.ts +87 -0
  93. package/src/core/tool-failure.ts +60 -0
  94. package/src/core/tool-input.ts +6 -0
  95. package/src/core/tool-mode-protocol.ts +25 -0
  96. package/src/core/transcript.ts +19 -4
  97. package/src/core/trial-lifecycle.ts +14 -0
  98. package/src/core/tui-mode.ts +29 -0
  99. package/src/core/update-check.ts +97 -0
  100. package/src/core/version.ts +55 -0
  101. package/src/core/work-state.ts +49 -3
  102. package/src/core/working-set.ts +91 -0
  103. package/src/runtime/auth.ts +11 -4
  104. package/src/runtime/branch-jobs.ts +36 -0
  105. package/src/runtime/catalog.ts +13 -3
  106. package/src/runtime/clipboard.ts +15 -4
  107. package/src/runtime/command-artifacts.ts +64 -0
  108. package/src/runtime/control-connection.ts +104 -0
  109. package/src/runtime/environment-tools.ts +60 -0
  110. package/src/runtime/evidence-notes.ts +4 -2
  111. package/src/runtime/exec-sessions.ts +206 -61
  112. package/src/runtime/execution-control.ts +1 -0
  113. package/src/runtime/extension-ui.ts +22 -30
  114. package/src/runtime/extensions.ts +95 -16
  115. package/src/runtime/harness-comparison.ts +27 -7
  116. package/src/runtime/harness-eval.ts +462 -0
  117. package/src/runtime/history-cache.ts +57 -0
  118. package/src/runtime/history-tools.ts +62 -12
  119. package/src/runtime/integrations.ts +210 -0
  120. package/src/runtime/loop.ts +1088 -282
  121. package/src/runtime/model-discovery.ts +208 -0
  122. package/src/runtime/model.ts +9 -0
  123. package/src/runtime/native-eval-fixtures.ts +33 -0
  124. package/src/runtime/native-eval.ts +146 -0
  125. package/src/runtime/oauth.ts +80 -3
  126. package/src/runtime/pi-provider.ts +169 -12
  127. package/src/runtime/program-status.ts +38 -0
  128. package/src/runtime/provider-connect.ts +109 -0
  129. package/src/runtime/provider-presets.ts +87 -0
  130. package/src/runtime/provider-request.ts +13 -2
  131. package/src/runtime/provider.ts +23 -9
  132. package/src/runtime/query-eval-verifier.ts +28 -0
  133. package/src/runtime/recovery-coverage.ts +71 -0
  134. package/src/runtime/recovery-evidence.ts +76 -0
  135. package/src/runtime/recovery-fixtures.ts +116 -0
  136. package/src/runtime/runtime-target.ts +149 -0
  137. package/src/runtime/session-control.ts +198 -0
  138. package/src/runtime/tool-broker.ts +137 -0
  139. package/src/runtime/tool-discovery.ts +57 -0
  140. package/src/runtime/tool-mode-eval.ts +146 -0
  141. package/src/runtime/tool-queue.ts +77 -0
  142. package/src/runtime/tools.ts +628 -183
  143. package/src/runtime/trial-control.ts +45 -0
  144. package/src/runtime/update-check.ts +47 -0
  145. package/src/runtime/window-trial.ts +2 -1
  146. package/src/runtime/working-notes.ts +33 -3
  147. package/src/runtime/workspace-lifecycle.ts +97 -0
  148. package/src/store/acceptance.ts +15 -0
  149. package/src/store/checkpoint-history.ts +43 -0
  150. package/src/store/command-artifacts.ts +96 -0
  151. package/src/store/command.ts +65 -37
  152. package/src/store/file-checkpoints.ts +104 -0
  153. package/src/store/file-index.ts +13 -1
  154. package/src/store/git-checkpoints.ts +67 -0
  155. package/src/store/observations.ts +61 -20
  156. package/src/store/process-receipts.ts +34 -0
  157. package/src/store/query-source.ts +13 -8
  158. package/src/store/recovery.ts +2 -3
  159. package/src/store/replay.ts +16 -4
  160. package/src/store/runtime-target.ts +64 -0
  161. package/src/store/search.ts +171 -132
  162. package/src/store/session-archive.ts +4 -0
  163. package/src/store/source-cancellation.ts +6 -0
  164. package/src/store/update-cache.ts +36 -0
  165. package/src/store/workspace-observation.ts +113 -0
  166. package/src/tui/ansi-spans.ts +126 -0
  167. package/src/tui/style.ts +19 -16
  168. package/src/tui/ui/atomic-draft.ts +121 -0
  169. package/src/tui/ui/canvas.ts +96 -0
  170. package/src/tui/ui/colors.ts +70 -0
  171. package/src/tui/ui/composer.ts +200 -0
  172. package/src/tui/ui/control-panel.ts +23 -0
  173. package/src/tui/ui/dialog.ts +379 -0
  174. package/src/tui/ui/draft.ts +23 -0
  175. package/src/tui/ui/edge.ts +186 -0
  176. package/src/tui/ui/image-size.ts +40 -0
  177. package/src/tui/ui/ink.ts +210 -0
  178. package/src/tui/ui/items.ts +40 -0
  179. package/src/tui/ui/keys.ts +40 -0
  180. package/src/tui/ui/layout.ts +74 -0
  181. package/src/tui/ui/list-model.ts +86 -0
  182. package/src/tui/ui/list-view.ts +172 -0
  183. package/src/tui/ui/mascot.ts +67 -0
  184. package/src/tui/ui/messages.ts +518 -0
  185. package/src/tui/ui/motion.ts +28 -0
  186. package/src/tui/ui/paths.ts +10 -0
  187. package/src/tui/ui/pictures.ts +78 -0
  188. package/src/tui/ui/screen.ts +421 -0
  189. package/src/tui/ui/status.ts +279 -0
  190. package/src/tui/ui/styled.ts +85 -0
  191. package/src/tui/ui/testing.ts +5 -0
  192. package/src/tui/ui/tool-card.ts +150 -0
  193. package/src/apps/extension-ui.ts +0 -330
  194. package/src/apps/resource-browser.ts +0 -75
  195. package/src/apps/session-browser.ts +0 -113
  196. package/src/apps/session-picker.ts +0 -55
  197. package/src/tui/ansi.ts +0 -67
  198. package/src/tui/autocomplete.ts +0 -29
  199. package/src/tui/chat-viewport.ts +0 -47
  200. package/src/tui/complete.ts +0 -87
  201. package/src/tui/editor.ts +0 -940
  202. package/src/tui/host.ts +0 -264
  203. package/src/tui/input.ts +0 -75
  204. package/src/tui/keys.ts +0 -378
  205. package/src/tui/markdown.ts +0 -17
  206. package/src/tui/render.ts +0 -153
  207. package/src/tui/select.ts +0 -258
  208. package/src/tui/settings.ts +0 -44
  209. package/src/tui/statusline.ts +0 -162
  210. package/src/tui/system-theme.ts +0 -700
  211. package/src/tui/terminal.ts +0 -30
  212. package/src/tui/text.ts +0 -52
  213. package/src/tui/toolview.ts +0 -386
  214. package/src/tui/transcript.ts +0 -123
  215. package/src/tui/tree.ts +0 -471
  216. package/src/vendor/pi-tui/LICENSE.upstream +0 -21
  217. package/src/vendor/pi-tui/UPSTREAM.json +0 -50
  218. package/src/vendor/pi-tui/VENDOR.md +0 -25
  219. package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
  220. package/src/vendor/pi-tui/autocomplete.ts +0 -860
  221. package/src/vendor/pi-tui/colors.ts +0 -367
  222. package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
  223. package/src/vendor/pi-tui/components/box.ts +0 -166
  224. package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
  225. package/src/vendor/pi-tui/components/editor.ts +0 -2472
  226. package/src/vendor/pi-tui/components/h-stack.ts +0 -44
  227. package/src/vendor/pi-tui/components/image.ts +0 -127
  228. package/src/vendor/pi-tui/components/input.ts +0 -494
  229. package/src/vendor/pi-tui/components/loader.ts +0 -101
  230. package/src/vendor/pi-tui/components/markdown.ts +0 -1021
  231. package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
  232. package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
  233. package/src/vendor/pi-tui/components/select-list.ts +0 -273
  234. package/src/vendor/pi-tui/components/settings-list.ts +0 -328
  235. package/src/vendor/pi-tui/components/spacer.ts +0 -28
  236. package/src/vendor/pi-tui/components/stack.ts +0 -154
  237. package/src/vendor/pi-tui/components/text.ts +0 -107
  238. package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
  239. package/src/vendor/pi-tui/components/v-stack.ts +0 -33
  240. package/src/vendor/pi-tui/editor-component.ts +0 -74
  241. package/src/vendor/pi-tui/fuzzy.ts +0 -138
  242. package/src/vendor/pi-tui/index.ts +0 -186
  243. package/src/vendor/pi-tui/keybindings.ts +0 -320
  244. package/src/vendor/pi-tui/keys.ts +0 -1401
  245. package/src/vendor/pi-tui/kill-ring.ts +0 -46
  246. package/src/vendor/pi-tui/latex.ts +0 -1506
  247. package/src/vendor/pi-tui/layout-node.ts +0 -51
  248. package/src/vendor/pi-tui/layout.ts +0 -449
  249. package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
  250. package/src/vendor/pi-tui/native/clipboard.h +0 -208
  251. package/src/vendor/pi-tui/native/darwin/README.md +0 -22
  252. package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
  253. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  254. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  255. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
  256. package/src/vendor/pi-tui/native/linux/README.md +0 -23
  257. package/src/vendor/pi-tui/native/linux/build.sh +0 -65
  258. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  259. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  260. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
  261. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
  262. package/src/vendor/pi-tui/native/napi.h +0 -91
  263. package/src/vendor/pi-tui/native/win32/README.md +0 -31
  264. package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
  265. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  266. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  267. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
  268. package/src/vendor/pi-tui/native-modifiers.ts +0 -13
  269. package/src/vendor/pi-tui/native-module-path.ts +0 -31
  270. package/src/vendor/pi-tui/native-platform.ts +0 -65
  271. package/src/vendor/pi-tui/oklab.ts +0 -233
  272. package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
  273. package/src/vendor/pi-tui/terminal-colors.ts +0 -91
  274. package/src/vendor/pi-tui/terminal-image.ts +0 -731
  275. package/src/vendor/pi-tui/terminal.ts +0 -554
  276. package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
  277. package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
  278. package/src/vendor/pi-tui/tui.ts +0 -1493
  279. package/src/vendor/pi-tui/undo-stack.ts +0 -28
  280. package/src/vendor/pi-tui/utils.ts +0 -1385
  281. package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
  282. package/src/vendor/pi-tui/word-navigation.ts +0 -117
@@ -14,34 +14,43 @@
14
14
  * is what lets deriveHandoff verify a completion claim against evidence rather
15
15
  * than believing it.
16
16
  *
17
- * `bash` can reach outside any path the model declared, which the tree design
18
- * has to account for -- see appendix I. This module does not try to prevent
19
- * that by making the shell less capable; it records what it can and leaves the
20
- * workspace-snapshot cross-check to the layer above.
17
+ * P.3 separates shell execution facts from recoverable file changes. Commands
18
+ * observe a bounded source scope for verification; optional file checkpoints
19
+ * belong to the admitted execution boundary, not session startup or turn end.
21
20
  */
22
21
 
23
- import { readFile, writeFile, mkdir, stat } from "node:fs/promises";
22
+ import { readFile, stat } from "node:fs/promises";
24
23
  import { createHash } from "node:crypto";
25
- import { dirname, resolve, relative, isAbsolute, join, sep } from "node:path";
24
+ import { hostname } from "node:os";
25
+ import { resolve, relative, isAbsolute, join, sep } from "node:path";
26
26
  import type { Effect, LineRange, ArtifactRef } from "../core/events.ts";
27
- import { archiveObservation, type ArchivedObservation } from "../store/observations.ts";
27
+ import { archiveObservation, readObservation, type ArchivedObservation } from "../store/observations.ts";
28
28
  import { blobAddress } from "../store/blobs.ts";
29
29
  import { commandHeader, headTailView } from "../core/observation-view.ts";
30
30
  import {
31
- DEFAULT_YIELD_MS, MAX_YIELD_MS, MIN_YIELD_MS, findSession, runningSessions, startSession, takeOutput, waitSession,
31
+ DEFAULT_YIELD_MS, MAX_YIELD_MS, MIN_YIELD_MS, findSession, closedSession, runningSessions, startSession, pollSession,
32
32
  } from "./exec-sessions.ts";
33
- import { observeCommand } from "../store/command.ts";
33
+ import { recoverProcessReceipt, type ProcessReceipt, type latestProcessReceipt } from "../core/process-receipt.ts";
34
+ import { observeCommand, observeArgvCommand } from "../store/command.ts";
34
35
  import { executionOutcome, type ExecutionOutcome } from "../core/outcome.ts";
36
+ import { commandCompletion, commandTimeout } from "../core/command-contract.ts";
37
+ import { previewReadObservation, readObservationText, type ObservationReceipt } from "../core/read-observation.ts";
38
+ import { checkFilePrecondition, isFileDigest, isFileReadDescriptor, type FileReadDescriptor, type FilePrecondition } from "../core/file-precondition.ts";
39
+ import { previewSearchObservation, searchObservationText } from "../core/search-observation.ts";
35
40
  import { waitForAdmission } from "./execution-control.ts";
36
41
  import { assertNever } from "../core/exhaustive.ts";
42
+ import { artifactRecovery, toolDescription, type ToolGuidance } from "../core/prompt-policy.ts";
37
43
  import { classifyGate } from "../core/evidence.ts";
38
44
  import { readBounds } from "../core/read-bounds.ts";
39
- import { closestName, inputError, isJsonValue, type JsonValue, type ToolDiagnostic } from "../core/tool-contract.ts";
45
+ import { closestName, inputError, isJsonValue, minimalInput, type JsonValue, type ToolDiagnostic } from "../core/tool-contract.ts";
46
+ import { failureDiagnostic, failureFromError, scopeFailureGuidance, NOT_STARTED, READ_ATTEMPTED, UNKNOWN_EFFECT, type FailureFacts } from "../core/tool-failure.ts";
40
47
  import { planEdits, type EditSpec } from "../core/edit-plan.ts";
41
48
  import { toView } from "../core/text-normalize.ts";
42
49
  import { repairToolInput } from "../core/tool-input.ts";
43
50
  import { HISTORY_SECTIONS } from "../core/history.ts";
44
- import { parseDataQuery, queryData } from "../core/data-query.ts";
51
+ import { RECOVERY_SECTIONS } from "../core/recovery-sources.ts";
52
+ import { queryData } from "../core/data-query.ts";
53
+ import { parseQueryInput, MAX_SQL_CHARS, SQL_QUERY_HELP } from "../core/sql-query.ts";
45
54
  import { readQuerySources } from "../store/query-source.ts";
46
55
  import {
47
56
  askWithoutTtyMessage,
@@ -52,14 +61,25 @@ import {
52
61
  type PermissionRule,
53
62
  } from "../core/permissions.ts";
54
63
  import type { ToolSchema } from "./provider.ts";
64
+ import type { HistoryCache } from "./history-cache.ts";
55
65
  import type { ImageRef } from "../core/images.ts";
56
66
  import { imageDescription } from "../core/images.ts";
57
67
  import { attachImageBytes } from "./images.ts";
58
68
  import { detectSupportedImageMimeType } from "../vendor/pi-rich/mime.ts";
59
- import { find as findFiles, grep, ls } from "../store/search.ts";
69
+ import { find as findFiles, grep, ls, MAX_FILE_BYTES, MAX_SEARCH_CAPTURE_BYTES, type SearchResult } from "../store/search.ts";
60
70
  import { scanWorkspace, workspaceVersion, DEFAULT_IGNORE } from "../store/workspace.ts";
71
+ import { observeWorkspace } from "../store/workspace-observation.ts";
72
+ import { readEditableFile, replaceFile } from "../store/file-checkpoints.ts";
73
+ import type { WorkspaceLifecycle } from "./workspace-lifecycle.ts";
74
+ import { environmentProbeCommand } from "../core/environment.ts";
75
+ import { observeEnvironment } from "./environment-tools.ts";
76
+ import { beginCommandArtifacts, prepareArtifactArguments, commandArtifactSummary } from "./command-artifacts.ts";
77
+ import { MAX_COMMAND_ARTIFACTS } from "../core/command-artifacts.ts";
78
+ import { checkSourceCancellation } from "../store/source-cancellation.ts";
61
79
 
62
80
  export interface ToolResult {
81
+ /** Broker-owned identity, returned to a composer only after the original receipt commits. */
82
+ invocation?: { call_id: string; event: string };
63
83
  images?: readonly ImageRef[];
64
84
  /** What the model sees. */
65
85
  content: string;
@@ -70,18 +90,50 @@ export interface ToolResult {
70
90
  content_sha256?: string;
71
91
  /** Observed bytes without presentation/version metadata, for D9 novelty. */
72
92
  observation_sha256?: string;
93
+ observation?: ObservationReceipt;
73
94
  outcome?: ExecutionOutcome;
74
95
  data?: JsonValue;
75
96
  diagnostic?: ToolDiagnostic;
97
+ warnings?: readonly ToolDiagnostic[];
76
98
  }
77
99
 
78
100
  export interface ToolContext {
101
+ /** R10: model-visible names constrain new advice without granting tool admission. */
102
+ advice?: readonly string[];
103
+ /** Runtime adapter identity, never supplied by a model tool argument. */
104
+ hostId?: string;
105
+ /** R4: trusted Session projection over original log ancestry, never model input. */
106
+ observedFileVersion?: (absolute: string) => Promise<string | undefined>;
107
+ /** R7: original active ancestry, scoped by Session rather than a model-supplied log path. */
108
+ observedProcess?: (session: number) => Promise<ReturnType<typeof latestProcessReceipt>>;
109
+ /** P.3: admitted execution owns file recovery; extensions cannot supply model-authored effects. */
110
+ workspace?: WorkspaceLifecycle;
111
+ ownedStatePaths?: readonly string[];
112
+ /** Historical fingerprint compatibility, never enabled by the normal CLI. */
113
+ legacyWorkspace?: boolean;
114
+ /** O.15: optional disposable view; the reader still validates the original log. */
115
+ historyCache?: HistoryCache;
116
+ /** O.14: opaque process ownership is supplied by Session, never by model input. */
117
+ execOwner?: string;
79
118
  /** Session-owned registrations never mutate the built-in table (D13). */
80
119
  extensions?: ReadonlyMap<string, ToolDefinition>;
120
+ /** R11: current locally selected optional schemas, never restored as authority from historical text. */
121
+ deferredTools?: readonly ToolSchema[];
81
122
  callId?: string;
123
+ parentCallId?: string;
124
+ /** Pi A: a live composing scope supplied by Session, never model arguments. */
125
+ invoke?: (name: string, input: unknown, options?: NestedInvocationOptions) => Promise<ToolResult>;
126
+ /** Trusted extension host capability. Never exposed in ExtensionContext or a provider schema. */
127
+ invokeHost?: (input: NativeCommandInput, signal?: AbortSignal) => Promise<ToolResult>;
128
+ runOwnedCommand?: (execute: () => Promise<ToolResult>) => Promise<ToolResult>;
129
+ drainNested?: () => Promise<void>;
130
+ origin?: "extension";
131
+ readonlyBranch?: boolean;
82
132
  extensionDepth?: number;
83
133
  extensionHooks?: {
84
- before(name: string, input: unknown, id: string | undefined): Promise<string | undefined>;
134
+ serial?(): boolean;
135
+ scope?<T>(ctx: ToolContext, action: () => T): T;
136
+ before(name: string, input: unknown, id: string | undefined, parentId?: string): Promise<string | undefined>;
85
137
  event(type: string, fields: Record<string, unknown>): Promise<void>;
86
138
  };
87
139
  imageStore?: string;
@@ -97,6 +149,8 @@ export interface ToolContext {
97
149
  * paths and the scope of every fingerprint and snapshot -- the whole disk.
98
150
  */
99
151
  pathRoot?: string;
152
+ /** O.12: exact frozen skill resources can be read without widening the path boundary. */
153
+ skillResources?: readonly { path: string; text: string; modelInvocable: boolean }[];
100
154
  /**
101
155
  * Which tools this caller may run at all.
102
156
  *
@@ -141,6 +195,8 @@ export interface ToolContext {
141
195
  askSlot?: { current?: (request: PermissionRequest) => Promise<ApprovalChoice> };
142
196
  }
143
197
 
198
+ export interface NestedInvocationOptions { signal?: AbortSignal }
199
+
144
200
  /**
145
201
  * What the person picked at the approval prompt.
146
202
  *
@@ -152,7 +208,8 @@ export type ApprovalChoice = "once" | "command" | "tool" | "deny";
152
208
  export const DEFAULT_TOOL_CONTEXT: Omit<ToolContext, "root"> = {
153
209
  // Runner prefixes are resolved in command position so `python3 -m pytest`
154
210
  // works without letting `echo pytest` forge a gate (D11 / M.2).
155
- gates: { pytest: "pytest", tsc: "typecheck", cargo: "cargo", jest: "test", vitest: "test", go: "go" },
211
+ gates: { pytest: "pytest", tsc: "typecheck", cargo: "cargo", jest: "test", vitest: "test", go: "go",
212
+ test: "test", typecheck: "typecheck", check: "check", build: "build", lint: "lint", gate: "gate" },
156
213
  // Five minutes covers typical compiles and installs; longer work asks for
157
214
  // its own `timeout` up to the hour ceiling (omp uses the same 300 / 3600).
158
215
  // Approved change from 120s (2026-09-28): 120s hard-killed real builds.
@@ -163,6 +220,16 @@ export const DEFAULT_TOOL_CONTEXT: Omit<ToolContext, "root"> = {
163
220
 
164
221
  const sha256 = (s: string | Uint8Array) => createHash("sha256").update(s).digest("hex");
165
222
 
223
+ function failure(code: ToolDiagnostic["code"], content: string, facts: FailureFacts = NOT_STARTED,
224
+ extra: Pick<ToolDiagnostic, "recover" | "example"> = {}): ToolResult {
225
+ return { ok: false, content, diagnostic: failureDiagnostic(code, content, facts, extra) };
226
+ }
227
+
228
+ function caughtFailure(error: unknown, facts: FailureFacts, prefix = ""): ToolResult {
229
+ const diagnostic = failureFromError(error, facts);
230
+ return { ok: false, content: prefix + diagnostic.message, diagnostic };
231
+ }
232
+
166
233
  /**
167
234
  * The configured gate a command counts as, if any.
168
235
  *
@@ -205,6 +272,13 @@ const searchRoot = (ctx: ToolContext, abs: string) => within(ctx.root, abs) ? ct
205
272
  // ---------------------------------------------------------------------------
206
273
 
207
274
  export interface ToolDefinition extends ToolSchema {
275
+ /** Q.18: opt-in builtins are absent from every default profile, including full. */
276
+ optional?: boolean;
277
+ /** Q.13: current adapter observation only; excluded from the frozen model schema. */
278
+ integrationHealth?: () => import("./integrations.ts").IntegrationObservation;
279
+ /** R10: optional companion advice is rendered only for the actual builtin capabilities. */
280
+ guidance?: readonly ToolGuidance[];
281
+ example?: { readonly [key: string]: JsonValue };
208
282
  access: "read" | "write" | "shell" | "state";
209
283
  execution: "parallel" | "exclusive";
210
284
  branch: "read" | "write" | "none";
@@ -214,9 +288,20 @@ export interface ToolDefinition extends ToolSchema {
214
288
  run?: (ctx: ToolContext, input: Record<string, unknown>) => Promise<ToolResult>;
215
289
  }
216
290
 
291
+ const commandArtifactsSchema = {
292
+ type: "object", required: ["paths"], additionalProperties: false,
293
+ description: "Optionally observe up to 16 workspace-relative ordinary output files before/after this command (512 MiB per phase). Records digests and input scope, not proof of production or runtime acceptance. Missing/unstable files remain unknown; the command exit is retained.",
294
+ properties: {
295
+ paths: { type: "array", minItems: 1, maxItems: MAX_COMMAND_ARTIFACTS, items: { type: "string" } },
296
+ output_roots: { type: "array", minItems: 1, maxItems: MAX_COMMAND_ARTIFACTS, items: { type: "string" },
297
+ description: "Nonoverlapping output directories/files excluded from the input fingerprint, each covering requested files. Defaults to paths. Never exclude source inputs." },
298
+ },
299
+ };
300
+
217
301
  export const TOOL_DEFINITIONS: ToolDefinition[] = [
218
302
  {
219
303
  name: "query",
304
+ example: { paths: ["data.csv"], format: "csv", aggregate: [{ op: "count", as: "rows" }] },
220
305
  access: "read", execution: "parallel", branch: "read", run: toolQuery,
221
306
  description: "Compute over complete confined files (up to 32 MiB / 250000 records). Concatenates paths in order. CSV supports quoted records; JSON pointer selects nested data; JSONL parses each record; lines exposes line/text columns. Filter, group, aggregate and sort without shell or code execution. Returns full result with source hashes; large output has retrievable artifact pages. Numeric aggregates reject non-numeric values; empty/null values are excluded.",
222
307
  input_schema: {
@@ -224,6 +309,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
224
309
  paths: { type: "array", items: { type: "string" } },
225
310
  format: { type: "string", enum: ["csv", "json", "jsonl", "lines"] },
226
311
  pointer: { type: "string", description: "JSON pointer, e.g. /records" },
312
+ sql: { type: "string", maxLength: MAX_SQL_CHARS, description: SQL_QUERY_HELP + " Example: SELECT category, SUM(amount) AS total FROM source GROUP BY category ORDER BY total DESC. Mutually exclusive with JSON operators. Fields preserve case and use exact-key-first dotted lookup. Uses the JSON query kernel: numeric literals coerce nonempty numeric strings; ordering keeps string values lexical; COUNT(field) excludes null/missing/empty strings. SQL-style, not ANSI SQL." },
227
313
  where: { type: "array", items: { type: "object", required: ["field", "op", "value"], properties: {
228
314
  field: { type: "string" }, op: { type: "string", enum: ["eq", "ne", "contains", "gt", "gte", "lt", "lte"] }, value: {},
229
315
  } } },
@@ -238,8 +324,21 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
238
324
  },
239
325
  },
240
326
  },
327
+ {
328
+ name: "target",
329
+ guidance: [{ requires: ["write_stdin"], text: "Continue or stop the returned handle with write_stdin." }],
330
+ access: "shell", execution: "exclusive", branch: "write",
331
+ description: "Start or inspect a runtime declared by the trusted acceptance contract. Start requires a matching successful build command with the exact declared artifacts plan. Launches literal argv under this session's process owner; never adopts a PID or reuses an existing server. Inspect checks current build inputs, entry, executable and optional IPv4 loopback port ownership. Ready still requires user acceptance. No persistent service handoff.",
332
+ input_schema: { type: "object", required: ["action", "target"], properties: {
333
+ action: { type: "string", enum: ["start", "inspect"] }, target: { type: "string" },
334
+ session_id: { type: "integer", minimum: 1, description: "Inspect this exact previously returned handle; never adopts a process" },
335
+ yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "Milliseconds to wait for launch; default 10000, distinct from the hard timeout" },
336
+ timeout: { type: "integer", minimum: 1, maximum: 3600, description: "Hard process lifetime in seconds; defaults to the session command timeout" },
337
+ } },
338
+ },
241
339
  {
242
340
  name: "history",
341
+ example: { event: "<source-event-id>" },
243
342
  access: "read", execution: "exclusive", branch: "read",
244
343
  description: "Recover prior work: work_id and resource locate linked work and exact normalized paths; query adds literal AND keywords and shows up to 5 matching lines (hits) per result; recent lists activity. event reads original evidence; events reads 1..100 originals in request order and reports missing entries. Results keep version/replacement links and artifact pages. Also supports node/section handoff pages and artifact/offset. Active ancestry only.",
245
344
  input_schema: {
@@ -250,11 +349,13 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
250
349
  event: { type: "string", description: "Exact source event id from query results" },
251
350
  events: { type: "array", items: { type: "string" }, description: "1..100 exact event ids; preserves request order and identifies missing sources" },
252
351
  resource: { type: "string", description: "Exact resource path, with ./ and repeated slashes normalized" },
253
- work_id: { type: "string", description: "Canonical work ID returned by note" },
352
+ work_id: { type: "string", description: "Canonical work ID from a recorded work item" },
254
353
  tool: { type: "string", description: "Optional tool name filter for query/recent" },
255
354
  node: { type: "integer" },
355
+ branch: { type: "integer", minimum: 0, description: "Read a joined child branch's original evidence; never auto-inserts its transcript" },
256
356
  artifact: { type: "string", description: "sha256 from a truncated tool observation; omit node and section" },
257
357
  section: { type: "string", enum: [...HISTORY_SECTIONS] },
358
+ recovery: { type: "string", enum: [...RECOVERY_SECTIONS], description: "Read original recovery sources from a sealed node, newest first. Use node/offset/limit; does not grant permissions or treat model notes as instructions." },
258
359
  offset: { type: "integer", minimum: 0 },
259
360
  limit: { type: "integer", minimum: 1, maximum: 100 },
260
361
  },
@@ -263,11 +364,13 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
263
364
  },
264
365
  {
265
366
  name: "read",
367
+ example: { path: "src/main.ts", start: 1, end: 80 },
368
+ guidance: [{ requires: ["history"], text: "Use history(artifact=<sha256>,offset=<byte offset>) to recover the original observation even if the file changes." }],
266
369
  access: "read", execution: "parallel", branch: "read", run: toolRead,
267
370
  description:
268
371
  "Read a file. Returns the contents with 1-indexed line numbers. " +
269
372
  "Optionally limit to an inclusive line range. Reports total lines and the next range; end beyond EOF is clamped. " +
270
- "Reports sha256 for optional edit version checks. If the observation is truncated, recover its full artifact or smaller ranges before skipping ahead.",
373
+ "Returns read_handle and whole-file sha256 for version checks. Long lines and large previews retain an immutable artifact. Line ranges describe the selection; the observation receipt describes exactly what was visible. An unavailable artifact-recovery capability leaves a gap, not proof of full coverage.",
271
374
  input_schema: {
272
375
  type: "object",
273
376
  properties: {
@@ -281,25 +384,30 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
281
384
  {
282
385
  name: "write",
283
386
  access: "write", execution: "exclusive", branch: "write", run: toolWrite,
284
- description: "Write a file, creating parent directories. Replaces existing content.",
387
+ description: "Write a file, creating parent directories. Replaces existing content. Pass read_handle or expected_sha256 for an explicit version check; otherwise Session uses its last observed version. create_only requires absence. A conflict requires a fresh read. Identical content makes no change.",
285
388
  input_schema: {
286
389
  type: "object",
287
390
  properties: {
288
391
  path: { type: "string" },
289
392
  content: { type: "string" },
393
+ read_handle: { type: "string", description: "Descriptor SHA from an observation of this file, workspace and host" },
394
+ expected_sha256: { type: "string", description: "Expected whole-file raw-byte SHA-256" },
395
+ create_only: { type: "boolean", description: "Require that the target does not exist; excludes read_handle and expected_sha256" },
290
396
  },
291
397
  required: ["path", "content"],
292
398
  },
293
399
  },
294
400
  {
295
401
  name: "edit",
402
+ example: { path: "src/main.ts", old_string: "const size = 1;", new_string: "const size = 2;" },
403
+ guidance: [{ requires: ["read"], text: "Use read to obtain current text and a version receipt before an edit." }],
296
404
  access: "write", execution: "exclusive", branch: "write", run: toolEdit,
297
405
  description:
298
- "Replace exact text in a file. old_string must match once unless replace_all is set; copy it from read output, " +
406
+ "Replace exact text in a file. old_string must match once unless replace_all is set; copy it from observed file content, " +
299
407
  "including indentation (line numbers are not part of the file). For several changes to one file pass edits[], " +
300
408
  "each matched against the original; all apply or none do. If there is no exact match, a unique match that differs only in " +
301
409
  "trailing whitespace, quote or dash style is applied and flagged fuzzy. A failure shows the closest text in the file. " +
302
- "Pass expected_sha256 from read to reject stale edits.",
410
+ "Pass read_handle or expected_sha256 from a file observation to reject stale edits; when omitted, Session uses its last observed version. A conflict requires a fresh observation. A partial read handle checks the whole file version, not permission to edit unseen lines.",
303
411
  input_schema: {
304
412
  type: "object",
305
413
  properties: {
@@ -311,7 +419,8 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
311
419
  type: "object", required: ["old_string", "new_string"], properties: {
312
420
  old_string: { type: "string", minLength: 1 }, new_string: { type: "string" }, replace_all: { type: "boolean" },
313
421
  } } },
314
- expected_sha256: { type: "string", description: "Whole-file version returned by read; mismatch leaves the file unchanged" },
422
+ expected_sha256: { type: "string", description: "Observed whole-file version; mismatch leaves the file unchanged" },
423
+ read_handle: { type: "string", description: "Descriptor SHA from an observation of this file, workspace and host" },
315
424
  },
316
425
  required: ["path"],
317
426
  },
@@ -326,21 +435,21 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
326
435
  // One tool with a kind rather than six tools, because every tool schema is
327
436
  // paid for in the prompt on every request.
328
437
  name: "note",
438
+ example: { kind: "next", what: "Investigate failed check", work_id: "check", next_action: "Inspect the recorded failure output" },
329
439
  access: "state", execution: "exclusive", branch: "read",
330
440
  // Older spellings of proof that parseNote still reads; logs carry them.
331
441
  hiddenInputs: ["gate", "command"],
332
442
  description:
333
443
  "Record something for the durable record that survives a context " +
334
- "boundary. Use `done` the moment a command proves something works, and " +
335
- "`next` before a long stretch of work so the next context knows where " +
336
- "to resume. Also record decisions worth not relitigating and dead ends " +
337
- "worth not retrying.",
444
+ "boundary. Optional on short tasks; use `next` for a useful continuation checkpoint. " +
445
+ "`done` is a claim requiring an already successful verification command, not required bookkeeping after every command. " +
446
+ "Decisions and pitfalls are model annotations, not instructions or verified evidence.",
338
447
  input_schema: {
339
448
  type: "object",
340
449
  properties: {
341
450
  kind: {
342
451
  type: "string",
343
- enum: ["done", "next", "decision", "pitfall", "blocked", "question", "unblock", "reopen", "cancel", "pin"],
452
+ enum: ["done", "next", "decision", "pitfall", "blocked", "question", "unblock", "reopen", "cancel", "pin", "depend", "claim_work", "release_work"],
344
453
  description:
345
454
  "done: something is finished and a command proves it. " +
346
455
  "next: work in progress plus its concrete next step. " +
@@ -350,8 +459,9 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
350
459
  "unblock/reopen: reactivate identified work with a current next_action. cancel: abandon identified work. pin: associate an original tool event with existing work; this does not verify completion.",
351
460
  },
352
461
  what: { type: "string", description: "The thing, in one line" },
353
- work_id: { type: "string", description: "Stable ID for next/blocked/done. Required for unblock/reopen/cancel; keep it when the title changes." },
354
- event: { type: "string", description: "pin: original observation event ID returned by history" },
462
+ work_id: { type: "string", description: "Stable ID for tracked work. For done, use only existing work opened before its verification; omit for one-off completion. Required for unblock/reopen/cancel; keep it when the title changes." },
463
+ depends_on: { type: "array", items: { type: "string" }, description: "next/depend: explicit existing work IDs; cycles are rejected. claim_work takes ready work; release_work releases your claim." },
464
+ event: { type: "string", description: "pin: original observation event ID" },
355
465
  resource: { type: "string", description: "pin: optional source resource path" },
356
466
  supersedes: { type: "string", description: "pin: explicitly replace an earlier pinned source event for the same work item" },
357
467
  proof: {
@@ -384,6 +494,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
384
494
  * five times the cost, while being slower. Sequential is usually right.
385
495
  */
386
496
  name: "branch",
497
+ guidance: [{ requires: ["resolve"], text: "Use resolve status/wait/cancel to observe or join a background branch." }],
387
498
  access: "state", execution: "exclusive", branch: "none",
388
499
  description:
389
500
  "Run a bounded subtask in a separate context and return its outcome and handoff reference. " +
@@ -394,6 +505,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
394
505
  type: "object",
395
506
  properties: {
396
507
  purpose: { type: "string", description: "What this branch is for, in one line" },
508
+ background: { type: "boolean", description: "Return a reference while the child runs. Requires a branch-observation capability for later results; without it, prefer the default foreground call." },
397
509
  prompt: {
398
510
  type: "string",
399
511
  description:
@@ -417,13 +529,14 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
417
529
  name: "resolve",
418
530
  access: "state", execution: "exclusive", branch: "none",
419
531
  description:
420
- "Adopt or discard a branch that has come back. Every branch must be " +
532
+ "Wait, cancel, inspect, adopt or discard a branch. Every branch must be " +
421
533
  "resolved; an unresolved one stays an open question.",
422
534
  input_schema: {
423
535
  type: "object",
424
536
  properties: {
425
537
  node: { type: "number", description: "The branch's node number" },
426
- resolution: { type: "string", enum: ["adopt", "discard"] },
538
+ resolution: { type: "string", enum: ["adopt", "discard", "wait", "cancel", "status"] },
539
+ wait_ms: { type: "number", minimum: 0, maximum: 60000, description: "wait: bounded milliseconds, defaults to 10000" },
427
540
  why: { type: "string", description: "discard: why it was rejected" },
428
541
  },
429
542
  required: ["node", "resolution"],
@@ -431,37 +544,64 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
431
544
  },
432
545
  {
433
546
  name: "bash",
547
+ example: { command: "bun test", workdir: ".", timeout: 300 },
548
+ guidance: [{ requires: ["exec_command", "write_stdin"], text: "For foreground servers or interactive input, use exec_command and observe the handle with write_stdin." }],
434
549
  access: "shell", execution: "exclusive", branch: "write", run: toolBash,
550
+ prepareArguments: prepareArtifactArguments,
435
551
  description:
436
552
  "Run a command with bash in a fresh non-interactive shell (no TTY; cwd, variables and `cd` do not persist between calls). " +
437
553
  "Returns a header `exit N · wall time` with any timeout, then stdout and stderr interleaved; long output keeps its head and tail. " +
438
- "Default timeout 300s; pass timeout for longer work. Start servers or watchers in the background with output redirected, " +
439
- "e.g. `nohup cmd > server.log 2>&1 &`, then poll the log or port.",
554
+ "Default timeout 300 seconds; pass timeout for longer work. Timeout or cancellation cleans the process group. " +
555
+ "Normal shell exit can leave background children; this call exposes no handle for later control. Keep work in the foreground and do not start detached servers or watchers here.",
440
556
  input_schema: {
441
557
  type: "object",
442
558
  properties: {
443
559
  command: { type: "string" },
560
+ artifacts: commandArtifactsSchema,
444
561
  timeout: { type: "integer", minimum: 1, maximum: 3600, description: "Seconds before the process group is killed; default 300" },
445
562
  workdir: { type: "string", description: "Directory to run in, relative to the workspace root; default the root" },
446
563
  },
447
564
  required: ["command"],
448
565
  },
449
566
  },
567
+ {
568
+ name: "environment",
569
+ example: { program: "python3", probe: "python" },
570
+ access: "shell", execution: "exclusive", branch: "write", run: toolEnvironment,
571
+ description: "Probe one program in the same fresh login Bash as commands. Requires shell permission. Returns the resolved executable, reported version, source identity, observation time and raw output reference. probe=python uses isolated Python to inspect actual tarfile.extractall filter support. Every call probes again; old observations can expire or be invalidated. This describes this executor only, not an SSH target or another container. Functions and aliases require an exact executable path. Bounded to 5s and an output stop budget of 256 KiB; large or incomplete reports remain unconfirmed.",
572
+ hiddenInputs: ["command"],
573
+ prepareArguments(input) {
574
+ if (input === null || typeof input !== "object" || Array.isArray(input)) return input;
575
+ const args = input as Record<string, unknown>;
576
+ return { ...args, probe: args["probe"] ?? "version", command: environmentProbeCommand(args["program"], args["probe"] ?? "version") };
577
+ },
578
+ input_schema: { type: "object", required: ["program"], properties: {
579
+ program: { type: "string", description: "Executable name or literal path; never shell code. Prefer the exact interpreter used by the failing command." },
580
+ probe: { type: "string", enum: ["version", "python"], description: "version runs --version (default); python observes the actual interpreter API in isolated mode." },
581
+ workdir: { type: "string", description: "Directory to run in, relative to the workspace root; default the root" },
582
+ } },
583
+ },
450
584
  {
451
585
  name: "exec_command",
586
+ example: { cmd: "bun test", workdir: ".", yield_time_ms: 1000, timeout: 300 },
587
+ guidance: [{ requires: ["write_stdin"], text: "Continue the returned handle with write_stdin until exit; empty chars polls without replaying the command." }],
452
588
  access: "shell", execution: "exclusive", branch: "write", run: toolExec,
589
+ prepareArguments: prepareArtifactArguments,
453
590
  description:
454
591
  "Start a command that may keep running: a server, a REPL, a long build, or a program that asks for input. " +
455
- "Waits up to yield_time_ms; if it has exited, returns `exit N · wall time` and its output like bash, otherwise " +
456
- "`running · session <id>` with the output so far while the process keeps running. Continue with write_stdin. " +
457
- "tty=true runs it in a terminal, for programs that need one (REPLs, prompts, curses). Prefer bash for commands that simply finish.",
592
+ "Uses a fresh login shell; set workdir because cwd and variables do not persist. " +
593
+ "Waits up to yield_time_ms milliseconds; if it has exited, returns `exit N · wall time` and its output, otherwise " +
594
+ "`running · session <id>` with the output so far while the process keeps running. Running is not completion; if no handle-observation capability is available, report unfinished execution rather than rerunning it. " +
595
+ "tty=true runs it in a terminal for REPLs and prompts. Keep servers in the foreground: the owned group is cleaned when the command exits, times out, or its xeno session closes. " +
596
+ "A wait deadline only ends this call; timeout is the command's hard deadline. No persistent service handoff.",
458
597
  input_schema: {
459
598
  type: "object",
460
599
  properties: {
461
- cmd: { type: "string", description: "Shell command, run with bash -lc" },
600
+ cmd: { type: "string", description: "Shell command, run with bash -o pipefail -lc" },
601
+ artifacts: commandArtifactsSchema,
462
602
  workdir: { type: "string", description: "Directory to run in; default the workspace root" },
463
603
  tty: { type: "boolean", description: "Run in a pseudo-terminal; default false" },
464
- yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "How long to wait before returning; default 10000" },
604
+ yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "Milliseconds to wait before returning; default 10000" },
465
605
  timeout: { type: "integer", minimum: 1, maximum: 3600, description: "Seconds before the session is killed; default 300" },
466
606
  },
467
607
  required: ["cmd"],
@@ -469,17 +609,19 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
469
609
  },
470
610
  {
471
611
  name: "write_stdin",
612
+ example: { session_id: 1, chars: "", yield_time_ms: 1000 },
472
613
  access: "shell", execution: "exclusive", branch: "write", run: toolWriteStdin,
473
614
  description:
474
- "Send characters to a running exec_command session and return the output since the last call. " +
615
+ "Send characters to an owned running process handle and return the output since the last call. " +
475
616
  "Empty chars polls. Include \\n to submit a line; \\u0003 is Ctrl-C, \\u0004 is Ctrl-D (end of input). " +
476
- "When the process has exited, returns its exit code and the session closes.",
617
+ "After exit, empty chars retrieves the last receipt without rerunning or producing fresh verification. Source and byte cursor are in data; full_output recovers all captured output. " +
618
+ "Lost handles cannot accept input; a last running observation is not an exit. Ctrl-C may interrupt a PTY foreground job while its REPL stays alive.",
477
619
  input_schema: {
478
620
  type: "object",
479
621
  properties: {
480
622
  session_id: { type: "integer", minimum: 1 },
481
623
  chars: { type: "string", description: "Characters to write; empty to poll" },
482
- yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "How long to wait for output; default 10000" },
624
+ yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "Milliseconds to wait for output; default 10000" },
483
625
  },
484
626
  required: ["session_id"],
485
627
  },
@@ -493,7 +635,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
493
635
  "Search file contents for a regex. Stays inside the workspace, does " +
494
636
  "not follow symlinks, and skips .git, node_modules and .xeno. Uses " +
495
637
  "ripgrep when it is already on PATH, otherwise a TypeScript walk. " +
496
- "Truncates with a count of what was left out.",
638
+ "Reports scan completeness separately from preview truncation. Long matches and omitted results remain in an immutable history artifact; use its sha256 and byte offset to recover them. Unknown or partial coverage cannot prove absence.",
497
639
  input_schema: {
498
640
  type: "object",
499
641
  properties: {
@@ -511,7 +653,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
511
653
  description:
512
654
  "Find files by glob pattern. Stays inside the workspace, does not " +
513
655
  "follow symlinks, and skips .git, node_modules and .xeno. Uses fd " +
514
- "when it is already on PATH, otherwise a TypeScript walk.",
656
+ "when it is already on PATH, otherwise a TypeScript walk. Omitted paths remain in a history artifact; scan completeness is reported separately.",
515
657
  input_schema: {
516
658
  type: "object",
517
659
  properties: {
@@ -528,7 +670,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
528
670
  description:
529
671
  "List a directory. Directories are marked with a trailing '/'. Stays " +
530
672
  "inside the workspace, does not follow symlinks, and skips .git, " +
531
- "node_modules and .xeno.",
673
+ "node_modules and .xeno. Omitted entries remain in a history artifact with exact byte offsets.",
532
674
  input_schema: {
533
675
  type: "object",
534
676
  properties: {
@@ -541,12 +683,94 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
541
683
  ];
542
684
 
543
685
  /** Runtime policy metadata never leaks into provider-specific wire schemas. */
544
- export const TOOL_SCHEMAS: ToolSchema[] = TOOL_DEFINITIONS.map(({ name, description, input_schema }) => ({ name, description, input_schema }));
686
+ export function modelToolSchema(tool: ToolSchema & Pick<ToolDefinition, "guidance" | "example">, builtins: readonly string[]): ToolSchema {
687
+ const description = toolDescription(tool.description, tool.guidance ?? [], builtins);
688
+ return { name: tool.name, description: description + (tool.example ? ` Example input (paths and IDs are illustrative): ${JSON.stringify(tool.example)}` : ""), input_schema: tool.input_schema };
689
+ }
690
+
691
+ export const ALL_TOOL_SCHEMAS: ToolSchema[] = TOOL_DEFINITIONS.map(tool => modelToolSchema(tool, TOOL_DEFINITIONS.map(item => item.name)));
692
+ export const TOOL_SCHEMAS: ToolSchema[] = TOOL_DEFINITIONS.filter(tool => !tool.optional).map(tool => modelToolSchema(tool, TOOL_DEFINITIONS.filter(item => !item.optional).map(item => item.name)));
545
693
 
546
694
  export function toolDefinition(name: string, ctx?: ToolContext): ToolDefinition | undefined {
547
695
  return ctx?.extensions?.get(name) ?? TOOL_DEFINITIONS.find(tool => tool.name === name);
548
696
  }
549
697
 
698
+ /** Visible builtin contracts only; a same-named extension does not implement that contract. */
699
+ export function adviceTools(ctx: ToolContext): string[] {
700
+ return TOOL_DEFINITIONS.map(tool => tool.name).filter(name => !ctx.extensions?.has(name) &&
701
+ (ctx.advice === undefined || ctx.advice.includes(name)) &&
702
+ (ctx.allow === undefined || ctx.allow.includes(name)) &&
703
+ decide([...(ctx.sessionRules ?? []), ...(ctx.rules ?? [])], { tool: name }) !== "deny");
704
+ }
705
+
706
+ /** A proposed recovery is data, not execution. Validate it against today's exact admission inputs. */
707
+ function scopeToolAdvice(ctx: ToolContext, result: ToolResult): ToolResult {
708
+ const names = adviceTools(ctx);
709
+ const scope = (diagnostic: ToolDiagnostic): ToolDiagnostic => {
710
+ let allowed = false;
711
+ const recover = diagnostic.recover;
712
+ if (recover && names.includes(recover.tool)) {
713
+ try {
714
+ const input = normalizeToolInput(ctx, recover.tool, recover.input);
715
+ const definition = toolDefinition(recover.tool, ctx)!;
716
+ allowed = inputError(definition.input_schema, input, "input", definition.hiddenInputs) === null &&
717
+ decide([...(ctx.sessionRules ?? []), ...(ctx.rules ?? [])], permissionRequest(recover.tool, input)) !== "deny";
718
+ } catch { /* Unavailable or invalid advice is omitted; the original failure remains. */ }
719
+ }
720
+ return scopeFailureGuidance(diagnostic, names, allowed);
721
+ };
722
+ return { ...result, ...(result.diagnostic ? { diagnostic: scope(result.diagnostic) } : {}),
723
+ ...(result.warnings ? { warnings: result.warnings.map(scope) } : {}) };
724
+ }
725
+
726
+ export const NATIVE_COMMAND_NAME = "$extension.exec";
727
+ export interface NativeCommandInput { argv: string[]; cwd?: string; timeout?: number }
728
+ export interface NativeCommandResult { stdout: string; stderr: string; code: number; killed: boolean }
729
+
730
+ /** Pi A: host authority is installed by the broker, not selected by a model parameter. */
731
+ export function nativeCommandContext(ctx: ToolContext, signal?: AbortSignal): ToolContext {
732
+ return { ...ctx, origin: "extension", allow: ctx.readonlyBranch ? [] : [NATIVE_COMMAND_NAME], rules: [], sessionRules: [],
733
+ extensions: new Map([[NATIVE_COMMAND_NAME, nativeCommandDefinition]]),
734
+ ...(signal ? { signal: ctx.signal ? AbortSignal.any([ctx.signal, signal]) : signal } : {}) };
735
+ }
736
+
737
+ const nativeCommandDefinition: ToolDefinition = {
738
+ name: NATIVE_COMMAND_NAME, description: "Trusted local extension command with literal argv and independent pipe artifacts.",
739
+ access: "write", execution: "exclusive", branch: "none",
740
+ input_schema: { type: "object", properties: { argv: { type: "array", minItems: 1, items: { type: "string" } },
741
+ cwd: { type: "string" }, timeout: { type: "number", exclusiveMinimum: 0 } }, required: ["argv"] },
742
+ run: async (ctx, input) => {
743
+ const argv = input["argv"] as string[];
744
+ const requested = input["timeout"] as number | undefined;
745
+ if (requested !== undefined && (!Number.isFinite(requested) || requested <= 0)) return failure("invalid_input", "native timeout must be positive milliseconds");
746
+ const timeoutMs = commandTimeout(requested === undefined ? undefined : requested / 1000, ctx.timeoutMs, ctx.maxTimeoutMs);
747
+ const captured = await observeArgvCommand({ argv, cwd: typeof input["cwd"] === "string" ? resolve(ctx.root, input["cwd"]) : ctx.root,
748
+ outputStore: ctx.outputStore ?? join(ctx.root, ".xeno", "observations"), maxOutputBytes: ctx.maxOutputBytes, timeoutMs,
749
+ ...(ctx.signal ? { signal: ctx.signal } : {}), ...(ctx.onOutput ? { onOutput: ctx.onOutput } : {}) });
750
+ // A display representation only: execution above never reparses a command string.
751
+ const command = argv.map(arg => "'" + arg.replaceAll("'", "'\\''") + "'").join(" ");
752
+ const gate = matchGate(ctx, command);
753
+ const header = commandHeader({ exit: captured.exit, wallMs: captured.wallMs, timeoutMs,
754
+ ...(requested !== undefined ? { requestedMs: requested } : {}), ...(captured.stopped ? { stopped: captured.stopped } : {}) });
755
+ return { ...commandCompletion(captured.exit, captured.stopped, captured.cleanup),
756
+ content: header + "\n" + observationView(captured, ctx.maxOutputBytes - utf8Length(header) - 1, captured.prefixBytes, adviceTools(ctx)),
757
+ content_ref: captured.ref,
758
+ data: { code: captured.exit, killed: captured.stopped !== undefined, capture: captured.capture, stdout: captured.stdout, stderr: captured.stderr,
759
+ ...(captured.cleanup ? { cleanup: "unknown" } : {}) },
760
+ effect: { kind: "command", cmd: command, exit: captured.exit, ...(gate ? { gate } : {}), ...await commandFingerprint(ctx) } };
761
+ },
762
+ };
763
+
764
+ /** SDK strings are hydrated from integrity-checked artifacts after the independent receipt commits. */
765
+ export async function nativeCommandResult(result: ToolResult, store: string): Promise<NativeCommandResult> {
766
+ if (result.effect?.kind !== "command" || typeof result.data !== "object" || !result.data || Array.isArray(result.data)) throw new Error(result.content);
767
+ const data = result.data as { code: number; killed: boolean; capture: string; stdout: ArtifactRef; stderr: ArtifactRef };
768
+ if ("cleanup" in data && data.cleanup === "unknown") throw new Error("native command exited but process-group cleanup is unconfirmed; inspect the retained receipt before retrying");
769
+ if (data.capture !== "complete") throw new Error("native command exited but pipe capture is partial; inspect the retained receipt before retrying");
770
+ const [stdout, stderr] = await Promise.all([data.stdout, data.stderr].map(ref => readObservation(store, ref.sha256, 0, Math.max(1, ref.bytes))));
771
+ return { stdout: stdout!.text, stderr: stderr!.text, code: data.code, killed: data.killed };
772
+ }
773
+
550
774
  // ---------------------------------------------------------------------------
551
775
  // Implementations
552
776
  // ---------------------------------------------------------------------------
@@ -557,25 +781,23 @@ export function toolDefinition(name: string, ctx?: ToolContext): ToolDefinition
557
781
  * or a minified bundle cannot spend the observation budget on its own.
558
782
  */
559
783
  const READ_DEFAULT_LINES = 2_000;
560
- const READ_MAX_LINE_CHARS = 2_000;
561
-
562
- function clipLine(line: string, number: number): string {
563
- if (line.length <= READ_MAX_LINE_CHARS) return line;
564
- return `${line.slice(0, READ_MAX_LINE_CHARS)} [line truncated: ${line.length} chars; see the rest with bash, e.g. \`sed -n '${number}p' FILE | cut -c${READ_MAX_LINE_CHARS + 1}-\`]`;
565
- }
566
784
 
567
785
  async function toolRead(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
568
786
  const path = String(input["path"] ?? "");
569
- const abs = confine(ctx, path);
570
- if (typeof abs !== "string") return { content: abs.error, ok: false };
787
+ const resource = ctx.skillResources?.find(item => item.path === resolve(ctx.root, path));
788
+ if (resource && !resource.modelInvocable) return failure("permission_denied", "this skill requires explicit user invocation");
789
+ const abs = resource ? resource.path : confine(ctx, path);
790
+ if (typeof abs !== "string") return failure("permission_denied", abs.error);
571
791
 
572
792
  let text: string;
573
793
  let bytes: Uint8Array;
574
794
  try {
575
- bytes = await readFile(abs);
795
+ checkSourceCancellation(ctx.signal);
796
+ bytes = resource ? new TextEncoder().encode(resource.text) : await readFile(abs, ctx.signal ? { signal: ctx.signal } : {});
797
+ checkSourceCancellation(ctx.signal);
576
798
  text = Buffer.from(bytes).toString("utf8");
577
799
  } catch (err) {
578
- return { content: `cannot read ${path}: ${(err as Error).message}`, ok: false };
800
+ return caughtFailure(err, READ_ATTEMPTED, `cannot read ${path}: `);
579
801
  }
580
802
 
581
803
  if (ctx.imageStore && detectSupportedImageMimeType(bytes)) {
@@ -583,7 +805,7 @@ async function toolRead(ctx: ToolContext, input: Record<string, unknown>): Promi
583
805
  return { ok: true, content: imageDescription(image), images: [image], effect: { kind: "read", path, sha256: blobAddress(bytes) } };
584
806
  }
585
807
  if (bytes.subarray(0, 8_192).includes(0)) {
586
- return { ok: false, content: `${path} looks binary (${bytes.length} bytes, contains NUL); inspect it with bash, e.g. \`file ${path}\` or \`xxd ${path} | head\`` };
808
+ return failure("invalid_input", `${path} looks binary (${bytes.length} bytes, contains NUL); use a binary inspection tool such as xxd if permitted`, READ_ATTEMPTED);
587
809
  }
588
810
  const lines = toView(text).text.split("\n");
589
811
  // Without an end, a read stops after READ_DEFAULT_LINES so one large file
@@ -591,52 +813,106 @@ async function toolRead(ctx: ToolContext, input: Record<string, unknown>): Promi
591
813
  const first = input["start"] ?? 1;
592
814
  const end = input["end"] === undefined && Number.isSafeInteger(first) ? (first as number) + READ_DEFAULT_LINES - 1 : input["end"];
593
815
  const selected = readBounds(lines.length, input["start"], end);
594
- if (!selected.ok) return { ok: false, content: selected.message };
816
+ if (!selected.ok) return failure("invalid_input", selected.message, READ_ATTEMPTED);
595
817
  const { start } = selected;
596
818
  const slice = lines.slice(start - 1, selected.end);
597
- const numbered = slice.map((l, i) => `${start + i}\t${clipLine(l, start + i)}`).join("\n");
819
+ const version = sha256(bytes);
820
+ const store = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
821
+ async function* one(value: Uint8Array): AsyncGenerator<Uint8Array> { yield value; }
822
+ // Archive actual bytes before either presentation cut. Re-reading the current file is not recovery.
823
+ const raw = await archiveObservation(store, one(bytes), 0);
824
+ const source = { host: ctx.hostId ?? hostname(), workspace: resolve(ctx.root), resource: abs, sha256: version, raw: raw.ref };
825
+ const selection = { start, end: selected.end, total_lines: lines.length };
826
+ const descriptor: FileReadDescriptor = { kind: "file_read", version: 1, source, selection };
827
+ const handle = await archiveObservation(store, one(new TextEncoder().encode(JSON.stringify(descriptor))), 0);
828
+ const header = `${selected.notice}\nsha256: ${version}\nread_handle: ${handle.ref.sha256}\n`;
829
+ const full = new TextEncoder().encode(readObservationText(header, slice, start));
830
+ const observation = await archiveObservation(store, one(full), 0);
831
+ const preview = previewReadObservation(header, slice, start, observation.ref.sha256, ctx.maxOutputBytes, undefined, adviceTools(ctx));
598
832
 
599
833
  const ranges: LineRange[] = [[start, selected.end]];
600
834
  return {
601
- ...await presentObservation(ctx, `${selected.notice}\nsha256: ${sha256(bytes)}\n${numbered}`),
835
+ content: preview.text, content_ref: observation.ref, content_sha256: observation.ref.sha256,
836
+ observation: { version: 1, source, artifact: observation.ref, descriptor: handle.ref, selection, preview: preview.preview },
602
837
  ok: true,
603
- data: { path, sha256: sha256(bytes), start, end: selected.end, total_lines: lines.length, next_start: selected.end < lines.length ? selected.end + 1 : null },
604
- observation_sha256: sha256(numbered),
838
+ data: { path, sha256: version, read_handle: handle.ref.sha256, start, end: selected.end, total_lines: lines.length, next_start: selected.end < lines.length ? selected.end + 1 : null },
839
+ observation_sha256: sha256(preview.semantic),
605
840
  // The digest is of the whole file, not the slice: it answers "has this
606
841
  // file changed since the agent looked", which a partial read still bears on.
607
- effect: { kind: "read", path, sha256: sha256(bytes), ranges },
842
+ effect: { kind: "read", path, sha256: version, ranges },
608
843
  };
609
844
  }
610
845
 
846
+ /** Read descriptors are CAS data, not executable state or an alternate permission path. */
847
+ async function filePrecondition(ctx: ToolContext, input: Record<string, unknown>, absolute: string, current: string | null):
848
+ Promise<{ ok: true; precondition: FilePrecondition } | { ok: false; result: ToolResult }> {
849
+ const path = String(input["path"]), handle = input["read_handle"] as string | undefined;
850
+ const expected = input["expected_sha256"] as string | undefined, createOnly = input["create_only"] === true;
851
+ const recover = { tool: "read", input: { path } };
852
+ const reject = (code: ToolDiagnostic["code"], message: string): { ok: false; result: ToolResult } => ({ ok: false,
853
+ result: { ...failure(code, message, READ_ATTEMPTED, { recover }), data: { path, sha256: current } } });
854
+ let descriptor: FileReadDescriptor | undefined;
855
+ if (handle !== undefined) {
856
+ if (!isFileDigest(handle)) return reject("invalid_input", "read_handle must be a descriptor SHA-256 returned by read");
857
+ try {
858
+ // Assumed metadata budget: descriptors contain identity and range, never file text.
859
+ const page = await readObservation(ctx.outputStore ?? join(ctx.root, ".xeno", "observations"), handle, 0, 64 * 1024);
860
+ if (page.next !== null) return reject("invalid_input", "read_handle descriptor exceeds the 64 KiB metadata limit");
861
+ const value: unknown = JSON.parse(page.text);
862
+ if (!isFileReadDescriptor(value)) return reject("invalid_input", "read_handle does not name a file read descriptor");
863
+ descriptor = value;
864
+ } catch { return reject("not_found", "read_handle descriptor is missing, corrupt, or unreadable; read the file here again"); }
865
+ }
866
+ const observed = handle === undefined && expected === undefined && !createOnly ? await ctx.observedFileVersion?.(absolute) : undefined;
867
+ const decision = checkFilePrecondition({ host: ctx.hostId ?? hostname(), workspace: resolve(ctx.root), resource: absolute }, current, {
868
+ ...(descriptor === undefined ? {} : { descriptor }), ...(handle === undefined ? {} : { read_handle: handle }),
869
+ ...(expected === undefined ? {} : { expected_sha256: expected }), ...(observed === undefined ? {} : { observed }), create_only: createOnly,
870
+ });
871
+ if (decision.ok) return decision;
872
+ const rejected = reject(decision.code, `${decision.message} (${path})`);
873
+ if (decision.precondition) rejected.result.data = { path, sha256: current, precondition: { ...decision.precondition } };
874
+ return rejected;
875
+ }
876
+
877
+ async function fileCommitFailure(ctx: ToolContext, absolute: string, path: string, error: unknown): Promise<ToolResult> {
878
+ const result = caughtFailure(error, UNKNOWN_EFFECT, `cannot write ${path}: `);
879
+ if (result.diagnostic?.code !== "version_mismatch") return result;
880
+ try {
881
+ const current = await readEditableFile(ctx.pathRoot ?? ctx.root, absolute);
882
+ const version = current.bytes === null ? null : sha256(current.bytes);
883
+ return { ...result, content: `${result.content}\ncurrent sha256: ${version ?? "absent"}`, data: { path, sha256: version } };
884
+ } catch { return result; } // A failed follow-up observation cannot invent the current version.
885
+ }
886
+
611
887
  async function toolWrite(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
612
888
  const path = String(input["path"] ?? "");
613
889
  const content = String(input["content"] ?? "");
614
890
  const abs = confine(ctx, path);
615
- if (typeof abs !== "string") return { content: abs.error, ok: false };
891
+ if (typeof abs !== "string") return failure("permission_denied", abs.error);
616
892
 
617
- let before: Uint8Array | null = null;
618
- try {
619
- before = await readFile(abs);
620
- } catch (error) {
621
- if ((error as { code?: string }).code !== "ENOENT") return { ok: false, content: `cannot fingerprint ${path}: ${String(error)}` };
622
- }
893
+ const saved = await readEditableFile(ctx.pathRoot ?? ctx.root, abs);
894
+ const before = saved.bytes;
895
+ const previous = before === null ? null : sha256(before), after = new TextEncoder().encode(content), version = sha256(after);
896
+ const checked = await filePrecondition(ctx, input, abs, previous);
897
+ if (!checked.ok) return checked.result;
898
+ const data = { path, bytes: after.length, sha256_before: previous, sha256_after: version, precondition: { ...checked.precondition } };
899
+ if (previous === version) return { ok: true, content: `no change to ${path}; sha256: ${version}`, data: { ...data, changed: false } };
623
900
 
624
901
  try {
625
- await mkdir(dirname(abs), { recursive: true });
626
- await writeFile(abs, content, "utf8");
902
+ await commitEdit(ctx, abs, saved, after);
627
903
  } catch (err) {
628
- return { content: `cannot write ${path}: ${(err as Error).message}`, ok: false };
904
+ return fileCommitFailure(ctx, abs, path, err);
629
905
  }
630
906
 
631
907
  return {
632
908
  content: `wrote ${new TextEncoder().encode(content).length} bytes to ${path}`,
633
909
  ok: true,
634
- data: { path, bytes: new TextEncoder().encode(content).length, sha256_before: before === null ? null : sha256(before), sha256_after: sha256(content) },
910
+ data: { ...data, changed: true },
635
911
  effect: {
636
912
  kind: "edit",
637
913
  path,
638
- sha256_before: before === null ? null : sha256(before),
639
- sha256_after: sha256(content),
914
+ sha256_before: previous,
915
+ sha256_after: version,
640
916
  },
641
917
  };
642
918
  }
@@ -651,26 +927,21 @@ async function toolEdit(ctx: ToolContext, input: Record<string, unknown>): Promi
651
927
  : [];
652
928
  if (edits.length === 0) {
653
929
  const message = "edit needs old_string and new_string, or edits[]";
654
- return { ok: false, content: message, diagnostic: { code: "invalid_input", message } };
930
+ return failure("invalid_input", message);
655
931
  }
656
932
 
657
933
  const abs = confine(ctx, path);
658
- if (typeof abs !== "string") return { content: abs.error, ok: false };
659
-
660
- let before: string;
661
- try {
662
- before = await readFile(abs, "utf8");
663
- } catch (err) {
664
- return { content: `cannot read ${path}: ${(err as Error).message}`, ok: false };
665
- }
934
+ if (typeof abs !== "string") return failure("permission_denied", abs.error);
666
935
 
667
- const version = sha256(before);
936
+ const saved = await readEditableFile(ctx.pathRoot ?? ctx.root, abs);
937
+ const version = saved.bytes === null ? null : sha256(saved.bytes);
938
+ const checked = await filePrecondition(ctx, input, abs, version);
939
+ if (!checked.ok) return checked.result;
940
+ if (saved.bytes === null) return failure("not_found", `cannot read ${path}: file does not exist`, READ_ATTEMPTED);
668
941
  const recover = { tool: "read", input: { path } };
669
- if (input["expected_sha256"] !== undefined && input["expected_sha256"] !== version) {
670
- const message = `file version changed for ${path}; current sha256: ${version}. Read the current file before retrying.`;
671
- return { ok: false, content: message, data: { path, sha256: version },
672
- diagnostic: { code: "version_mismatch", message, recover } };
673
- }
942
+ let before: string;
943
+ try { before = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(saved.bytes); }
944
+ catch { return failure("invalid_input", `cannot edit ${path}: invalid UTF-8; byte-preserving inspection is required`, READ_ATTEMPTED); }
674
945
  const plan = planEdits(before, edits);
675
946
  if (!plan.ok) {
676
947
  const closest = plan.closest === undefined ? "" :
@@ -681,38 +952,47 @@ async function toolEdit(ctx: ToolContext, input: Record<string, unknown>): Promi
681
952
  return { ok: false, content: `${message}${closest}\ncurrent sha256: ${version}`,
682
953
  data: { path, sha256: version, occurrences: plan.occurrences, edit: plan.edit,
683
954
  ...(plan.closest === undefined ? {} : { closest: { start_line: plan.closest.startLine, end_line: plan.closest.endLine, score: plan.closest.score } }) },
684
- diagnostic: { code: plan.code, message, recover: plan.closest === undefined ? recover
685
- : { tool: "read", input: { path, start: plan.closest.startLine, end: plan.closest.endLine } } } };
955
+ diagnostic: failureDiagnostic(plan.code, message, READ_ATTEMPTED, { recover: plan.closest === undefined ? recover
956
+ : { tool: "read", input: { path, start: plan.closest.startLine, end: plan.closest.endLine } } }) };
686
957
  }
687
958
  const { after } = plan;
688
959
  try {
689
- await writeFile(abs, after, "utf8");
960
+ await commitEdit(ctx, abs, saved, new TextEncoder().encode(after));
690
961
  } catch (err) {
691
- return { content: `cannot write ${path}: ${(err as Error).message}`, ok: false };
962
+ return fileCommitFailure(ctx, abs, path, err);
692
963
  }
693
964
 
694
965
  const fuzzy = plan.fuzzy ? " (fuzzy: matched ignoring trailing whitespace, quote and dash style; check the diff)" : "";
695
966
  return {
696
967
  ...await presentObservation(ctx, `replaced ${plan.replacements} occurrence(s) in ${path}${fuzzy}\nsha256: ${sha256(after)}\n${plan.diff}`),
697
968
  ok: true,
698
- data: { path, replacements: plan.replacements, fuzzy: plan.fuzzy, first_changed_line: plan.firstLine, sha256_before: version, sha256_after: sha256(after) },
969
+ data: { path, changed: true, precondition: { ...checked.precondition }, replacements: plan.replacements, fuzzy: plan.fuzzy, first_changed_line: plan.firstLine, sha256_before: version, sha256_after: sha256(after) },
699
970
  effect: {
700
971
  kind: "edit",
701
972
  path,
702
- sha256_before: sha256(before),
973
+ sha256_before: version,
703
974
  sha256_after: sha256(after),
704
975
  },
705
976
  };
706
977
  }
707
978
 
979
+ async function toolEnvironment(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
980
+ const cwd = typeof input["workdir"] === "string" && input["workdir"].length > 0 ? confine(ctx, input["workdir"]) : ctx.root;
981
+ if (typeof cwd !== "string") return failure("permission_denied", cwd.error);
982
+ const result = await observeEnvironment(ctx, cwd, String(input["program"]), input["probe"] === "python" ? "python" : "version", String(input["command"]));
983
+ return result.effect?.kind === "command" ? { ...result, effect: { ...result.effect, ...await commandFingerprint(ctx) } } : result;
984
+ }
985
+
708
986
  async function toolBash(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
709
987
  const command = String(input["command"] ?? "");
710
- if (command.trim().length === 0) return { content: "empty command", ok: false };
988
+ if (command.trim().length === 0) return failure("invalid_input", "empty command");
711
989
  const cwd = typeof input["workdir"] === "string" && input["workdir"].length > 0 ? confine(ctx, input["workdir"]) : ctx.root;
712
- if (typeof cwd !== "string") return { content: cwd.error, ok: false };
990
+ if (typeof cwd !== "string") return failure("permission_denied", cwd.error);
713
991
  const requestedMs = typeof input["timeout"] === "number" ? input["timeout"] * 1000 : undefined;
714
- const timeoutMs = Math.min(requestedMs ?? ctx.timeoutMs, ctx.maxTimeoutMs ?? ctx.timeoutMs);
992
+ const timeoutMs = commandTimeout(typeof input["timeout"] === "number" ? input["timeout"] : undefined, ctx.timeoutMs, ctx.maxTimeoutMs);
715
993
 
994
+ const observer = await beginCommandArtifacts(ctx, command, cwd, input["artifacts"]);
995
+ if (ctx.signal?.aborted) return cancelledTool();
716
996
  const captured = await observeCommand({ command, cwd,
717
997
  outputStore: ctx.outputStore ?? join(ctx.root, ".xeno", "observations"),
718
998
  maxOutputBytes: ctx.maxOutputBytes, timeoutMs,
@@ -722,26 +1002,29 @@ async function toolBash(ctx: ToolContext, input: Record<string, unknown>): Promi
722
1002
 
723
1003
  const gate = matchGate(ctx, command);
724
1004
  const exit = captured.exit;
725
- const observed = await scanWorkspace(ctx.root, FINGERPRINT_IGNORE);
1005
+ const artifacts = await observer?.finish(captured);
1006
+ const observation = await commandFingerprint(ctx);
726
1007
  const header = commandHeader({ exit, wallMs: captured.wallMs, timeoutMs,
727
1008
  ...(requestedMs !== undefined ? { requestedMs } : {}),
728
1009
  ...(captured.stopped ? { stopped: captured.stopped } : {}) });
1010
+ const artifactSummary = commandArtifactSummary(artifacts);
729
1011
 
730
1012
  return {
731
1013
  // The artifact starts with `exit N\n`; the view replaces that line with
732
1014
  // the full header and pages the rest, so offsets stay artifact offsets.
733
- content: header + "\n" + observationView(captured, ctx.maxOutputBytes - utf8Length(header) - 1, captured.prefixBytes),
1015
+ content: header + artifactSummary + "\n" + observationView(captured,
1016
+ ctx.maxOutputBytes - utf8Length(header + artifactSummary) - 1, captured.prefixBytes, adviceTools(ctx)),
734
1017
  content_ref: captured.ref,
735
- ok: captured.stopped === undefined && exit === 0,
736
- outcome: executionOutcome(captured.stopped ?? (exit === 0 ? "tool_completed" : "tool_failed")),
737
- data: { exit, output: captured.ref },
1018
+ ...commandCompletion(exit, captured.stopped, captured.cleanup),
1019
+ data: { exit, output: captured.ref, ...(captured.cleanup ? { cleanup: "unknown" } : {}), ...(artifacts ? { artifacts } : {}) },
738
1020
  effect: {
739
1021
  kind: "command",
740
1022
  cmd: command,
741
1023
  exit,
742
1024
  // Attached from configured patterns, never guessed from the text.
743
1025
  ...(gate !== undefined ? { gate } : {}),
744
- ...(observed.ok ? { workspace_sha256: workspaceVersion(observed.files) } : {}),
1026
+ ...observation,
1027
+ ...(artifacts ? { artifacts } : {}),
745
1028
  },
746
1029
  };
747
1030
  }
@@ -759,61 +1042,100 @@ const FINGERPRINT_IGNORE: readonly string[] = [...DEFAULT_IGNORE,
759
1042
  "target", ".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox",
760
1043
  ".gradle", ".next", ".turbo", ".cache"];
761
1044
 
1045
+ async function commitEdit(ctx: ToolContext, absolute: string, before: Awaited<ReturnType<typeof readEditableFile>>, after: Uint8Array): Promise<void> {
1046
+ const path = relative(ctx.root, absolute);
1047
+ if (ctx.workspace && path !== ".." && !path.startsWith(`..${sep}`)) await ctx.workspace.edit(path, before, after, ctx.signal);
1048
+ else await replaceFile(ctx.pathRoot ?? ctx.root, absolute, before.version, after, before.version?.mode ?? (0o666 & ~process.umask()), ctx.signal);
1049
+ }
1050
+
1051
+ async function commandFingerprint(ctx: ToolContext): Promise<{ workspace_sha256?: string; workspace_observed?: boolean; workspace_scope?: "source-v1" }> {
1052
+ if (ctx.legacyWorkspace) {
1053
+ const observed = await scanWorkspace(ctx.root, FINGERPRINT_IGNORE);
1054
+ return observed.ok ? { workspace_sha256: workspaceVersion(observed.files) } : {};
1055
+ }
1056
+ const observed = await observeWorkspace(ctx.root, ctx.signal, ctx.ownedStatePaths);
1057
+ return observed.ok ? { workspace_sha256: observed.fingerprint, workspace_observed: true, workspace_scope: "source-v1" } : { workspace_observed: false, workspace_scope: "source-v1" };
1058
+ }
1059
+
762
1060
  const clampYield = (value: unknown) =>
763
1061
  Math.min(MAX_YIELD_MS, Math.max(MIN_YIELD_MS, typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_YIELD_MS));
764
1062
 
765
1063
  async function toolExec(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
766
1064
  const command = String(input["cmd"] ?? "");
767
- if (command.trim().length === 0) return { content: "empty command", ok: false };
1065
+ if (command.trim().length === 0) return failure("invalid_input", "empty command");
768
1066
  const cwd = typeof input["workdir"] === "string" && input["workdir"].length > 0 ? confine(ctx, input["workdir"]) : ctx.root;
769
- if (typeof cwd !== "string") return { content: cwd.error, ok: false };
770
- const requestedMs = typeof input["timeout"] === "number" ? input["timeout"] * 1000 : undefined;
771
- const timeoutMs = Math.min(requestedMs ?? ctx.timeoutMs, ctx.maxTimeoutMs ?? ctx.timeoutMs);
1067
+ if (typeof cwd !== "string") return failure("permission_denied", cwd.error);
1068
+ const timeoutMs = commandTimeout(typeof input["timeout"] === "number" ? input["timeout"] : undefined, ctx.timeoutMs, ctx.maxTimeoutMs);
772
1069
  const outputStore = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
773
- const session = await startSession({ command, cwd, tty: input["tty"] === true, timeoutMs, spoolDir: outputStore });
774
- if ("error" in session) return { ok: false, content: session.error };
775
- await waitSession(session, clampYield(input["yield_time_ms"]), ctx.signal);
776
- return sessionResult(ctx, session.id, command, timeoutMs, await takeOutput(session, outputStore, ctx.maxOutputBytes));
1070
+ const artifactObserver = await beginCommandArtifacts(ctx, command, cwd, input["artifacts"]);
1071
+ if (ctx.signal?.aborted) return cancelledTool();
1072
+ const session = await startSession({ command, cwd, tty: input["tty"] === true, timeoutMs, spoolDir: outputStore,
1073
+ source: { host: ctx.hostId ?? hostname(), workspace: resolve(ctx.root), cwd, runtime: `bun/${Bun.version}` },
1074
+ ...(artifactObserver ? { artifactObserver } : {}),
1075
+ ...(ctx.execOwner === undefined ? {} : { owner: ctx.execOwner }) });
1076
+ if ("error" in session) return failure("execution_failed", session.error);
1077
+ return sessionResult(ctx, command, timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, clampYield(input["yield_time_ms"]), "", ctx.signal));
777
1078
  }
778
1079
 
779
1080
  async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
780
1081
  const id = Number(input["session_id"]);
781
- const session = findSession(id);
1082
+ const chars = typeof input["chars"] === "string" ? input["chars"] : "";
1083
+ const session = findSession(id, ctx.execOwner);
782
1084
  if (session === undefined) {
783
- const running = runningSessions().map((s) => s.id).join(", ");
784
- return { ok: false, content: `no running session ${id}` +
785
- (running.length > 0 ? `; running sessions: ${running}` : "; sessions end when their process exits or xeno restarts") };
1085
+ const closed = closedSession(id, ctx.execOwner);
1086
+ if (closed) return receiptResult(ctx, { ...closed.receipt, replayed: true }, chars);
1087
+ const original = await ctx.observedProcess?.(id);
1088
+ if (original) return receiptResult(ctx, recoverProcessReceipt(original.receipt, original.origin), chars);
1089
+ const running = runningSessions(ctx.execOwner).map((s) => s.id).join(", ");
1090
+ return { ...failure("unknown_effect", `no running session ${id}; no observation in this session's active history` +
1091
+ (running.length > 0 ? `; running sessions: ${running}` : ""), UNKNOWN_EFFECT),
1092
+ data: { kind: "process", version: 1, session_id: id, state: "unknown", capability: "unknown", source: null, running: null, exit: null, replayed: true } };
786
1093
  }
787
- const chars = typeof input["chars"] === "string" ? input["chars"] : "";
788
- if (chars.length > 0 && session.exit === null) session.write(chars);
789
- await waitSession(session, clampYield(input["yield_time_ms"]), ctx.signal);
790
1094
  const outputStore = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
791
- return sessionResult(ctx, id, session.command, session.timeoutMs, await takeOutput(session, outputStore, ctx.maxOutputBytes));
1095
+ return sessionResult(ctx, session.command, session.timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, clampYield(input["yield_time_ms"]), chars, ctx.signal));
1096
+ }
1097
+
1098
+ /** A successful historical query proves only that the receipt was found, never a new command exit. */
1099
+ function receiptResult(ctx: ToolContext, receipt: ProcessReceipt, chars: string): ToolResult {
1100
+ const location = receipt.origin ? `; original node ${receipt.origin.node} event ${receipt.origin.event}` : "";
1101
+ const observed = receipt.running === false ? `last observed ${receipt.state}, exit ${receipt.exit}` : "last observed running; current process state unknown";
1102
+ const artifact = receipt.full_output ?? receipt.output;
1103
+ const content = `session ${receipt.session_id}: ${observed}; control ${receipt.capability}; historical receipt, no execution${location}` + commandArtifactSummary(receipt.artifacts) +
1104
+ (artifact ? `\nCaptured output: ${artifactRecovery(artifact.sha256, 0, adviceTools(ctx))}` : "");
1105
+ return { ...(chars.length > 0 ? failure("not_found", "no live handle; input was not sent", NOT_STARTED)
1106
+ : receipt.running === false ? { ok: true } : failure("unknown_effect", "live handle lost; inspect existing state before replay", UNKNOWN_EFFECT)),
1107
+ content: content + (chars.length > 0 ? "\nInput was not sent: no live handle." : ""), data: receipt,
1108
+ ...(receipt.output ? { content_ref: receipt.output, content_sha256: receipt.output.sha256 } : {}) };
792
1109
  }
793
1110
 
794
1111
  /**
795
1112
  * One call's view of a session. A running session proves nothing yet, so it
796
1113
  * carries no effect; only an observed exit does, exactly as bash's would (D11).
797
1114
  */
798
- async function sessionResult(ctx: ToolContext, id: number, command: string, timeoutMs: number,
799
- taken: Awaited<ReturnType<typeof takeOutput>>): Promise<ToolResult> {
1115
+ export async function sessionResult(ctx: ToolContext, command: string, timeoutMs: number,
1116
+ taken: Awaited<ReturnType<typeof pollSession>>): Promise<ToolResult> {
800
1117
  const { stored, exit } = taken;
1118
+ const id = taken.receipt.session_id;
1119
+ if (taken.receipt.replayed) return receiptResult(ctx, taken.receipt, "");
801
1120
  const header = exit === null
802
- ? `running · session ${id} · ${(taken.wallMs / 1000).toFixed(1)}s elapsed; poll or send input with write_stdin(session_id=${id})`
803
- : commandHeader({ exit, wallMs: taken.wallMs, timeoutMs, ...(taken.stopped ? { stopped: taken.stopped } : {}) }) + ` · session ${id} closed`;
804
- const content = header + "\n" + observationView(stored, ctx.maxOutputBytes - utf8Length(header) - 1);
1121
+ ? `running · session ${id} · ${(taken.wallMs / 1000).toFixed(1)}s elapsed; ` + (adviceTools(ctx).includes("write_stdin")
1122
+ ? `poll or send input with write_stdin(session_id=${id})`
1123
+ : "process-control capability unavailable; execution remains unfinished; do not replay")
1124
+ : commandHeader({ exit, wallMs: taken.wallMs, timeoutMs, managed: true, ...(taken.stopped ? { stopped: taken.stopped } : {}) }) + ` · session ${id} closed`;
1125
+ const artifactSummary = commandArtifactSummary(taken.receipt.artifacts);
1126
+ const content = header + artifactSummary + "\n" + observationView(stored, ctx.maxOutputBytes - utf8Length(header + artifactSummary) - 1, 0, adviceTools(ctx));
1127
+ const warnings = taken.receipt.capture === "partial" ? [failureDiagnostic("unknown_effect", "output capture is partial; full_output contains only captured bytes", UNKNOWN_EFFECT)] : [];
805
1128
  if (exit === null) {
806
- return { ok: true, content, content_ref: stored.ref, data: { session_id: id, running: true, output: stored.ref } };
1129
+ return { ok: true, content, content_ref: stored.ref, content_sha256: stored.ref.sha256, data: taken.receipt, ...(warnings.length ? { warnings } : {}) };
807
1130
  }
808
1131
  const gate = matchGate(ctx, command);
809
- const observed = await scanWorkspace(ctx.root, FINGERPRINT_IGNORE);
1132
+ const observation = await commandFingerprint(ctx);
810
1133
  return {
811
- ok: exit === 0 && taken.stopped === undefined,
812
- content, content_ref: stored.ref,
813
- outcome: executionOutcome(taken.stopped ?? (exit === 0 ? "tool_completed" : "tool_failed")),
814
- data: { session_id: id, running: false, exit, output: stored.ref },
1134
+ ...commandCompletion(exit, taken.stopped),
1135
+ content, content_ref: stored.ref, content_sha256: stored.ref.sha256,
1136
+ data: taken.receipt, ...(warnings.length ? { warnings } : {}),
815
1137
  effect: { kind: "command", cmd: command, exit, ...(gate !== undefined ? { gate } : {}),
816
- ...(observed.ok ? { workspace_sha256: workspaceVersion(observed.files) } : {}) },
1138
+ ...observation, ...(taken.receipt.artifacts ? { artifacts: taken.receipt.artifacts } : {}) },
817
1139
  };
818
1140
  }
819
1141
 
@@ -825,11 +1147,11 @@ const utf8Length = (text: string) => new TextEncoder().encode(text).length;
825
1147
  * The marker names the artifact and the byte offset where the gap starts, so
826
1148
  * the missing middle is one `history` call away rather than a rerun.
827
1149
  */
828
- function observationView(stored: ArchivedObservation, limit: number, skip = 0): string {
1150
+ function observationView(stored: ArchivedObservation, limit: number, skip = 0, tools?: readonly string[]): string {
829
1151
  const { ref } = stored;
830
1152
  const marker = (omitted: number, offset: number) =>
831
1153
  `\n[truncated ${omitted} bytes at offset ${offset + skip}; full output: ${ref.path}; ` +
832
- `history artifact=${ref.sha256} offset=${offset + skip}]\n`;
1154
+ `${artifactRecovery(ref.sha256, offset + skip, tools)}]\n`;
833
1155
  const total = ref.bytes - skip;
834
1156
  const tail = stored.tail.subarray(Math.max(0, stored.tail.length - total));
835
1157
  return headTailView(stored.head.subarray(skip), tail, total, Math.max(0, limit), marker);
@@ -840,7 +1162,7 @@ export async function presentObservation(ctx: ToolContext, content: string): Pro
840
1162
  if (bytes.length <= ctx.maxOutputBytes) return { content, content_sha256: sha256(bytes) };
841
1163
  async function* source(): AsyncGenerator<Uint8Array> { yield bytes; }
842
1164
  const stored = await archiveObservation(ctx.outputStore ?? join(ctx.root, ".xeno", "observations"), source(), ctx.maxOutputBytes);
843
- return { content: observationView(stored, ctx.maxOutputBytes), content_ref: stored.ref, content_sha256: stored.ref.sha256 };
1165
+ return { content: observationView(stored, ctx.maxOutputBytes, 0, adviceTools(ctx)), content_ref: stored.ref, content_sha256: stored.ref.sha256 };
844
1166
  }
845
1167
 
846
1168
  function toolPath(ctx: ToolContext, input: Record<string, unknown>): string | { error: string } {
@@ -861,7 +1183,7 @@ function grepEngine(input: Record<string, unknown>): "auto" | "ripgrep" | "walk"
861
1183
 
862
1184
  async function toolGrep(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
863
1185
  const abs = toolPath(ctx, input);
864
- if (typeof abs !== "string") return { content: abs.error, ok: false };
1186
+ if (typeof abs !== "string") return failure("permission_denied", abs.error);
865
1187
  const glob = typeof input["glob"] === "string" ? input["glob"] : undefined;
866
1188
  const limit = optionalLimit(input);
867
1189
  const result = await grep({
@@ -870,16 +1192,50 @@ async function toolGrep(ctx: ToolContext, input: Record<string, unknown>): Promi
870
1192
  pattern: String(input["pattern"] ?? ""),
871
1193
  engine: grepEngine(input),
872
1194
  timeoutMs: ctx.timeoutMs,
1195
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
873
1196
  ...(glob !== undefined ? { glob } : {}),
874
1197
  ...(limit !== undefined ? { limit } : {}),
875
1198
  });
876
- return { ...await presentObservation(ctx, result.content), ok: result.ok };
1199
+ return presentSearch(ctx, "grep", input, abs, result);
1200
+ }
1201
+
1202
+ async function presentSearch(ctx: ToolContext, tool: string, input: Record<string, unknown>, resource: string, result: SearchResult): Promise<ToolResult> {
1203
+ const observation = result.observation;
1204
+ if (!observation) return { ok: result.ok, ...await presentObservation(ctx, result.content), ...(result.diagnostic ? { diagnostic: result.diagnostic } : {}) };
1205
+ const store = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
1206
+ const archive = async (bytes: Uint8Array) => {
1207
+ async function* source() { yield bytes; }
1208
+ return (await archiveObservation(store, source(), 0)).ref;
1209
+ };
1210
+ const full = await archive(new TextEncoder().encode(searchObservationText(observation.rows)));
1211
+ const raw = observation.raw ? await archive(observation.raw) : undefined;
1212
+ const rawErrors = observation.rawErrors?.length ? await archive(observation.rawErrors) : undefined;
1213
+ const manifest = await archive(new TextEncoder().encode(JSON.stringify({ version: 1, tool, input, resource, engine: observation.engine,
1214
+ scan: observation.scan, sources: observation.sources, scope: { ignored: DEFAULT_IGNORE, follows_symlinks: false,
1215
+ max_file_bytes: tool === "grep" ? MAX_FILE_BYTES : null, execution_byte_budget: MAX_SEARCH_CAPTURE_BYTES },
1216
+ matches: observation.rows.length, artifact: full, ...(raw ? { raw } : {}), ...(rawErrors ? { raw_errors: rawErrors } : {}) })));
1217
+ const preview = previewSearchObservation(observation, full.sha256, ctx.maxOutputBytes, 500,
1218
+ `[engine=${observation.engine}; scan=${observation.scan.status}; sources=${observation.scan.source_versions}; manifest=${manifest.sha256}]` +
1219
+ (result.diagnostic ? `\n[${result.diagnostic.message}]` : ""), adviceTools(ctx));
1220
+ const data = { kind: "search", version: 1, source: { host: ctx.hostId ?? hostname(), workspace: ctx.root, resource },
1221
+ engine: observation.engine, scan: { status: observation.scan.status, reasons: [...new Set(observation.scan.reasons.map(reason => reason.code))],
1222
+ examined_files: observation.scan.examined_files, source_versions: observation.scan.source_versions },
1223
+ matches: observation.rows.length, artifact: full, manifest, ...(raw ? { raw } : {}), ...(rawErrors ? { raw_errors: rawErrors } : {}), preview: preview.preview };
1224
+ if (!isJsonValue(data)) throw new Error("search produced a non-JSON receipt");
1225
+ return { ok: result.ok, content: preview.text, content_ref: full, content_sha256: full.sha256,
1226
+ observation_sha256: sha256(preview.semantic), data, ...(result.diagnostic ? { diagnostic: result.diagnostic,
1227
+ ...(result.diagnostic.code === "cancelled" || result.diagnostic.code === "timeout" ? { outcome: executionOutcome(result.diagnostic.code) } : {}) } : {}) };
877
1228
  }
878
1229
 
879
1230
  async function toolQuery(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
880
- const query = parseDataQuery(input);
881
- const { text, sources } = await readQuerySources(ctx.root, input["paths"]);
882
- const value = { sources, ...queryData(text, query) };
1231
+ let query: ReturnType<typeof parseQueryInput>;
1232
+ try { query = parseQueryInput(input); }
1233
+ catch (error) { return failure("invalid_input", String(error)); }
1234
+ const { text, sources } = await readQuerySources(ctx.root, input["paths"], ctx.signal);
1235
+ checkSourceCancellation(ctx.signal);
1236
+ let value: { sources: typeof sources } & ReturnType<typeof queryData>;
1237
+ try { value = { sources, ...queryData(text, query) }; }
1238
+ catch (error) { return failure("invalid_input", String(error), READ_ATTEMPTED); }
883
1239
  if (!isJsonValue(value)) throw new Error("query produced a non-JSON value");
884
1240
  const observation = await presentObservation(ctx, JSON.stringify(value));
885
1241
  return { ok: true, ...observation, data: observation.content_ref ? { artifact: observation.content_ref, format: "json" } : value };
@@ -887,28 +1243,30 @@ async function toolQuery(ctx: ToolContext, input: Record<string, unknown>): Prom
887
1243
 
888
1244
  async function toolFind(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
889
1245
  const abs = toolPath(ctx, input);
890
- if (typeof abs !== "string") return { content: abs.error, ok: false };
1246
+ if (typeof abs !== "string") return failure("permission_denied", abs.error);
891
1247
  const limit = optionalLimit(input);
892
1248
  const result = await findFiles({
893
1249
  root: searchRoot(ctx, abs),
894
1250
  path: abs,
895
1251
  pattern: String(input["pattern"] ?? ""),
896
1252
  timeoutMs: ctx.timeoutMs,
1253
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
897
1254
  ...(limit !== undefined ? { limit } : {}),
898
1255
  });
899
- return { ...await presentObservation(ctx, result.content), ok: result.ok };
1256
+ return presentSearch(ctx, "find", input, abs, result);
900
1257
  }
901
1258
 
902
1259
  async function toolLs(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
903
1260
  const abs = toolPath(ctx, input);
904
- if (typeof abs !== "string") return { content: abs.error, ok: false };
1261
+ if (typeof abs !== "string") return failure("permission_denied", abs.error);
905
1262
  const limit = optionalLimit(input);
906
1263
  const result = await ls({
907
1264
  root: searchRoot(ctx, abs),
908
1265
  path: abs,
1266
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
909
1267
  ...(limit !== undefined ? { limit } : {}),
910
1268
  });
911
- return { ...await presentObservation(ctx, result.content), ok: result.ok };
1269
+ return presentSearch(ctx, "ls", input, abs, result);
912
1270
  }
913
1271
 
914
1272
  /**
@@ -924,11 +1282,12 @@ export const BRANCH_TOOL_NAMES = TOOL_DEFINITIONS.filter(tool => tool.branch ===
924
1282
  /** Writing adds effects to the shared evidence tools; new readers cannot go missing. */
925
1283
  export const WRITING_BRANCH_TOOL_NAMES = TOOL_DEFINITIONS.filter(tool => tool.branch !== "none").map(tool => tool.name);
926
1284
 
927
- export function branchTools(writes = false): ToolSchema[] {
1285
+ export function branchTools(writes = false, only?: readonly string[]): ToolSchema[] {
928
1286
  const allowed = new Set<string>(
929
1287
  writes ? WRITING_BRANCH_TOOL_NAMES : BRANCH_TOOL_NAMES,
930
1288
  );
931
- return TOOL_SCHEMAS.filter((t) => allowed.has(t.name));
1289
+ const definitions = TOOL_DEFINITIONS.filter(t => allowed.has(t.name) && (only === undefined || only.includes(t.name)));
1290
+ return definitions.map(tool => modelToolSchema(tool, definitions.map(item => item.name)));
932
1291
  }
933
1292
 
934
1293
  /**
@@ -951,15 +1310,16 @@ export async function gateTool(
951
1310
  // the approval prompt. The model should not have seen it; if a call
952
1311
  // arrives anyway it is answered, not executed.
953
1312
  if (ctx.allow && !ctx.allow.includes(name)) {
954
- return {
955
- content: `refused: ${name} is not available here (allowed: ${ctx.allow.join(", ")})`,
956
- ok: false,
957
- };
1313
+ return failure("permission_denied", `refused: ${name} is not available here (allowed: ${ctx.allow.join(", ")})`);
958
1314
  }
959
1315
  const definition = toolDefinition(name, ctx);
960
1316
  input = repairToolInput(name, input);
961
1317
  const invalid = definition ? inputError(definition.input_schema, input, "input", definition.hiddenInputs) : null;
962
- if (invalid) return { ok: false, content: invalid, diagnostic: { code: "invalid_input", message: invalid } };
1318
+ if (invalid) {
1319
+ const example = minimalInput(definition!.input_schema);
1320
+ return failure("invalid_input", invalid, NOT_STARTED, example !== null && typeof example === "object" && !Array.isArray(example) &&
1321
+ inputError(definition!.input_schema, example) === null ? { example: example as { [key: string]: JsonValue } } : {});
1322
+ }
963
1323
  if (ctx.rules === undefined) return null;
964
1324
 
965
1325
  const request = permissionRequest(name, input);
@@ -969,13 +1329,15 @@ export async function gateTool(
969
1329
  case "allow":
970
1330
  return null;
971
1331
  case "deny":
972
- return { content: deniedMessage(request), ok: false };
1332
+ return failure("permission_denied", deniedMessage(request));
973
1333
  case "ask": {
974
1334
  const ask = ctx.ask ?? ctx.askSlot?.current;
975
1335
  if (ask === undefined) {
976
- return { content: askWithoutTtyMessage(request), ok: false };
1336
+ return failure("permission_denied", askWithoutTtyMessage(request));
977
1337
  }
978
- const choice = await waitForAdmission(ask(request), ctx.signal);
1338
+ let choice: ApprovalChoice | undefined;
1339
+ try { choice = await waitForAdmission(ask(request), ctx.signal); }
1340
+ catch (error) { return caughtFailure(error, NOT_STARTED, "approval unavailable: "); }
979
1341
  if (choice === undefined || ctx.signal?.aborted) return cancelledTool();
980
1342
  switch (choice) {
981
1343
  case "once":
@@ -997,7 +1359,7 @@ export async function gateTool(
997
1359
  return null;
998
1360
  }
999
1361
  case "deny":
1000
- return { content: deniedMessage(request), ok: false };
1362
+ return failure("permission_denied", deniedMessage(request));
1001
1363
  default:
1002
1364
  return assertNever(choice, "gateTool");
1003
1365
  }
@@ -1018,21 +1380,79 @@ export async function runTool(
1018
1380
 
1019
1381
  /** O.5: ordered admission returns a capability for this exact call, never a policy bypass flag. */
1020
1382
  export async function prepareTool(ctx: ToolContext, name: string, input: unknown): Promise<ToolResult | (() => Promise<ToolResult>)> {
1021
- try { input = toolDefinition(name, ctx)?.prepareArguments?.(input) ?? input; }
1022
- catch (error) { return finishToolResult({ ok: false, content: `Invalid ${name} arguments: ${String(error)}` }); }
1023
- const gated = await gateTool(ctx, name, input);
1024
- if (gated) return finishToolResult(gated);
1025
- const veto = await ctx.extensionHooks?.before(name, input, ctx.callId);
1026
- if (veto) return finishToolResult({ ok: false, content: veto });
1383
+ try { input = normalizeToolInput(ctx, name, input); }
1384
+ catch (error) { return scopeToolAdvice(ctx, finishToolResult(failure("invalid_input", `Invalid ${name} arguments: ${String(error)}`))); }
1385
+ return prepareNormalizedTool(ctx, name, input);
1386
+ }
1387
+
1388
+ /** Shared with the broker so the durable invocation names the arguments actually admitted. */
1389
+ export function normalizeToolInput(ctx: ToolContext, name: string, input: unknown): unknown {
1390
+ // Freeze the adapter's normalized arguments before admission and durable logging.
1391
+ return repairToolInput(name, toolDefinition(name, ctx)?.prepareArguments?.(input) ?? input);
1392
+ }
1393
+
1394
+ /** D11: notification observers receive values, never capabilities or mutable execution evidence. */
1395
+ function observerValue<T>(value: T): T {
1396
+ return value === undefined ? value : JSON.parse(JSON.stringify(value)) as T;
1397
+ }
1398
+
1399
+ /** Normalization is not admission: this entrance still enforces every permission and hook. */
1400
+ export async function prepareNormalizedTool(ctx: ToolContext, name: string, input: unknown,
1401
+ prepareAction?: () => Promise<() => Promise<ToolResult>>): Promise<ToolResult | (() => Promise<ToolResult>)> {
1402
+ const scoped = <T>(action: () => T): T => ctx.extensionHooks?.scope ? ctx.extensionHooks.scope(ctx, action) : action();
1403
+ try {
1404
+ const gated = await gateTool(ctx, name, input);
1405
+ if (gated) return scopeToolAdvice(ctx, finishToolResult(gated));
1406
+ const veto = await scoped(() => ctx.extensionHooks?.before(name, observerValue(input), ctx.callId, ctx.parentCallId));
1407
+ await ctx.drainNested?.();
1408
+ if (veto) return scopeToolAdvice(ctx, finishToolResult(failure("permission_denied", veto)));
1409
+ } catch (error) { return scopeToolAdvice(ctx, finishToolResult(caughtFailure(error, UNKNOWN_EFFECT))); }
1410
+ let owned: (() => Promise<ToolResult>) | undefined;
1411
+ let begun = false;
1412
+ const lifecycle = { toolName: name, toolCallId: ctx.callId, ...(ctx.parentCallId ? { parentToolCallId: ctx.parentCallId } : {}), ...(ctx.origin ? { origin: ctx.origin } : {}) };
1413
+ if (prepareAction) {
1414
+ // Branch spawn preparation is ordered after admission and before siblings
1415
+ // begin, under the same lifecycle observers as ordinary workspace tools.
1416
+ try {
1417
+ begun = true;
1418
+ await scoped(() => ctx.extensionHooks?.event("tool_execution_start", { ...lifecycle, args: observerValue(input) }));
1419
+ await ctx.drainNested?.();
1420
+ owned = ctx.signal?.aborted ? async () => cancelledTool() : await prepareAction();
1421
+ } catch (error) { owned = async () => { throw error; }; }
1422
+ }
1027
1423
  return async () => {
1028
- await ctx.extensionHooks?.event("tool_execution_start", { toolName: name, toolCallId: ctx.callId, args: input });
1029
- const result = await dispatchTool({ ...ctx, onOutput: chunk => {
1030
- ctx.onOutput?.(chunk);
1031
- void ctx.extensionHooks?.event("tool_execution_update", { toolName: name, toolCallId: ctx.callId, partialResult: { content: [{ type: "text", text: new TextDecoder().decode(chunk) }] } });
1032
- } }, name, input);
1033
- await ctx.extensionHooks?.event("tool_result", { toolName: name, toolCallId: ctx.callId, input, content: [{ type: "text", text: result.content }], isError: !result.ok, details: result.data });
1034
- await ctx.extensionHooks?.event("tool_execution_end", { toolName: name, toolCallId: ctx.callId, result, isError: !result.ok });
1035
- return result;
1424
+ let observed: ToolResult | undefined;
1425
+ const warnings: ToolDiagnostic[] = [], updates = new Set<Promise<void>>();
1426
+ try {
1427
+ if (!begun) await scoped(() => ctx.extensionHooks?.event("tool_execution_start", { ...lifecycle, args: observerValue(input) }));
1428
+ await ctx.drainNested?.();
1429
+ const execute = async () => {
1430
+ const executionContext: ToolContext = { ...ctx, onOutput: chunk => {
1431
+ ctx.onOutput?.(chunk);
1432
+ const update = scoped(() => ctx.extensionHooks?.event("tool_execution_update", { ...lifecycle, partialResult: { content: [{ type: "text", text: new TextDecoder().decode(chunk) }] } }));
1433
+ if (update) {
1434
+ const pending = update.catch(error => { warnings.push(failureFromError(error, UNKNOWN_EFFECT)); }).finally(() => { updates.delete(pending); });
1435
+ updates.add(pending);
1436
+ }
1437
+ } };
1438
+ observed = owned ? finishToolResult(ctx.signal?.aborted ? cancelledTool() : await owned(),
1439
+ toolDefinition(name, ctx)?.access === "read" ? READ_ATTEMPTED : UNKNOWN_EFFECT) : await dispatchTool(executionContext, name, input);
1440
+ return observed;
1441
+ };
1442
+ const command = ["bash", "environment", "target", "exec_command", "write_stdin", NATIVE_COMMAND_NAME].includes(name);
1443
+ const operation = () => ctx.workspace && command ? ctx.workspace.command(name, input, execute, ctx.signal) : execute();
1444
+ // Progress handlers may enqueue native commands. They run only after this
1445
+ // process drains, before the outer receipt; awaiting them inside it deadlocks.
1446
+ observed = command && ctx.runOwnedCommand ? await ctx.runOwnedCommand(operation) : await operation();
1447
+ await scoped(() => ctx.extensionHooks?.event("tool_result", { ...lifecycle, input: observerValue(input), content: [{ type: "text", text: observed!.content }], isError: !observed!.ok, details: observerValue(observed!.data) }));
1448
+ await scoped(() => ctx.extensionHooks?.event("tool_execution_end", { ...lifecycle, result: observerValue(observed), isError: !observed!.ok }));
1449
+ } catch (error) {
1450
+ // Notification/checkpoint failures after an observed operation cannot erase its actual result.
1451
+ if (observed) warnings.push(failureFromError(error, UNKNOWN_EFFECT));
1452
+ else observed = finishToolResult(caughtFailure(error, UNKNOWN_EFFECT));
1453
+ }
1454
+ await Promise.all(updates);
1455
+ return scopeToolAdvice(ctx, warnings.length ? { ...observed, warnings: [...(observed.warnings ?? []), ...warnings] } : observed);
1036
1456
  };
1037
1457
  }
1038
1458
 
@@ -1040,30 +1460,36 @@ async function dispatchTool(ctx: ToolContext, name: string, input: unknown): Pro
1040
1460
  if (ctx.signal?.aborted) return finishToolResult(cancelledTool());
1041
1461
  const impl = toolDefinition(name, ctx)?.run;
1042
1462
  if (!impl) {
1043
- const near = closestName(name, TOOL_DEFINITIONS.filter((tool) => tool.run !== undefined).map((tool) => tool.name));
1044
- return finishToolResult({ content: `unknown tool: ${name}` + (near === undefined ? "" : `; did you mean ${near}?`), ok: false });
1463
+ const near = closestName(name, TOOL_DEFINITIONS.filter((tool) => tool.run !== undefined && (ctx.allow === undefined || ctx.allow.includes(tool.name))).map((tool) => tool.name));
1464
+ return finishToolResult(failure("invalid_input", `unknown tool: ${name}` + (near === undefined ? "" : `; did you mean ${near}?`)));
1045
1465
  }
1046
1466
  if (typeof input !== "object" || input === null) {
1047
- return finishToolResult({ content: `tool ${name} needs an object input`, ok: false });
1467
+ return finishToolResult(failure("invalid_input", `tool ${name} needs an object input`));
1048
1468
  }
1049
1469
  try {
1050
1470
  const result = await impl(ctx, repairToolInput(name, input) as Record<string, unknown>);
1051
- return finishToolResult(result);
1471
+ return finishToolResult(result, toolDefinition(name, ctx)?.access === "read" && !ctx.extensions?.has(name) ? READ_ATTEMPTED : UNKNOWN_EFFECT);
1052
1472
  } catch (err) {
1053
1473
  // A tool that throws must still produce a result: an unanswered tool_use
1054
1474
  // makes the next request unpairable.
1055
- return finishToolResult({ content: `tool ${name} failed: ${(err as Error).message}`, ok: false });
1475
+ return finishToolResult(caughtFailure(err, toolDefinition(name, ctx)?.access === "read" && !ctx.extensions?.has(name) ? READ_ATTEMPTED : UNKNOWN_EFFECT, `tool ${name} failed: `));
1056
1476
  }
1057
1477
  }
1058
1478
 
1059
1479
  /** One finalizer also covers session-owned tools; result text never determines success. */
1060
- export function finishToolResult(result: ToolResult): ToolResult {
1061
- return { ...result, outcome: result.outcome ?? executionOutcome(result.ok ? "tool_completed" : "tool_failed"),
1062
- ...(!result.ok && !result.diagnostic ? { diagnostic: { code: "execution_failed" as const, message: result.content } } : {}) };
1480
+ export function finishToolResult(result: ToolResult, facts = UNKNOWN_EFFECT): ToolResult {
1481
+ if (result.ok) return { ...result, outcome: result.outcome ?? executionOutcome("tool_completed") };
1482
+ const reason = result.outcome?.reason;
1483
+ const actual = result.effect?.kind === "command" ? { execution: "started" as const, effects: "possible" as const } : facts;
1484
+ const code = reason === "cancelled" || reason === "not_started" ? "cancelled" : reason === "timeout" ? "timeout"
1485
+ : result.effect?.kind === "command" ? "command_failed" : actual.effects === "possible" ? "unknown_effect" : "execution_failed";
1486
+ const diagnostic = result.diagnostic?.execution !== undefined ? result.diagnostic : failureDiagnostic(result.diagnostic?.code ?? code,
1487
+ result.diagnostic?.message ?? result.content, actual, { ...(result.diagnostic?.recover ? { recover: result.diagnostic.recover } : {}) });
1488
+ return { ...result, diagnostic, outcome: result.outcome ?? executionOutcome("tool_failed") };
1063
1489
  }
1064
1490
 
1065
1491
  function cancelledTool(): ToolResult {
1066
- return { ok: false, content: "interrupted: cancelled before this tool started", outcome: executionOutcome("not_started") };
1492
+ return { ...failure("cancelled", "interrupted: cancelled before this tool started"), outcome: executionOutcome("not_started") };
1067
1493
  }
1068
1494
 
1069
1495
  export async function workspaceRoot(path: string): Promise<string> {
@@ -1095,7 +1521,9 @@ export type Annotation =
1095
1521
  proof?: string;
1096
1522
  work_id?: string;
1097
1523
  }
1098
- | { t: "next_action"; what: string; next_action: string; work_id?: string }
1524
+ | { t: "next_action"; what: string; next_action: string; work_id?: string; depends_on?: readonly string[] }
1525
+ | { t: "work_dependencies"; what: string; work_id: string; depends_on: readonly string[] }
1526
+ | { t: "work_owner"; what: string; work_id: string; operation: "claim" | "release" }
1099
1527
  | { t: "decision"; chose: string; because: string; rejected?: readonly string[] }
1100
1528
  | { t: "pitfall"; tried: string; failed_because: string }
1101
1529
  | { t: "blocked"; what: string; reason: string; work_id?: string }
@@ -1109,6 +1537,12 @@ export interface NoteOutcome {
1109
1537
  }
1110
1538
 
1111
1539
  export function parseNote(input: unknown): NoteOutcome {
1540
+ const parsed = parseNoteInput(input);
1541
+ return parsed.result.ok ? parsed : { ...parsed,
1542
+ result: { ...failure("invalid_input", parsed.result.content), data: { recorded: false } } };
1543
+ }
1544
+
1545
+ function parseNoteInput(input: unknown): NoteOutcome {
1112
1546
  if (typeof input !== "object" || input === null) {
1113
1547
  return { result: { content: "note needs an object input", ok: false } };
1114
1548
  }
@@ -1121,8 +1555,11 @@ export function parseNote(input: unknown): NoteOutcome {
1121
1555
  return { result: { ok: false, content: "work_id must be a non-empty string" } };
1122
1556
  }
1123
1557
  const identity = typeof id === "string" ? { work_id: id.trim() } : {};
1124
- if (id !== undefined && !["next", "done", "blocked", "unblock", "reopen", "cancel", "pin"].includes(kind)) {
1125
- return { result: { ok: false, content: "work_id applies to work lifecycle notes, not decisions, pitfalls or questions" } };
1558
+ const deps = o["depends_on"];
1559
+ if (deps !== undefined && (identity.work_id === undefined || !["next", "depend"].includes(kind) || !Array.isArray(deps) ||
1560
+ deps.some(value => typeof value !== "string" || !value.trim()))) return { result: { ok: false, content: "depends_on requires next/depend with a work_id and explicit non-empty IDs" } };
1561
+ if (id !== undefined && !["next", "done", "blocked", "unblock", "reopen", "cancel", "pin", "decision", "pitfall", "depend", "claim_work", "release_work"].includes(kind)) {
1562
+ return { result: { ok: false, content: "work_id applies to work lifecycle, decisions, pitfalls and pins" } };
1126
1563
  }
1127
1564
 
1128
1565
  if (what.length === 0) {
@@ -1130,6 +1567,14 @@ export function parseNote(input: unknown): NoteOutcome {
1130
1567
  }
1131
1568
 
1132
1569
  switch (kind) {
1570
+ case "depend": {
1571
+ if (!identity.work_id || !Array.isArray(deps)) return { result: { ok: false, content: "depend requires work_id and depends_on" } };
1572
+ return { result: { ok: true, content: "recorded dependencies" }, annotation: { t: "work_dependencies", what, work_id: identity.work_id, depends_on: deps as string[] } };
1573
+ }
1574
+ case "claim_work": case "release_work": {
1575
+ if (!identity.work_id) return { result: { ok: false, content: "work ownership requires a work_id" } };
1576
+ return { result: { ok: true, content: "recorded work ownership" }, annotation: { t: "work_owner", what, work_id: identity.work_id, operation: kind === "claim_work" ? "claim" : "release" } };
1577
+ }
1133
1578
  case "pin": {
1134
1579
  const source = o["event"];
1135
1580
  if (!identity.work_id || typeof source !== "string" || !source.trim()) return { result: { ok: false, content: "pin requires an existing work_id and original event id" } };
@@ -1169,7 +1614,7 @@ export function parseNote(input: unknown): NoteOutcome {
1169
1614
  }
1170
1615
  return {
1171
1616
  result: { content: `recorded: next "${what}" -> ${next}`, ok: true },
1172
- annotation: { t: "next_action", what, next_action: next, ...identity },
1617
+ annotation: { t: "next_action", what, next_action: next, ...identity, ...(Array.isArray(deps) ? { depends_on: deps as string[] } : {}) },
1173
1618
  };
1174
1619
  }
1175
1620
  case "decision": {
@@ -1182,7 +1627,7 @@ export function parseNote(input: unknown): NoteOutcome {
1182
1627
  return {
1183
1628
  result: { content: `recorded: decision "${what}"`, ok: true },
1184
1629
  annotation: {
1185
- t: "decision", chose: what, because: why,
1630
+ t: "decision", chose: what, because: why, ...identity,
1186
1631
  ...(rejected && rejected.length > 0 ? { rejected } : {}),
1187
1632
  },
1188
1633
  };
@@ -1193,7 +1638,7 @@ export function parseNote(input: unknown): NoteOutcome {
1193
1638
  }
1194
1639
  return {
1195
1640
  result: { content: `recorded: pitfall "${what}"`, ok: true },
1196
- annotation: { t: "pitfall", tried: what, failed_because: why },
1641
+ annotation: { t: "pitfall", tried: what, failed_because: why, ...identity },
1197
1642
  };
1198
1643
  }
1199
1644
  case "blocked": {