my-durable 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/.env.example +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +328 -2
  4. package/ai_docs/durable_pi/announcement.md +860 -0
  5. package/ai_docs/durable_pi/changelog.md +51 -0
  6. package/ai_docs/durable_pi/chord-delta-findings.md +362 -0
  7. package/ai_docs/durable_pi/coding-agent-integration.md +70 -0
  8. package/ai_docs/durable_pi/pico-v5-chord-usage.md +391 -0
  9. package/ai_docs/durable_pi/pico-v5-handoff.md +921 -0
  10. package/ai_docs/durable_pi/readme.md +617 -0
  11. package/ai_docs/durable_pi/sources.md +22 -0
  12. package/ai_docs/durable_pi/spec.md +4696 -0
  13. package/ai_docs/durable_pi/vacation-planner.md +57 -0
  14. package/ai_docs/pi/chord.md +268 -0
  15. package/ai_docs/pi/cli-integration.md +110 -0
  16. package/ai_docs/pi/cli.md +325 -0
  17. package/ai_docs/pi/codemode.md +211 -0
  18. package/ai_docs/pi/compaction.md +467 -0
  19. package/ai_docs/pi/configuration.md +51 -0
  20. package/ai_docs/pi/containerization.md +187 -0
  21. package/ai_docs/pi/custom-provider.md +177 -0
  22. package/ai_docs/pi/environment-variables.md +102 -0
  23. package/ai_docs/pi/extensions.md +278 -0
  24. package/ai_docs/pi/how-pi-works.md +53 -0
  25. package/ai_docs/pi/index.md +43 -0
  26. package/ai_docs/pi/json.md +230 -0
  27. package/ai_docs/pi/keybindings.md +198 -0
  28. package/ai_docs/pi/llama-cpp.md +118 -0
  29. package/ai_docs/pi/mcp.md +274 -0
  30. package/ai_docs/pi/message-types.md +265 -0
  31. package/ai_docs/pi/models.md +209 -0
  32. package/ai_docs/pi/overview.md +166 -0
  33. package/ai_docs/pi/packages.md +133 -0
  34. package/ai_docs/pi/pi-agent-core.md +568 -0
  35. package/ai_docs/pi/pi-ai.md +1942 -0
  36. package/ai_docs/pi/prompt-templates.md +63 -0
  37. package/ai_docs/pi/providers.md +204 -0
  38. package/ai_docs/pi/quickstart.md +140 -0
  39. package/ai_docs/pi/readme.md +99 -0
  40. package/ai_docs/pi/rpc-commands.md +864 -0
  41. package/ai_docs/pi/rpc-extension-ui.md +204 -0
  42. package/ai_docs/pi/rpc.md +197 -0
  43. package/ai_docs/pi/sdk.md +151 -0
  44. package/ai_docs/pi/security.md +103 -0
  45. package/ai_docs/pi/session-format.md +297 -0
  46. package/ai_docs/pi/sessions.md +70 -0
  47. package/ai_docs/pi/settings.md +173 -0
  48. package/ai_docs/pi/shell-aliases.md +97 -0
  49. package/ai_docs/pi/skills.md +97 -0
  50. package/ai_docs/pi/slash-commands.md +64 -0
  51. package/ai_docs/pi/sources.md +57 -0
  52. package/ai_docs/pi/terminal-setup.md +223 -0
  53. package/ai_docs/pi/termux.md +121 -0
  54. package/ai_docs/pi/themes.md +140 -0
  55. package/ai_docs/pi/tmux.md +59 -0
  56. package/ai_docs/pi/toolkit.md +168 -0
  57. package/ai_docs/pi/tui.md +129 -0
  58. package/ai_docs/pi/usage.md +98 -0
  59. package/ai_docs/pi/virtual-models.md +118 -0
  60. package/ai_docs/pi/windows.md +71 -0
  61. package/apps/dpi-backend/.nvmrc +1 -0
  62. package/apps/dpi-backend/API.md +99 -0
  63. package/apps/dpi-backend/README.md +119 -0
  64. package/apps/dpi-backend/deploy/backend.env.example +9 -0
  65. package/apps/dpi-backend/deploy/dpi-backend.plist +26 -0
  66. package/apps/dpi-backend/deploy/dpi-backend.service +22 -0
  67. package/apps/dpi-backend/package.json +33 -0
  68. package/apps/dpi-backend/prompts/one_sentence.md +3 -0
  69. package/apps/dpi-backend/prompts/smart_ass_opus_fix.md +169 -0
  70. package/apps/dpi-backend/scripts/check-contract.mjs +151 -0
  71. package/apps/dpi-backend/scripts/migrate-playground.ts +66 -0
  72. package/apps/dpi-backend/scripts/record-nested-fixture.mts +154 -0
  73. package/apps/dpi-backend/scripts/token.ts +57 -0
  74. package/apps/dpi-backend/src/addons.ts +139 -0
  75. package/apps/dpi-backend/src/auth.ts +20 -0
  76. package/apps/dpi-backend/src/config.ts +54 -0
  77. package/apps/dpi-backend/src/contract-check.ts +118 -0
  78. package/apps/dpi-backend/src/errors.ts +50 -0
  79. package/apps/dpi-backend/src/extensions.ts +510 -0
  80. package/apps/dpi-backend/src/faux.ts +80 -0
  81. package/apps/dpi-backend/src/history.ts +165 -0
  82. package/apps/dpi-backend/src/idempotency.ts +20 -0
  83. package/apps/dpi-backend/src/jev.ts +43 -0
  84. package/apps/dpi-backend/src/jobs.ts +790 -0
  85. package/apps/dpi-backend/src/main.ts +76 -0
  86. package/apps/dpi-backend/src/model-catalog.ts +180 -0
  87. package/apps/dpi-backend/src/pairing-files.ts +142 -0
  88. package/apps/dpi-backend/src/pairing.ts +459 -0
  89. package/apps/dpi-backend/src/routes.ts +217 -0
  90. package/apps/dpi-backend/src/shared-state.ts +85 -0
  91. package/apps/dpi-backend/src/store.ts +1240 -0
  92. package/apps/dpi-backend/src/streams.ts +256 -0
  93. package/apps/dpi-backend/src/tunnel.ts +955 -0
  94. package/apps/dpi-backend/tsconfig.json +13 -0
  95. package/apps/dpi-playground/README.md +83 -0
  96. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +77 -0
  97. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +1 -0
  98. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/anthropic.png +0 -0
  99. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/deepseek.png +0 -0
  100. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/durable-pi.svg +14 -0
  101. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/gemini.png +0 -0
  102. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/grok.png +0 -0
  103. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/kimi.png +0 -0
  104. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/lab.png +0 -0
  105. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/meta.png +0 -0
  106. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/minimax.png +0 -0
  107. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/openai.png +0 -0
  108. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/qwen.png +0 -0
  109. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/xiaomi.png +0 -0
  110. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/zai.png +0 -0
  111. package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +16 -0
  112. package/apps/dpi-playground/apps/dpi-playground-fe/index.html +15 -0
  113. package/apps/dpi-playground/apps/dpi-playground-fe/package.json +5 -0
  114. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/anthropic.png +0 -0
  115. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/deepseek.png +0 -0
  116. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/durable-pi.svg +14 -0
  117. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/gemini.png +0 -0
  118. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/grok.png +0 -0
  119. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/kimi.png +0 -0
  120. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/lab.png +0 -0
  121. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/meta.png +0 -0
  122. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/minimax.png +0 -0
  123. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/openai.png +0 -0
  124. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/qwen.png +0 -0
  125. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/xiaomi.png +0 -0
  126. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/zai.png +0 -0
  127. package/apps/dpi-playground/apps/dpi-playground-fe/src/App.vue +180 -0
  128. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/commands.ts +119 -0
  129. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/composables.ts +129 -0
  130. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/hub.ts +40 -0
  131. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/icon-path.ts +7 -0
  132. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/ids.ts +2 -0
  133. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/jobs.ts +80 -0
  134. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/link-policy.ts +16 -0
  135. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown-policy.ts +115 -0
  136. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown.ts +60 -0
  137. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-close.ts +23 -0
  138. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +56 -0
  139. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +166 -0
  140. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +323 -0
  141. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/signature.ts +146 -0
  142. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/store.ts +373 -0
  143. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/stream.ts +183 -0
  144. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +342 -0
  145. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Composer.vue +198 -0
  146. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ContextBar.vue +254 -0
  147. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceCredentialsDialog.vue +40 -0
  148. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceIcon.vue +56 -0
  149. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DirBrowser.vue +95 -0
  150. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ExtensionsPanel.vue +96 -0
  151. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/HelpDialog.vue +69 -0
  152. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +425 -0
  153. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/JobCard.vue +221 -0
  154. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/MobileNav.vue +39 -0
  155. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ModelPicker.vue +71 -0
  156. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NetworkSidebar.vue +236 -0
  157. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +114 -0
  158. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/OfflinePanel.vue +128 -0
  159. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +433 -0
  160. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PromptDialog.vue +89 -0
  161. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/StateEditor.vue +179 -0
  162. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TasksPanel.vue +280 -0
  163. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Toast.vue +11 -0
  164. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TokenManager.vue +175 -0
  165. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TopBar.vue +114 -0
  166. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TranscriptItem.vue +152 -0
  167. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +195 -0
  168. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TunnelStatusBar.vue +107 -0
  169. package/apps/dpi-playground/apps/dpi-playground-fe/src/extensions.css +105 -0
  170. package/apps/dpi-playground/apps/dpi-playground-fe/src/jobs.css +655 -0
  171. package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +22 -0
  172. package/apps/dpi-playground/apps/dpi-playground-fe/src/network.css +675 -0
  173. package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +3203 -0
  174. package/apps/dpi-playground/apps/dpi-playground-fe/tsconfig.json +16 -0
  175. package/apps/dpi-playground/apps/dpi-playground-fe/vite.config.ts +14 -0
  176. package/apps/dpi-playground/apps/dpi-playground-hub/package.json +5 -0
  177. package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +406 -0
  178. package/apps/dpi-playground/apps/dpi-playground-hub/src/client-ip.ts +72 -0
  179. package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +265 -0
  180. package/apps/dpi-playground/apps/dpi-playground-hub/src/guard.ts +43 -0
  181. package/apps/dpi-playground/apps/dpi-playground-hub/src/http-errors.ts +156 -0
  182. package/apps/dpi-playground/apps/dpi-playground-hub/src/identity.ts +60 -0
  183. package/apps/dpi-playground/apps/dpi-playground-hub/src/limiter.ts +165 -0
  184. package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +460 -0
  185. package/apps/dpi-playground/apps/dpi-playground-hub/src/monitor.ts +320 -0
  186. package/apps/dpi-playground/apps/dpi-playground-hub/src/mux.ts +277 -0
  187. package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +816 -0
  188. package/apps/dpi-playground/apps/dpi-playground-hub/src/proxy.ts +128 -0
  189. package/apps/dpi-playground/apps/dpi-playground-hub/src/rotation.ts +583 -0
  190. package/apps/dpi-playground/apps/dpi-playground-hub/src/seed.ts +11 -0
  191. package/apps/dpi-playground/apps/dpi-playground-hub/src/transport.ts +108 -0
  192. package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-token.ts +11 -0
  193. package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-transport.ts +560 -0
  194. package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +873 -0
  195. package/apps/dpi-playground/apps/dpi-playground-hub/tsconfig.json +13 -0
  196. package/apps/dpi-playground/package.json +37 -0
  197. package/apps/dpi-sdk/README.md +74 -0
  198. package/apps/dpi-sdk/package.json +20 -0
  199. package/apps/dpi-sdk/scripts/net.ts +73 -0
  200. package/apps/dpi-sdk/src/api.ts +631 -0
  201. package/apps/dpi-sdk/src/app-state.ts +111 -0
  202. package/apps/dpi-sdk/src/client.ts +330 -0
  203. package/apps/dpi-sdk/src/errors.ts +16 -0
  204. package/apps/dpi-sdk/src/jobs.ts +99 -0
  205. package/apps/dpi-sdk/src/models.ts +472 -0
  206. package/apps/dpi-sdk/src/node/chunk.ts +488 -0
  207. package/apps/dpi-sdk/src/node/config.ts +175 -0
  208. package/apps/dpi-sdk/src/node/drivers.ts +402 -0
  209. package/apps/dpi-sdk/src/node/ops.ts +114 -0
  210. package/apps/dpi-sdk/src/node/private-dir.ts +48 -0
  211. package/apps/dpi-sdk/src/node/registry-migration.ts +549 -0
  212. package/apps/dpi-sdk/src/node/registry.ts +390 -0
  213. package/apps/dpi-sdk/src/node/sse-writer.ts +151 -0
  214. package/apps/dpi-sdk/src/protocol/backoff.ts +31 -0
  215. package/apps/dpi-sdk/src/protocol/limits.ts +199 -0
  216. package/apps/dpi-sdk/src/protocol/pairing.ts +311 -0
  217. package/apps/dpi-sdk/src/protocol/rest-routes.ts +105 -0
  218. package/apps/dpi-sdk/src/protocol/tunnel.ts +331 -0
  219. package/apps/dpi-sdk/src/sse.ts +117 -0
  220. package/apps/dpi-sdk/src/transcript.ts +291 -0
  221. package/apps/dpi-sdk/tsconfig.json +13 -0
  222. package/apps/dpi-sdk/w +20 -0
  223. package/apps/dpi-tui/README.md +54 -0
  224. package/apps/dpi-tui/bin/dpi-tui.mjs +5 -0
  225. package/apps/dpi-tui/package.json +20 -0
  226. package/apps/dpi-tui/src/app.ts +433 -0
  227. package/apps/dpi-tui/src/commands.ts +151 -0
  228. package/apps/dpi-tui/src/main.ts +122 -0
  229. package/apps/dpi-tui/src/pairing.ts +287 -0
  230. package/apps/dpi-tui/src/pickers.ts +238 -0
  231. package/apps/dpi-tui/src/render.ts +164 -0
  232. package/apps/dpi-tui/src/theme.ts +57 -0
  233. package/apps/dpi-tui/tsconfig.json +14 -0
  234. package/bin/device.mjs +5 -0
  235. package/bin/hub.mjs +5 -0
  236. package/bin/tui.mjs +5 -0
  237. package/deploy/Caddyfile +120 -0
  238. package/docs/verification.md +102 -0
  239. package/dpi-network.example.yaml +62 -0
  240. package/images/01_durable_agent_network.svg +185 -0
  241. package/images/02_pi_made_durable.svg +204 -0
  242. package/images/03_restart_mid_tool.svg +168 -0
  243. package/images/07_one_api_every_client.svg +123 -0
  244. package/images/08_device_session_agent.svg +123 -0
  245. package/images/09_compact_keep_history.svg +143 -0
  246. package/images/10_shared_app_state.svg +177 -0
  247. package/images/11_durable_jobs_wait.svg +141 -0
  248. package/images/12_tools_change_mid_run.svg +231 -0
  249. package/images/13_hook_blocks_tool.svg +122 -0
  250. package/images/14_fork_at_any_entry.svg +119 -0
  251. package/images/15_web_client.png +0 -0
  252. package/images/16_terminal_client.svg +43 -0
  253. package/images/17_devices_are_data.svg +182 -0
  254. package/images/18_should_every_agent_be_durable.svg +118 -0
  255. package/images/19_sqlite_on_every_device.svg +155 -0
  256. package/images/20_admission_and_effects.svg +134 -0
  257. package/justfile +33 -0
  258. package/package.json +41 -4
  259. package/scripts/verify.mjs +210 -0
@@ -0,0 +1,4696 @@
1
+ > Source: [packages/durable/docs/spec.md](https://github.com/earendil-works/pi/blob/b78e6a9085343ec0f308c3d377da72528b4cf7ee/packages/durable/docs/spec.md)
2
+ > Retrieved with Firecrawl: 2026-10-05. Repository snapshot: `b78e6a9085343ec0f308c3d377da72528b4cf7ee`.
3
+ > Relative documentation links point to local copies where available, otherwise to the source snapshot.
4
+
5
+ # Pico5 specification
6
+
7
+ Pico5 is a durable, extensible agent harness. This document is normative.
8
+ Pico5 uses existing package types as follows:
9
+
10
+ ```ts
11
+ import type {
12
+ AttachedReplicatedState,
13
+ Context,
14
+ Draft,
15
+ JsonRepresentation,
16
+ JsonValue,
17
+ ReplicatedState,
18
+ } from "@earendil-works/chord";
19
+ import type { Op } from "@earendil-works/chord/delta";
20
+ import type {
21
+ AssistantMessage,
22
+ CacheRetention,
23
+ DeferredHandle,
24
+ Message,
25
+ Models,
26
+ ModelThinkingLevel,
27
+ TextContent,
28
+ Tool,
29
+ ToolCall,
30
+ ToolReference,
31
+ ToolResultMessage,
32
+ Transport,
33
+ UserMessage,
34
+ } from "@earendil-works/pi-ai";
35
+ import type { ExecutionEnv } from "@earendil-works/pi-durable/env";
36
+
37
+ type JsonObject = { [key: string]: JsonValue };
38
+ type TaskOutcomeError = { message: string; detail?: JsonValue };
39
+ ```
40
+
41
+ Pico5 targets the transcript `SystemMessage` contract from pi-ai PR
42
+ [#9548](https://github.com/earendil-works/pi/pull/9548). `Message` includes that
43
+ type once the PR lands.
44
+
45
+ The core rule is:
46
+
47
+ > A Session atomically commits immutable entries, full task records, and
48
+ > Chord-tracked documents. Only committed state is observable.
49
+
50
+ ## 1. Terms and invariants
51
+
52
+ - A **Session** owns one mutation line, conversations, entries, tasks,
53
+ submissions, and documents.
54
+ - A **conversation** is a transcript scope. It may fork another conversation.
55
+ - An **entry** is an immutable transcript record.
56
+ - A **task** is a durable state machine attached to one conversation.
57
+ - A **document** is mutable JSON state represented by Chord operations and
58
+ occasional complete bases.
59
+ - A **definition** is a typed token describing one document or document family.
60
+ - A **source** exposes committed document changes to Chord without exposing a
61
+ mutable object.
62
+ - A **turn** is one assistant response and the tool calls it makes. A **run**
63
+ is the sequence of turns from an admitted input to the final answer; turn
64
+ boundaries (section 6) sit between its turns. A conversation is busy while a
65
+ run is active.
66
+
67
+ Required invariants:
68
+
69
+ 1. One Session commit is atomic across all record and document writes.
70
+ 2. A document update is published only after its storage commit succeeds.
71
+ 3. All visible progress is durable. There is no volatile publication path.
72
+ 4. External effects do not run inside the Session mutation transaction.
73
+ 5. Entries and IDs are immutable and never reused after a committed write.
74
+ 6. Document drafts are fully revoked when their transaction callback settles:
75
+ the Session synchronously prepares or aborts every open change at that point.
76
+ Values assigned into a draft are copied by value and must be strict JSON.
77
+ 7. The mutation line remains held through storage settlement and committed-state
78
+ adoption. Commit/close observers run synchronously only to capture immutable
79
+ state; document-state and watch user callbacks run later, off the line.
80
+ 8. An uncertain storage failure is fatal to the open Session. It publishes
81
+ nothing and must be reopened. Preparation and checkpoint failures occur before
82
+ storage admission and roll back normally.
83
+
84
+ ## 2. Core records
85
+
86
+ The concrete JSON representations may add bookkeeping fields, but must preserve
87
+ these contracts.
88
+
89
+ ```ts
90
+ declare const idBrand: unique symbol;
91
+ type Id<Kind extends string, Type = unknown> = number & {
92
+ readonly [idBrand]: {
93
+ readonly kind: Kind;
94
+ readonly type: Type;
95
+ };
96
+ };
97
+ type ConversationId = Id<"conversation">;
98
+ type EntryId = Id<"entry">;
99
+ type TaskId<Result = unknown> = Id<"task", Result>;
100
+ type SubmissionId = Id<"submission">;
101
+ type DocumentId = Id<"document">;
102
+
103
+ declare const seqBrand: unique symbol;
104
+ /** Strictly increases between commits; gaps are permitted. */
105
+ type Seq = number & { readonly [seqBrand]: "sequence" };
106
+ const ROOT_CONVERSATION_ID = 1 as ConversationId;
107
+
108
+ type ConversationOwnership =
109
+ | { readonly kind: "ownerless" }
110
+ | { readonly kind: "task"; readonly taskId: TaskId };
111
+
112
+ type ConversationRecord = {
113
+ readonly id: ConversationId;
114
+ readonly parent?: {
115
+ readonly conversationId: ConversationId;
116
+ readonly at: EntryId;
117
+ };
118
+ readonly owner?: {
119
+ readonly conversationId: ConversationId;
120
+ readonly taskId: TaskId;
121
+ };
122
+ };
123
+ ```
124
+
125
+ The referenced pi-ai member is:
126
+
127
+ ```ts
128
+ interface SystemMessage {
129
+ role: "system";
130
+ content: string | TextContent[];
131
+ sections?: Record<string, string | null>;
132
+ toolsAdded?: Tool[];
133
+ toolsRemoved?: ToolReference[];
134
+ timestamp: number;
135
+ }
136
+ ```
137
+
138
+ In pi-ai, `content` is the base prompt on the leading message and additional
139
+ instruction text on later messages. `sections` is an ordered named patch: a
140
+ string adds or replaces a section, while `null` removes it. `toolsRemoved` is
141
+ applied before `toolsAdded` within one message. Replaying every system message
142
+ in transcript order yields the effective prompt and tool set.
143
+
144
+ Pico always writes `content: ""`. Every prompt part, including the preamble, is
145
+ a named section resolved from the conversation's agent (section 7.4).
146
+
147
+ ```ts
148
+ type ContextEdit = {
149
+ readonly target: EntryId;
150
+ } & (
151
+ | { readonly action: "omit"; readonly messages?: never }
152
+ | { readonly action: "replace"; readonly messages: readonly Message[] }
153
+ );
154
+
155
+ type EntryRecord = {
156
+ readonly id: EntryId;
157
+ readonly conversationId: ConversationId;
158
+ readonly kind: string;
159
+ readonly model?: readonly Message[];
160
+ readonly data?: JsonValue;
161
+ readonly head?: EntryId;
162
+ readonly edits?: readonly ContextEdit[];
163
+ readonly byTaskId?: TaskId;
164
+ };
165
+
166
+ type EntryDraft = Omit<EntryRecord, "id" | "conversationId" | "byTaskId" | "head"> & {
167
+ readonly head?: EntryId | "self";
168
+ };
169
+
170
+ type SubmissionRecordBase = {
171
+ readonly id: SubmissionId;
172
+ readonly conversationId: ConversationId;
173
+ readonly requestId?: string;
174
+ };
175
+
176
+ type SubmissionRecord =
177
+ | (SubmissionRecordBase & {
178
+ readonly type: "input";
179
+ } & (
180
+ | {
181
+ readonly status: "queued";
182
+ readonly entry?: never;
183
+ readonly answer?: never;
184
+ readonly reason?: never;
185
+ readonly detail?: never;
186
+ }
187
+ | {
188
+ readonly status: "placed";
189
+ readonly entry: EntryId;
190
+ readonly answer?: never;
191
+ readonly reason?: never;
192
+ readonly detail?: never;
193
+ }
194
+ | {
195
+ readonly status: "done";
196
+ readonly entry: EntryId;
197
+ readonly answer: EntryId;
198
+ readonly reason?: never;
199
+ readonly detail?: never;
200
+ }
201
+ | {
202
+ readonly status: "unanswered";
203
+ readonly entry?: EntryId;
204
+ readonly answer?: never;
205
+ readonly reason: string;
206
+ readonly detail?: JsonValue;
207
+ }
208
+ ))
209
+ | (SubmissionRecordBase & {
210
+ readonly type: "write";
211
+ } & (
212
+ | {
213
+ readonly status: "queued";
214
+ readonly entry?: never;
215
+ readonly answer?: never;
216
+ readonly reason?: never;
217
+ readonly detail?: never;
218
+ }
219
+ | {
220
+ readonly status: "done";
221
+ readonly entry: EntryId;
222
+ readonly answer?: never;
223
+ readonly reason?: never;
224
+ readonly detail?: never;
225
+ }
226
+ | {
227
+ readonly status: "unanswered";
228
+ readonly entry?: never;
229
+ readonly answer?: never;
230
+ readonly reason: string;
231
+ readonly detail?: JsonValue;
232
+ }
233
+ ));
234
+
235
+ type SubmissionSettlement =
236
+ | { readonly status: "done"; readonly answer: EntryId }
237
+ | { readonly status: "unanswered"; readonly reason: string; readonly detail?: JsonValue };
238
+
239
+ type SubmissionCreate = SubmissionRecord extends infer Record
240
+ ? Record extends SubmissionRecord
241
+ ? Omit<Record, "id">
242
+ : never
243
+ : never;
244
+ ```
245
+
246
+ ID brands are compile-time only. IDs and sequences remain ordinary numbers in
247
+ memory, JSON, JSONL, and SQLite. Code applies a brand only at a trusted creation
248
+ or decoding boundary. The distinct sequence brand prevents an entity ID from
249
+ being used as a document commit point.
250
+
251
+ Conversation history parenting and task ownership are separate:
252
+
253
+ - `parent` controls inherited entries and historical documents.
254
+ - `owner` records task attribution and connects scopes for subtree abort and idle traversal. It is not an access-control capability.
255
+ - Creation always supplies `ConversationOwnership` explicitly. The Session
256
+ derives the persisted owner's conversation from the named task; callers never
257
+ construct the persisted owner pair.
258
+
259
+ A conversation's owner remains recorded after the owning task becomes terminal.
260
+
261
+ ### 2.1 Entries and context
262
+
263
+ Entry IDs are Session-global and ordered. `parent.at` is an entry in the parent
264
+ history visible to the child.
265
+
266
+ The active transcript is the raw entry range from the newest applicable `head`
267
+ through the tail. A head on an entry changes subsequent context; it does not
268
+ remove older entries from storage. Fork traversal is child entries followed by
269
+ parent entries through each `parent.at` cap.
270
+
271
+ Context derivation:
272
+
273
+ 1. Find the newest visible entry `H` at or before the cutoff that has `head`.
274
+ 2. Let `from = H.head`, or transcript start when `H` is absent.
275
+ 3. Scan visible entries from `from` through the cutoff.
276
+ 4. For each target, the newest edit in that range wins. `omit` contributes no
277
+ model messages; `replace` contributes its `messages` instead of the target's.
278
+ 5. If `H` exists, context entries are `H` followed by non-head entries in the
279
+ range. Otherwise they are the range.
280
+ 6. Keep every positional system message and its tool/section changes.
281
+ 7. Move each assistant's tool results, found before the next assistant message,
282
+ directly after it in tool-call order, ahead of any intervening user or system
283
+ message. The provider protocol requires results to follow their call.
284
+ 8. Synthesize an error result for every call without one, for example after a
285
+ fork cut or at an interrupted tail. Drop tool results with no preceding call.
286
+ 9. Exclude model-less entries and assistant messages with `aborted`, `error`, or
287
+ `deferred` stop reasons from future provider requests.
288
+
289
+ Views carry raw active entries. UI reduction and model-context reduction are
290
+ separate consumers. Older stored history is available through the owning
291
+ `Conversation` object's cursor-based `entries()` scan.
292
+
293
+ ### 2.2 Public Harness surface
294
+
295
+ This is the v1 host-facing API. Pico5 is not implemented yet, but implementations
296
+ must expose this shape rather than inventing a different facade during package
297
+ 24.
298
+
299
+ ```ts
300
+ type ModelRef = {
301
+ readonly provider: string;
302
+ readonly modelId: string;
303
+ };
304
+
305
+ type UserInput = UserMessage["content"];
306
+
307
+ type SubmissionDraft = {
308
+ readonly requestId?: string;
309
+ } & (
310
+ | {
311
+ readonly type: "input";
312
+ readonly content: UserInput;
313
+ readonly whenBusy?: "steer" | "followUp" | "reject";
314
+ readonly entry?: never;
315
+ }
316
+ | {
317
+ readonly type: "write";
318
+ readonly entry: EntryDraft;
319
+ readonly content?: never;
320
+ readonly whenBusy?: never;
321
+ }
322
+ );
323
+
324
+ type InputSubmissionDraft = Extract<SubmissionDraft, { readonly type: "input" }>;
325
+
326
+ /** Runs inside the creating commit, after the creation hook and the `agent` change. */
327
+ type ConversationInit = (tx: Tx, conversationId: ConversationId) => void | Promise<void>;
328
+
329
+ type ConversationCreateOptions = {
330
+ readonly ownership: ConversationOwnership;
331
+ /** Applied in the creating commit after the creation hook's copy, before `init`. */
332
+ readonly agent?: AgentChange;
333
+ readonly init?: ConversationInit;
334
+ };
335
+
336
+ type AnyTask = {
337
+ readonly definition: {
338
+ readonly name: string;
339
+ readonly version: number;
340
+ readonly initial: unknown;
341
+ readonly phases: Readonly<Record<string, unknown>>;
342
+ readonly abort: unknown;
343
+ readonly migrate?: unknown;
344
+ readonly hooks?: object;
345
+ };
346
+ };
347
+
348
+ type HarnessOptions<Tool extends ToolRegistration = ToolRegistration> = {
349
+ readonly models: Models; // the pi-ai Models interface
350
+ readonly registry: RegistryReader<Tool>; // section 7.1
351
+ readonly settings?: HarnessSettings;
352
+ /** Builds a conversation's environment. Never called on the Session line; may be async. */
353
+ readonly env?: (
354
+ target: { readonly conversationId: ConversationId; readonly cwd?: string; readonly read: DocumentReader },
355
+ context: Context,
356
+ ) => ExecutionEnv | undefined | Promise<ExecutionEnv | undefined>;
357
+ /** Runs in every commit that creates or forks a conversation, after the built-in creation hook (below). */
358
+ readonly conversationCreated?: (tx: Tx, conversation: ConversationRecord) => void | Promise<void>;
359
+ readonly now?: () => number;
360
+ readonly onReport?: (error: unknown) => void;
361
+ };
362
+
363
+ /** Curated pi-ai request options; absent fields use pi-ai defaults. */
364
+ type ConversationStreamOptions = {
365
+ transport?: Transport;
366
+ timeoutMs?: number;
367
+ /** Provider/SDK retries inside one request attempt. */
368
+ maxRetries?: number;
369
+ maxRetryDelayMs?: number;
370
+ headers?: Record<string, string>;
371
+ metadata?: JsonObject;
372
+ cacheRetention?: CacheRetention;
373
+ deferred?: boolean | { window?: "15m" | "1h" | "24h" };
374
+ };
375
+
376
+ /** Durable generation attempt retries; the JSON shape of pi-ai `RetryPolicy`. */
377
+ type ConversationRetryPolicy = {
378
+ enabled: boolean;
379
+ maxRetries: number;
380
+ baseDelayMs: number;
381
+ maxAgentDelayMs?: number;
382
+ };
383
+
384
+ /** Automatic compaction thresholds (section 8.7); manual compaction ignores `enabled`. */
385
+ type CompactionPolicy = {
386
+ /** Threshold and overflow compaction. */
387
+ enabled: boolean;
388
+ /** Room kept free for the answer: generation blocks to compact above `contextWindow - reserveTokens`. */
389
+ reserveTokens: number;
390
+ /** Approximate size of the recent context a summary keeps verbatim. */
391
+ keepRecentTokens: number;
392
+ /** Background compaction starts `backgroundTokens` below the blocking threshold; `0` disables it. */
393
+ backgroundTokens: number;
394
+ };
395
+
396
+ type ToolExecutionMode = "parallel" | "sequential";
397
+
398
+ /** How many queued items of one mode a boundary selects (section 6). */
399
+ type QueueMode = "all" | "one-at-a-time";
400
+
401
+ /** Read at every resolution and never copied; getters are fine. Synchronous: some readers run on the Session line. */
402
+ type HarnessSettings = {
403
+ /** Default extension selection; absent: every installed extension, in install order. */
404
+ readonly extensions?: readonly Extension[];
405
+ readonly stream?: ConversationStreamOptions;
406
+ readonly retry?: Partial<ConversationRetryPolicy>;
407
+ readonly compaction?: Partial<CompactionPolicy>;
408
+ readonly toolExecution?: ToolExecutionMode;
409
+ readonly steeringMode?: QueueMode;
410
+ readonly followUpMode?: QueueMode;
411
+ };
412
+
413
+ /** Resolved: every field over its built-in default, object fields merged. */
414
+ type Settings = {
415
+ readonly extensions?: readonly Extension[]; // default: every installed extension, in install order
416
+ readonly stream: ConversationStreamOptions; // default {}
417
+ readonly retry: ConversationRetryPolicy; // { enabled: true, maxRetries: 3, baseDelayMs: 2000, maxAgentDelayMs: 60000 }
418
+ readonly compaction: CompactionPolicy; // { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000, backgroundTokens: 32768 }
419
+ readonly toolExecution: ToolExecutionMode; // "parallel"
420
+ readonly steeringMode: QueueMode; // "one-at-a-time"
421
+ readonly followUpMode: QueueMode; // "one-at-a-time"
422
+ };
423
+
424
+ /** Stored choices of one conversation; names, not objects. Unset fields follow the host. */
425
+ type AgentState = {
426
+ model?: ModelRef;
427
+ thinkingLevel?: ModelThinkingLevel;
428
+ /** An array selects exactly these extensions, in order. An object edits the host default selection. */
429
+ extensions?: string[] | { add?: string[]; remove?: string[] };
430
+ /** Filters the selected extensions' tools. An array offers exactly these, in order. */
431
+ tools?: string[] | { remove: string[] };
432
+ /** Rendered after every extension section, as the section `instructions`. */
433
+ instructions?: string;
434
+ /** Directory within the environment's file system, passed to `HarnessOptions.env`. */
435
+ cwd?: string;
436
+ };
437
+
438
+ /** Built-in rewindable agent document; see below. */
439
+ declare const AgentDoc: RewindableConversationDocToken<AgentState>;
440
+
441
+ /** Objects are handles for their names. */
442
+ type AgentChange = {
443
+ readonly model?: ModelRef | null;
444
+ readonly thinkingLevel?: ModelThinkingLevel | null;
445
+ readonly extensions?:
446
+ | readonly Extension[]
447
+ | { readonly add?: readonly Extension[]; readonly remove?: readonly Extension[] }
448
+ | null;
449
+ readonly tools?: readonly ToolRegistration[] | { readonly remove: readonly ToolRegistration[] } | null;
450
+ readonly instructions?: string | null;
451
+ readonly cwd?: string | null;
452
+ };
453
+
454
+ /** One change in any commit. A given field replaces the stored one; `null` clears it; `undefined` changes nothing. */
455
+ function configure(tx: Tx, conversationId: ConversationId, change: AgentChange): Promise<void>;
456
+
457
+ /** A conversation's agent resolved against a registry snapshot and the settings (section 7.1). */
458
+ type Agent<Tool extends ToolRegistration = ToolRegistration> = {
459
+ readonly model?: ModelRef;
460
+ readonly thinkingLevel: ModelThinkingLevel;
461
+ readonly extensions: readonly Extension<Tool>[];
462
+ readonly tools: readonly Tool[];
463
+ /** Extension sections, then `instructions` when set. */
464
+ readonly sections: readonly PromptSection<Tool>[];
465
+ readonly instructions?: string;
466
+ readonly cwd?: string;
467
+ };
468
+
469
+ /** Entry whose `data` has type `D`; `never` means the kind carries no data. */
470
+ type TypedEntry<D extends JsonValue> = Omit<EntryRecord, "data"> &
471
+ ([D] extends [never] ? { readonly data?: never } : { readonly data: D });
472
+
473
+ type TypedEntryDraft<D extends JsonValue> = Omit<EntryDraft, "kind" | "data"> &
474
+ ([D] extends [never] ? { readonly data?: never } : { readonly data: D });
475
+
476
+ interface Entry<D extends JsonValue = never> {
477
+ readonly kind: string;
478
+ is(entry: EntryRecord | undefined): entry is TypedEntry<D>;
479
+ }
480
+
481
+ function defineEntry<D extends JsonValue = never>(kind: string): Entry<D>;
482
+
483
+ type ContextView = {
484
+ readonly head: EntryRecord | undefined;
485
+ readonly entries: readonly EntryRecord[];
486
+ /** Per entry of `entries`, its model messages after edits and rule 9, before rules 7 and 8. */
487
+ readonly contributions: readonly (readonly Message[])[];
488
+ readonly messages: readonly Message[];
489
+ };
490
+
491
+ type SettledSubmissionRecord = SubmissionRecord & {
492
+ readonly status: "done" | "unanswered";
493
+ };
494
+
495
+ interface Submission {
496
+ readonly id: SubmissionId;
497
+ status(context: Context): Promise<SubmissionRecord>;
498
+ wait(context: Context): Promise<SettledSubmissionRecord>;
499
+ abort(context: Context): Promise<"aborted" | "already_placed" | "settled">;
500
+ }
501
+
502
+ type SettledTask<R> = TaskRecord<JsonValue, JsonValue, R> & {
503
+ readonly state: Extract<TaskState<JsonValue, R>, { status: "terminal" }>;
504
+ };
505
+
506
+ type TaskInspection = {
507
+ readonly record: TaskRecord<JsonValue, JsonValue, JsonValue>;
508
+ readonly state:
509
+ | { readonly kind: "running" }
510
+ | { readonly kind: "ready"; readonly migrates: boolean }
511
+ | { readonly kind: "waiting"; readonly on: readonly TaskId[] }
512
+ /** Outcome held until its ordinary owned work drains (section 5.5). */
513
+ | { readonly kind: "completing" }
514
+ | {
515
+ readonly kind: "blocked";
516
+ readonly reason: "missing_task" | "task_too_old" | "migration_failed";
517
+ readonly error?: unknown;
518
+ };
519
+ };
520
+
521
+ type HarnessInspection = {
522
+ readonly scheduling: "paused" | "running" | "closing";
523
+ readonly tasks: readonly TaskInspection[];
524
+ /** Queued and placed submissions in ID order. */
525
+ readonly submissions: readonly SubmissionRecord[];
526
+ };
527
+
528
+ type ConversationWatch = WatchHandle<ConversationView>;
529
+
530
+ type HooksOf<K> = K extends Task<infer _I, infer _S, infer _R, infer H>
531
+ ? H
532
+ : never;
533
+
534
+ interface Conversation {
535
+ readonly id: ConversationId;
536
+ submit(submission: SubmissionDraft, context: Context): Promise<Submission>;
537
+ /** Resolved with the current registry snapshot and settings. */
538
+ agent(context: Context): Promise<Agent>;
539
+ /** `configure()` in its own commit. */
540
+ configure(change: AgentChange, context: Context): Promise<void>;
541
+
542
+ commit<T>(
543
+ change: (tx: Tx) => T | Promise<T>,
544
+ context: Context,
545
+ ): Promise<T>;
546
+ context(context: Context): Promise<ContextView>;
547
+ entries(
548
+ query: Omit<EntryQuery, "conversationId">,
549
+ limit: number,
550
+ cursor: Cursor | undefined,
551
+ context: Context,
552
+ ): Promise<Page<EntryRecord, Cursor>>;
553
+ fork(
554
+ at: EntryId,
555
+ options: ConversationCreateOptions,
556
+ context: Context,
557
+ ): Promise<Conversation>;
558
+ compact(instructions: string | undefined, context: Context): Promise<TaskId<CompactionResult>>;
559
+ reset(handoff: string | undefined, context: Context): Promise<void>;
560
+ /** `{ background: true }` also aborts background work under the conversation (section 5.4). */
561
+ abort(context: Context, options?: { readonly background?: boolean }): Promise<void>;
562
+ waitForIdle(context: Context): Promise<void>;
563
+ viewState(context: Context): Promise<AttachedReplicatedState<ConversationView>>;
564
+ watch(context: Context): Promise<ConversationWatch>;
565
+ }
566
+
567
+ interface Harness extends Session {
568
+ resume(): void;
569
+
570
+ root(
571
+ context: Context,
572
+ options?: { readonly agent?: AgentChange; readonly init?: ConversationInit },
573
+ ): Promise<Conversation>;
574
+ conversation(id: ConversationId, context: Context): Promise<Conversation | undefined>;
575
+ createConversation(options: ConversationCreateOptions, context: Context): Promise<Conversation>;
576
+
577
+ getTask<R>(id: TaskId<R>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, R> | undefined>;
578
+ inspect(context: Context): Promise<HarnessInspection>;
579
+ submission(id: SubmissionId, context: Context): Promise<Submission | undefined>;
580
+ abortSubmission(
581
+ id: SubmissionId,
582
+ context: Context,
583
+ conversationId?: ConversationId,
584
+ ): Promise<"aborted" | "already_placed" | "settled" | "not_found">;
585
+ abortTask(id: TaskId, context: Context): Promise<"marked" | "terminal">;
586
+ waitForTask<R>(id: TaskId<R>, context: Context): Promise<SettledTask<R>>;
587
+ waitForIdle(context: Context): Promise<void>;
588
+ /** Session total of every conversation's `pi.usage` (section 8.6). */
589
+ usage(context: Context): Promise<UsageState>;
590
+ /** Live tasks as a structural view (section 9.5). */
591
+ taskGraph(context: Context): Promise<AttachedReplicatedState<TaskGraph>>;
592
+ watchTaskGraph(context: Context): Promise<TaskGraphWatch>;
593
+ }
594
+
595
+ declare const Harness: {
596
+ open<Tool extends ToolRegistration>(
597
+ storage: Storage,
598
+ options: HarnessOptions<Tool>,
599
+ context: Context,
600
+ ): Promise<Harness>;
601
+ };
602
+ ```
603
+
604
+ This intentionally retains the useful Pico3 host shape. It removes Pico3's
605
+ fixed `rewindable()`/`sticky()` accessors, namespace router, semantic view events,
606
+ and manual Chord view bridge. Typed Pico5 documents and the structural
607
+ conversation watch replace those surfaces. `submit()` durably admits either a
608
+ user input or passive entry write and returns one `Submission` that tracks its
609
+ settlement.
610
+
611
+ `Harness.open()` binds the Session to one application-owned registry (section
612
+ 7.1). `createRegistry()` always holds the built-in task definitions (section 8),
613
+ and open rejects a registry whose snapshot lacks any of them.
614
+ Open changes surviving `running` tasks to `pending`, keeps `waiting` tasks
615
+ waiting, and re-evaluates finalization and `failFast` for `completing` and
616
+ `waiting` tasks (section 5.5). It never migrates or terminalizes a task because
617
+ its definition is missing or unmigratable. Such a task stays `pending` or `waiting` and is **blocked**: the
618
+ scheduler skips it and reconsiders it whenever the registry changes (section
619
+ 5.4). Any built-in document touched by recovery migrates through its ordinary
620
+ typed access path. Open does not scan or migrate other documents. No handler
621
+ dispatches during open. Applications should install their extensions before
622
+ open so recovered work can resume immediately; an install after open also
623
+ unblocks it.
624
+
625
+ The root conversation always has reserved ID `ROOT_CONVERSATION_ID` (`1`).
626
+ `root(context, { agent, init })` creates it lazily: when the root is absent, one
627
+ commit on the Session line creates the ownerless root, runs the creation hook
628
+ (below), applies `agent` with `configure()`, and runs `init(tx, rootId)`. When
629
+ the root already exists, `agent` and `init` are ignored and no write occurs.
630
+ Reopen finds the same root by its reserved ID. A conversation whose agent has no
631
+ model produces a durable `no_model` generation failure.
632
+
633
+ `resume()` is idempotent while running and only enables scheduling. It does not
634
+ repeat open-time reconciliation, and it throws after close. Work that must happen
635
+ before any task runs, such as installing extensions or seeding, happens before
636
+ `resume()`. Calls that ask for progress also enable scheduling, so they never
637
+ wait on a paused Harness: `Conversation.submit()`, `Conversation.compact()`,
638
+ `Conversation.abort()`, `Submission.wait()`, `Harness.waitForTask()`,
639
+ `Harness.waitForIdle()`, and `Conversation.waitForIdle()`. Recovered work starts
640
+ with them. A viewer that only reads never enables scheduling: `inspect()`,
641
+ `getTask()`, `submission()`, `Submission.status()`, `usage()`, document reads,
642
+ every conversation read, state, and watch, and the task graph.
643
+
644
+ `createConversation({ ownership, agent, init })` and
645
+ `fork(at, { ownership, agent, init })` commit atomically: the conversation, its
646
+ explicitly selected ownership, the creation hook's documents, the `agent`
647
+ change, and every write made by `init`.
648
+ A first input is an ordinary `submit()` afterward. A request ID makes a retried
649
+ `submit()` to the same conversation exactly-once; a host that must survive a
650
+ crash between the two calls first finds its conversation again, for example
651
+ through a key written by `init`. Host callers must choose ownerless or task
652
+ ownership; neither the Harness nor a conversation handle infers ownership from
653
+ call context.
654
+
655
+ Every Harness commit that creates or forks a conversation runs the built-in
656
+ creation hook in the same commit, whether through the conveniences or through
657
+ raw `tx.createConversation()` and `tx.forkConversation()`, for example inside a
658
+ tool commit. It runs inside `tx.createConversation()` and
659
+ `tx.forkConversation()`, before they return, so a `configure()` later in the
660
+ same callback overrides its copy. It creates empty `pi.live`, `pi.inbox`, and
661
+ `pi.usage`, creates `pi.provider` with a fresh provider-facing UUIDv7, and handles
662
+ `pi.agent`:
663
+
664
+ - A fork keeps the `asOf` copy of its parent's `pi.agent` (section 3.7),
665
+ whatever its ownership.
666
+ - A new task-owned conversation gets a copy of the stored `pi.agent` of the
667
+ owner task's conversation, every field, `instructions` included. A later
668
+ change to the owner does not reach the child. Fields the owner leaves unset
669
+ stay unset and follow the host.
670
+ - A new ownerless conversation gets an empty `pi.agent`, `{}`.
671
+
672
+ `HarnessOptions.conversationCreated`, if given, then runs in the same commit
673
+ with the new record, for every creation path, so the host can create the
674
+ documents its application needs in every conversation; the record's `parent`
675
+ and `owner` tell forks and task-owned conversations apart. Table reads throw
676
+ `ReadAfterWrite` there, as in `init`, and a throw fails the creating commit.
677
+ The conveniences then apply their `agent` change and run `init`. Extensions
678
+ have no creation hook, so their hooks and sections treat an absent document of
679
+ their own as its default. A conversation created by a plain Session has no
680
+ built-in documents.
681
+
682
+ ```ts
683
+ // In a tool's commit. The child starts as a copy of this conversation's agent: model, extensions, tools, cwd.
684
+ const child = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
685
+ // A cheaper model, only the read tool, and its own worktree; everything else stays as copied.
686
+ await configure(tx, child.id, { model: haiku, tools: [readTool], cwd: worktree });
687
+ ```
688
+
689
+ `init` runs after the conversation creation, which is a table write, so table
690
+ reads inside it throw `ReadAfterWrite` (section 4). Document access remains
691
+ available.
692
+
693
+ The built-in agent document is final at version 1:
694
+
695
+ | field | value |
696
+ |---|---|
697
+ | kind | `pi.agent` |
698
+ | version | `1` (no migration) |
699
+ | scope/history/fork | conversation, `rewindable`, `asOf` |
700
+ | schema | `AgentState` |
701
+ | `initial()` | `{}` |
702
+ | checkpoint | complete base on every change |
703
+ | view mount | `docs["pi.agent"]` |
704
+
705
+ It stores only what someone chose for the conversation: extension and tool
706
+ names, never code, and no prompt text other than `instructions`. Code comes
707
+ from the registry by name when the agent is resolved (section 7.1).
708
+ `configure(tx, id, change)` edits it in any commit, including the one that
709
+ creates or forks the conversation; `Conversation.configure()` does so in its own
710
+ commit. `Conversation.agent()` resolves it with the current registry snapshot
711
+ and settings, reading an absent document as `{}`; it never writes.
712
+
713
+ `configure()` replaces whole fields: a given field replaces the stored one,
714
+ `null` clears it, and `undefined` leaves it. Extension and tool objects stand
715
+ for their names, so an old object of an extension that was installed again
716
+ still selects it.
717
+
718
+ ```ts
719
+ await root.configure({ tools: { remove: [editTool] } }, context);
720
+ await root.configure({ tools: { remove: [bashTool] } }, context); // edit is offered again
721
+ await root.configure({ tools: null }, context); // every tool of the selected extensions again
722
+ ```
723
+
724
+ A UI toggle therefore reads the current value and writes the new one. An
725
+ `extensions` object `{ add, remove }` always edits the host default selection
726
+ (section 7.1), never a copied selection, whether that is an array or an
727
+ `{ add, remove }` object; to edit a copied selection, write an array computed
728
+ from the resolved agent's `extensions`.
729
+
730
+ Nothing checks that a stored extension or tool name is installed. The document
731
+ records choices; the registry records what this process can execute now. A
732
+ stored name may therefore be uninstalled, for example during extension reload,
733
+ after restart before installation, or in a fork opened by a process that lacks
734
+ an extension. Resolution skips it, the document is never rewritten because of
735
+ registry movement, and the name takes effect again when it is installed again.
736
+
737
+ A change neither starts generation nor appends a system entry. Request
738
+ preparation later compares the desired prompt and tools with transcript history
739
+ and appends the required positional system baseline or delta (section 7.4).
740
+
741
+ The built-in provider document is final at version 1:
742
+
743
+ | field | value |
744
+ |---|---|
745
+ | kind | `pi.provider` |
746
+ | version | `1` (no migration) |
747
+ | scope/history/fork | conversation, `latest`, `initial` |
748
+ | schema | `{ sessionId: string }` |
749
+ | `initial()` | `{ sessionId: uuidv7() }` |
750
+ | checkpoint | complete base on every change |
751
+ | view mount | `docs["pi.provider"]` |
752
+
753
+ The UUID is a provider-facing conversation identity, not the numeric Durable
754
+ `ConversationId` and not an enclosing application's Session ID. Every new,
755
+ task-owned, raw-created, and forked conversation receives its own UUID in its
756
+ creating commit; a fork never copies its parent's UUID. Generation requests and
757
+ compaction summarization pass it to pi-ai as `options.sessionId`. Reset,
758
+ compaction, model changes, and reopen do not change it. A legacy conversation without the document creates and persists it
759
+ on the Session line before its first generation or compaction provider request.
760
+ Concurrent callers therefore observe one winner. Provider behavior still
761
+ applies: for example, Codex suppresses cache/session identity when
762
+ `cacheRetention` is `"none"`.
763
+
764
+ A missing tool implementation never fails a request. Request preparation offers
765
+ only the agent's resolved tools. If the replayed tool state still offers a tool
766
+ that no longer resolves, the appended system delta lists it in `toolsRemoved`;
767
+ when it resolves again, a later delta adds its current declaration. When the
768
+ model calls a tool that its request did not offer, or whose implementation does
769
+ not resolve when its tool task runs (section 7.3), an error tool result with
770
+ an error diagnostic with code `tool_unavailable` (section 7.3) states that the tool is not available,
771
+ and the run continues so the model can react (section 8.3).
772
+
773
+ `HarnessOptions.settings` holds Harness-wide run policy: the default extension
774
+ selection, request options, retry, compaction thresholds, tool execution, and
775
+ queue modes. Nothing copies or stores it. Every reader resolves it anew into
776
+ `Settings`, each absent field over its built-in default and object fields merged
777
+ key by key, and uses that value for one decision (section 7.1 lists the
778
+ readers). A settings object with getters therefore follows the user's
779
+ preferences without a Session write. Settings are synchronous because queue
780
+ modes are read on the Session line. `stream` is forwarded to generation
781
+ requests; `stream.maxRetries` are provider retries inside one request, while
782
+ `retry` governs durable generation attempts (section 8.3). Settings changes make
783
+ no commit and emit no event.
784
+
785
+ ```ts
786
+ // The user's settings, read live through getters.
787
+ class UserSettings implements HarnessSettings {
788
+ readonly manager: SettingsManager;
789
+ constructor(manager: SettingsManager) {
790
+ this.manager = manager;
791
+ }
792
+ get stream() {
793
+ return { timeoutMs: this.manager.get("timeoutMs") };
794
+ }
795
+ get compaction() {
796
+ return { enabled: this.manager.get("autoCompact") };
797
+ }
798
+ }
799
+ manager.set("autoCompact", false); // no Session write; every conversation follows at its next threshold check
800
+ ```
801
+
802
+ `HarnessOptions.env` builds a conversation's execution environment from its ID,
803
+ its agent's `cwd`, and a `DocumentReader` for committed documents. The Harness
804
+ never calls it on the Session line and calls it at each use:
805
+
806
+ - The tool task calls it for each execution and passes the result as `api.env`
807
+ (section 7.3). A rerun after recovery gets the conversation's environment at
808
+ that time, including its current `cwd`. A throw becomes an ordinary error
809
+ result of the call.
810
+ - Generation `prepare` calls it once for the section renderers (`input.env`,
811
+ section 7.4). A throw is reported, and the sections render with
812
+ `env: undefined`.
813
+ - `runtime.env(context)` calls it for custom tasks and rejects with its error.
814
+
815
+ Without an `env` option, or when it returns `undefined`, there is no
816
+ environment. The host caches what is expensive, such as one environment per
817
+ directory. Extensions read the built environment, such as `env.cwd`, never a
818
+ descriptor. An application that needs more than a directory per conversation
819
+ keeps that in its own document and reads it through `target.read`:
820
+
821
+ ```ts
822
+ // Absent: the conversation runs locally. Only conversations with this document run in a container.
823
+ // Subagents do not copy it: their creator writes it too when they should run in the container.
824
+ const ContainerDoc = defineDoc<{ image: string }>({
825
+ kind: "app.container", version: 1, scope: "conversation", history: "latest", fork: "current",
826
+ initial: () => ({ image: "node:22" }),
827
+ });
828
+ const harness = await Harness.open(storage, {
829
+ models,
830
+ registry,
831
+ env: async ({ conversationId, cwd, read }, context) => {
832
+ const container = await read.snapshot(ContainerDoc, conversationId, context);
833
+ return container !== undefined
834
+ ? containers.env(container.image, cwd ?? "/work", context)
835
+ : localEnv(cwd ?? process.cwd()); // cached NodeExecutionEnv per directory
836
+ },
837
+ }, context);
838
+ ```
839
+
840
+ Agent and settings changes take effect when their readers resolve them (section
841
+ 7.1). Generation preparation resolves the agent once per request and fixes that
842
+ turn's prompt, offered tools, model, and request options. A change made while
843
+ the turn runs applies at the next preparation. A tool the model already called
844
+ is resolved when its tool task runs; when its name is no longer among the
845
+ agent's tools, because the filter, the selection, or the registry changed, the
846
+ call produces `tool_unavailable` (section 7.3). The retry
847
+ policy is read when generation classifies an attempt's result, because it
848
+ governs the next attempt (section 8.3).
849
+
850
+ A `Conversation.commit()` is a Session commit bound to that conversation.
851
+ `tx.createTask()` defaults `TaskOptions.conversationId` to the bound conversation.
852
+ `Conversation.entries()` binds the query to that conversation and paginates its
853
+ fork-aware stored history; callers cannot substitute another conversation ID.
854
+ Generic Session-wide document operations remain available directly on `Harness`
855
+ because `Harness extends Session`.
856
+
857
+ `fork()` requires a concrete visible parent entry and explicit ownership, then
858
+ applies section 3.7.
859
+ `compact()` admits a manual compaction task in one commit and returns its ID,
860
+ not its future summary entry. The task is conversation-owned and not background,
861
+ so `Conversation.abort()` cancels it and idle waits include it. It does not take
862
+ run control, so it does not make the conversation busy: the conversation keeps
863
+ working while it summarizes, and its summary is placed through a write
864
+ submission, at once when idle, otherwise at the next boundary (section 8.7). `reset()` durably admits a write submission of a
865
+ `pi.reset` entry (section 8.1) with `head: "self"`, carrying the handoff text as
866
+ a user message when given, and then resolves; while busy, placement follows
867
+ section 6 and may occur later. Observe its placement through the conversation
868
+ watch. An idle wait does not guarantee placement of queued passive writes.
869
+
870
+ `abortTask()` commits `abortRequested`, then signals and joins the active run;
871
+ the scheduler then starts the abort invocation and marks the foreground-owned
872
+ subtree in its next reconcile commit (section 5.4). `Conversation.abort()` withdraws queued
873
+ input submissions, marks non-background tasks selected by ordinary ownership
874
+ traversal, signals them, and resolves only after that scope is ordinarily idle.
875
+ Passive writes and background subtrees survive. Conversation idle means no live
876
+ non-background task selected from that conversation. Harness idle applies the
877
+ same traversal from every ownerless conversation root. Waiting,
878
+ deadline-blocked, and `completing` work is still live and therefore not idle. Cancelling an idle
879
+ wait aborts only that waiter.
880
+
881
+ Conversation handles are stateless; compare them by `id`. Hosts discover
882
+ conversations through lookups and scans. There is no creation listener; the task
883
+ graph view (section 9.5), `inspect()`, and the idle waits report live work.
884
+
885
+ `submit()` returns after durable admission, not settlement. An input submission
886
+ creates a user message with the Harness clock's timestamp at placement, which is
887
+ admission when it is placed at once; `whenBusy` defaults to
888
+ `followUp`. A write submission uses the ordered passive path in section 6 and
889
+ never starts generation. `Submission.wait()` settles an input only after its
890
+ answer or terminal failure; it settles a write when the entry is placed or the
891
+ write becomes terminally unplaceable. Cancelling `wait()` only cancels that wait.
892
+ It does not withdraw the submission; `Submission.abort()` is the explicit queued
893
+ withdrawal operation. `Harness.submission()` reacquires a submission after
894
+ reopen; records remain queryable after settlement.
895
+
896
+ `abortTask()` durably requests cancellation and returns `marked` after the mark
897
+ is committed and any active run invocation has joined, or after a blocked task
898
+ without live ordinary owned work has been settled as `orphaned` (section 5.4).
899
+ The abort invocation starts once the task's ordinary owned work is no longer
900
+ live; a `completing` task is only marked, and its final record keeps its held
901
+ outcome with `abortRequested: true` (section 5.5). It does not await terminal
902
+ settlement. `waitForTask()` observes
903
+ the terminal receipt. Aborting an already terminal task returns `terminal`; an
904
+ unknown ID rejects. Explicit task abort includes a background task. Cancelling a
905
+ task or idle wait does not abort work.
906
+
907
+ `inspect()` returns live work at one point on the Session line, for recovery
908
+ decisions after open, viewers, and diagnostics. It writes nothing, does not
909
+ enable scheduling, and runs no task code. Each live task carries its state
910
+ under the current registry: `running` with an active invocation; `completing`
911
+ for a held outcome; `waiting` with the live tasks it still waits for, the live
912
+ part of its `on`, or, when abort-marked, its live ordinary owned work (section
913
+ 5.5); `ready` when the next scheduling pass would reserve it, with `migrates`
914
+ when its definition is newer and has `migrate`; or `blocked` (section 5.4). A migration shows as failed only after the scheduler
915
+ tried it, or when the newer definition has no `migrate`; inspection never runs
916
+ one to find out. Blocked reasons are derived, never stored. Queued and placed
917
+ submissions complete the view. Finished
918
+ tasks, settled submissions, transcripts, and documents use their own APIs.
919
+
920
+ `Conversation.viewState()` returns its current structural mount as a disposable
921
+ read-only Chord state. `Conversation.watch()` atomically captures that immutable
922
+ revision and registers for later exact complete-commit frames. Its `WatchHandle`
923
+ uses the same serialized asynchronous, bounded-buffer contract as `watchDoc()`
924
+ in section 9.2. Neither carries semantic events or owns a second persistence
925
+ authority.
926
+
927
+ `close()` seals mutation admission and task reservation, signals invocations,
928
+ and ends every document state, view state, and watch at that seal: a commit
929
+ that settles during close publishes nothing to them, and a state keeps its last
930
+ value. Outside the Session line it lets already-admitted storage commits settle,
931
+ joins task, tool, and hook invocations, including code that ignores its signal,
932
+ then closes Storage. Once `close()` resolves, no invocation code of this Harness
933
+ runs and no handler holds Storage, so a new Harness may open the same Storage.
934
+ Already-running watch callbacks remain caller-owned and may finish
935
+ independently. Close writes no task outcome. Cancelling a `close()` call cancels
936
+ only that wait; shutdown continues, and every later `close()` awaits the same
937
+ shutdown.
938
+
939
+ Handles belong to that open Harness and must be reacquired after reopen. Once
940
+ close begins, every new Harness, conversation, and submission operation rejects,
941
+ and `resume()` throws. Of the operations already queued on the Session line at
942
+ the seal, commits settle (section 4) and reads complete. Waits, states, and
943
+ watches reject, except two that need no later commit: `Submission.wait()` of an
944
+ already settled submission resolves, and an acquisition of an absent document
945
+ resolves `undefined`. An `inspect()` among them reports `scheduling: "closing"`.
946
+
947
+ ## 3. Documents
948
+
949
+ ### 3.1 Definitions
950
+
951
+ Scope directly determines document ownership and lifetime. Only conversation
952
+ documents declare history and fork behavior.
953
+
954
+ ```ts
955
+ type LatestConversationSemantics = {
956
+ readonly scope: "conversation";
957
+ readonly history: "latest";
958
+ readonly fork: "current" | "initial";
959
+ };
960
+
961
+ type RewindableConversationSemantics = {
962
+ readonly scope: "conversation";
963
+ readonly history: "rewindable";
964
+ readonly fork: "asOf" | "current" | "initial";
965
+ };
966
+
967
+ type DocumentSemantics =
968
+ | { readonly scope: "session" }
969
+ | LatestConversationSemantics
970
+ | RewindableConversationSemantics
971
+ | { readonly scope: "task" };
972
+
973
+ type CheckpointInfo = {
974
+ /** Deltas already stored after the newest base, excluding this change. */
975
+ readonly deltasSinceBase: number;
976
+ };
977
+
978
+ type CommonDocDefinition<T extends JsonObject> = {
979
+ readonly kind: string;
980
+ readonly version: number;
981
+ initial(): T;
982
+ migrate?(value: JsonObject, fromVersion: number): T;
983
+ checkpointWhen?(value: Readonly<T>, ops: readonly Op[], info: CheckpointInfo): boolean;
984
+ };
985
+
986
+ type DocDefinition<T extends JsonObject> =
987
+ CommonDocDefinition<T> & DocumentSemantics;
988
+
989
+ type DocFamilyDefinition<T extends JsonObject, I extends JsonValue> =
990
+ Omit<CommonDocDefinition<T>, "initial"> & DocumentSemantics & {
991
+ readonly family: true;
992
+ initial(seed: I): T;
993
+ };
994
+
995
+ declare const docType: unique symbol;
996
+ interface DocToken<T extends JsonObject, D extends DocDefinition<T>> {
997
+ readonly definition: D;
998
+ readonly [docType]?: T;
999
+ }
1000
+ interface DocFamilyToken<
1001
+ T extends JsonObject,
1002
+ I extends JsonValue,
1003
+ D extends DocFamilyDefinition<T, I>,
1004
+ > {
1005
+ readonly definition: D;
1006
+ readonly [docType]?: T;
1007
+ }
1008
+
1009
+ type SessionDocToken<T extends JsonObject> = DocToken<
1010
+ T,
1011
+ CommonDocDefinition<T> & { readonly scope: "session" }
1012
+ >;
1013
+ type ConversationDocToken<T extends JsonObject> = DocToken<
1014
+ T,
1015
+ CommonDocDefinition<T> & (LatestConversationSemantics | RewindableConversationSemantics)
1016
+ >;
1017
+ type RewindableConversationDocToken<T extends JsonObject> = DocToken<
1018
+ T,
1019
+ CommonDocDefinition<T> & RewindableConversationSemantics
1020
+ >;
1021
+ type TaskDocToken<T extends JsonObject> = DocToken<
1022
+ T,
1023
+ CommonDocDefinition<T> & { readonly scope: "task" }
1024
+ >;
1025
+
1026
+ type SessionDocFamilyToken<T extends JsonObject, I extends JsonValue> = DocFamilyToken<
1027
+ T,
1028
+ I,
1029
+ DocFamilyDefinition<T, I> & { readonly scope: "session" }
1030
+ >;
1031
+ type ConversationDocFamilyToken<T extends JsonObject, I extends JsonValue> = DocFamilyToken<
1032
+ T,
1033
+ I,
1034
+ DocFamilyDefinition<T, I> & (LatestConversationSemantics | RewindableConversationSemantics)
1035
+ >;
1036
+ type RewindableConversationDocFamilyToken<T extends JsonObject, I extends JsonValue> = DocFamilyToken<
1037
+ T,
1038
+ I,
1039
+ DocFamilyDefinition<T, I> & RewindableConversationSemantics
1040
+ >;
1041
+ type TaskDocFamilyToken<T extends JsonObject, I extends JsonValue> = DocFamilyToken<
1042
+ T,
1043
+ I,
1044
+ DocFamilyDefinition<T, I> & { readonly scope: "task" }
1045
+ >;
1046
+
1047
+ function defineDoc<T extends JsonObject>(
1048
+ definition: CommonDocDefinition<T> & { readonly scope: "session" },
1049
+ ): SessionDocToken<T>;
1050
+ function defineDoc<T extends JsonObject>(
1051
+ definition: CommonDocDefinition<T> & LatestConversationSemantics,
1052
+ ): ConversationDocToken<T>;
1053
+ function defineDoc<T extends JsonObject>(
1054
+ definition: CommonDocDefinition<T> & RewindableConversationSemantics,
1055
+ ): RewindableConversationDocToken<T>;
1056
+ function defineDoc<T extends JsonObject>(
1057
+ definition: CommonDocDefinition<T> & { readonly scope: "task" },
1058
+ ): TaskDocToken<T>;
1059
+
1060
+ function defineDocFamily<T extends JsonObject, I extends JsonValue>(
1061
+ definition: Omit<CommonDocDefinition<T>, "initial"> & {
1062
+ readonly family: true;
1063
+ readonly scope: "session";
1064
+ initial(seed: I): T;
1065
+ },
1066
+ ): SessionDocFamilyToken<T, I>;
1067
+ function defineDocFamily<T extends JsonObject, I extends JsonValue>(
1068
+ definition: Omit<CommonDocDefinition<T>, "initial"> & LatestConversationSemantics & {
1069
+ readonly family: true;
1070
+ initial(seed: I): T;
1071
+ },
1072
+ ): ConversationDocFamilyToken<T, I>;
1073
+ function defineDocFamily<T extends JsonObject, I extends JsonValue>(
1074
+ definition: Omit<CommonDocDefinition<T>, "initial"> & RewindableConversationSemantics & {
1075
+ readonly family: true;
1076
+ initial(seed: I): T;
1077
+ },
1078
+ ): RewindableConversationDocFamilyToken<T, I>;
1079
+ function defineDocFamily<T extends JsonObject, I extends JsonValue>(
1080
+ definition: Omit<CommonDocDefinition<T>, "initial"> & {
1081
+ readonly family: true;
1082
+ readonly scope: "task";
1083
+ initial(seed: I): T;
1084
+ },
1085
+ ): TaskDocFamilyToken<T, I>;
1086
+ ```
1087
+
1088
+ Validation rules:
1089
+
1090
+ - Versions are positive integers.
1091
+ - Typed access rejects when the token's scope or conversation history/fork policy
1092
+ disagrees with the persisted incarnation. A migration cannot reinterpret those
1093
+ lifetime semantics.
1094
+ - Session documents are current-only and belong to the Session. Closing and
1095
+ reopening the Session does not retire them.
1096
+ - Conversation documents declare `history` and `fork`; `fork: "asOf"` requires
1097
+ `history: "rewindable"`.
1098
+ - Task documents are current-only, are never copied by a conversation fork, and
1099
+ retire atomically when their task becomes terminal.
1100
+ - `initial()` and `migrate()` return JSON objects.
1101
+
1102
+ `checkpointWhen()` only selects complete storage bases to bound replay. It does
1103
+ not change scope, lifetime, history, or fork semantics.
1104
+
1105
+ Concrete built-in document grouping and semantics are declared when the built-in
1106
+ definitions are implemented. The generic document mechanism does not special
1107
+ case model, tool, inbox, or presentation state.
1108
+
1109
+ ### 3.2 Records and lifetimes
1110
+
1111
+ A persisted document instance has one `DocumentRecord`:
1112
+
1113
+ ```ts
1114
+ type DocumentRecord = {
1115
+ readonly id: DocumentId; // unique incarnation
1116
+ readonly kind: string; // stable definition kind
1117
+ readonly key?: string; // families only
1118
+ readonly createdAt: Seq; // stamped by the committing storage
1119
+ readonly retiredAt?: Seq;
1120
+ } & (
1121
+ | { readonly scope: { readonly kind: "session" } }
1122
+ | ({ readonly scope: { readonly kind: "conversation"; readonly conversationId: ConversationId } } & (
1123
+ | { readonly history: "latest"; readonly fork: "current" | "initial" }
1124
+ | {
1125
+ readonly history: "rewindable";
1126
+ readonly fork: "asOf" | "current" | "initial";
1127
+ }
1128
+ ))
1129
+ | { readonly scope: { readonly kind: "task"; readonly taskId: TaskId } }
1130
+ );
1131
+
1132
+ type DocumentCreate = DocumentRecord extends infer Record
1133
+ ? Record extends DocumentRecord
1134
+ ? Omit<Record, "createdAt" | "retiredAt">
1135
+ : never
1136
+ : never;
1137
+ ```
1138
+
1139
+ `id` is never reused. Retiring and recreating the same logical kind, scope, and
1140
+ family key creates a new incarnation. Membership is the half-open interval
1141
+ `createdAt <= at < retiredAt`; an unretired incarnation has no upper bound. A
1142
+ creation retired in the same commit has an empty lifetime.
1143
+
1144
+ A singleton is identified logically by kind and scope. A family is identified
1145
+ logically by kind, scope, and `key`. The record preserves scope and conversation
1146
+ history/fork semantics so unavailable extension code does not make existing data
1147
+ disappear. Definition versions belong to stored bases and deltas because one
1148
+ incarnation may contain records written by multiple definition versions.
1149
+ `DocumentCreate` is not another persisted record; it is the same scoped union
1150
+ without storage-assigned lifetime fields.
1151
+
1152
+ ### 3.3 Access and creation
1153
+
1154
+ There is no mutable `session.document()` API.
1155
+
1156
+ ```ts
1157
+ interface Session extends DocumentObserver {
1158
+ commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T>;
1159
+ close(context: Context): Promise<void>;
1160
+ subscribeCommits(listener: (publication: CommitPublication, context: Context) => void): () => void;
1161
+ subscribeClose(listener: () => void): () => void;
1162
+
1163
+ snapshot<T extends JsonObject>(token: SessionDocToken<T>, context: Context): Promise<Readonly<T> | undefined>;
1164
+ snapshot<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId, context: Context): Promise<Readonly<T> | undefined>;
1165
+ snapshot<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId, context: Context): Promise<Readonly<T> | undefined>;
1166
+ snapshot<T extends JsonObject, I extends JsonValue>(token: SessionDocFamilyToken<T, I>, key: string, context: Context): Promise<Readonly<T> | undefined>;
1167
+ snapshot<T extends JsonObject, I extends JsonValue>(token: ConversationDocFamilyToken<T, I>, conversationId: ConversationId, key: string, context: Context): Promise<Readonly<T> | undefined>;
1168
+ snapshot<T extends JsonObject, I extends JsonValue>(token: TaskDocFamilyToken<T, I>, taskId: TaskId, key: string, context: Context): Promise<Readonly<T> | undefined>;
1169
+
1170
+ snapshotAsOf<T extends JsonObject>(token: RewindableConversationDocToken<T>, conversationId: ConversationId, at: EntryId, context: Context): Promise<Readonly<T> | undefined>;
1171
+ snapshotAsOf<T extends JsonObject, I extends JsonValue>(token: RewindableConversationDocFamilyToken<T, I>, conversationId: ConversationId, key: string, at: EntryId, context: Context): Promise<Readonly<T> | undefined>;
1172
+
1173
+ documentState<T extends JsonObject>(token: SessionDocToken<T>, context: Context): Promise<DocumentState<T> | undefined>;
1174
+ documentState<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId, context: Context): Promise<DocumentState<T> | undefined>;
1175
+ documentState<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId, context: Context): Promise<DocumentState<T> | undefined>;
1176
+ documentState<T extends JsonObject, I extends JsonValue>(token: SessionDocFamilyToken<T, I>, key: string, context: Context): Promise<DocumentState<T> | undefined>;
1177
+ documentState<T extends JsonObject, I extends JsonValue>(token: ConversationDocFamilyToken<T, I>, conversationId: ConversationId, key: string, context: Context): Promise<DocumentState<T> | undefined>;
1178
+ documentState<T extends JsonObject, I extends JsonValue>(token: TaskDocFamilyToken<T, I>, taskId: TaskId, key: string, context: Context): Promise<DocumentState<T> | undefined>;
1179
+ }
1180
+
1181
+ interface Tx {
1182
+ conversation(id: ConversationId): Promise<ConversationRecord | undefined>;
1183
+ entry(id: EntryId): Promise<EntryRecord | undefined>;
1184
+ /** Undefined when the entry is absent or has another kind. */
1185
+ entry<D extends JsonValue>(token: Entry<D>, id: EntryId): Promise<TypedEntry<D> | undefined>;
1186
+ task(id: TaskId): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
1187
+ scanConversations(query: ConversationQuery, limit: number, cursor?: Cursor): Promise<Page<ConversationRecord, Cursor>>;
1188
+ scanEntries(query: EntryQuery, limit: number, cursor?: Cursor): Promise<Page<EntryRecord, Cursor>>;
1189
+ scanTasks(query: TaskQuery, limit: number, cursor?: Cursor): Promise<Page<TaskRecord<JsonValue, JsonValue, JsonValue>, Cursor>>;
1190
+ submissionByRequest(conversationId: ConversationId, requestId: string): Promise<SubmissionRecord | undefined>;
1191
+
1192
+ createConversation(options: { readonly ownership: ConversationOwnership }): Promise<ConversationRecord>;
1193
+ forkConversation(
1194
+ parentConversationId: ConversationId,
1195
+ at: EntryId,
1196
+ options: { readonly ownership: ConversationOwnership },
1197
+ ): Promise<ConversationRecord>;
1198
+ appendEntry(conversationId: ConversationId, value: EntryDraft): Promise<EntryRecord>;
1199
+ /** The token supplies `kind` and types `data`. */
1200
+ appendEntry<D extends JsonValue>(
1201
+ token: Entry<D>, conversationId: ConversationId, value: TypedEntryDraft<D>,
1202
+ ): Promise<TypedEntry<D>>;
1203
+ createTask<I, S extends { phase: string }, R, H extends object>(
1204
+ task: Task<I, S, R, H>, input: I, options: TaskOptions,
1205
+ ): Promise<TaskId<R>>;
1206
+ /** Raw submission record; no admission rules (busy check, inbox, placement). Hosts use `Conversation.submit()`. */
1207
+ createSubmission(create: SubmissionCreate): Promise<SubmissionRecord>;
1208
+ /** Settle a queued or placed submission; only a placed input can be answered. A settled one stays unchanged. */
1209
+ settleSubmission(id: SubmissionId, settlement: SubmissionSettlement): void;
1210
+ /** Newest visible entry of the conversation that carries a `head`. */
1211
+ latestHeadMarker(conversationId: ConversationId): Promise<(EntryRecord & { readonly head: EntryId }) | undefined>;
1212
+ /** Record a queued submission's placement at `entry`: an input becomes `placed`, a write `done` (section 6). The caller appends the entry and edits `pi.inbox` and `pi.live`. */
1213
+ placeSubmission(id: SubmissionId, entry: EntryId): void;
1214
+
1215
+ doc<T extends JsonObject>(token: SessionDocToken<T>): Promise<Draft<T>>;
1216
+ doc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId): Promise<Draft<T>>;
1217
+ doc<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId): Promise<Draft<T>>;
1218
+ doc<T extends JsonObject, I extends JsonValue>(token: SessionDocFamilyToken<T, I>, key: string, seed: I): Promise<Draft<T>>;
1219
+ doc<T extends JsonObject, I extends JsonValue>(token: ConversationDocFamilyToken<T, I>, conversationId: ConversationId, key: string, seed: I): Promise<Draft<T>>;
1220
+ doc<T extends JsonObject, I extends JsonValue>(token: TaskDocFamilyToken<T, I>, taskId: TaskId, key: string, seed: I): Promise<Draft<T>>;
1221
+
1222
+ retireDoc<T extends JsonObject>(token: SessionDocToken<T>): Promise<void>;
1223
+ retireDoc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId): Promise<void>;
1224
+ retireDoc<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId): Promise<void>;
1225
+ retireDoc<T extends JsonObject, I extends JsonValue>(token: SessionDocFamilyToken<T, I>, key: string): Promise<void>;
1226
+ retireDoc<T extends JsonObject, I extends JsonValue>(token: ConversationDocFamilyToken<T, I>, conversationId: ConversationId, key: string): Promise<void>;
1227
+ retireDoc<T extends JsonObject, I extends JsonValue>(token: TaskDocFamilyToken<T, I>, taskId: TaskId, key: string): Promise<void>;
1228
+ }
1229
+ ```
1230
+
1231
+ ID-creating transaction methods are asynchronous because remote storage may
1232
+ allocate globally unique numeric IDs durably. Conversation creation always
1233
+ requires explicit ownership; no transaction wrapper injects the executing task.
1234
+ For task ownership, the caller supplies only a typed task ID. The Session derives
1235
+ the persisted owner conversation from the task's final candidate record.
1236
+
1237
+ An owner task may be committed or staged earlier in the same transaction. Before
1238
+ Storage admission, the Session rejects a missing, `completing`, terminal, or
1239
+ abort-marked owner of a new task or conversation, judged on the owner's final
1240
+ candidate record in that transaction. A task therefore cannot create owned work
1241
+ in the commit that finishes it. A child task whose explicit `conversationId`
1242
+ differs from its owner's conversation, and a child task with `background: true`,
1243
+ reject the same way. Existing owner edges remain valid when their owners finish
1244
+ afterward. Conversation
1245
+ creation returns an inert record, never an operational handle.
1246
+ `forkConversation()` additionally validates one visible entry and applies section
1247
+ 3.7. Only public typed `tx.doc()` is get-or-create. Internal fork copying may create
1248
+ new incarnations directly from stored values without a definition. `tx.doc()`
1249
+ receives the definition token that supplies
1250
+ its static type, initializer, migration, and checkpoint policy. Scope-preserving
1251
+ token overloads require callers to supply only the concrete conversation/task ID
1252
+ and, for a family, its key and creation seed. Ordinary definitions are not
1253
+ registered and ordinary documents are not scanned at open.
1254
+
1255
+ - Existing instances are reconstructed and migrated lazily according to section
1256
+ 3.6. A migration reached through `tx.doc()` is staged in its enclosing
1257
+ transaction.
1258
+ - A missing singleton calls the token's `initial()`. A missing family member calls
1259
+ `initial(seed)`. Creation stores an initial base.
1260
+ - The first acquisition of one logical address is memoized before awaiting. Later
1261
+ acquisitions in that transaction return the same draft; for a missing family,
1262
+ the first call's detached seed wins and later seeds are ignored.
1263
+ - `snapshot()`, `snapshotAsOf()`, `documentState()`, and `watchDoc()` never create
1264
+ or persist migration. They return `undefined` when the requested incarnation is
1265
+ absent and migrate a reconstructed value only in memory.
1266
+ - Task-scoped `tx.doc()` validates against the transaction's latest candidate task
1267
+ record, falling back to committed state. This internal validation is not a
1268
+ caller table read and does not trigger `ReadAfterWrite`. Its conversation is
1269
+ derived from the task record.
1270
+ - `retireDoc()` resolves the logical address without creating it. Retirement of
1271
+ an acquired draft persists its final content before retirement. A later
1272
+ `tx.doc()` at that address in the same transaction creates a new incarnation
1273
+ with a new draft and ID.
1274
+ - A terminal candidate rejects later task-document access. Terminal settlement
1275
+ retires both existing task documents and task documents created earlier in the
1276
+ same transaction.
1277
+ - `snapshot()` returns the current shareable immutable revision. Mutation of it
1278
+ or any retained descendant is unsupported; callers that need mutable ownership
1279
+ must copy it first.
1280
+ - `documentState()` returns a disposable read-only Chord state bound to one committed incarnation.
1281
+ - `tx.doc()` returns one revocable Astra overlay `Draft<T>` for the transaction.
1282
+ - Historical reads never create documents in the past.
1283
+
1284
+ `snapshotAsOf()` is available only for rewindable conversation documents. It
1285
+ validates that `at` is visible through the requested conversation's ancestry,
1286
+ selects the ancestor conversation that owns that entry, then finds the logical
1287
+ singleton/family incarnation whose creation/retirement interval contains the
1288
+ entry's commit. It never starts from today's incarnation and never creates an
1289
+ instance. It returns `undefined` when no such instance existed.
1290
+
1291
+ After `B` forks `A` at entry `E`, asking for `B`'s state at inherited `E` reads
1292
+ `A`'s historical instance; `B`'s copied incarnation was created later. If an
1293
+ instance was retired and recreated, historical lookup selects the incarnation
1294
+ alive at the target commit.
1295
+
1296
+ ### 3.4 Mutation ownership
1297
+
1298
+ Pico uses Chord Delta's Astra-immutable transaction shape directly:
1299
+
1300
+ ```ts
1301
+ interface Prepared<T extends object> {
1302
+ readonly base: T;
1303
+ readonly value: T;
1304
+ readonly ops: readonly Op[];
1305
+ readonly baseRevision: number;
1306
+ abort(): void;
1307
+ }
1308
+ interface Change<T extends object> {
1309
+ readonly state: Draft<T>;
1310
+ prepare(): Prepared<T>;
1311
+ abort(): void;
1312
+ }
1313
+ interface Tracker<T extends object> {
1314
+ readonly value: T;
1315
+ readonly revision: number;
1316
+ beginChange(): Change<T>;
1317
+ prepareReplace(value: T): Prepared<T>;
1318
+ adopt(prepared: Prepared<T>): void;
1319
+ }
1320
+ ```
1321
+
1322
+ Each loaded document owns one tracker whose `value` is its current immutable
1323
+ revision. Immutability is a trusted ownership contract, not runtime freezing:
1324
+ a revision and its descendants must never be mutated after transfer to the
1325
+ tracker. `prepare()` revokes the draft, emits detached self-contained
1326
+ operations, and computes `value` with the optimized immutable applier before
1327
+ Storage admission. Unchanged subtrees are structurally shared with `base`.
1328
+ Operation placement payloads may also be shared with `value`; mutating either a
1329
+ prepared operation or an immutable revision is unsupported. Astra preserves an
1330
+ empty operation batch for changes it proves are no-ops, but Pico performs no
1331
+ additional whole-value equality pass for nonempty structural batches.
1332
+
1333
+ `abort()` is idempotent. `adopt()` accepts only a prepared result from that
1334
+ tracker at its current revision, then performs only a synchronous pointer swap
1335
+ to the already-computed immutable `value`. It performs no diffing, application,
1336
+ allocation, or callback. The Session line permits at most one open change per
1337
+ tracker and ensures no result becomes stale between preparation, Storage
1338
+ settlement, and adoption.
1339
+
1340
+ The first `tx.doc()` acquisition calls `tracker.beginChange()` and memoizes that
1341
+ change's draft for the rest of the possibly async Session callback.
1342
+
1343
+ Transaction behavior:
1344
+
1345
+ ```text
1346
+ begin transaction
1347
+ acquire and memoize document changes by logical address
1348
+ mutate revocable Astra overlay drafts
1349
+ callback settles
1350
+ seal Tx; synchronously prepare or abort every open change, revoking every draft
1351
+ if any acquisition is pending: abort open changes, reject, then drain and abort it
1352
+ callback fails with no pending acquisition
1353
+ abort every open change; persist and publish nothing
1354
+ callback succeeds with no pending acquisition
1355
+ prepare every open change -> immutable next revision + self-contained Chord Op[]
1356
+ Session evaluates each required/ordinary document write exactly once
1357
+ Storage.commit persists the atomic batch while the Session line remains held
1358
+ storage succeeds
1359
+ adopt every prepared change by pointer swap and enqueue immutable revision/ops publication
1360
+ commit listeners, including conversation view mounts (section 9.3), capture it synchronously
1361
+ release the line; invoke listeners later
1362
+ storage fails
1363
+ abort every prepared change, poison Session, and publish nothing
1364
+ ```
1365
+
1366
+ A pending acquisition that resolves after sealing never exposes a draft; its
1367
+ change is aborted and its promise rejects. The Session observes every such
1368
+ settlement before releasing the line. Initializer, migration, and replacement
1369
+ roots come from caller code: the Session copies each one into exclusive kernel
1370
+ ownership, rejecting any value that is not strict JSON, before it becomes an
1371
+ immutable tracker revision. Loaded and fork-copy roots are already detached
1372
+ strict JSON from Storage and enter the tracker without another copy. Chord's
1373
+ `track()` and `prepareReplace()` take ownership in O(1) without traversal, so
1374
+ every root they receive must come from one of these sources. Migration callbacks
1375
+ never receive a live tracker revision.
1376
+
1377
+ Preparation, validation, or checkpoint failure occurs before Storage admission
1378
+ and rolls back normally. The Session performs no
1379
+ strict-JSON walk of prepared operations or selected bases: roots are checked on
1380
+ entry and Chord checks every draft placement, so every revision, operation
1381
+ payload, and base is strict JSON by construction. Tracker branding and `baseRevision` enforce ownership and staleness; the
1382
+ Session never substitutes caller-created prepared values. The prepared immutable
1383
+ candidate itself becomes the adopted and published value. Storage receives that complete value only when the
1384
+ Session selects a base; otherwise it receives only the prepared operation batch.
1385
+ An existing current-version document with an empty batch writes and publishes
1386
+ nothing. A nonempty structural batch whose final value is deeply equal to its
1387
+ base remains a valid durable change and publication. Creation and required
1388
+ version transitions still write a base when their prepared batch is empty; an
1389
+ equal-value version base does not emit a watch update.
1390
+
1391
+ Values assigned into a draft are copied immediately by value. Repeated
1392
+ placements are independent. Chord checks each placement while copying it and
1393
+ throws at the offending assignment, before the draft changes, when the value is
1394
+ not strict JSON: `undefined` array elements or nested object values, non-finite
1395
+ numbers, functions, symbols, bigints, accessors, symbol keys, sparse arrays, or
1396
+ objects whose prototype is neither `Object.prototype` nor `null`. Assigning
1397
+ `undefined` directly to an object property deletes that property. Draft reads, draft writes, and all `Tx` operations
1398
+ reject after the callback settles. The prepared immutable value remains readable
1399
+ by Session-owned checkpoint and Storage preparation.
1400
+
1401
+ ```ts
1402
+ let escaped: Draft<LiveState> | undefined;
1403
+ await session.commit(async tx => {
1404
+ escaped = await tx.doc(LiveDoc, conversationId);
1405
+ }, context);
1406
+ escaped!.generation = undefined; // throws: the draft was revoked
1407
+ ```
1408
+
1409
+ Fire-and-forget work that mutates a draft before the owner callback settles may
1410
+ silently enter that transaction and is unsupported.
1411
+
1412
+ ### 3.5 Bases and checkpoints
1413
+
1414
+ Creation always stores a complete base.
1415
+
1416
+ For an ordinary later mutation, the definition alone decides whether the
1417
+ storage record is a base:
1418
+
1419
+ ```ts
1420
+ const useBase = definition.checkpointWhen?.(candidateValue, ops, { deltasSinceBase }) ?? false;
1421
+ ```
1422
+
1423
+ The Session evaluates this predicate exactly once after tracker preparation.
1424
+ `deltasSinceBase` counts the deltas already stored after the incarnation's newest
1425
+ base, excluding the change being evaluated. Storage reports it when it
1426
+ materializes the current value; the Session advances it after each adopted write
1427
+ and resets it after every base, so evaluation performs no Storage read.
1428
+ Creation and version transitions require bases and do not call it. The Session
1429
+ then gives Storage only the selected representation:
1430
+
1431
+ ```text
1432
+ required or predicate true -> base with complete value
1433
+ otherwise -> delta with the prepared Chord operation batch
1434
+ ```
1435
+
1436
+ A Chord root-replacement operation remains a delta unless the definition selected
1437
+ a checkpoint; it does not authorize reclamation. Predicate failure aborts the
1438
+ prepared transaction before Storage admission. Storage executes no definition
1439
+ code and never receives an unused complete candidate with a selected delta.
1440
+
1441
+ - For Session, task, and latest conversation documents, a committed base permits
1442
+ physical reclamation of older records.
1443
+ - For rewindable conversation documents, bases bound replay but never permit
1444
+ removal of addressable history.
1445
+ - A definition that never checkpoints may create an unbounded replay tail. That
1446
+ is a definition bug, not a backend heuristic.
1447
+ - Storage does not count encoded bytes, compare against `initial()`, or invent
1448
+ checkpoints.
1449
+
1450
+ A definition can bound replay directly:
1451
+
1452
+ ```ts
1453
+ checkpointWhen: (_value, _ops, info) => info.deltasSinceBase >= 31
1454
+ ```
1455
+
1456
+ A high-churn live document can checkpoint when it becomes empty:
1457
+
1458
+ ```ts
1459
+ checkpointWhen: (value, _ops) =>
1460
+ value.generation === undefined &&
1461
+ value.tools === undefined
1462
+ ```
1463
+
1464
+ ### 3.6 Versions and migrations
1465
+
1466
+ One migration callback handles every supported older version.
1467
+
1468
+ ```text
1469
+ stored == token -> use value
1470
+ stored < token -> call migrate(value, storedVersion)
1471
+ stored > token -> reject typed access
1472
+ no migrate -> reject older stored version
1473
+ ```
1474
+
1475
+ `migrate()` is pure and returns a complete current-version value. Migration is
1476
+ access-driven: `tx.doc()`, `snapshot()`, `snapshotAsOf()`, `documentState()`, and
1477
+ `watchDoc()` reconstruct and migrate through the token supplied to that call.
1478
+ Harness open does not sweep ordinary documents.
1479
+
1480
+ - Read-only access migrates only in memory and never writes. It may cache a
1481
+ tracker over that migrated immutable revision together with the older stored
1482
+ version marker; the next successful `tx.doc()` access still writes the required
1483
+ current-version base. A cached tracker serves only tokens of the version its value was
1484
+ materialized for. Access with any other version, such as a token from reloaded
1485
+ extension code, drops it and reloads the stored value, so each token migrates
1486
+ from the stored version or rejects a newer one. Migration always starts from a detached stored value.
1487
+ `tx.doc()` stages migration in its enclosing transaction; callback failure
1488
+ persists nothing, and later draft edits coalesce into one final required base.
1489
+ - Rewindable history is not rewritten. Current and historical reconstructed
1490
+ values are migrated after replay.
1491
+ - The first `tx.doc()` transaction after any stored-version migration writes a
1492
+ required current-version base, even when the migrated JSON is deeply equal.
1493
+ - A fork obtains the selected stored value/version from Storage rather than a
1494
+ typed migrated tracker cache. The child copies that stored pair and migrates on
1495
+ later typed access.
1496
+ - Unaccessed documents and documents with unavailable definitions preserve their stored instances,
1497
+ versions, and bytes.
1498
+
1499
+ ### 3.7 Forks
1500
+
1501
+ A conversation fork points to one concrete visible entry `E`.
1502
+
1503
+ The child transcript includes entries through `E`, even if the same commit also
1504
+ appended later entries. Document state at `E` is the final state of the commit
1505
+ containing `E`. Different document states require separate commits.
1506
+
1507
+ Each conversation document follows the history/fork policy persisted in its
1508
+ `DocumentRecord`:
1509
+
1510
+ | conversation setting | child value |
1511
+ |---|---|
1512
+ | `fork: "asOf"` | parent value at `E`'s commit |
1513
+ | `fork: "current"` | committed parent value selected when the fork commit runs |
1514
+ | `fork: "initial"` | no copied instance; initializer on first child access |
1515
+
1516
+ `current` and `asOf` copy logically present conversation singleton and family
1517
+ instances, preserving unknown definitions and their stored versions. Copied
1518
+ values become independent child instances with new IDs and initial bases.
1519
+ `initial` copies no instance; first access in the child creates it from the
1520
+ supplied definition. Task documents and tasks are never copied. Session documents
1521
+ remain shared and are not rewindable.
1522
+
1523
+ Fork copying reads committed stored values rather than typed tracker caches. A
1524
+ transaction that creates a fork therefore rejects if it also writes one of the
1525
+ parent's `fork: "current"` documents; commit the parent change first so the fork
1526
+ has one unambiguous stored source revision.
1527
+
1528
+ Forks stage backend-side `document.copy` commands carrying the child create
1529
+ record and an exact source incarnation/point. Storage materializes each source
1530
+ and persists its stored value/version as the child's independent initial base;
1531
+ remote Storage performs this server-side. Every copy reads committed pre-batch
1532
+ source state independent of write-array order. A selected source may not be
1533
+ created, changed, or retired in the same batch. Copying remains atomic with the
1534
+ conversation, overrides, registry writes, and other mutations.
1535
+
1536
+ An unaccessed copy retains only its descriptor in Session memory. Typed access
1537
+ inside the creating transaction lazily reads the detached source, migrates when
1538
+ required, and replaces the copy with one ordinary child create containing the
1539
+ final prepared value. Definition-free copies publish explicit `document.copy`
1540
+ metadata rather than a value. That metadata announces Storage-backed initial
1541
+ state and is never interpreted as a document value. Document states, watches,
1542
+ and mounted views hydrate by capturing their baseline and subscription
1543
+ atomically on the Session line: a later commit already present becomes the
1544
+ baseline, while one committed after registration is delivered. Publications
1545
+ and watches are convergence mechanisms, not audit streams. A mounted aggregate
1546
+ must acquire all of its document baselines and commit subscription in one
1547
+ Session-line operation so it never exposes a mixture from one commit.
1548
+
1549
+ ## 4. Transactions and storage ownership
1550
+
1551
+ A Session commit callback may be asynchronous. It owns the Session mutation
1552
+ line through callback execution, preparation, storage settlement, committed
1553
+ baseline adoption, and publication enqueue. Commit observers run on the
1554
+ line; document-state and watch user callbacks run later.
1555
+ External model, process, tool, network, and human effects run outside it.
1556
+ `subscribeCommits()` observes complete immutable publications synchronously on
1557
+ the line after adoption. `subscribeClose()` observes close synchronously when it
1558
+ begins, after admission is sealed; the Harness stops watches and signals task
1559
+ invocations there. Both return idempotent disposers; their listeners must not
1560
+ throw, block, or call Session APIs. Document-state subscribers and watch
1561
+ listeners still run later, off the line.
1562
+
1563
+ ```ts
1564
+ await session.commit(async tx => {
1565
+ const conversation = await tx.conversation(conversationId); // table read
1566
+ const live = await tx.doc(LiveDoc, conversationId);
1567
+
1568
+ await tx.appendEntry(conversationId, message); // first table write
1569
+ delete live.generation; // document mutation remains valid
1570
+ await tx.createTask(Follow, {}, { ownership: { kind: "conversation" }, conversationId }); // further table writes are fine
1571
+ }, context);
1572
+ ```
1573
+
1574
+ Mutation admission occurs on the Session line before a commit callback starts. Closing seals mutation admission and task
1575
+ reservation. Already-admitted commits settle before storage closes. Once a
1576
+ commit is admitted, caller cancellation does not interrupt storage settlement or
1577
+ undo the commit. Cancelling a close wait does not reopen admission.
1578
+
1579
+ Table rules:
1580
+
1581
+ - Tables are conversations, entries, tasks, and submissions.
1582
+ - Table reads are allowed before the first table write.
1583
+ - Any table read after the first table write throws `ReadAfterWrite`.
1584
+ - Document access and read-your-writes remain available after table writes.
1585
+ - Creation methods return their created ID/record; callers do not read it back.
1586
+
1587
+ Storage ownership:
1588
+
1589
+ - Commit arguments are borrowed until `Storage.commit()` settles.
1590
+ - Anything retained after settlement is detached first.
1591
+ - Memory storage recursively copies retained JSON containers.
1592
+ - JSONL and SQLite detach through serialization and decoded indexes.
1593
+ - Every storage read returns a detached JSON value.
1594
+ - Immutable strings may be shared; mutable arrays and objects may not.
1595
+
1596
+ ## 5. Tasks
1597
+
1598
+ ### 5.1 Definition
1599
+
1600
+ ```ts
1601
+ type TaskOutcome<R> =
1602
+ | { readonly status: "completed"; readonly result: R }
1603
+ | { readonly status: "failed"; readonly error: TaskOutcomeError; readonly result?: R }
1604
+ | { readonly status: "aborted"; readonly reason?: string; readonly result?: R }
1605
+ | { readonly status: "orphaned"; readonly reason: string }
1606
+ | { readonly status: "faulted"; readonly error: TaskOutcomeError };
1607
+
1608
+ type JoinPolicy = "failFast" | "allSettled";
1609
+
1610
+ type TaskState<S, R> =
1611
+ | { readonly status: "pending"; readonly checkpoint: S }
1612
+ | { readonly status: "running"; readonly checkpoint: S }
1613
+ /** Parked without an invocation until every task in `on` is terminal; then resumes at `checkpoint` (section 5.5). */
1614
+ | {
1615
+ readonly status: "waiting";
1616
+ readonly checkpoint: S;
1617
+ readonly on: readonly TaskId[];
1618
+ readonly policy: JoinPolicy;
1619
+ }
1620
+ /** Outcome decided; terminal once no ordinary owned work below is live (section 5.5). Runs no more code. */
1621
+ | { readonly status: "completing"; readonly outcome: TaskOutcome<R> }
1622
+ | { readonly status: "terminal"; readonly outcome: TaskOutcome<R> };
1623
+
1624
+ /** Who owns a task: its conversation (a top-level task) or another task of the same conversation. */
1625
+ type TaskOwnership = { readonly kind: "conversation" } | { readonly kind: "task"; readonly taskId: TaskId };
1626
+
1627
+ type TaskRecord<I, S, R> = {
1628
+ readonly id: TaskId<R>;
1629
+ readonly conversationId: ConversationId;
1630
+ readonly kind: string;
1631
+ readonly version: number;
1632
+ readonly input: I;
1633
+ /** Owning task; absent for a task its conversation owns. Immutable. */
1634
+ readonly owner?: TaskId;
1635
+ readonly background: boolean;
1636
+ readonly abortRequested: boolean;
1637
+ } & (
1638
+ | {
1639
+ readonly state: Extract<TaskState<S, R>, { status: "pending" | "running" | "waiting" }>;
1640
+ readonly memos?: Readonly<Record<string, JsonValue>>;
1641
+ }
1642
+ | {
1643
+ readonly state: Extract<TaskState<S, R>, { status: "completing" | "terminal" }>;
1644
+ readonly memos?: never;
1645
+ }
1646
+ );
1647
+
1648
+ type RunningTask<I, S, R> = TaskRecord<I, S, R> & {
1649
+ readonly state: Extract<TaskState<S, R>, { status: "running" }>;
1650
+ };
1651
+
1652
+ /**
1653
+ * State a task commits for itself: a replacement checkpoint, a wait, or its outcome. A returned `terminal` state
1654
+ * becomes `completing` while ordinary owned work below is live (section 5.5).
1655
+ */
1656
+ type NextTaskState<S, R> = Extract<TaskState<S, R>, { status: "running" | "waiting" | "terminal" }>;
1657
+
1658
+ interface HookRunner<H extends object> {
1659
+ each<K extends keyof H>(name: K, invoke: (handler: NonNullable<H[K]>) => void | Promise<void>): Promise<void>;
1660
+ }
1661
+
1662
+ type PhaseHandler<I, P, S, R, H extends object> = (
1663
+ task: RunningTask<I, P, R>,
1664
+ runtime: TaskRuntime<I, S, R, H>,
1665
+ context: Context,
1666
+ ) => Promise<void>;
1667
+
1668
+ interface TaskRuntime<I, S, R, H extends object> extends DocumentObserver, DocumentReader {
1669
+ readonly taskId: TaskId<R>;
1670
+ readonly conversationId: ConversationId;
1671
+ readonly signal: AbortSignal;
1672
+ /** Registry snapshot of the current phase; refreshed at every phase boundary. */
1673
+ readonly registry: RegistrySnapshot;
1674
+ /** The task's conversation's agent, resolved once for this phase (section 7.1). */
1675
+ agent(context: Context): Promise<Agent>;
1676
+ /** `HarnessOptions.settings`, resolved at each access. */
1677
+ readonly settings: Settings;
1678
+ readonly models: Models;
1679
+ readonly hooks: HookRunner<H>;
1680
+ /** Calls `HarnessOptions.env` for the task's conversation; rejects with its error. */
1681
+ env(context: Context): Promise<ExecutionEnv | undefined>;
1682
+
1683
+ commit(
1684
+ change: (
1685
+ tx: Tx,
1686
+ current: RunningTask<I, S, R>,
1687
+ ) => NextTaskState<S, R> | undefined | Promise<NextTaskState<S, R> | undefined>,
1688
+ context: Context,
1689
+ ): Promise<void>;
1690
+
1691
+ memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
1692
+ memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
1693
+ conversation(id: ConversationId, context: Context): Promise<ConversationHandle | undefined>;
1694
+ /** Committed task record. */
1695
+ getTask<T>(id: TaskId<T>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, T> | undefined>;
1696
+ /** Terminal receipt; rejects when the invocation ends. */
1697
+ waitForTask<T>(id: TaskId<T>, context: Context): Promise<SettledTask<T>>;
1698
+ /** Outcomes of terminal tasks, in order; rejects when one is not terminal. Used after a wait (section 5.5). */
1699
+ outcomes<T>(ids: readonly TaskId<T>[], context: Context): Promise<TaskOutcome<T>[]>;
1700
+ /** Committed entry visible from the task's conversation. */
1701
+ entry(id: EntryId, context: Context): Promise<EntryRecord | undefined>;
1702
+ entry<D extends JsonValue>(token: Entry<D>, id: EntryId, context: Context): Promise<TypedEntry<D> | undefined>;
1703
+ /** Committed raw active transcript and model context, optionally cut off at `at`. */
1704
+ context(conversationId: ConversationId, context: Context, at?: EntryId): Promise<ContextView>;
1705
+ /** The Harness clock. */
1706
+ now(): number;
1707
+ /** Forward a non-fatal failure to `HarnessOptions.onReport`. */
1708
+ report(error: unknown): void;
1709
+ sleep(until: number, context: Context): Promise<void>;
1710
+ }
1711
+
1712
+ type TaskDefinition<I, S extends { phase: string }, R, H extends object> = {
1713
+ readonly name: string;
1714
+ readonly version: number;
1715
+ initial(input: I): S;
1716
+ readonly phases: {
1717
+ [P in S["phase"]]: PhaseHandler<I, Extract<S, { phase: P }>, S, R, H>;
1718
+ };
1719
+ abort(task: RunningTask<I, S, R>, runtime: TaskRuntime<I, S, R, H>, context: Context): Promise<void>;
1720
+ migrate?(input: JsonValue, checkpoint: JsonValue, fromVersion: number): {
1721
+ input: I;
1722
+ checkpoint: S;
1723
+ };
1724
+ readonly hooks?: H;
1725
+ };
1726
+
1727
+ interface Task<I, S extends { phase: string }, R, H extends object> {
1728
+ readonly definition: TaskDefinition<I, S, R, H>;
1729
+ }
1730
+
1731
+ type TaskOptions = {
1732
+ /** Required: a task always names its owner (section 5.5). */
1733
+ readonly ownership: TaskOwnership;
1734
+ /** Default: the owner task's conversation, or the transaction's bound conversation. */
1735
+ readonly conversationId?: ConversationId;
1736
+ /** Conversation-owned tasks only: excluded from conversation abort and idle, and from cascades. */
1737
+ readonly background?: boolean;
1738
+ };
1739
+
1740
+ function defineTask<I, S extends { phase: string }, R, H extends object = {}>(
1741
+ definition: TaskDefinition<I, S, R, H>,
1742
+ ): Task<I, S, R, H>;
1743
+ ```
1744
+
1745
+ `TaskId<R>` is the numeric task ID itself with an erased result-type brand. It
1746
+ replaces a separate task-reference wrapper: `createTask()` returns `TaskId<R>`,
1747
+ typed waits infer `R` from that ID, and result-agnostic operations accept
1748
+ `TaskId<unknown>`. A task's `conversationId` is immutable after creation; a
1749
+ replacement that moves an existing task to another conversation rejects before
1750
+ Storage admission so persisted conversation-owner edges cannot become stale.
1751
+
1752
+ The phase map is exhaustive and phase-narrowed. A handler may perform several
1753
+ commits around one effect, but each durable checkpoint is a full replacement.
1754
+ `TaskRuntime.commit()` rereads and gates the current durable task on the Session
1755
+ line before invoking its callback. It rejects when the invocation has ended, the
1756
+ Harness is closing, the task is terminal, or a run invocation's task carries an
1757
+ abort mark. Its `tx.createTask()` defaults to the task's conversation, and every
1758
+ entry it appends records the task as `byTaskId`. When the
1759
+ callback returns a state, the runtime replaces the task's state in the same
1760
+ commit, so the checkpoint or outcome is atomic with the callback's entries,
1761
+ documents, and child tasks and is type-checked against the task's checkpoint and
1762
+ result types. Returning nothing leaves the state unchanged. A terminal or
1763
+ completing state drops the memos. A `waiting` state ends the invocation even
1764
+ when its checkpoint is unchanged; it rejects when `on` names a missing task, the
1765
+ task itself, or a task on its owner chain, which could never finish first, and
1766
+ an empty `on` resumes at the next scheduling pass. `pending` is never returned;
1767
+ only reconciliation and handover write it.
1768
+
1769
+ `Tx` has no task replacement operation. A task changes only its own state,
1770
+ through its runtime. The scheduler owns reservation, reconciliation, handover,
1771
+ faults, and orphaning; `abortTask()` owns abort marks. Other code
1772
+ stops a task with `abortTask()` and reads its result with `waitForTask()`.
1773
+
1774
+ ```ts
1775
+ // Intent, effect, outcome.
1776
+ prepare: async (task, runtime, context) => {
1777
+ await runtime.commit(() => ({ status: "running", checkpoint: { phase: "charge", key: newKey() } }), context);
1778
+ },
1779
+ charge: async (task, runtime, context) => {
1780
+ const receipt = await payments.charge(task.state.checkpoint.key); // idempotent by key
1781
+ await runtime.commit(async (tx, current) => {
1782
+ const entry = await tx.appendEntry(current.conversationId, receiptEntry(receipt));
1783
+ return { status: "terminal", outcome: { status: "completed", result: { entryId: entry.id } } };
1784
+ }, context);
1785
+ },
1786
+ // The abort handler decides the outcome; returning without one faults the task.
1787
+ abort: async (task, runtime, context) => {
1788
+ await payments.cancel(task.state.checkpoint);
1789
+ await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted", reason: "user" } }), context);
1790
+ },
1791
+ ```
1792
+ `memo(name, candidate)` is one gated commit; `memo(name)` reads the committed
1793
+ record. `sleep(until)` compares against the Harness `now` clock and rejects when
1794
+ the invocation is signalled or its context is cancelled. Watches acquired through
1795
+ the runtime stop when the invocation ends. `snapshot()`/`snapshotAsOf()` read
1796
+ committed documents, for example to supply `PromptInput.read` (section 7.4).
1797
+ `getTask()` and `entry()` read committed records with one lookup each; `waitForTask()` waits for a terminal receipt,
1798
+ for example a child task created by a tool.
1799
+ `context()` captures its bounds on the Session line and derives the view from
1800
+ immutable entries off the line, like `Conversation.context()`. Like every runtime
1801
+ operation, these reject after the invocation ends.
1802
+
1803
+ Reservation durably changes `pending`, or `waiting` once it may resume, to
1804
+ `running`. One invocation runs phase
1805
+ handlers in sequence; checkpoint commits retain `running`. Before every phase,
1806
+ including the first, the scheduler runs one step callback on the Session line.
1807
+ It reads the committed task and synchronously applies the first matching rule
1808
+ below to the phase that just returned. It writes any fault or handover in the
1809
+ same commit, together with the Harness cleanup for a scheduler-written outcome
1810
+ (section 5.4), which may await document access inside that callback. It ends
1811
+ the invocation there when a rule stops it. A runtime commit the
1812
+ invocation queued earlier therefore either lands before the step and counts, or
1813
+ reaches the line after it and rejects. Before the first phase only rules 1–3
1814
+ apply:
1815
+
1816
+ 1. Terminal, `completing`, or `waiting`: stop.
1817
+ 2. Session closing: stop; preserve the checkpoint and any abort mark for reopen.
1818
+ 3. Run mode with a durable abort mark: end and join the run invocation; a fresh
1819
+ abort invocation starts once the task's ordinary owned work is no longer
1820
+ live (section 5.5).
1821
+ 4. Uncaught error: write terminal `faulted`.
1822
+ 5. Checkpoint changed, including progress within the same phase: refresh the
1823
+ registry snapshot and either hand over (section 5.4) or invoke the phase
1824
+ handler in the same task invocation.
1825
+ 6. Checkpoint unchanged: write terminal `faulted` because no durable progress
1826
+ was made.
1827
+
1828
+ An abort invocation runs its abort handler once. After it settles, a step
1829
+ applies rules 1, 2, and 4; a handler that returns without a terminal outcome
1830
+ faults the task. When Storage rejects a step's fault or handover write, the
1831
+ invocation still ends; the task stays `running` and the next reservation runs it
1832
+ again.
1833
+
1834
+ On open, running-task reconciliation changes surviving `running` tasks back to
1835
+ `pending`, preserving their checkpoint and abort mark; `waiting` tasks stay
1836
+ waiting, and `completing` and `waiting` tasks are re-evaluated (section 5.5). Task migration runs at
1837
+ reservation, atomically with `pending` or `waiting` -> `running`. One callback handles every
1838
+ supported older version. A task whose definition is missing, older than the
1839
+ stored version, or fails migration stays `pending` or `waiting` and blocked until a fitting
1840
+ definition is installed or the task is aborted (section 5.4).
1841
+ `close()` marks the runtime closing, seals admission and reservation, signals
1842
+ invocations, and ends states and watches. Outside the Session line it settles admitted
1843
+ commits and joins invocations before closing storage. Already-running watch callbacks remain
1844
+ caller-owned. Later runtime commits reject, and close writes no task outcome. Closing
1845
+ starts no fresh phase or abort invocation. It does not set abort marks,
1846
+ terminalize tasks, retire task documents, or publish document retirement. The
1847
+ hosting layer withdraws services and
1848
+ detaches clients before it closes the Harness, for example by disposing its Chord
1849
+ facet host (see the Chord usage guide); reconnecting to a reopened Session
1850
+ hydrates the last committed state and resumes recovery from its durable
1851
+ checkpoints.
1852
+
1853
+ ### 5.2 Effect sandwich
1854
+
1855
+ ```text
1856
+ commit intent phase
1857
+ perform external effect
1858
+ commit outcome or next phase
1859
+ ```
1860
+
1861
+ Reopening in an intent phase means the effect may have happened. The phase
1862
+ handler retries safely, polls an external handle, or records interruption.
1863
+ Deferred providers are represented by a durable phase containing their handle
1864
+ and next poll time.
1865
+
1866
+ Runtime-owned memos are small first-writer-wins values stored in the live task
1867
+ envelope. Candidate insertion and reading the winner are one Session commit, so
1868
+ concurrent candidates return the same durable winner. Memos survive checkpoints
1869
+ and disappear in the terminal replacement. Bulk progress belongs in a document.
1870
+
1871
+ ### 5.3 Terminal tasks
1872
+
1873
+ The terminal task record is the durable result receipt. Its result may directly
1874
+ contain a small value or reference an entry:
1875
+
1876
+ ```ts
1877
+ { status: "completed", result: { entryId: toolResultId } }
1878
+ ```
1879
+
1880
+ A terminal transition atomically:
1881
+
1882
+ 1. Writes the terminal task record.
1883
+ 2. Appends any result entries.
1884
+ 3. Retires all documents scoped to that task.
1885
+ 4. Resolves any submissions settled by the task.
1886
+
1887
+ A task whose ordinary owned work is live splits this at a `completing` hold
1888
+ (section 5.5, rule 4): a task-written outcome's own writes land at hold, and
1889
+ items 1 and 3 and its waiters follow in the final commit.
1890
+
1891
+ The execution checkpoint and memos disappear from the terminal representation.
1892
+ Terminal records remain queryable for waits, waiters, inspection, and reopen.
1893
+ There are no free-standing task dependencies: ordering comes from a task waiting
1894
+ on other tasks (section 5.5). An abort mark lets pending work reach its abort handler,
1895
+ or its `orphaned` settlement when its definition is unavailable.
1896
+
1897
+ ### 5.4 Scheduler, abort, and ownership
1898
+
1899
+ The scheduler serially reserves eligible tasks, then runs handlers off the
1900
+ Session line. One in-memory `TaskInvocation` contains mode, abort controller,
1901
+ and completion promise.
1902
+
1903
+ Abort protocol:
1904
+
1905
+ ```text
1906
+ commit abortRequested
1907
+ signal and join active run invocation
1908
+ wait until ordinary owned work is no longer live (section 5.5)
1909
+ start a fresh abort invocation
1910
+ abort handler commits terminal outcome
1911
+ ```
1912
+
1913
+ A run invocation may not commit after its durable abort mark appears. Every
1914
+ runtime operation rejects after its owning invocation ends, even while the
1915
+ Session remains open, and the invocation's signal and handler context abort when
1916
+ it ends for any reason. An invocation ends only after its last handler returned,
1917
+ so this cancels only detached leftovers, such as an unawaited `waitForTask()` or
1918
+ a fetch started with the handler's context; they could no longer write anything. Returning from one phase handler does not end an invocation
1919
+ that continues into another phase. Invocation mode is volatile and derived from
1920
+ the durable mark on reopen. Cancelling one caller's `Context` only cancels
1921
+ that call or wait; it does not durably abort shared work unless the invoked API
1922
+ commits an abort mark.
1923
+
1924
+ A task may create owned conversations and owned tasks (section 5.5).
1925
+ Conversations are durable scopes; tasks are the units of live work counted by
1926
+ idle and marked by abort. History parents are irrelevant to ownership traversal.
1927
+
1928
+ Ordinary traversal starts at an explicitly addressed conversation, visits its
1929
+ conversation-owned tasks, follows the tasks each non-background task owns, and
1930
+ follows the conversations each non-background task owns. It follows owner edges after the
1931
+ owner becomes terminal, but a background task is a boundary: ordinary traversal
1932
+ skips that task and its complete owned subtree.
1933
+ Direct conversation operations start inside that conversation regardless of its
1934
+ owner. Directly aborting a live background task includes that task and follows
1935
+ its ordinary owned subtree; nested background owners remain boundaries.
1936
+ `close()` stops every invocation, foreground and background, without writing an
1937
+ outcome, so the work resumes on reopen. A host that must durably cancel
1938
+ everything aborts each live task that `inspect()` lists.
1939
+
1940
+ `Conversation.abort()` withdraws queued inputs and marks live non-background
1941
+ tasks selected by ordinary traversal. With `{ background: true }` it crosses
1942
+ background boundaries: it marks every live task that traversal ignoring the
1943
+ background flag reaches when the operation is admitted and withdraws the queued
1944
+ inputs of every conversation it reaches. It then waits until every task it
1945
+ marked is terminal and the conversation is ordinarily idle; background work
1946
+ created afterwards is neither marked nor awaited. `Conversation.waitForIdle()` waits until
1947
+ that traversal contains no live non-background task. Harness idle performs the
1948
+ same traversal from every ownerless conversation root rather than globally
1949
+ counting tasks, so ordinary work below a background owner does not block it.
1950
+ Explicit `waitForTask()` waits for its referenced task regardless of the task's
1951
+ background flag.
1952
+
1953
+ An abort mark cascades idempotently to foreground-owned work. The durable
1954
+ cancellation intent is a live owner's own record: its abort mark, or a held
1955
+ `completing` outcome other than `completed` (section 5.5). A terminal owner never
1956
+ cascades: it became terminal only after its ordinary owned work drained, so its
1957
+ intent is already applied, and work later started in a conversation it owned,
1958
+ for example a user interrogating a finished subagent, runs normally and is
1959
+ reached by ordinary traversal from above. Queued inputs in its owned
1960
+ conversations stay queued, as after a failed run (section 6). The scheduler derives the marks in a
1961
+ later reconcile commit, and again at open, so a crash in between loses nothing;
1962
+ work already queued on the Session line may make one more commit first. Every
1963
+ live non-background task whose owner chain, walking up through owning tasks and
1964
+ owning conversations, reaches a cancelled live owner, without first crossing a
1965
+ background task that has no cancellation intent of its own, gets an abort mark, including work
1966
+ created there after the cascade. Deriving the marks in their own commit
1967
+ keeps them out of the owner's commit, which may already have written the tasks
1968
+ involved. Every conversation the cascade reaches is treated like
1969
+ `Conversation.abort()`: its queued input submissions become `unanswered` with
1970
+ reason `aborted` and leave its inbox, while queued writes stay. The cascade starts
1971
+ below the cancelled task, so that task's own conversation keeps its queue.
1972
+ Held outcomes `failed`, `faulted`, `orphaned`, and `aborted` record the same
1973
+ durable cancellation intent as an abort mark; `completed` does not. Conversation
1974
+ records and owner edges are never retired with the task. Active invocations are
1975
+ signalled after commit. A terminal receipt guarantees that the task's ordinary
1976
+ owned work drained; background work below it may continue.
1977
+
1978
+ Every run invocation start resolves the task's definition by `TaskRecord.kind`
1979
+ from the scheduler's current registry snapshot:
1980
+
1981
+ ```text
1982
+ definition missing -> stay pending, blocked: missing_task
1983
+ stored version newer than it -> stay pending, blocked: task_too_old
1984
+ stored version older than it -> migrate(input, checkpoint, storedVersion)
1985
+ failure -> stay pending, blocked: migration_failed
1986
+ success -> commit migrated record + running atomically
1987
+ equal version -> commit running
1988
+ ```
1989
+
1990
+ An abort invocation resolves the definition the same way. When the definition
1991
+ can take the task, its abort handler runs; otherwise the task is orphaned as
1992
+ described below.
1993
+
1994
+ A failed migration is reported once through `onReport` and retried only after
1995
+ the registry resolves a different definition object for the task's kind.
1996
+
1997
+ The Harness never terminalizes a task merely because registry code is missing or
1998
+ incompatible, neither at open nor later. A blocked task without an abort mark
1999
+ keeps its durable record unchanged, remains live, still blocks ordinary idle
2000
+ waits, and is reconsidered whenever the registry changes. The blocked reason is derived
2001
+ runtime state, not a persisted task status. Document migration remains
2002
+ access-driven.
2003
+
2004
+ Aborting a blocked task cannot run its abort handler, because that code is
2005
+ missing or cannot take the task. The Harness therefore settles it as terminal
2006
+ `orphaned` instead of `aborted`: `aborted` means the task's own abort handler
2007
+ ran and decided the outcome, while `orphaned` means no task code ran, so external
2008
+ effects the task started may remain uncleaned. The orphaned `reason` is the
2009
+ blocked reason (`missing_task`, `task_too_old`, or `migration_failed`). When
2010
+ `abortTask()` finds no active invocation, no live ordinary owned work, and a
2011
+ current snapshot that cannot take the task, the marking commit settles it
2012
+ directly; otherwise the scheduler settles it when it would reserve the abort
2013
+ invocation, which is after the owned work drained (section 5.5), so an orphaned
2014
+ outcome never holds. Only an abort (direct, by
2015
+ conversation, or by cascade) orphans a task; a missing definition alone never
2016
+ does. The orphaning commit performs the cleanup the task's code cannot: affected
2017
+ input submissions become unanswered with the reason, any matching active run
2018
+ control is cleared, and task-scoped documents retire. The terminal task record
2019
+ and unanswered submissions carry the reason; the only transcript entry written
2020
+ is the conversion of a committed generation partial (below).
2021
+ Faulting a run task performs the same control/submission cleanup with a
2022
+ `faulted` outcome; a fault while ordinary owned work is live holds, and its
2023
+ cleanup runs in the final commit (section 5.5, rule 4).
2024
+
2025
+ The scheduler knows nothing about runs or task kinds. The Harness, which owns
2026
+ submissions, run control, and the built-in tasks, gives it one hook that the
2027
+ scheduler calls in the commit that makes an outcome it wrote itself (`faulted`
2028
+ and `orphaned`) terminal, which is the final commit after a hold. The hook ignores tasks whose kind is not a built-in
2029
+ run, tool, or compaction kind, so it never creates `pi.live` elsewhere. It settles the run when
2030
+ `pi.live.run` names the task (section 8): a committed generation partial becomes
2031
+ an aborted `pi.assistant` entry, exactly as the generation abort handler converts
2032
+ it, so the transcript keeps what the model produced and `pi.usage` counts its
2033
+ spend (the scheduler's commit has no task scope, so the entry has no
2034
+ `byTaskId`); its inputs become `unanswered` with reason `faulted` (detail: the error
2035
+ message) or the blocked reason; and `run`, `generation`, and `tools` are
2036
+ removed. Faults come from task bugs or malformed provider data, such as a
2037
+ non-JSON value in a response, or a commit the Storage rejected without effect
2038
+ (`StorageRejected`). An uncertain storage failure poisons the Session and writes
2039
+ no outcome. For a `pi.tool` task it marks the task's tool slot `done`
2040
+ without an entry; the run continues, and context derivation synthesizes the
2041
+ missing result (section 2.1). For a `pi.compaction` task it removes the task's
2042
+ compaction status (section 8.7). Outcomes a task commits for itself do their own settlement.
2043
+ The Harness also supplies the per-phase agent resolution behind `runtime.hooks`
2044
+ and `runtime.agent()` (section 7.1); the scheduler only passes each phase's
2045
+ registry snapshot to it.
2046
+
2047
+ At every normal phase boundary (section 5.1, rule 5), the step refreshes the
2048
+ invocation's registry snapshot. If the task definition resolved by name is a different object
2049
+ than the one the invocation started with and the new definition can reserve the
2050
+ task (same version, or a higher version with `migrate`), the invocation hands
2051
+ over: the step commits the task back to `pending` with its checkpoint, memos,
2052
+ and abort mark and ends the invocation, and the next reservation starts a fresh invocation under the new
2053
+ definition, applying the reservation rules above. When the definition is missing
2054
+ or cannot reserve the task, the invocation keeps running under its old
2055
+ definition, reports once through `onReport` per resolved definition, and
2056
+ reconsiders at its next boundary. A handler that
2057
+ never settles never hands over.
2058
+
2059
+ ### 5.5 Structured concurrency
2060
+
2061
+ Tasks and conversations form one ownership tree. Every task names its owner at
2062
+ creation, `tx.createTask(task, input, { ownership })` with a required
2063
+ `TaskOwnership`: its conversation (`{ kind: "conversation" }`, a top-level task)
2064
+ or a task (`{ kind: "task", taskId }`, a child task). Every conversation is
2065
+ ownerless or owned by a task (section 2.2). A child task always lives in its
2066
+ owner's conversation; only owned conversations cross conversation boundaries.
2067
+ Owner edges are immutable.
2068
+
2069
+ The **ordinary owned work** of a task `T` is every task that ordinary traversal
2070
+ reaches below `T`: the tasks `T` owns, the tasks in the conversations `T` owns,
2071
+ and, transitively, the same for each of those that is not background. A
2072
+ background task and everything below it are excluded. A conversation-owned task
2073
+ may be background; a child task may not.
2074
+
2075
+ Two rules follow from the tree.
2076
+
2077
+ **Abort flows down** (section 5.4). A live owner's cancellation intent (its abort
2078
+ mark, or a held non-`completed` outcome) marks its ordinary owned work. Background
2079
+ tasks are boundaries unless aborted directly or by `Conversation.abort(context,
2080
+ { background: true })`.
2081
+
2082
+ **Owned work does not outlive its owner's finish.** A task finishes only after
2083
+ its ordinary owned work drained:
2084
+
2085
+ 1. A terminal state a task commits, or a terminal outcome the scheduler writes
2086
+ (`faulted`, `orphaned`), while the task's ordinary owned work is live is
2087
+ stored as `{ status: "completing", outcome }` instead. The scheduler writes
2088
+ the final `terminal` record in a later commit once no ordinary owned work is
2089
+ live, evaluated after every commit, including work created after the hold.
2090
+ Whether work is live is judged on the commit's candidate records, so work the
2091
+ finishing commit itself creates, for example a task in a conversation the
2092
+ task owns, holds it. At open it re-evaluates every `completing` task.
2093
+ 2. A held outcome is final: no phase, runtime commit, or abort handler runs
2094
+ again, no definition is needed, the task is never reserved or migrated, and
2095
+ `abortTask()` on it only marks it and returns `marked`, which cancels the
2096
+ work below. The final record keeps the held outcome and the mark.
2097
+ 3. A held non-`completed` outcome is cancellation intent, so the work below is
2098
+ aborted, drains, and then the task becomes terminal. A held `completed`
2099
+ outcome waits for the work to finish normally.
2100
+ 4. Writes split at the hold. A task-written terminal commit lands all of its
2101
+ other writes, such as a tool's result entry and slot, at hold. Only the
2102
+ record's terminal state, task-document retirement, and task waiters are
2103
+ deferred to the final commit. A scheduler-written outcome writes only the
2104
+ record at hold; its Harness cleanup (section 5.4) runs in the final commit,
2105
+ so a faulted run task keeps `pi.live.run` until its tools drained.
2106
+ 5. Waiters, idle waits, inspection, and ordinary traversal see a `completing`
2107
+ task as live until its final commit.
2108
+
2109
+ New owned work needs a live owner: `createTask` with task ownership and
2110
+ `createConversation` with task ownership reject an owner that is `completing`,
2111
+ `terminal`, or abort-marked in the commit's final candidate (section 3.3).
2112
+ Conversations stay usable after their owner finished: new runs
2113
+ started there, for example to interrogate a finished subagent, are ordinary work
2114
+ of that conversation, reached by traversal from above, and not part of any
2115
+ finish.
2116
+
2117
+ **Waiting.** A phase may commit `{ status: "waiting", checkpoint, on, policy }`.
2118
+ The task stops without an invocation and resumes at `checkpoint` once every task
2119
+ in `on` is terminal. `on` may name any tasks, including already terminal ones;
2120
+ tasks the waiting task does not own, whose `owner` is not the waiting task,
2121
+ require `policy: "allSettled"`. `on` may not name a missing task, the task
2122
+ itself, or a task on its owner chain (section 5.1). With `failFast`, the first
2123
+ task in `on` that holds or ends with a non-`completed` outcome gives every other
2124
+ live task in `on` an abort mark, in the scheduler's next reconcile commit. The waiting task itself is not marked:
2125
+ it resumes once all of `on` is terminal and reads their outcomes with
2126
+ `runtime.outcomes()`. A task may create children and keep running phases before
2127
+ it waits, and may wait on any subset of its children in sequence.
2128
+
2129
+ **Abort order is bottom-up.** An abort invocation of a task starts only once its
2130
+ ordinary owned work is no longer live, so an abort handler sees final outcomes
2131
+ below it. An abort mark on a waiting task lets it leave the wait early for its
2132
+ abort handler under this same rule; tasks in its `on` that it does not own are
2133
+ not awaited. Ordering is judged on committed records: code a child runs after
2134
+ its terminal commit is not ordered. An abort handler may not return `waiting`
2135
+ and cannot create owned children, because its task is abort-marked (section
2136
+ 3.3): it compensates inline, or creates background conversation-owned tasks,
2137
+ which no cascade reaches, and waits for them with `runtime.waitForTask()`,
2138
+ which holds its invocation.
2139
+
2140
+ The cascade of the example `Checkout` task, which waits `failFast` on four
2141
+ `Payment` children: an expired card fails its payment; the three live payments
2142
+ get abort marks and refund in their own abort handlers; once all four are
2143
+ terminal, `Checkout` resumes, reads `[aborted, failed, aborted, aborted]`
2144
+ through `outcomes()`, and decides its own outcome. `abortTask(Checkout)` instead
2145
+ marks `Checkout`, the cascade marks the live payments, their abort handlers run
2146
+ first, then `Checkout.abort` runs and sees their final outcomes.
2147
+
2148
+ ## 6. Submissions and inbox
2149
+
2150
+ Submission records back awaitable host objects. Admission is Harness-internal;
2151
+ boundaries place queued submissions with `tx.placeSubmission()`, and run tasks
2152
+ settle the inputs they answer with `tx.settleSubmission()`. Queued submissions
2153
+ wait in the built-in inbox document, an ordered list of tagged items:
2154
+
2155
+ ```ts
2156
+ type InboxItem =
2157
+ | { readonly id: SubmissionId; readonly mode: "steer" | "followUp"; readonly content: UserInput }
2158
+ /** `entry` is the write's `EntryDraft`, stored as plain JSON. */
2159
+ | { readonly id: SubmissionId; readonly mode: "write"; readonly entry: JsonObject };
2160
+
2161
+ type InboxState = { items: InboxItem[] };
2162
+ ```
2163
+
2164
+ | field | value |
2165
+ |---|---|
2166
+ | kind | `pi.inbox` |
2167
+ | version | `1` |
2168
+ | scope/history/fork | conversation, `latest`, `initial` |
2169
+ | `initial()` | `{ items: [] }` |
2170
+ | checkpoint | complete base whenever `items` is empty |
2171
+ | view mount | `docs["pi.inbox"]` |
2172
+ | created | with every Harness conversation (section 2.2) |
2173
+
2174
+ Items are in ID order. A queued input stores its content; its `pi.user` entry
2175
+ gets the Harness clock's timestamp at placement. Queued submissions belong to
2176
+ their conversation, so a fork starts with an empty inbox.
2177
+
2178
+ Run control lives in the built-in live document `pi.live` (section 8). Its
2179
+ optional `run` value names the task currently responsible for the run and its
2180
+ placed input-submission IDs. `run !== undefined` defines `busy`; get-or-create
2181
+ of the idle document does not. The value remains while generation, tools, and
2182
+ tools hand work to one another: `taskId` names the generation that settles the
2183
+ inputs, including while its tool round runs, then the next
2184
+ generation. Tool tasks never own the run; the current round's tool tasks are
2185
+ listed in `pi.live.tools`. The ID list is mutable state because a
2186
+ boundary adds placed steering inputs to an active run; every terminal path
2187
+ settles exactly the listed inputs.
2188
+
2189
+ Admission and terminal transitions:
2190
+
2191
+ | action | submission state | other writes |
2192
+ |---|---|---|
2193
+ | input, idle with empty inbox | `placed`, with user entry | create run and generation |
2194
+ | write, idle with empty inbox | `done`, with entry | append entry; no run |
2195
+ | input or write, busy or non-empty inbox | `queued` | append inbox item |
2196
+ | boundary places user item | `placed`, with entry | add ID to current or successor run |
2197
+ | boundary places write | `done`, with entry | append entry |
2198
+ | run answers | input `done`, with required answer entry | remove `run`, or hand it to a successor |
2199
+ | run fails or its task aborts | input `unanswered`, with reason | remove `run`; inbox unchanged |
2200
+ | withdraw queued item | `unanswered`, reason `aborted` | remove inbox item |
2201
+ | stale head write | `unanswered`, reason `stale` | remove inbox item |
2202
+
2203
+ `requestId` deduplicates within one conversation before any write; reusing one
2204
+ for the other submission type rejects. A busy input with `whenBusy: "reject"`
2205
+ writes no record and reports `ConversationBusy`. An idle input queues with mode
2206
+ `steer` when `whenBusy` is `steer` and `followUp` otherwise.
2207
+
2208
+ An idle conversation with a non-empty inbox, for example after a failed run,
2209
+ queues every new submission behind the waiting items and runs a final boundary
2210
+ in the same commit. Order is preserved: a follow-up queued before the failure is
2211
+ selected before the new input.
2212
+
2213
+ A `Submission` waits until `done` or `unanswered`; abort withdraws only a still-
2214
+ queued submission, reports `already_placed` for a placed input, and reports
2215
+ `settled` for any terminal submission. Conversation abort withdraws queued
2216
+ steer/follow-up submissions but keeps writes for later placement.
2217
+
2218
+ Boundary selection is deterministic by item ID, with the settings'
2219
+ `steeringMode` and `followUpMode` (section 2.2), read at each boundary on the
2220
+ Session line: `one-at-a-time` selects the first item of that mode, `all` every
2221
+ item of that mode.
2222
+
2223
+ | boundary | write | steer | follow-up |
2224
+ |---|---|---|---|
2225
+ | `postTools` | all | first/all by mode | none |
2226
+ | `final` | all | first/all by mode | first/all by mode |
2227
+
2228
+ A boundary places its selected writes first, in ID order, and then its selected
2229
+ user items, in ID order. A user item queued before a reset or compaction summary
2230
+ therefore lands after it and runs in the new context. Queued user items never
2231
+ become stale because of a head.
2232
+
2233
+ A head write, queued or placed at once, whose target is older than the start of
2234
+ the active range, the newest head marker's `head`, is stale: placing it would bring back history that
2235
+ head cut. Heads placed earlier in the same boundary count: after a queued reset,
2236
+ a queued summary targeting an older entry is stale. A `head: "self"` write is
2237
+ never stale.
2238
+
2239
+ Compaction summaries are head writes (section 8.7), so this rule alone orders
2240
+ compactions by cut position: a summary cutting before the active range start is
2241
+ stale, and one cutting at or after it is placed, whenever it was selected.
2242
+ Example: background compaction B selects at tail 100 and cuts at 70. While it
2243
+ summarizes, a blocking compaction A cuts at 150 and appends its summary. B's
2244
+ summary then settles `stale`, because 70 is older than 150. Had A cut at 60
2245
+ instead, B would be placed after it: B summarized the context at its selection,
2246
+ the then-newest summary plus the entries before 70, which covers everything A's
2247
+ summary covers. That holds while the only heads are compaction summaries and
2248
+ resets, which make older cuts stale; an application edit placed while a
2249
+ compaction summarizes is lost, and an application head can be undone (section
2250
+ 12).
2251
+
2252
+ A `postTools` boundary that selects a `head: "self"` write (a reset) behaves as
2253
+ `final`: it also selects follow-ups, and the current run ends with its inputs
2254
+ `unanswered` with reason `reset`, because its context was cut before an answer.
2255
+ A reset placed at a `final` boundary follows the answer, so the inputs are
2256
+ already `done`.
2257
+
2258
+ At ordinary `postTools`, generation continues even with no queued trigger;
2259
+ selected steer IDs join that continuation. A terminating or handoff tool round
2260
+ (section 8.5) uses final behavior instead. At `final`, the current run's placed
2261
+ input submissions settle first, unless an `onYield` continuation keeps them open
2262
+ (below); selected user IDs start one successor generation with those IDs as the
2263
+ new run's inputs. Writes never trigger generation by
2264
+ themselves. A final boundary without user triggers leaves the conversation idle,
2265
+ except for an `onYield` continuation (section 8.3), which applies only when the
2266
+ boundary selected no user item and no reset.
2267
+
2268
+ Only successful run ends apply the final boundary: an answer, `terminate`, or
2269
+ `handoff`. Failure, a run task's abort handler, fault, and orphan settle the
2270
+ run's inputs `unanswered` and leave the inbox alone (`Conversation.abort()`
2271
+ separately withdraws queued user items); the queued items stay visible in
2272
+ `pi.inbox` until the next submission's boundary or their withdrawal.
2273
+
2274
+ Selected and stale items are removed positionally while retained item order is
2275
+ preserved. Chord's Astra operation generator must express scattered removals
2276
+ without carrying retained values; IDs are not substituted for positional inbox
2277
+ semantics.
2278
+
2279
+ ## 7. Extensions, hooks, tools, and system prompt
2280
+
2281
+ ### 7.1 Extensions, registry, and agent resolution
2282
+
2283
+ Extension code reaches the Harness through one application-owned registry of
2284
+ extensions. An extension is a named bundle of tools, prompt sections, hooks,
2285
+ tool and section wrappers, and task definitions. The registry is process-local,
2286
+ may outlive a Harness, and is not persisted; durable state stays in
2287
+ conversations, entries, tasks, and documents. A conversation selects extensions
2288
+ by name in its `pi.agent` document (section 2.2).
2289
+
2290
+ ```ts
2291
+ type DocumentReader = Pick<Session, "snapshot" | "snapshotAsOf">;
2292
+
2293
+ type PromptInput<Tool extends ToolRegistration = ToolRegistration> = {
2294
+ readonly conversationId: ConversationId;
2295
+ /** The request's resolution; `agent.tools` are the tools offered in this request. */
2296
+ readonly agent: Agent<Tool>;
2297
+ /** Built by `HarnessOptions.env` for this preparation (section 2.2). */
2298
+ readonly env: ExecutionEnv | undefined;
2299
+ /** Sections already in effect after replaying the active transcript. */
2300
+ readonly shown: Readonly<Record<string, string>>;
2301
+ /** Committed document reads. */
2302
+ readonly read: DocumentReader;
2303
+ };
2304
+
2305
+ type PromptSection<Tool extends ToolRegistration = ToolRegistration> = {
2306
+ readonly key: string;
2307
+ render(input: PromptInput<Tool>, context: Context): string | undefined | Promise<string | undefined>;
2308
+ /** Default true: wrap the text as `<key>\n...\n</key>`. */
2309
+ readonly tag?: boolean;
2310
+ };
2311
+
2312
+ /** Built by `hook()`; matches tasks by name. */
2313
+ type HookRegistration = { readonly task: string; readonly handlers: object };
2314
+
2315
+ /** Built by `wrapTool()` and `wrapSection()`; targets a tool name or a section key. */
2316
+ type Wrap<Tool extends ToolRegistration = ToolRegistration> =
2317
+ | { readonly tool: string; wrap(tool: Tool): Tool }
2318
+ | { readonly section: string; wrap(section: PromptSection<Tool>): PromptSection<Tool> };
2319
+
2320
+ interface Extension<Tool extends ToolRegistration = ToolRegistration> {
2321
+ readonly name: string;
2322
+ readonly tools?: readonly Tool[];
2323
+ readonly sections?: readonly PromptSection<Tool>[];
2324
+ readonly hooks?: readonly HookRegistration[];
2325
+ /** Apply where this extension is selected, in order. */
2326
+ readonly wraps?: readonly Wrap<Tool>[];
2327
+ /** Resolved by name for every task, whichever conversations select this extension. */
2328
+ readonly tasks?: readonly AnyTask[];
2329
+ }
2330
+
2331
+ function defineExtension<Tool extends ToolRegistration = ToolRegistration>(extension: Extension<Tool>): Extension<Tool>;
2332
+ function section<Tool extends ToolRegistration = ToolRegistration>(
2333
+ key: string,
2334
+ render: PromptSection<Tool>["render"],
2335
+ options?: { readonly tag?: boolean },
2336
+ ): PromptSection<Tool>;
2337
+ function hook<K extends AnyTask>(task: K, handlers: Partial<HooksOf<K>>): HookRegistration;
2338
+ function wrapTool<Tool extends ToolRegistration>(tool: Tool, wrapper: (tool: Tool) => Tool): Wrap<Tool>;
2339
+ function wrapSection<Tool extends ToolRegistration = ToolRegistration>(
2340
+ key: string,
2341
+ wrapper: (section: PromptSection<Tool>) => PromptSection<Tool>,
2342
+ ): Wrap<Tool>;
2343
+
2344
+ interface RegistryReader<Tool extends ToolRegistration = ToolRegistration> {
2345
+ /** Immutable view of the whole current registry. */
2346
+ snapshot(): RegistrySnapshot<Tool>;
2347
+ /** Called synchronously after every publication; wakes the scheduler to reconsider blocked tasks. */
2348
+ subscribe(listener: () => void): () => void;
2349
+ }
2350
+
2351
+ interface RegistrySnapshot<Tool extends ToolRegistration = ToolRegistration> {
2352
+ installed(): readonly Extension<Tool>[];
2353
+ extension(name: string): Extension<Tool> | undefined;
2354
+ /** Every installed tool with its extension, in install order. Names may repeat across extensions. */
2355
+ tools(): readonly { readonly extension: Extension<Tool>; readonly tool: Tool }[];
2356
+ sections(): readonly { readonly extension: Extension<Tool>; readonly section: PromptSection<Tool> }[];
2357
+ /** Built-in and installed task definitions. */
2358
+ tasks(): readonly AnyTask[];
2359
+ task(name: string): AnyTask | undefined;
2360
+ }
2361
+
2362
+ interface Registry<Tool extends ToolRegistration = ToolRegistration> extends RegistryReader<Tool> {
2363
+ /** Installs `extension`, or replaces the installed extension with its name in place. Publishes at once. */
2364
+ install(extension: Extension<Tool>): void;
2365
+ /** Removes the installed extension with `extension.name`, whichever object it is. A later install appends. */
2366
+ uninstall(extension: Extension): void;
2367
+ }
2368
+
2369
+ function createRegistry<Tool extends ToolRegistration = ToolRegistration>(): Registry<Tool>;
2370
+ ```
2371
+
2372
+ The `Tool` parameter lets an application attach metadata, such as prompt
2373
+ snippets, to its tools and read it typed in its section renderers. The Harness
2374
+ only relies on `ToolRegistration`; its own surfaces, such as `runtime.agent()`,
2375
+ use the default. Metadata fields must be optional: the registry does not check
2376
+ them, and a default-typed extension may be installed. Pi-ai declarations derived from a tool keep only pi-ai `Tool`
2377
+ fields (`toToolDeclaration`), so application metadata never enters the
2378
+ transcript.
2379
+
2380
+ ```ts
2381
+ type CodingAgentTool = ToolRegistration & { readonly promptSnippet?: string };
2382
+ // The renderer sees CodingAgentTool: no cast needed to read promptSnippet.
2383
+ export const ToolList = defineExtension<CodingAgentTool>({
2384
+ name: "tool-list",
2385
+ sections: [section("tools", ({ agent }) => agent.tools.map((tool) => `- ${tool.name}: ${tool.promptSnippet ?? tool.description}`).join("\n") || undefined)],
2386
+ });
2387
+ const registry = createRegistry<CodingAgentTool>();
2388
+ ```
2389
+
2390
+ Rules:
2391
+
2392
+ - The built-in tasks of section 8 are always present and are not an extension:
2393
+ `tasks()` includes them, and nothing uninstalls or replaces them. Any
2394
+ extension registers hooks against them.
2395
+ - Tool names and section keys are unique within one extension; across
2396
+ extensions they may repeat. Section keys match `^[a-z][a-z0-9_-]*$`, and the
2397
+ key `instructions` is reserved for the agent's instructions (below).
2398
+ - Task names are unique across the built-in tasks and every installed
2399
+ extension. `install()` validates the registry as it would be after the
2400
+ replacement, including the rules above, and throws without publishing
2401
+ anything when it is invalid. `defineExtension()` and `section()` check
2402
+ nothing.
2403
+ - Publication is synchronous, invokes no extension callback, and notifies
2404
+ subscribers. There is no batch: an extension is the unit of reload, and
2405
+ installing it again replaces its tools, sections, hooks, wrappers, and tasks
2406
+ in one publication at its install position.
2407
+ - Extensions, tools, and task definitions are immutable, and wrappers are pure:
2408
+ a wrapper returns a new object and never mutates its input.
2409
+ - An extension or tool object passed as a selection or wrapper target stands
2410
+ for its name: `settings.extensions` holding an old `Skills` object resolves to
2411
+ the installed `skills` extension.
2412
+
2413
+ A snapshot is a plain immutable value; nothing is released. The scheduler takes
2414
+ one per phase-handler invocation and passes it through the runtime; handlers and
2415
+ hooks read that snapshot and never take their own. At every normal phase
2416
+ boundary the scheduler takes a fresh one, and task definitions hand over as in
2417
+ section 5.4. Different phases may observe different registry states; nothing
2418
+ requires one run to see a single registry state across its generation and tool
2419
+ tasks.
2420
+
2421
+ ```ts
2422
+ export const ContextFiles = defineExtension({ name: "context-files", sections: [section("agents-md", renderAgentsMd)] });
2423
+ export const Skills = defineExtension({ name: "skills", sections: [section("skills", renderSkills)] });
2424
+ export const Coding = defineExtension({
2425
+ name: "coding",
2426
+ sections: [
2427
+ section("preamble", () => "You are an expert coding assistant.", { tag: false }),
2428
+ // The environment the host built for this conversation, in the conversation's directory.
2429
+ section("cwd", (input) => input.env && `Working directory: ${input.env.cwd}`),
2430
+ ],
2431
+ });
2432
+ export const Permissions = defineExtension({
2433
+ name: "permissions",
2434
+ hooks: [hook(ToolTask, { beforeTool: async (call) => (isDangerous(call) ? { block: "Needs approval" } : undefined) })],
2435
+ });
2436
+ // A role and a review loop, for conversations that select it.
2437
+ export const Reviewer = defineExtension({
2438
+ name: "reviewer",
2439
+ sections: [section("role", () => "You review diffs. Report problems as a list. Never edit files.")],
2440
+ hooks: [hook(GenerationTask, { onYield: requestSecondPass })],
2441
+ });
2442
+
2443
+ const registry = createRegistry();
2444
+ for (const extension of [CodingTools, Coding, ContextFiles, Skills, Permissions, Reviewer]) registry.install(extension);
2445
+ const harness = await Harness.open(storage, {
2446
+ models,
2447
+ registry,
2448
+ // Reviewer is installed but not selected by default: only conversations that select it get its role and hooks.
2449
+ settings: { extensions: [CodingTools, Coding, ContextFiles, Skills, Permissions] },
2450
+ env: ({ cwd }) => localEnv(cwd ?? process.cwd()), // cached NodeExecutionEnv per directory
2451
+ }, context);
2452
+ // The conversation remembers its model and directory; a restart elsewhere keeps both.
2453
+ const root = await harness.root(context, { agent: { model: sonnet, cwd: process.cwd() } });
2454
+ ```
2455
+
2456
+ The registry does not track which running work still uses a replaced or
2457
+ uninstalled extension. Work that already started keeps using the code it took,
2458
+ so an extension that frees resources right after `uninstall()` can make a
2459
+ still-running call fail with an ordinary error result. Extensions that need
2460
+ graceful disposal manage their resources' lifetime themselves, for example by
2461
+ reference counting.
2462
+
2463
+ **Resolution.** A conversation's `Agent` is resolved from its stored `pi.agent`
2464
+ (absent: every field unset), a registry snapshot, and the resolved settings:
2465
+
2466
+ ```text
2467
+ extensions base = settings.extensions ?? every installed extension; in that order
2468
+ array: exactly these, in array order; { add, remove }: base, then add appended, minus remove
2469
+ duplicates keep their first position; names not installed are skipped
2470
+ tools the selected extensions' tools in extension order; a later same-name tool replaces an earlier one in place
2471
+ then every selected extension's tool wrappers, in extension order, then in each extension's order
2472
+ then the filter: an array keeps exactly these names in its order, a repeated name at its first position;
2473
+ { remove } drops these names
2474
+ sections the selected extensions' sections, same-key replacement in place, then their section wrappers,
2475
+ then `instructions` when set
2476
+ hooks the selected extensions' hooks for the task's name, in extension order
2477
+ model stored, else none: the request fails no_model
2478
+ thinking stored, else "off"
2479
+ cwd stored, else undefined
2480
+ ```
2481
+
2482
+ A wrapper that throws or returns a tool or section with a different name drops
2483
+ its target for that resolution: the tool is not offered, and calls to it produce
2484
+ `tool_unavailable`; the failure is reported through `onReport`. A wrapper that
2485
+ finds no target does nothing, so `Timing` below may be selected where no `bash`
2486
+ is. When `instructions` is set, `Agent.sections` ends
2487
+ with the tagged section `instructions` rendering that text; wrappers do not
2488
+ apply to it.
2489
+
2490
+ A later extension's same-name tool overrides an earlier one for the
2491
+ conversations that select both, and wrappers apply to whichever tool won:
2492
+
2493
+ ```ts
2494
+ export const Timing = defineExtension({
2495
+ name: "timing",
2496
+ wraps: [wrapTool(bashTool, (tool) => ({
2497
+ ...tool,
2498
+ execute: async (args, api, context) => {
2499
+ const start = Date.now();
2500
+ try {
2501
+ return await tool.execute(args, api, context);
2502
+ } finally {
2503
+ metrics.record("bash", Date.now() - start);
2504
+ }
2505
+ },
2506
+ }))],
2507
+ });
2508
+ // A bash inside a Python virtualenv for one conversation: it replaces CodingTools' bash in place,
2509
+ // and Timing, if selected, wraps it.
2510
+ export const Venv = defineExtension({
2511
+ name: "venv",
2512
+ tools: [createBashTool({ commandPrefix: "source .venv/bin/activate" })],
2513
+ });
2514
+ registry.install(Venv); // installed, but not in the default selection
2515
+ await conversation.configure({ extensions: { add: [Venv] } }, context);
2516
+ ```
2517
+
2518
+ **Who resolves what, and when.** Each reader resolves once per decision:
2519
+
2520
+ | reader | resolves | when |
2521
+ |---|---|---|
2522
+ | generation `prepare` | agent (tools, sections, model, thinking level), stream options, compaction thresholds | once per request; model, stream options, and offered tools are fixed in the request checkpoint (section 8.3) |
2523
+ | every task phase | extension selection for hooks; `runtime.agent()` | at most once per phase handler, at its first hook dispatch or `agent()` call, from that phase's snapshot and the committed `pi.agent`, off the Session line, with the invocation's context; fixed for the rest of the phase |
2524
+ | tool task | the implementation of an accepted call | at `call` and `execute`: the name among the phase agent's `tools`, the current selection after the tools filter; no such tool, for example one removed after preparation: `tool_unavailable`. Replay policy on reopen uses the same lookup (section 7.3) |
2525
+ | generation, when a tool round starts | tool execution mode | once per round (section 8.3) |
2526
+ | generation, compaction | retry policy | at each attempt's classification |
2527
+ | compaction `select` | model, thinking level, stream options, compaction thresholds | once; the summary request stays pinned across its retries (section 8.7) |
2528
+ | boundaries and idle admission, on the Session line | queue modes | per boundary (section 6) |
2529
+ | tool task, `prepare`, `runtime.env()` | environment | at each call (section 2.2) |
2530
+
2531
+ Registry installs and settings changes make no commit, so they emit nothing
2532
+ (section 9.4). A conversation on the default selection changes when the
2533
+ settings or the installed extensions change; its next preparation appends the
2534
+ system delta (section 7.4). A UI showing resolved tools or a resolved model
2535
+ resolves again through `Conversation.agent()`.
2536
+
2537
+ ### 7.2 Hooks
2538
+
2539
+ A hook is a typed question asked by a task before it commits a decision. Hooks
2540
+ are declared by task definition and come with an extension, built by
2541
+ `hook(task, handlers)` (section 7.1). A task asks the hooks of the extensions
2542
+ its conversation selects. `hook()` is typed by the task token, but dispatch
2543
+ matches the task's name, so hooks survive a reload of their task; keeping hook
2544
+ signatures compatible across task versions is the task author's
2545
+ responsibility. Hooks run in extension order off the line; a crash before the
2546
+ consuming commit may rerun them. Abort errors always propagate.
2547
+
2548
+ | hook | composition | ordinary throw |
2549
+ |---|---|---|
2550
+ | `beforeRequest` | replacement chain | report, continue |
2551
+ | `afterResponse` | all observers | report, continue |
2552
+ | `onYield` | first continuation wins | report, continue |
2553
+ | `beforeTool` | argument replacement chain; first block wins | block tool with error text |
2554
+ | `afterTool` | result replacement chain | report, continue |
2555
+ | `afterTools` | all observers | report, continue |
2556
+ | `beforeCompact` | first decision wins | report, continue |
2557
+
2558
+ Hooks use task memos for durable first-writer-wins decisions. There is no public
2559
+ semantic event channel; current UI status is document state.
2560
+
2561
+ ```ts
2562
+ type HookResult<T> = T | undefined | Promise<T | undefined>;
2563
+
2564
+ /** What a hook may use: committed reads and the asking task's memos. */
2565
+ interface HookApi extends DocumentReader {
2566
+ readonly taskId: TaskId;
2567
+ readonly conversationId: ConversationId;
2568
+ memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
2569
+ memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
2570
+ }
2571
+
2572
+ interface GenerationHooks {
2573
+ /** Before every request attempt, including recovery; the result is used for that request only. */
2574
+ beforeRequest(
2575
+ request: { readonly messages: readonly Message[] },
2576
+ api: HookApi,
2577
+ context: Context,
2578
+ ): HookResult<{ readonly messages: readonly Message[] }>;
2579
+ /** Every terminal provider message, before classification. */
2580
+ afterResponse(message: AssistantMessage, api: HookApi, context: Context): void | Promise<void>;
2581
+ /** A final answer; `continue` appends a user message and continues the run. */
2582
+ onYield(answer: AssistantMessage, api: HookApi, context: Context): HookResult<{ readonly continue: UserInput }>;
2583
+ /** After every tool of the round is terminal; `results` are the round's result entries in call order. */
2584
+ afterTools(assistant: EntryId, results: readonly EntryId[], api: HookApi, context: Context): void | Promise<void>;
2585
+ }
2586
+
2587
+ interface ToolHooks {
2588
+ /** Before intent; replaces the arguments or blocks the call with error text. */
2589
+ beforeTool(
2590
+ call: ToolCall,
2591
+ api: HookApi,
2592
+ context: Context,
2593
+ ): HookResult<{ readonly arguments?: JsonObject; readonly block?: string }>;
2594
+ /** After execution, before the result entry; replaces the result. */
2595
+ afterTool(
2596
+ call: ToolCall,
2597
+ result: ToolExecutionResult,
2598
+ api: HookApi,
2599
+ context: Context,
2600
+ ): HookResult<ToolExecutionResult>;
2601
+ }
2602
+
2603
+ interface CompactionHooks {
2604
+ /**
2605
+ * After range selection, before summarizing; the first decision wins. `entries` are the active entries the summary
2606
+ * replaces, the head marker first, and `messages` their model context, the summarizer's source; `firstKept` is the
2607
+ * first entry kept verbatim.
2608
+ */
2609
+ beforeCompact(
2610
+ compaction: {
2611
+ readonly reason: CompactionReason;
2612
+ readonly entries: readonly EntryRecord[];
2613
+ readonly messages: readonly Message[];
2614
+ readonly firstKept: EntryId;
2615
+ readonly instructions?: string;
2616
+ },
2617
+ api: HookApi,
2618
+ context: Context,
2619
+ ): HookResult<{ readonly decline: true } | { readonly summary: string }>;
2620
+ }
2621
+ ```
2622
+
2623
+ `GenerationTask`, `ToolTask`, and `CompactionTask` are the exported built-in task
2624
+ tokens whose `H` parameters are these interfaces. `runtime.hooks.each(name,
2625
+ invoke)` calls `invoke` with every handler under `name` among the phase's
2626
+ resolved hooks: those of the extensions the task's conversation selects, for the
2627
+ task's name, in extension order (section 7.1). An ordinary throw from
2628
+ `invoke` is reported through `onReport` and `each` continues with the next
2629
+ handler; once the invocation is signalled, the error propagates. A task that
2630
+ composes differently, such as `beforeTool` turning a throw into a block, or stops
2631
+ after a first decision, does so inside its own `invoke`. Hooks receive the task's
2632
+ runtime as their `HookApi`; hook memos and the task's own memos share one
2633
+ namespace, so hook authors prefix their memo names.
2634
+
2635
+ Selection decides which behavior a conversation has; the extension's own
2636
+ document holds that behavior's state:
2637
+
2638
+ ```ts
2639
+ const PlanModeDoc = defineDoc<{ enabled: boolean }>({
2640
+ kind: "app.plan-mode", version: 1, scope: "conversation", history: "latest", fork: "current",
2641
+ initial: () => ({ enabled: false }),
2642
+ });
2643
+ export const PlanMode = defineExtension({
2644
+ name: "plan-mode",
2645
+ hooks: [hook(ToolTask, {
2646
+ // An absent document means plan mode is off.
2647
+ beforeTool: async (call, api, context) =>
2648
+ (await api.snapshot(PlanModeDoc, api.conversationId, context))?.enabled && writes(call)
2649
+ ? { block: "Plan mode: read-only" }
2650
+ : undefined,
2651
+ })],
2652
+ });
2653
+ // PlanMode is in the default selection; /plan toggles this conversation's state.
2654
+ await root.commit(async (tx) => {
2655
+ (await tx.doc(PlanModeDoc, root.id)).enabled = true;
2656
+ }, context);
2657
+ ```
2658
+
2659
+ ### 7.3 Tools
2660
+
2661
+ ```ts
2662
+ type ToolControl = {
2663
+ readonly addTools?: readonly string[];
2664
+ readonly terminate?: true;
2665
+ readonly handoff?: string;
2666
+ };
2667
+
2668
+ /** Remark about a call for the model and the UI; never part of the tool's data. */
2669
+ type ToolDiagnostic = {
2670
+ readonly severity: "info" | "warn" | "error";
2671
+ readonly message: string;
2672
+ readonly code?: string;
2673
+ };
2674
+
2675
+ type ToolExecutionResult<TDetails extends JsonValue = JsonValue> = {
2676
+ readonly content?: ToolResultMessage["content"];
2677
+ readonly isError?: boolean;
2678
+ readonly details?: TDetails;
2679
+ readonly diagnostics?: readonly ToolDiagnostic[];
2680
+ /** Spend of the execution itself, such as a model call; stored on the result and in `pi.usage.tools`. */
2681
+ readonly usage?: Usage;
2682
+ readonly control?: ToolControl;
2683
+ };
2684
+
2685
+ interface ConversationHandle {
2686
+ readonly id: ConversationId;
2687
+ submit(submission: InputSubmissionDraft, context: Context): Promise<Submission>;
2688
+ abort(context: Context): Promise<void>;
2689
+ waitForIdle(context: Context): Promise<void>;
2690
+ }
2691
+
2692
+ interface ToolExecutionApi<TDetails extends JsonValue = JsonValue> extends DocumentObserver, DocumentReader {
2693
+ readonly taskId: TaskId;
2694
+ readonly conversationId: ConversationId;
2695
+ readonly callId: string;
2696
+ /** The tool task's phase snapshot. */
2697
+ readonly registry: RegistrySnapshot;
2698
+ /** The calling conversation's agent, as the tool task's phase resolved it. */
2699
+ agent(context: Context): Promise<Agent>;
2700
+ /** Built by `HarnessOptions.env` for this call. */
2701
+ readonly env: ExecutionEnv | undefined;
2702
+ output(chunk: string | Uint8Array): void;
2703
+ diagnostic(diagnostic: ToolDiagnostic): void;
2704
+ details(value: TDetails, context: Context): Promise<void>;
2705
+ commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T>;
2706
+ memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
2707
+ memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
2708
+ createTask<I, S extends { phase: string }, R, H extends object>(
2709
+ task: Task<I, S, R, H>,
2710
+ input: I,
2711
+ options: Omit<TaskOptions, "conversationId">,
2712
+ context: Context,
2713
+ ): Promise<TaskId<R>>;
2714
+ getTask<R>(id: TaskId<R>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, R> | undefined>;
2715
+ waitForTask<R>(id: TaskId<R>, context: Context): Promise<SettledTask<R>>;
2716
+ conversation(id: ConversationId, context: Context): Promise<ConversationHandle | undefined>;
2717
+ }
2718
+
2719
+ type ToolRegistration<TParameters extends TSchema = TSchema, TDetails extends JsonValue = JsonValue> =
2720
+ Tool<TParameters> & {
2721
+ readonly replay?: "safe" | "unsafe";
2722
+ readonly executionMode?: ToolExecutionMode;
2723
+ /** Pure repair of commonly malformed arguments; runs before validation, which still checks its result. */
2724
+ prepareArguments?(args: unknown): Static<TParameters>;
2725
+ readonly outputLimits?: {
2726
+ readonly maxBytes?: number;
2727
+ readonly maxLines?: number;
2728
+ readonly retain?: "head" | "tail";
2729
+ };
2730
+ execute(
2731
+ args: Static<TParameters>,
2732
+ api: ToolExecutionApi<TDetails>,
2733
+ context: Context,
2734
+ ): Promise<ToolExecutionResult<TDetails>>;
2735
+ };
2736
+
2737
+ /** Identity function that infers `TParameters` from `parameters` and `TDetails` from the reported details. */
2738
+ function defineTool<TParameters extends TSchema, TDetails extends JsonValue = JsonValue>(
2739
+ tool: ToolRegistration<TParameters, TDetails>,
2740
+ ): ToolRegistration<TParameters, TDetails>;
2741
+ ```
2742
+
2743
+ `execute()` receives `args` typed by `parameters`: the Harness validates the
2744
+ call's arguments against that schema before it runs. `defineTool()` makes an
2745
+ inline tool literal infer its types, as in `tools: [defineTool({ ... })]`.
2746
+ `execute` is a method, so differently typed tools share one `tools` array.
2747
+
2748
+ Omitted `replay` is `unsafe`. Omitted `outputLimits` are 50 KiB, 2,000 lines,
2749
+ and `retain: "head"`. Omitted `executionMode` follows the settings'
2750
+ `toolExecution`; one `sequential` call makes its whole round sequential
2751
+ (section 8.3).
2752
+
2753
+ Tools reach files and processes only through `api.env`, never through an
2754
+ environment captured when the tool was built. The tool task builds it for each
2755
+ call with `HarnessOptions.env` from the conversation's ID and `cwd` (section
2756
+ 2.2), so a conversation's directory or sandbox needs no wrapper. A throw from
2757
+ `env` becomes an ordinary error result of the call. A rerun after recovery gets
2758
+ the conversation's environment at that time. `api` is a plain object so
2759
+ wrappers can spread it. A tool that needs an environment and receives none
2760
+ throws, which produces an ordinary error result.
2761
+
2762
+ A running tool reports output and details to the UI, mirroring the two halves of
2763
+ its final result, plus diagnostics (below):
2764
+
2765
+ - `output(chunk)` appends running text output, like stdout. If `execute()` omits
2766
+ `content`, the final retained output becomes one text content item; no output
2767
+ becomes an empty content list. Retained output is an exact slice of whole lines
2768
+ of the stream (the first lines for `head`, the last for `tail`), trailing
2769
+ newline included; a single line longer than `maxBytes` is cut at the byte limit
2770
+ on a character boundary. Control characters other than tab and newline are
2771
+ removed from the retained text; accepting a chunk does no per-chunk sanitizing.
2772
+ - `details(value)` replaces the running details with a complete JSON value; it
2773
+ does not merge keys. If `execute()` omits `details`, the last value becomes the
2774
+ final `details`, so a renderer handles one details shape from the first
2775
+ update through the final result.
2776
+
2777
+ Neither is sent to the model while the tool runs. `output()` synchronously
2778
+ accepts UTF-8 output into that invocation-owned bounded buffer and throws after
2779
+ invocation end. Throttled commits publish the retained output, dropped
2780
+ byte/line counts, and the current details and diagnostics in its `pi.live.tools`
2781
+ slot. The throttle is adaptive, like the environment's shell output capture: the
2782
+ first change after an idle period commits at once; each commit then delays the
2783
+ next by at least `settings.progress.outputIntervalMs` (default 100 ms) and by
2784
+ its written size at 100 KiB/s, so a large rewrite buys a proportionally longer
2785
+ pause. Changes made during the delay
2786
+ coalesce into the next commit. The throttle is Harness policy, not part of
2787
+ `outputLimits`, which only bounds what is retained. Explicit
2788
+ text in explicit result content is bounded by the same limits before transcript
2789
+ persistence; non-text content is retained as declared by its pi-ai type. When
2790
+ bounding drops text, the Harness adds a `warn` diagnostic with code `truncated`
2791
+ stating the dropped lines and bytes.
2792
+
2793
+ Diagnostics are a channel, not text. Remarks about a call, such as truncated
2794
+ output, a spill path, a corrected path, a file changed on disk, or a capped
2795
+ search, go through `api.diagnostic()` or `ToolExecutionResult.diagnostics`,
2796
+ never into the content the model reads as the tool's data. The Harness adds the
2797
+ ones it owns, and a tool adds only what it alone knows. Every diagnostic is
2798
+ model-visible; information only for UIs belongs in `details`. `diagnostic()`
2799
+ synchronously records the diagnostic in the tool's slot with the next throttled
2800
+ commit and throws after invocation end. At settlement the result's diagnostics
2801
+ are those recorded through `api`, then those in the result, then the Harness's.
2802
+ When there are any, the result content ends with one text item:
2803
+
2804
+ ```text
2805
+ <harness>
2806
+ [warn] output truncated: 51,204 lines, 2,301,112 bytes dropped
2807
+ </harness>
2808
+ ```
2809
+
2810
+ with one `[severity] message` line per diagnostic, and the `pi.tool-result`
2811
+ entry stores the structured list, possibly empty, as `data: { diagnostics }`
2812
+ (section 8.1). The
2813
+ stored message is exactly what the model saw, while UIs and code read the list.
2814
+ A `warn` diagnostic does not set `isError`.
2815
+
2816
+ `output()` never spills complete output to a file because spilling requires a
2817
+ filesystem, which may be remote or unavailable. A tool that must preserve
2818
+ complete output spills through the `ExecutionEnv` or `FileSystem` it was given,
2819
+ and reports the resulting path in a diagnostic, and in its details when a
2820
+ renderer needs it. The environment's shell streams raw output chunks and spills
2821
+ the complete output to a file once it crosses byte or line thresholds; it keeps
2822
+ no bounded view of its own, so `output()` is the one place output is bounded,
2823
+ sanitized, and throttled. The `bash` tool pipes those chunks into `output()`,
2824
+ reports the spill path as a diagnostic, and throws on a nonzero exit or timeout;
2825
+ the error result still carries the retained output and diagnostics.
2826
+
2827
+ An environment that moves output over a slow link, such as one on another host,
2828
+ need not move all of it. `api.outputWindow` names the tail a tail-retaining
2829
+ call keeps and the pace of its progress commits; the `bash` tool passes it as
2830
+ `ShellExecOptions.window`. The environment may then omit output and report
2831
+ the omission as `info.skipped` on the chunk that follows: the decoded byte
2832
+ count, the newline count, and whether the omitted text ended with a newline.
2833
+ It may omit only output followed by more than the window, by at least one byte
2834
+ or one line, and delivers all of that following output in the same chunk, so
2835
+ the omitted text can never be part of the kept tail and no progress commit sees
2836
+ a gap. `output(chunk, skipped)` adds the omission to the dropped counts, so
2837
+ the retained tail, the dropped counts, and the `truncated` diagnostic are the
2838
+ same as if every byte had arrived; only the moments at which progress is
2839
+ sampled differ. The environment should deliver no faster than the pace, since
2840
+ progress commits sample no faster. A head-retaining call has no window. A
2841
+ wrapper that replaces `output` to transform text sets `outputWindow` to
2842
+ `undefined`, so no omitted text bypasses its transform.
2843
+
2844
+ `exec` takes a string or an argv array. A string runs through the
2845
+ environment's shell. An array runs its first element directly with the rest as
2846
+ arguments, without a shell, so a host that builds a command from data, such as
2847
+ a file name, never quotes it for a particular shell. Each `onOutput` chunk names
2848
+ the stream it came from; the `bash` tool ignores it, while a host that needs
2849
+ stdout and stderr apart collects them separately, bounds them itself, and
2850
+ aborts the call when it has enough. `openBinaryReader` opens one regular file
2851
+ for positional reads, so a host reads a bounded range instead of the whole
2852
+ file, and every read sees the file it opened even if the path is renamed;
2853
+ `noFollow` refuses a symbolic link as the final path component. Its
2854
+ `scanLines` makes one pass over the file inside the environment and reports
2855
+ the newline count, the byte range of a span of lines, and the decoded sizes of
2856
+ that span and its first line, so a caller can count and locate lines without
2857
+ moving the file. `openDirReader`
2858
+ pages a directory in file-system order, reading metadata only for the entries
2859
+ it returns and skipping entries removed meanwhile. All operations stop when
2860
+ their context is aborted, and only that call's work stops: a timeout or abort
2861
+ kills only that command's processes. `cleanup()` kills every command the
2862
+ environment still runs and belongs to its owner's shutdown, never to a single
2863
+ request. `watch` reports changes to files and directories for hosts that load
2864
+ resources, such as instructions or skills, from the environment; Durable
2865
+ itself never calls it. A target may be missing, and creating it is a change; a
2866
+ recursive target covers its subtree except excluded entries, without following
2867
+ symbolic links below it. When `watch` resolves, coverage is established, so a
2868
+ host that watches before it loads cannot lose a change made during the load. A
2869
+ change arrives as reported paths (each covering its subtree; calls may be
2870
+ spurious), as `overflow` when coverage was uncertain for a while and everything
2871
+ must be rescanned, or as a final `error`. A `native` watcher reports changes
2872
+ within about two seconds; a `polling` one compares snapshots because its file
2873
+ system, for example a network or FUSE file system, does not report changes made
2874
+ elsewhere, and can miss a change undone between two snapshots. `NodeExecutionEnv`
2875
+ uses events only to trigger rescans and reports differences between snapshots,
2876
+ so replaced files, renamed parents, and directories created with their contents
2877
+ are reported whatever events the platform sends. An environment is trusted, not
2878
+ a confinement boundary: checking a
2879
+ canonical path before opening it does not prevent a concurrent rename or
2880
+ symlink swap, so callers that restrict paths do so for hygiene, not security.
2881
+
2882
+ The `details()` promise resolves after the corresponding or coalesced document
2883
+ commit. During normal settlement the tool task stops its throttle and awaits the
2884
+ commit in flight; the terminal commit, which appends the result entry, is the
2885
+ final flush and settles any `details()` promise still pending. Abort and close obey
2886
+ invocation and Session admission gates: uncommitted buffered updates may be
2887
+ discarded, while admitted commits settle. Cancellation, callback, tracker
2888
+ preparation, and checkpoint failures occur before Storage admission and do not
2889
+ poison the Session. An uncertain Storage failure follows the fatal Session rule.
2890
+
2891
+ Tools come with extensions (section 7.1) and have a name, description, JSON
2892
+ schema, replay policy, and execute function. `wrapTool(tool, wrapper)` decorates
2893
+ a tool without replacing it. Each resolution composes the winning tool of that
2894
+ name with the selected extensions' wrappers (section 7.1); a wrapper never
2895
+ captures a base, so reloading the base keeps its wrappers. The composite
2896
+ supplies the declaration, argument validation, replay policy, and execution.
2897
+
2898
+ A tool call is accepted only if its request offered the tool. Generation checks
2899
+ this against the tool set replayed from the committed model context through the
2900
+ request's `cutoff`; `beforeRequest` replacements do not change it. It answers a
2901
+ call to a tool it did not offer with the `tool_unavailable` result described in
2902
+ section 2.2, without a tool task. An agent change after preparation does not
2903
+ affect this check (section 2.2). The tool task resolves the called name among
2904
+ the `tools` of the phase's agent resolution, the tools filter applied. It uses
2905
+ that implementation
2906
+ until execution settles: one phase handler resolves, validates, runs
2907
+ `beforeTool`, records intent, executes, and commits the result, so no phase
2908
+ boundary separates resolution from settlement. A name that does not resolve, for
2909
+ example because its extension was uninstalled or deselected, or the tools filter
2910
+ dropped it after the request was prepared, produces `tool_unavailable`.
2911
+ The implementation's `prepareArguments`, if any, first repairs the call's
2912
+ arguments, for example `edits` sent as a JSON string; the stored call keeps what
2913
+ the model sent. Arguments are validated against the resolved implementation's
2914
+ schema before and after `beforeTool`; a failure, or a throwing repair, produces an
2915
+ `invalid_arguments` error result.
2916
+
2917
+ After hooks and validation, the tool task durably records the final arguments and
2918
+ resolved replay policy before execution. Recovery does not rerun `beforeTool`
2919
+ and passes the same stored arguments to `execute()`. A replay-safe tool may
2920
+ reconstruct a submission from those arguments when that transformation is pure.
2921
+ Random values, timestamps, mutable document/configuration reads, or other derived
2922
+ inputs that must remain stable are first captured in a durable memo, checkpoint,
2923
+ or task input. A background reporter task receives the final message in its own
2924
+ durable input so it can finish independently. Recovery does not let a
2925
+ changed registry declaration alter the stored replay policy.
2926
+
2927
+ A tool-acquired conversation handle accepts only input submissions; tools use
2928
+ ordinary transaction writes for passive entries.
2929
+
2930
+ A tool executes in a durable task. It may:
2931
+
2932
+ - publish bounded running output and details to its `pi.live.tools` slot;
2933
+ - commit memos;
2934
+ - create and wait for tasks;
2935
+ - atomically create or fork explicitly owned conversations through `commit()`;
2936
+ - observe documents for which it has a token/reference;
2937
+ - mutate authorized documents through `commit()`;
2938
+ - return bounded model content and separate diagnostic details.
2939
+
2940
+ Tool operations use the invoking task's admission and invocation-lifetime gates.
2941
+ Task creation defaults to that task's conversation. Trusted document and
2942
+ conversation access follows sections 3.3 and 9.2; there is no additional subtree
2943
+ authorization layer. Possession of a Session-global typed ID is sufficient in
2944
+ trusted code.
2945
+
2946
+ Tools have no conversation-creation convenience method. They create or fork a
2947
+ conversation inside `commit()`, state its ownership explicitly, atomically stage
2948
+ any related document/task writes, and receive only the inert record. After that
2949
+ commit settles, `conversation(id)` acquires an invocation-bound operational
2950
+ handle for submission, explicit abort, and idle waits without changing
2951
+ ownership. A commit callback must not call `conversation()` or use a previously
2952
+ acquired handle: nested Session operations and external effects are forbidden
2953
+ while the mutation line is held. A handle's operations reject after the
2954
+ invocation ends. A `Submission` returned by its `submit()` is invocation-bound in
2955
+ the same way; the admitted submission remains durable after those methods reject.
2956
+ Invocation-owned document watches stop when the invocation ends.
2957
+
2958
+ A foreground subagent conversation is explicitly owned by its tool task. One
2959
+ transaction creates or forks the child; the owner edge is its durable record, so
2960
+ a replay-safe rerun finds the child with `scanConversations({ ownerTaskId })`
2961
+ before creating one. After settlement the tool reacquires the child, submits
2962
+ with a request ID derived from its task ID, which a rerun keeps, and waits for
2963
+ that submission's result. Provider call IDs are not unique across a Session and
2964
+ must not serve as request IDs. Aborting or abnormally
2965
+ terminalizing the tool task cascades through the owned scope. The tool reports
2966
+ the child in its running details, for example `api.details({ conversationId })`,
2967
+ so a UI that sees the call can attach to the child's view or events.
2968
+
2969
+ ```ts
2970
+ export const Subagent = defineExtension({
2971
+ name: "subagent",
2972
+ tools: [defineTool({
2973
+ name: "subagent",
2974
+ description: "Delegate a self-contained task to a subagent and get its answer back.",
2975
+ parameters: Type.Object({ task: Type.String() }),
2976
+ replay: "safe",
2977
+ execute: async (args, api, context) => {
2978
+ const { task } = args;
2979
+ const child = await api.commit(async (tx) => {
2980
+ const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
2981
+ if (existing !== undefined) return existing.id;
2982
+ // Starts as a copy of this conversation's agent: model, thinking level, cwd, extensions, tools.
2983
+ const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
2984
+ // Without this extension, the child is not offered this tool.
2985
+ await configure(tx, created.id, { extensions: { remove: [Subagent] } });
2986
+ return created.id;
2987
+ }, context);
2988
+ await api.details({ conversationId: child }, context);
2989
+ const handle = (await api.conversation(child, context))!;
2990
+ const request = { type: "input", content: task, requestId: `subagent:${api.taskId}` } as const;
2991
+ const settled = await (await handle.submit(request, context)).wait(context);
2992
+ if (settled.status !== "done" || settled.type !== "input") throw new Error(`Subagent failed: ${settled.status}`);
2993
+ return { content: [{ type: "text", text: await answerText(api, settled.answer, context) }] };
2994
+ },
2995
+ })],
2996
+ });
2997
+ ```
2998
+
2999
+ `{ remove: [Subagent] }` edits the host default selection (section 2.2). The
3000
+ main conversation here leaves its selection unset, so that is its selection
3001
+ too. A parent with any stored selection, an array or an `{ add, remove }`
3002
+ object, computes the child's array from `(await api.agent(context)).extensions`
3003
+ instead, before the commit.
3004
+
3005
+ A persistent background subagent outlives the parent's turns and can be
3006
+ messaged, steered, stopped, and listed later. One transaction, after
3007
+ deduplicating by the subagent's name in an application registry document,
3008
+ creates a background conversation-owned anchor task `A` in the parent, a child
3009
+ conversation `C` owned by `A`, and the registry mapping from the name to `C`.
3010
+ `A` completes at once; it holds `completing` while `C` has ordinary work
3011
+ (section 5.5) and then stays terminal. Its record keeps `background`, so the
3012
+ parent's ordinary abort and idle traversal stop at it, while
3013
+ `Conversation.abort(context, { background: true })` still reaches `C`'s work
3014
+ through it, and `Harness.waitForIdle()` does not wait for it. Later runs in `C`
3015
+ are ordinary work of `C`; a terminal owner never cascades.
3016
+
3017
+ Each message to a subagent is delivered by a background conversation-owned
3018
+ reporter task in the parent, created in the tool's transaction and keyed there
3019
+ by the tool task ID so a rerun of the call does not create another. The
3020
+ reporter submits to `C` with a request ID derived from its own task ID, waits
3021
+ for that submission's settlement, and posts the answer to the parent as an
3022
+ input with `whenBusy: "followUp"`, under another request ID derived from its
3023
+ task ID. A crash before either admission retries it; a crash after returns the
3024
+ existing `Submission`, so the child gets the message once and the parent gets
3025
+ the answer once. The reporter therefore needs no transaction-level admission;
3026
+ `tx.createSubmission()` writes a raw record without admission rules. The
3027
+ application's name document uses
3028
+ `fork: "initial"` so forks of the parent do not inherit it. A UI lists
3029
+ subagents from that document; a child is working while its `pi.live.run` is
3030
+ set. Owner edges alone drive abort and idle traversal. The anchor and reporter
3031
+ task definitions come with the subagent extension, so pending reporters resume
3032
+ after a restart once the host installs it again:
3033
+
3034
+ ```ts
3035
+ export const SubagentTools = defineExtension({ name: "subagent-tools", tasks: [Anchor, Reporter], tools: [subagentTool] });
3036
+
3037
+ // In subagentTool's spawn commit, after the name checks:
3038
+ const anchor = await tx.createTask(Anchor, null, { ownership: { kind: "conversation" }, background: true });
3039
+ // Owned by a task of the parent: starts as a copy of the parent's agent.
3040
+ const child = await tx.createConversation({ ownership: { kind: "task", taskId: anchor } });
3041
+ await configure(tx, child.id, {
3042
+ extensions: { remove: [SubagentTools] },
3043
+ instructions: `You are the subagent "${name}". Answer the main agent's requests.`,
3044
+ });
3045
+ ```
3046
+
3047
+ A tool result may request `addTools`, `terminate`, or `handoff`. The
3048
+ generation's `tools` phase (section 8.5) adds the named tools to the
3049
+ conversation's stored tools filter; they take effect at the next preparation. The round
3050
+ terminates only when every result of the round requests `terminate`, as in the
3051
+ pi agent loop; the `tools` phase then uses a final boundary. Any
3052
+ `handoff` in the round, the last one in call order when several ask, ends the
3053
+ run the same way after appending a `pi.reset` entry with `head: "self"` and the
3054
+ handoff text as a user message (section 8.1), exactly what `reset(handoff)`
3055
+ writes.
3056
+
3057
+ A result's `usage` is stored on the tool-result message and added to the
3058
+ conversation's `pi.usage.tools[toolName]` in the result commit (section 8.6). A
3059
+ tool that runs an owned conversation must not report that conversation's spend
3060
+ again: the child's own `pi.usage` already counts it (section 12).
3061
+
3062
+ On reopen, a tool reruns only when both its stored intent policy and the current
3063
+ declaration, resolved as at `call`, say `safe`. A current `unsafe` declaration
3064
+ may veto a stored-safe replay; a current-safe declaration never upgrades stored
3065
+ unsafe. A tool that no longer resolves is treated as `unsafe`. Every other orphaned
3066
+ effect produces an interrupted result containing the
3067
+ durable partial output. Completed, failed, and aborted tool terminal outcomes
3068
+ retain their tool-result entry ID for the generation's `tools` phase.
3069
+
3070
+ Error results the Harness writes itself set `isError` and carry an `error`
3071
+ diagnostic with one of the codes `tool_unavailable`, `invalid_arguments`,
3072
+ `blocked`, `interrupted`, `aborted`, or `tool_error` (a throw from `execute()`)
3073
+ and the error text as its message. Their content is the durable partial output,
3074
+ if any, and `details` is the tool's last reported value, if any.
3075
+
3076
+ `@earendil-works/pi-durable/tools` provides `read`, `write`, `edit`, and `bash`
3077
+ factories, ported from the agent harness tools, and the `CodingTools` extension
3078
+ with all four. They use only `api.env`; nothing
3079
+ installs them automatically. `read` does not return images yet. It reads a
3080
+ file through `openBinaryReader`: image detection reads the header (and a PNG's
3081
+ chunk headers), `scanLines` counts and locates the selected lines, and only the
3082
+ shown head is read and decoded, so its cost and transfer are bounded by the
3083
+ output limits plus one pass over the file inside the environment. Its result is
3084
+ exactly that of decoding the whole file, splitting it into lines, and
3085
+ truncating the selection. A file that changes while it is read is read again
3086
+ once, then fails. `edit` and
3087
+ `write` serialize their read-modify-write of one file within the process, keyed
3088
+ by the environment's `FileSystem.id` (equal ids see the same files at the same
3089
+ paths) and the canonical path, so two calls with fresh environment objects for
3090
+ one file system still queue, and other files and file systems never wait. It
3091
+ is not a lock against `bash` or other processes.
3092
+
3093
+ ### 7.4 System prompt and dynamic tools
3094
+
3095
+ Pico has no durable prompt sections. The conversation's resolved agent produces
3096
+ the desired sections for each request; the transcript's `pi.system` entries are
3097
+ the only durable record of what the model saw. Pico stores prompt and tool
3098
+ changes directly as PR #9548 `SystemMessage` values at their transcript
3099
+ positions, always with empty `content`:
3100
+
3101
+ ```ts
3102
+ type SystemEntry = EntryRecord & {
3103
+ readonly kind: "pi.system";
3104
+ readonly model: readonly [SystemMessage];
3105
+ };
3106
+
3107
+ const baseline: SystemMessage = {
3108
+ role: "system",
3109
+ content: "",
3110
+ sections: { preamble: renderedPreamble, cwd: renderedCwd },
3111
+ toolsAdded: allEffectiveTools,
3112
+ timestamp: now,
3113
+ };
3114
+
3115
+ const delta: SystemMessage = {
3116
+ role: "system",
3117
+ content: "",
3118
+ sections: { cwd: nextRenderedCwd, legacy: null },
3119
+ toolsRemoved: [{ name: "read" }],
3120
+ toolsAdded: [nextRead],
3121
+ timestamp: now,
3122
+ };
3123
+ ```
3124
+
3125
+ Generation preparation takes these steps against its phase snapshot:
3126
+
3127
+ 1. Resolve the conversation's agent (section 7.1) and replay the active
3128
+ transcript's system messages into the shown sections and offered tools.
3129
+ 2. The desired tools are the agent's `tools`, in order.
3130
+ 3. Build the environment with `HarnessOptions.env` (section 2.2) and render the
3131
+ agent's `sections`, in order, with `PromptInput`: the conversation, the
3132
+ agent, the environment, the shown sections, and a `DocumentReader` for
3133
+ committed documents (the task runtime). The results are the desired
3134
+ sections.
3135
+ 4. Compare desired sections and tool declarations with the replayed state and
3136
+ append one positional `pi.system` entry when they differ.
3137
+
3138
+ Preparation does not recheck the transcript before appending: only the Harness
3139
+ writes to a busy conversation, through submissions, run tasks, boundaries, and a
3140
+ blocking compaction, which appends only while its generation waits for it
3141
+ (section 8.7), so the transcript it read is still current (section 12).
3142
+
3143
+ Model and thinking level are request options, not prompt state.
3144
+
3145
+ The selected extensions' sections and the agent's `instructions` are the only
3146
+ source of prompt text; there is no separate builder. `section(key, render,
3147
+ { tag })` builds a section, and `wrapSection(key, wrapper)` decorates one,
3148
+ composed per resolution like tool wrappers. Sections render in the agent's
3149
+ order: extension order, a later extension's same-key section replacing the
3150
+ earlier one in place, and `instructions` last (section 7.1). A section whose
3151
+ `render` returns `undefined` is omitted. With `tag` omitted or true, text is
3152
+ wrapped as `<key>\n...\n</key>`. A section that throws keeps its shown text, if
3153
+ any, and is reported; the request is still sent. Errors thrown after the
3154
+ generation invocation is cancelled propagate; an abort error of the section's
3155
+ own, such as its fetch timing out, is an ordinary failure. With no sections, the
3156
+ desired section set is empty.
3157
+
3158
+ A minimal prompt is one untagged section:
3159
+
3160
+ ```ts
3161
+ const Chat = defineExtension({
3162
+ name: "chat",
3163
+ sections: [section("preamble", () => "You are a helpful assistant.", { tag: false })],
3164
+ });
3165
+ registry.install(Chat);
3166
+ ```
3167
+
3168
+ A conversation's prompt varies through its extension selection, its
3169
+ `instructions`, `input.env`, and `input.read`. A subagent that should not see
3170
+ skills does not select the skills extension, and a cwd section renders
3171
+ `input.env.cwd`; no section checks whether it runs in a subagent.
3172
+ Renderers must be deterministic for equal inputs: any change in rendered text,
3173
+ such as an embedded timestamp, appends a system delta and invalidates provider
3174
+ prompt caches.
3175
+
3176
+ Replay applies messages in transcript order. Non-empty `content` appends
3177
+ instructions. A section string adds or replaces that name without moving an
3178
+ existing section; `null` removes it, and a later re-addition appends it to the
3179
+ ordered section map. Within one message, tool removals happen before additions,
3180
+ so a same-name replacement gets the new declaration and position.
3181
+
3182
+ Preparation compares both values and order. If values can be patched without
3183
+ changing order, it emits the minimal patch. If shown and desired section order
3184
+ differ, one commit appends two `pi.system` entries: the first removes every
3185
+ shown section with `null`, and the second re-adds every desired section in
3186
+ desired order. This makes order-only changes and deletion/re-addition between
3187
+ requests replay exactly; merely restating equal values is insufficient.
3188
+
3189
+ A PR #9548 `SystemMessage` is always a patch, not a reset: it cannot remove
3190
+ previous `content` or restore section order merely by restating current values.
3191
+ Therefore, when a head removes the previous request-visible baseline, which is
3192
+ the case when the active context has a head marker and no `pi.system` entry
3193
+ was appended after that marker (has a higher ID), the new
3194
+ `pi.system` entry adds `ContextEdit` omissions for every earlier `pi.system`
3195
+ entry still retained after the cut. Its own message is then a complete baseline
3196
+ containing every desired section in order and every effective tool declaration.
3197
+ Model-context replay sees the new baseline instead of the omitted retained
3198
+ deltas. Preparation writes this baseline even when it restates the replayed
3199
+ values, so every later preparation finds a `pi.system` entry after the marker.
3200
+ Head rebaselining takes precedence over ordinary order/value patching.
3201
+ Without a head cut, an order mismatch uses the two-entry remove/re-add sequence
3202
+ above; only when order already matches does preparation emit the minimal changed
3203
+ values and `null` removals.
3204
+
3205
+ Tool changes are planned separately and ride on the last planned entry, or on
3206
+ one entry of their own when sections are unchanged. Declarations are compared
3207
+ with pi-ai `declarationsEqual()` and written with `toToolDeclaration()`, so
3208
+ application metadata never enters the transcript. A changed declaration is
3209
+ removed and re-added in the same message. Replay keeps retained tools in place
3210
+ and appends additions; when that would not yield the desired order, the message
3211
+ removes every offered tool and re-adds the desired tools in order. A head
3212
+ rebaseline adds every desired tool.
3213
+
3214
+ The rendered strings stored in historical `SystemMessage.sections` remain
3215
+ authoritative even if the current renderer changes. Pi-ai decides whether to
3216
+ send the messages positionally to a capable provider or fold them into one
3217
+ leading system message; Pico does not rewrite its stored transcript for provider
3218
+ compatibility.
3219
+
3220
+ ### 7.5 Extension reload
3221
+
3222
+ Extension code is reloaded in process through the registry. The host installs
3223
+ the new extension object under the installed name, which replaces the old one
3224
+ in place in one publication. The Harness keeps running; no close or reopen is
3225
+ required.
3226
+
3227
+ ```ts
3228
+ registry.install(SkillsV2); // same name: conversations selecting skills render v2 at their next request
3229
+ registry.uninstall(Skills); // selecting conversations get a system delta removing its section; nothing is rewritten
3230
+ // Restart: the host installs its extensions again; stored names resolve against them.
3231
+ ```
3232
+
3233
+ - New phase invocations and tool tasks use the new extension immediately;
3234
+ running invocations see it at their next phase boundary.
3235
+ - Work already running keeps its snapshot until its phase handler settles, and a
3236
+ pinned tool keeps its implementation until its execution settles. Replacement
3237
+ never signals or interrupts it. Resources the old code uses are the
3238
+ extension's responsibility (section 7.1).
3239
+ - A task definition replaced by name hands over at its next normal phase boundary
3240
+ (section 5.4). A task definition must increase its version when the meaning
3241
+ of persisted input or checkpoint state changes and migrate supported older
3242
+ state; a failed migration blocks the task rather than terminalizing it.
3243
+ - A replaced extension keeps its install position, so a reload does not move
3244
+ its hooks, tools, and sections relative to other extensions; within the
3245
+ extension, the new object's order applies. Moving a task definition from one extension to
3246
+ another leaves a gap between the two installs in which its pending tasks are
3247
+ blocked.
3248
+ - Document definitions are passed explicitly to typed access and need no
3249
+ registration; changed tokens take effect on their next access.
3250
+
3251
+ If old extension code ignores cancellation and never settles, new work already
3252
+ uses the replacement. Harness close still joins every invocation. Safe forced termination of arbitrary
3253
+ non-cooperative JavaScript requires worker/process isolation; that host
3254
+ terminates the process and reopens the Session from durable state. Old and new
3255
+ Harness instances must never own the same Session concurrently: open the new one
3256
+ after the old one's `close()` resolved, when none of its invocation code runs
3257
+ any more (section 2.2).
3258
+
3259
+ ## 8. Built-in tasks
3260
+
3261
+ The initial implementation provides:
3262
+
3263
+ | kind | responsibility |
3264
+ |---|---|
3265
+ | `pi.generation` | prepare system prompt and tools, request or poll model, retry, classify response |
3266
+ | `pi.tool` | validate, hook, execute, persist output and details, append result |
3267
+ | `pi.compaction` | select a transcript range, summarize, place a headed summary |
3268
+
3269
+ Generation uses `HarnessOptions.models` without a Pico-specific model adapter. It
3270
+ resolves `models.getModel(ref.provider, ref.modelId)`, builds a pi-ai `Context`
3271
+ from the prepared prompt/messages/tools, and calls `models.streamSimple()` with
3272
+ the task invocation's abort signal, the agent's thinking level, and the
3273
+ settings' stream options. Deferred
3274
+ continuation calls `models.fetchDeferred()` and `models.cancelDeferred()` with
3275
+ that same model and signal. Missing models and synchronous/streamed pi-ai errors
3276
+ are classified into the durable generation outcomes below.
3277
+
3278
+ Generation and tool progress are throttled durable document commits. A crash may
3279
+ lose only the uncommitted throttle window. Recovery converts committed partials
3280
+ to normal interrupted/aborted transcript entries, clears presentation state,
3281
+ and then retries or terminates according to the task phase. Retry deadlines,
3282
+ attempts, compaction, and tool progress are current document state for late
3283
+ joiners; completed-attempt usage/accounting is an entry or terminal detail.
3284
+ Truncation and spill paths are tool diagnostics (section 7.3).
3285
+
3286
+ Compaction changes model context by appending a summary entry with a head. It
3287
+ does not delete transcript history.
3288
+
3289
+ ### 8.1 Built-in entries
3290
+
3291
+ Built-in entry kinds carry no `data`, except `pi.tool-result`, whose token is
3292
+ `Entry<{ diagnostics: ToolDiagnostic[] }>`; every tool result carries `data`,
3293
+ with an empty list when it has no diagnostics (section 7.3), and
3294
+ `pi.compaction`, whose token is `Entry<{ reason: CompactionReason }>`. Each kind
3295
+ is exported as an `Entry` token.
3296
+
3297
+ | kind | `model` | written by |
3298
+ |---|---|---|
3299
+ | `pi.user` | `[UserMessage]`, timestamp from the Harness clock at admission or placement | submissions, `onYield` continuations |
3300
+ | `pi.assistant` | `[AssistantMessage]` with any stop reason | generation |
3301
+ | `pi.system` | `[SystemMessage]` with `content: ""` (section 7.4) | generation preparation |
3302
+ | `pi.tool-result` | `[ToolResultMessage]` | tool tasks; generation for calls to tools its request did not offer |
3303
+ | `pi.reset` | absent, or `[UserMessage]` with the handoff text; always `head: "self"` | `reset()`, generation `tools` phase for `handoff` |
3304
+ | `pi.compaction` | `[UserMessage]` with the wrapped summary; `head` is the first kept entry | compaction tasks (section 8.7) |
3305
+
3306
+ Every generation response becomes a `pi.assistant` entry: answers, failed attempts
3307
+ with their error text and usage, and converted partials with stop reason
3308
+ `aborted`. Context derivation (section 2.1, rule 9) keeps failed and aborted
3309
+ messages out of later requests, so no separate usage or notice kind exists.
3310
+
3311
+ ### 8.2 Live document
3312
+
3313
+ ```ts
3314
+ type LiveState = {
3315
+ /** Run control (section 6); present exactly while the conversation is busy. */
3316
+ run?: { taskId: TaskId; inputs: SubmissionId[] };
3317
+ /** Presentation of the current generation attempt. */
3318
+ generation?: {
3319
+ attempt: number;
3320
+ /** Committed throttled partial of the in-flight response. */
3321
+ message?: JsonRepresentation<AssistantMessage>;
3322
+ /** Durable backoff before the next attempt. */
3323
+ retry?: { at: number; error: string };
3324
+ /** Provider-side deferred response being polled. */
3325
+ deferred?: { pollAt: number };
3326
+ };
3327
+ /** The current tool round in call order, from the tool-calling answer until the generation's `tools` phase ends it. */
3328
+ tools?: {
3329
+ callId: string;
3330
+ name: string;
3331
+ /**
3332
+ * Absent for a call not started yet (sequential round, `pending`) and for a call its request did not offer,
3333
+ * which starts `done` with the `entry` generation wrote.
3334
+ */
3335
+ taskId?: TaskId;
3336
+ status: "pending" | "running" | "done";
3337
+ /** Retained running output and what the bounds dropped. */
3338
+ output?: string;
3339
+ droppedBytes?: number;
3340
+ droppedLines?: number;
3341
+ /** Last `details()` value. */
3342
+ details?: JsonValue;
3343
+ /** Diagnostics recorded through `api.diagnostic()`. */
3344
+ diagnostics?: ToolDiagnostic[];
3345
+ /** Result entry once done; absent when the tool task faulted or was orphaned. */
3346
+ entry?: EntryId;
3347
+ }[];
3348
+ /** Live compaction tasks in task ID order; absent when none (section 8.7). */
3349
+ compactions?: CompactionStatus[];
3350
+ };
3351
+
3352
+ type CompactionStatus = {
3353
+ taskId: TaskId<CompactionResult>;
3354
+ reason: CompactionReason;
3355
+ /** Whether a generation waits for it: a compaction the generation owns. */
3356
+ blocking: boolean;
3357
+ attempt: number;
3358
+ /** Durable backoff before the next summarization attempt. */
3359
+ retry?: { at: number; error: string };
3360
+ };
3361
+ ```
3362
+
3363
+ | field | value |
3364
+ |---|---|
3365
+ | kind | `pi.live` |
3366
+ | version | `1` |
3367
+ | scope/history/fork | conversation, `latest`, `initial` |
3368
+ | `initial()` | `{}` |
3369
+ | checkpoint | complete base whenever nothing runs: `generation` absent and no `running` tool slot |
3370
+ | view mount | `docs["pi.live"]` |
3371
+ | created | with every Harness conversation (section 2.2) |
3372
+
3373
+ Nothing runs while idle, at a final boundary, in the commit where generation
3374
+ hands over to its tool round (every slot is still `pending` or `done`), between
3375
+ the tools of a sequential round, and whenever no tool of a parallel round is
3376
+ `running`. A slot holds output only while `running`, so every base is small, and
3377
+ the stored delta chain spans at most one generation, including its retries and
3378
+ deferred polls, or the overlapping execution of one round's tools. Retained head output grows by Chord string appends. A sliding tail
3379
+ usually becomes a front trim plus an append; when Chord's bounded overlap search
3380
+ finds no shared part, as with highly repetitive output or a window larger than
3381
+ its 64 KiB scan, the commit writes the retained window as one set. Either way
3382
+ each commit writes at most one window, and the throttle bounds the rate.
3383
+
3384
+ Generation creates `tools` with one `pending` slot per call in the commit that
3385
+ appends the tool-calling answer; a call its request did not offer starts `done`
3386
+ with its result entry. A tool task sets its slot `running` in its intent commit,
3387
+ publishes throttled output and details into it, and in its terminal commit sets
3388
+ `done` and `entry` and removes `output`, `droppedBytes`, `droppedLines`,
3389
+ `details`, and `diagnostics`, which the result entry now carries (section 7.3).
3390
+ The generation's `tools` phase removes `tools`. Slot updates apply only while a slot with the task's
3391
+ `taskId` exists; without one, the durable partial output, details, and
3392
+ diagnostics are empty.
3393
+
3394
+ A compaction's status is added in the commit that creates the task and removed
3395
+ in the commit that decides its outcome: the task's own outcome commit, even when
3396
+ that outcome holds as `completing` (section 5.5), or the scheduler's cleanup
3397
+ (section 5.4).
3398
+ It stays small: the summary text lives in the task's placement, never here. Partials are
3399
+ normalized to strict JSON before assignment. Every terminal path of a run task
3400
+ removes `run`, `generation`, and `tools` in the commit that settles the run's
3401
+ inputs. `tx.settleSubmission()` stages each input's new status
3402
+ and resolves the transaction's latest candidate submission record, falling back
3403
+ to committed state, during assembly, like task-document validation (section 3.3),
3404
+ so it is not a caller table read and works after the commit's first table write.
3405
+
3406
+ ### 8.3 Generation
3407
+
3408
+ ```ts
3409
+ type GenerationInput = {};
3410
+ type GenerationCheckpoint =
3411
+ | {
3412
+ phase: "prepare";
3413
+ attempt: number;
3414
+ /** The blocking compaction this generation waited for; it starts no other compaction. */
3415
+ compacted?: TaskId<CompactionResult>;
3416
+ /** Error text of the overflow that started `compacted`; checked once when `prepare` resumes. */
3417
+ overflow?: string;
3418
+ }
3419
+ | {
3420
+ phase: "request";
3421
+ attempt: number;
3422
+ compacted?: TaskId<CompactionResult>;
3423
+ model: ModelRef;
3424
+ thinkingLevel: ModelThinkingLevel;
3425
+ streamOptions: ConversationStreamOptions;
3426
+ /** Newest entry included in the request. */
3427
+ cutoff: EntryId;
3428
+ }
3429
+ | { phase: "retry"; attempt: number; compacted?: TaskId<CompactionResult>; until: number }
3430
+ | {
3431
+ phase: "poll";
3432
+ attempt: number;
3433
+ compacted?: TaskId<CompactionResult>;
3434
+ model: ModelRef;
3435
+ cutoff: EntryId;
3436
+ handle: DeferredHandle;
3437
+ pollAt: number;
3438
+ }
3439
+ | {
3440
+ /** Waiting on the round's tool tasks; `waiting` holds this checkpoint (section 8.5). */
3441
+ phase: "tools";
3442
+ assistant: EntryId;
3443
+ tools: TaskId<ToolTaskResult>[];
3444
+ /** Calls of a sequential round not started yet, in call order. */
3445
+ pending: string[];
3446
+ };
3447
+ type GenerationResult = { entryId: EntryId };
3448
+ ```
3449
+
3450
+ `pi.generation` is version 1 and starts at `{ phase: "prepare", attempt: 1 }`.
3451
+ The run's inputs live in `pi.live.run`, not in the task input.
3452
+
3453
+ - `prepare` runs section 7.4 with the phase's agent resolution and resolves
3454
+ the settings once. When the agent has no model or `models.getModel()` does
3455
+ not know it, the task fails with `no_model`. When it resumes with `overflow`
3456
+ and its compaction did not complete with an `entryId`, the run fails with
3457
+ `model_error` and the overflow text as detail. Otherwise one commit appends
3458
+ the planned `pi.system` entries and moves to `request` with the new tail as
3459
+ `cutoff`, the agent's model and thinking level, and the settings' stream
3460
+ options. These stay fixed for this request attempt; a retry prepares again. `compacted` carries over to
3461
+ `request`, `retry`, `poll`, and the next `prepare`.
3462
+ - Before that commit, `prepare` checks the compaction thresholds (section 8.7)
3463
+ when the settings' compaction policy is enabled, the model's
3464
+ `contextWindow` is positive, and `compacted` is absent. The estimate starts at
3465
+ the newest assistant message in the committed model context whose entry was
3466
+ appended after the head marker (all qualify without one) and whose usage is
3467
+ nonzero: pi-ai `calculateContextTokens()` of its usage, plus pi-ai
3468
+ `estimateMessageTokens()` of every context message after it and of the planned
3469
+ system messages. Its request included the marker, because a head is placed
3470
+ only while no request is in flight. Without such a message, every message is
3471
+ estimated. Both thresholds apply only when range selection finds a cut.
3472
+ - Above `contextWindow - reserveTokens`, the blocking threshold: instead of
3473
+ appending, one commit creates a compaction owned by the generation with reason
3474
+ `threshold`, adds its status, and commits `waiting` on it with `allSettled`
3475
+ and checkpoint `{ phase: "prepare", attempt, compacted }`. Whatever its
3476
+ outcome, `prepare` then runs again and sends the request.
3477
+ - Above `contextWindow - reserveTokens - backgroundTokens` with
3478
+ `backgroundTokens > 0` and no compaction status in `pi.live`: the commit that
3479
+ moves to `request` also creates a background compaction, owned by the
3480
+ conversation, with reason `threshold`, and adds its status. The generation
3481
+ does not wait for it. The settings' retry policy is read when the
3482
+ attempt's result is classified, because it governs the next attempt (section
3483
+ 7.1).
3484
+ - `request` and `poll` resolve the checkpoint's model through
3485
+ `models.getModel()`; an unknown model fails the task with `no_model`, like
3486
+ `prepare`. `request` converts a leftover partial (below) before it resolves
3487
+ the model.
3488
+ - `request` first converts a committed partial left in `pi.live` by an
3489
+ interrupted attempt into an aborted `pi.assistant` entry. It then streams the
3490
+ model context through `cutoff` with the invocation signal, the thinking level
3491
+ as `reasoning` (omitted for `off`), the conversation's persisted provider
3492
+ `sessionId`, and the pinned `streamOptions`, committing throttled partials at
3493
+ most every `settings.progress.partialIntervalMs` (default 100 ms).
3494
+ Before streaming, the `beforeRequest` chain may
3495
+ replace the messages for this request only. Recovery resends the same
3496
+ committed messages with the same pinned model, thinking level, and stream
3497
+ options, and reruns `beforeRequest`.
3498
+ - `afterResponse` observes every terminal message, from `request` or `poll`,
3499
+ before classification; a still deferred result is not terminal.
3500
+ - Before classifying, the handler stops the partial throttle and awaits any
3501
+ partial commit in flight, so no stale partial lands after the outcome. The
3502
+ terminal message is classified in one commit that also clears the partial:
3503
+ - `stop`/`length`: before the commit, the `onYield` chain runs; the first
3504
+ `{ continue }` wins. The commit appends the answer and applies the final
3505
+ boundary (section 6). With a continuation and no selected user item or
3506
+ reset, it appends a `pi.user` entry with the continuation content, creates a
3507
+ successor generation, and hands it `pi.live.run`, keeping the inputs open.
3508
+ Otherwise it settles the run's inputs `done`, removes `run` and
3509
+ `generation`, and starts a successor run for the selected user items, if
3510
+ any; a dropped continuation is not retried. Both complete with
3511
+ `{ entryId }`.
3512
+ - `toolUse` with at least one tool call: the commit appends the assistant
3513
+ entry and starts the tool round described below, moving to `waiting` in the
3514
+ `tools` phase. A `toolUse` message without calls is classified like `stop`.
3515
+ - `error` that pi-ai `isContextOverflow()` recognizes, while the settings'
3516
+ compaction policy is enabled, `compacted` is absent, and range selection finds a cut:
3517
+ the commit appends the error entry, removes `generation`, creates a
3518
+ compaction owned by the generation with reason `overflow`, adds its status,
3519
+ and commits `waiting` on it with `allSettled` and checkpoint `{ phase:
3520
+ "prepare", attempt, compacted, overflow }`; the recovery request does not
3521
+ count against the retry policy. Any other overflow,
3522
+ including a second one, is never retried and fails like the non-retryable
3523
+ errors below. A `stop` or `length` response is never classified as overflow;
3524
+ the next threshold check handles silent overflow.
3525
+ - `error` that `isRetryableAssistantError()` accepts while the settings'
3526
+ retry policy allows another attempt (`enabled` and `attempt <= maxRetries`,
3527
+ so `maxRetries` counts retries after the first attempt): append the error entry and move to
3528
+ `retry` with `until = now + retryDelayMs(policy, attempt)`.
3529
+ - any other `error`, or `aborted` without an abort mark: append the error
3530
+ entry, settle the inputs `unanswered` with `model_error`, remove `run` and
3531
+ `generation`, and fail.
3532
+ - `deferred`: move to `poll` with `pollAt = now + (handle.pollAfterMs ?? 5000)`.
3533
+ - `retry` sleeps until `until`, then returns to `prepare` with the next attempt,
3534
+ so agent and settings changes made during the backoff apply.
3535
+ - `poll` sleeps until `pollAt` and calls `models.fetchDeferred()`. A still
3536
+ deferred result moves `pollAt` strictly later; any other result is classified
3537
+ as above.
3538
+ - The abort handler calls `models.cancelDeferred()` in `poll` when the model is
3539
+ known (a failure is reported), converts a committed partial, settles the inputs `unanswered` with
3540
+ `aborted`, removes `run` and `generation`, and ends `aborted`.
3541
+
3542
+ A tool round starts in the commit that appends the tool-calling answer:
3543
+
3544
+ 1. The offered tools are replayed with pi-ai `getCurrentTools()` from the
3545
+ committed model context through `cutoff`: `request` already holds it, and
3546
+ `poll` derives it again. A call to a tool not offered gets its
3547
+ `tool_unavailable` result entry here, without a task.
3548
+ 2. Every other call gets a `pi.tool` task owned by the generation, with input
3549
+ `{ assistant, callId }`. The round is sequential when the settings'
3550
+ `toolExecution`, read as the round starts, is `sequential` or any called
3551
+ tool, resolved from the phase's agent as the tool task resolves it (section
3552
+ 7.3), has `executionMode: "sequential"`: then only the first call gets its
3553
+ task now and the rest wait in `pending`. Otherwise every call gets its task at
3554
+ once and they run in parallel. The checkpoint's `tools` lists the tasks
3555
+ created so far and grows by one per started sequential call, while
3556
+ `pending` shrinks.
3557
+ 3. The generation commits `waiting` on its tool tasks with `allSettled` and the
3558
+ `tools` checkpoint (section 8.5); `pi.live.run` stays with it.
3559
+ 4. `pi.live.tools` receives the round's slots (section 8.2), and `generation` is
3560
+ removed.
3561
+
3562
+ Input submissions settle `unanswered` with one of these reasons: `no_model`,
3563
+ `model_error` (detail: provider error text), `aborted`, `faulted` (detail: error
3564
+ message), or an orphaning blocked reason (section 5.4). Fault and orphan
3565
+ settlement convert a committed partial into an aborted `pi.assistant` entry,
3566
+ like the abort handler.
3567
+
3568
+ ### 8.4 Tool
3569
+
3570
+ ```ts
3571
+ type ToolTaskInput = { assistant: EntryId; callId: string };
3572
+ type ToolTaskCheckpoint =
3573
+ | { phase: "call" }
3574
+ | { phase: "execute"; arguments: JsonObject; replay: "safe" | "unsafe" };
3575
+ type ToolTaskResult = { entryId: EntryId; control?: ToolControl };
3576
+ ```
3577
+
3578
+ `pi.tool` is version 1 and starts at `{ phase: "call" }`. The input stays small
3579
+ because the terminal record keeps it; the call is read from the assistant entry.
3580
+
3581
+ - `call` reads the call with `runtime.entry()`, resolves the tool among the
3582
+ phase's agent `tools` (section 7.3), validates
3583
+ the arguments with pi-ai
3584
+ `validateToolArguments()`, runs the `beforeTool` chain, and validates again
3585
+ (section 7.3). One commit then records intent: it moves to `execute` with the
3586
+ final arguments and the tool's replay policy and sets the slot `running`. The
3587
+ same handler executes the tool, runs the `afterTool` chain, and commits the
3588
+ result; it builds `api.env` with `HarnessOptions.env` right before executing
3589
+ (section 2.2). A tool that does not resolve, invalid arguments, or a block
3590
+ commits the corresponding error result instead, without intent.
3591
+ - `execute` is reached only by recovery. When both the stored and the current
3592
+ policy, resolved as in `call`, are `safe`, it executes again with the stored arguments,
3593
+ without `beforeTool`, and settles like `call`. Otherwise it commits an
3594
+ `interrupted` error result from the slot's durable partial output, details, and
3595
+ diagnostics, and ends `failed` with `{ entryId }`.
3596
+ - The result commit bounds the content, appends the diagnostics block (section
3597
+ 7.3), appends one `pi.tool-result` entry with `model: [{ role: "toolResult",
3598
+ toolCallId, toolName, content, details, isError, timestamp }]` and
3599
+ `data: { diagnostics }`, marks the slot `done`, and
3600
+ completes with `{ entryId, control }`. An `isError` result still completes.
3601
+ - A throw from `execute()`, or from `HarnessOptions.env` building its
3602
+ environment, becomes a `tool_error` result and the task ends
3603
+ `failed` with `{ entryId }`; once the invocation is signalled it propagates
3604
+ instead. Ending `failed`, like `aborted`, records cancellation intent (section
3605
+ 5.4), so conversations the call owns, which nothing supervises any more, are
3606
+ aborted. A returned `isError` result still completes, and its call's owned work
3607
+ survives. Either way the run continues with the result entry. A tool task whose
3608
+ owned conversations still have ordinary work when it commits its result holds
3609
+ `completing` with the result entry already written (section 5.5); the
3610
+ generation resumes only when the tool task is terminal.
3611
+ - The abort handler commits an `aborted` error result from the slot's durable
3612
+ partial output, details, and diagnostics and ends `aborted` with `{ entryId }`.
3613
+
3614
+ ### 8.5 Tool rounds
3615
+
3616
+ The generation resumes in its `tools` phase once every tool task it waits on is
3617
+ terminal. In a sequential round with calls left in `pending`, one commit creates
3618
+ the next call's owned tool task and waits on it again with the shorter `pending`.
3619
+ Otherwise it reads the tool records with `runtime.getTask()` and the round's
3620
+ result entries from the `pi.live.tools` slots, runs the `afterTools` observers,
3621
+ and then commits once:
3622
+
3623
+ - It edits the conversation's stored `pi.agent` `tools` for every `addTools`
3624
+ name: an array gets the name appended unless it holds it already, and
3625
+ `{ remove }` loses the name. With `tools` unset, every tool is already
3626
+ offered, and nothing is written. The next preparation offers the tool when it
3627
+ resolves.
3628
+ - When every result of the round requests `terminate`, or any requests
3629
+ `handoff`, it appends the handoff's `pi.reset` entry, if any, settles the
3630
+ run's inputs `done` with the tool-calling answer, removes `run` and `tools`,
3631
+ and applies the final boundary.
3632
+ - Otherwise it removes `tools` and applies the `postTools` boundary. When that
3633
+ boundary placed a reset, the run's inputs settle `unanswered` with `reset` and
3634
+ selected user items start a successor run (section 6). Otherwise selected
3635
+ steer IDs join `pi.live.run`, and it creates the next generation, owned by the
3636
+ conversation, and hands it the run.
3637
+
3638
+ It completes with `{ entryId }` of the tool-calling answer. Its abort handler
3639
+ runs only after its tool tasks are terminal (section 5.5). For every call still
3640
+ in `pending`, which never started, it appends an `aborted` error result; a
3641
+ started call whose task faulted or was orphaned keeps
3642
+ its `done` slot without an entry (section 8.2). It then settles the inputs
3643
+ `unanswered` with `aborted`, removes `run`, `generation`, and `tools`, and ends
3644
+ `aborted`. `abortTask()` on the generation therefore aborts its whole round.
3645
+
3646
+ ### 8.6 Usage
3647
+
3648
+ ```ts
3649
+ type UsageState = {
3650
+ /** Assistant entries and compaction summarization attempts, keyed `provider/modelId`. */
3651
+ models: Record<string, Usage>;
3652
+ /** Tool results, keyed by tool name; their usage has no model identity. */
3653
+ tools: Record<string, Usage>;
3654
+ };
3655
+ ```
3656
+
3657
+ | field | value |
3658
+ |---|---|
3659
+ | kind | `pi.usage` |
3660
+ | version | `1` |
3661
+ | scope/history/fork | conversation, `latest`, `initial` |
3662
+ | `initial()` | `{ models: {}, tools: {} }` |
3663
+ | checkpoint | complete base on every change |
3664
+ | view mount | `docs["pi.usage"]` |
3665
+ | created | with every Harness conversation (section 2.2) |
3666
+
3667
+ `pi.usage` is the ledger of the conversation's own spend. Every built-in writer
3668
+ of a `pi.assistant` entry adds its message's `usage` to `models` under the
3669
+ message's own `provider/model`, and every writer of
3670
+ a `pi.tool-result` entry with `usage` adds it to `tools`, in the same commit.
3671
+ Every summarization attempt of a compaction task adds its response's `usage` to
3672
+ `models` in the commit that classifies it (section 8.7); that spend has no entry,
3673
+ whether the summary is placed, fails, or ends stale. Failed and aborted attempts
3674
+ count. A fork starts at zero, so no spend is counted
3675
+ twice. `Harness.usage()` sums every conversation's document into the Session
3676
+ total; other totals, such as an ownership subtree, are application sums over
3677
+ `scanConversations()`. Nothing stores a total across conversations.
3678
+
3679
+ ### 8.7 Compaction
3680
+
3681
+ ```ts
3682
+ type CompactionReason = "manual" | "threshold" | "overflow";
3683
+ type CompactionInput = { reason: CompactionReason; instructions?: string };
3684
+ /** The pinned summarization request. */
3685
+ type SummaryRequest = {
3686
+ attempt: number;
3687
+ model: ModelRef;
3688
+ thinkingLevel: ModelThinkingLevel;
3689
+ streamOptions: ConversationStreamOptions;
3690
+ maxTokens: number;
3691
+ /** Newest entry of the context the range was selected from. */
3692
+ tail: EntryId;
3693
+ /** First entry kept verbatim; the summary's `head`. */
3694
+ firstKept: EntryId;
3695
+ };
3696
+ type CompactionCheckpoint =
3697
+ | { phase: "select" }
3698
+ | ({ phase: "summarize" } & SummaryRequest)
3699
+ | ({ phase: "retry"; until: number } & SummaryRequest);
3700
+ /**
3701
+ * `entryId` of a blocking compaction's summary, or the `submissionId` of a conversation-owned compaction's summary
3702
+ * write; both absent when nothing was compacted.
3703
+ */
3704
+ type CompactionResult = { entryId?: EntryId; submissionId?: SubmissionId };
3705
+ ```
3706
+
3707
+ `pi.compaction` is version 1 and starts at `{ phase: "select" }`. Compaction
3708
+ replaces an old prefix of the model context with a summary entry whose `head` is
3709
+ the first kept entry (section 2.1). Raw history stays in storage. There are three
3710
+ ways to start one; the task is the same, only ownership and placement differ:
3711
+
3712
+ | started by | owner | background | generation waits | placement |
3713
+ |---|---|---|---|---|
3714
+ | `Conversation.compact()` | conversation | no | no | write submission |
3715
+ | generation above the background threshold | conversation | yes | no | write submission |
3716
+ | generation above the blocking threshold, or on overflow | the generation | no | yes | direct append |
3717
+
3718
+ Only a blocking compaction runs while the conversation's run waits for it. The
3719
+ others never take run control: the conversation keeps working while they
3720
+ summarize, and their summary is placed like any passive write. Compactions do
3721
+ not coordinate with each other; several may run at once, and section 6 decides
3722
+ between their summaries.
3723
+
3724
+ **Range selection** is a pure function of a `ContextView`, whose
3725
+ `contributions` give each active entry's model messages after every edit in the
3726
+ range (section 2.1, rules 4 and 9), including edits carried by older in-range
3727
+ markers, and the settings' `keepRecentTokens`.
3728
+
3729
+ 1. Cut candidates are the non-marker entries whose contribution begins with a
3730
+ user or assistant message. Tool results and system entries are never
3731
+ candidates, so a kept assistant message keeps its tool results. A user entry
3732
+ is not a candidate either while a result for a call of the assistant before
3733
+ it follows it, before the next assistant (section 2.1, rule 7).
3734
+ 2. Walk the non-marker entries from newest to oldest, adding pi-ai
3735
+ `estimateMessageTokens()` of each contribution. At the first entry where the
3736
+ sum reaches `keepRecentTokens`, the cut is the first candidate at or after
3737
+ that entry in transcript order, or the newest candidate when none follows.
3738
+ 3. There is nothing to compact when the sum never reaches `keepRecentTokens`, or
3739
+ when no non-marker entry before the cut contributes a model message.
3740
+
3741
+ The summarized entries are the head marker, if any, followed by the non-marker
3742
+ entries before the cut. Their messages are their contributions, ordered as in
3743
+ section 2.1, rules 7 and 8, so an earlier summary is summarized again together
3744
+ with the history after it. Nothing is compacted while the context is smaller
3745
+ than `keepRecentTokens`, so with a window where `contextWindow - reserveTokens`
3746
+ is below it, overflow may come before any threshold compaction.
3747
+
3748
+ ```text
3749
+ 1 user 2 assistant (read) 3 tool result, 30k tokens 4 assistant 5 user 6 assistant
3750
+ keepRecentTokens 20k: the walk from 6 reaches 20k at 3; the first candidate at or after 3 is 4.
3751
+ The summary covers 1-3; the model context becomes [summary, 4, 5, 6].
3752
+ ```
3753
+
3754
+ Phases:
3755
+
3756
+ - `select` takes the phase's agent resolution, resolves the settings once, and
3757
+ captures the committed context. When the agent has no model or
3758
+ `models.getModel()` does not know it,
3759
+ the task fails with `no_model`. With nothing to compact it completes with
3760
+ `{}`. Otherwise the `beforeCompact` hooks (section 7.2) run with the
3761
+ summarized entries and messages; the first decision wins. `{ decline: true }` completes
3762
+ with `{}`, and `{ summary }` is placed as below in the same commit. Without a
3763
+ decision, one commit moves to `summarize` with attempt 1, the agent's model
3764
+ and thinking level, the settings' stream options, `maxTokens` = `min(floor(0.8 *
3765
+ reserveTokens), model.maxTokens)` (the model's value only when positive), the
3766
+ captured tail, and the cut as `firstKept`.
3767
+ - `summarize` derives the summarized messages again from the context at `tail`,
3768
+ which is immutable, and serializes them to text: `[User]: ...`,
3769
+ `[Assistant thinking]: ...`, `[Assistant]: ...`,
3770
+ `[Assistant tool calls]: name(key=json, ...)`, and `[Tool result]: ...`
3771
+ truncated to 2000 characters; system messages are omitted. The request has no
3772
+ tools and two messages: a system message with the built-in summarization
3773
+ system prompt, and a user message with the serialized text in
3774
+ `<conversation>` tags followed by the built-in summarization prompt, and
3775
+ `Additional focus: <instructions>` when the input has instructions. It uses the
3776
+ pinned thinking level as `reasoning`, the conversation's persisted provider
3777
+ `sessionId`, and the pinned stream options without `deferred`, with
3778
+ `cacheRetention: "none"` and the pinned `maxTokens`. Providers such as Codex
3779
+ may suppress that identity when caching is disabled. Recovery resends the same
3780
+ request. The response is classified in one commit
3781
+ that adds its usage to `pi.usage` (section 8.6):
3782
+ - `stop` with non-empty text and no tool call: the text is the summary, placed
3783
+ as below.
3784
+ - `error` that `isRetryableAssistantError()` accepts while the settings'
3785
+ retry policy, read at classification, allows another attempt: move to `retry` with `until = now +
3786
+ retryDelayMs(policy, attempt)`, and set the status's `attempt` and `retry`.
3787
+ - anything else, including a `length` stop, which leaves the summary
3788
+ incomplete: fail with `model_error`.
3789
+ - `retry` sleeps until `until`, then returns to `summarize` with the next attempt
3790
+ and the same pinned request.
3791
+
3792
+ The built-in prompts are the coding agent's structured checkpoint format. The
3793
+ prompt also asks the model to carry forward an earlier summary at the start of
3794
+ the conversation.
3795
+
3796
+ **Placement.** The summary entry is
3797
+
3798
+ ```ts
3799
+ {
3800
+ kind: "pi.compaction",
3801
+ head: firstKept,
3802
+ model: [{ role: "user", content: [{ type: "text", text: wrapped }], timestamp: now }],
3803
+ data: { reason },
3804
+ }
3805
+ ```
3806
+
3807
+ where `wrapped` is `"The conversation history before this point was compacted
3808
+ into the following summary:\n\n<summary>\n"`, the summary, and
3809
+ `"\n</summary>"`. The commit that places it also removes the task's status and
3810
+ commits the task's `completed` outcome:
3811
+
3812
+ - A blocking compaction appends the entry directly and completes with
3813
+ `{ entryId }`. Its generation holds the run and waits, so nothing else writes
3814
+ the conversation.
3815
+ - A conversation-owned compaction admits the entry as a write submission with
3816
+ request ID `compaction:<taskId>`, following section 6 exactly: in an idle
3817
+ conversation with an empty inbox it is appended at once or settles `stale`;
3818
+ in an idle conversation with queued items it queues and a final boundary runs;
3819
+ in a busy one it queues for the next boundary. It completes with
3820
+ `{ submissionId }` and never waits for a queued placement;
3821
+ `Harness.submission()` observes the placement.
3822
+
3823
+ The next generation's preparation finds a head marker without a later
3824
+ `pi.system` entry and writes a complete system baseline (section 7.4).
3825
+
3826
+ **Cancellation.** The abort handler removes the task's status and ends
3827
+ `aborted`; it writes no entry. An attempt cut short by abort or a crash has no
3828
+ committed response, so its usage is not counted. A manual compaction is
3829
+ ordinary work of its conversation: `Conversation.abort()` aborts it. A
3830
+ background compaction survives it and stops only through `abortTask()` or
3831
+ `Conversation.abort(context, { background: true })`. A blocking compaction is
3832
+ aborted with its generation, before the generation's abort handler runs
3833
+ (section 5.5). A summary that is already queued is a write submission:
3834
+ conversation abort keeps it, and `Submission.abort()` withdraws it.
3835
+
3836
+ When a blocking compaction ends without an `entryId`, because it found nothing
3837
+ to compact, was declined, failed, faulted, or was aborted directly, a threshold
3838
+ generation sends its request anyway and an overflow generation fails with
3839
+ `model_error` (section 8.3).
3840
+
3841
+
3842
+ ## 9. Document observation and Chord
3843
+
3844
+ ### 9.1 Document state
3845
+
3846
+ Chord's existing replicated-state layer exposes this source-adoption contract:
3847
+
3848
+ ```ts
3849
+ interface ReplicatedStateSourceFrame<T> {
3850
+ readonly cursor: number;
3851
+ readonly value: T;
3852
+ readonly ops: readonly Op[];
3853
+ readonly context: Context;
3854
+ }
3855
+
3856
+ interface ReplicatedStateSourceAttachment<T> {
3857
+ /** Fixed immutable snapshot captured at the atomic attachment boundary. */
3858
+ readonly snapshot: { readonly value: T; readonly cursor: number };
3859
+ /** Install the sole listener and synchronously drain every buffered frame. */
3860
+ activate(listener: (frame: ReplicatedStateSourceFrame<T>) => void): void;
3861
+ dispose(): void;
3862
+ }
3863
+
3864
+ interface ReplicatedStateSource<T> {
3865
+ /** Atomically capture a snapshot and begin buffering every later commit. */
3866
+ attach(): ReplicatedStateSourceAttachment<T>;
3867
+ }
3868
+
3869
+ interface ReplicatedStateSourceOptions {
3870
+ readonly onError?: (error: Error) => void;
3871
+ }
3872
+
3873
+ interface AttachedReplicatedState<T> extends ReplicatedState<T> {
3874
+ readonly value: T;
3875
+ dispose(): void;
3876
+ }
3877
+
3878
+ function replicatedState<T>(
3879
+ source: ReplicatedStateSource<T>,
3880
+ options?: ReplicatedStateSourceOptions,
3881
+ ): AttachedReplicatedState<T>;
3882
+
3883
+ type DocumentState<T extends JsonObject> =
3884
+ AttachedReplicatedState<Readonly<T> | null>;
3885
+
3886
+ type WatchEnd =
3887
+ | { readonly reason: "stopped" | "cancelled" | "session_closed" | "retired" }
3888
+ | { readonly reason: "listener_error"; readonly error: Error };
3889
+
3890
+ interface WatchHandle<T> {
3891
+ /** Acquisition revision before start; latest delivered immutable revision afterward. */
3892
+ readonly value: T;
3893
+ /** Installs the sole serialized asynchronous listener. */
3894
+ start(listener: (value: T, ops: readonly Op[], context: Context) => Promise<void>): void;
3895
+ /** Idempotently stops future callbacks and returns this watch's terminal result. */
3896
+ stop(): Promise<WatchEnd>;
3897
+ /** Settles when the watch terminates; an already-running callback remains caller-owned. */
3898
+ readonly closed: Promise<WatchEnd>;
3899
+ }
3900
+
3901
+ type DocumentWatch<T extends JsonObject> = WatchHandle<Readonly<T> | null>;
3902
+
3903
+ interface DocumentObserver {
3904
+ watchDoc<T extends JsonObject>(token: SessionDocToken<T>, context: Context): Promise<DocumentWatch<T> | undefined>;
3905
+ watchDoc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId, context: Context): Promise<DocumentWatch<T> | undefined>;
3906
+ watchDoc<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId, context: Context): Promise<DocumentWatch<T> | undefined>;
3907
+ watchDoc<T extends JsonObject, I extends JsonValue>(token: SessionDocFamilyToken<T, I>, key: string, context: Context): Promise<DocumentWatch<T> | undefined>;
3908
+ watchDoc<T extends JsonObject, I extends JsonValue>(token: ConversationDocFamilyToken<T, I>, conversationId: ConversationId, key: string, context: Context): Promise<DocumentWatch<T> | undefined>;
3909
+ watchDoc<T extends JsonObject, I extends JsonValue>(token: TaskDocFamilyToken<T, I>, taskId: TaskId, key: string, context: Context): Promise<DocumentWatch<T> | undefined>;
3910
+ }
3911
+ ```
3912
+
3913
+ `documentState()` attaches Pico's committed document stream to Chord before
3914
+ returning. The returned state is already hydrated with the exact shareable
3915
+ immutable revision, has no mutation methods, and publishes every later exact
3916
+ committed revision and operation batch without another tracker, value copy, or
3917
+ re-diff. Disposing it unregisters only that observation; Pico remains the sole
3918
+ document mutator.
3919
+
3920
+ State and watch acquisition never create; absent lookup returns `undefined`.
3921
+ Successful acquisition binds one concrete incarnation. Retirement publishes a
3922
+ JSON `null` root replacement and ends that incarnation's stream. If the state
3923
+ remains exposed, consumers see `null`, never stale state. A later recreation
3924
+ requires acquiring a new state or watch.
3925
+
3926
+ Session close ends every state at its seal: the state keeps its last value and
3927
+ receives no frame from a commit that settles during close.
3928
+
3929
+ Each document state assigns its own in-memory contiguous Chord delivery sequence.
3930
+ Pico does not persist or expose that sequence through `WatchHandle`. States and watches retain immutable revisions independently of the loaded tracker
3931
+ cache. Reopen creates a new state lifetime and hydration.
3932
+
3933
+ Internally, state attachment uses Chord's synchronous atomic snapshot-and-register
3934
+ boundary: the snapshot includes every commit before attachment, and every later
3935
+ exact frame is buffered until activation. This prevents a snapshot from being
3936
+ paired with operations based on a newer unseen revision.
3937
+
3938
+ ### 9.2 `watchDoc`
3939
+
3940
+ Tasks, hooks, and tools may observe any existing document for which their code
3941
+ has a token and owner/key. There is no additional subtree permission system inside trusted
3942
+ Session code.
3943
+
3944
+ On the Session line, `watchDoc()` resolves one existing concrete incarnation,
3945
+ captures its current immutable tracker revision in O(1), and registers for every
3946
+ later exact committed revision and operation batch. It returns `undefined` when
3947
+ absent. Before `start()`, `watch.value` remains the acquisition revision while
3948
+ later frames buffer.
3949
+
3950
+ ```ts
3951
+ const watch = await api.watchDoc(JobOutputDoc, producerTaskId, context);
3952
+ if (watch === undefined) return;
3953
+ try {
3954
+ await initializeConsumer(watch.value, context);
3955
+ watch.start(async (value, ops, deliveryContext) => {
3956
+ await consume(value, ops, deliveryContext);
3957
+ });
3958
+ } catch (error) {
3959
+ await watch.stop();
3960
+ throw error;
3961
+ }
3962
+ ```
3963
+
3964
+ `start()` installs the sole listener and schedules delivery; it never invokes
3965
+ user code inline. Each callback receives the exact immutable committed value and
3966
+ the exact operation batch that produced it. Immediately before invocation,
3967
+ `watch.value` advances to that value. The watch awaits the listener before
3968
+ starting its next callback, but Session commits never wait for callback
3969
+ settlement.
3970
+
3971
+ A watch retains at most 100 pending committed frames, excluding the frame already
3972
+ being delivered. Adding frame 101 replaces the complete undelivered suffix with
3973
+ one self-contained root replacement `[["r", newestValue]]`, using the newest
3974
+ exact immutable revision and that commit's Context. Later exact frames follow
3975
+ that replacement normally. Another overflow replaces the pending suffix again.
3976
+ No serialized-byte measurement, value copy, operation replay, or re-diff occurs.
3977
+ This is convergent observation, not an audit stream; consumers requiring every
3978
+ transition must persist those facts separately.
3979
+
3980
+ `watch.value` and every previously returned revision remain stable forever under
3981
+ the trusted immutability contract. Consumers must not mutate them or retained
3982
+ descendants. The selected commit Context's values are preserved without inheriting the
3983
+ producer's cancellation. A callback owns its own asynchronous work.
3984
+
3985
+ A watch remains bound to its original incarnation. Retirement queues the exact
3986
+ terminal `[["r", null]]` frame, or folds it into an overflow replacement with
3987
+ value `null`. After that callback settles, the watch closes as `retired` and never
3988
+ follows a replacement incarnation.
3989
+
3990
+ `stop()` is idempotent. It synchronously unregisters, discards pending frames,
3991
+ prevents another callback from starting, and returns the common terminal promise.
3992
+ An already-running callback is not aborted or joined and remains caller-owned.
3993
+ The acquisition `Context` governs watch lifetime. Cancellation during acquisition
3994
+ cleans up before rejecting; later cancellation and Session close stop future
3995
+ delivery similarly. Listener failure discards pending work and closes only that
3996
+ watch as `listener_error`. The first termination reason wins, and every late
3997
+ rejection is observed.
3998
+
3999
+ ### 9.3 Conversation view
4000
+
4001
+ The public view is a fixed structural mount of the conversation's active
4002
+ transcript and its built-in documents:
4003
+
4004
+ ```ts
4005
+ type ConversationView = {
4006
+ readonly conversation: ConversationRecord;
4007
+ /** Raw active entries, as `ContextView.entries` (section 2.1): the head marker, then the non-head entries from its head. */
4008
+ readonly entries: readonly EntryRecord[];
4009
+ /** `pi.agent`, `pi.live`, `pi.inbox`, `pi.provider`, and `pi.usage`, keyed by kind; absent documents are absent. */
4010
+ readonly docs: Readonly<Record<string, JsonObject>>;
4011
+ };
4012
+ ```
4013
+
4014
+ These kinds and their fields are public protocol. Third-party documents are
4015
+ exposed through their own Chord services, not mounted.
4016
+
4017
+ The Harness keeps at most one mount per conversation. The first `viewState()`,
4018
+ `watch()`, or event attachment builds its revision on the Session line from the
4019
+ committed active transcript and documents; the mount is dropped when its last
4020
+ observer detaches. A mount derives each next revision from the Session's
4021
+ `subscribeCommits()` publications, like `watchDoc()`: every publication is
4022
+ already durable, so the view shows only committed state. One publication that
4023
+ touches the conversation yields one Chord batch:
4024
+
4025
+ ```text
4026
+ document op ["s", ["generation", "message"], value]
4027
+ -> view op ["s", ["docs", "pi.live", "generation", "message"], value]
4028
+ ```
4029
+
4030
+ An appended entry without a head is a splice at the end of `entries`. An
4031
+ appended head marker `H` makes the entries `H` followed by the current non-head
4032
+ entries at or after `H.head`: the kept entries stay, and splices remove the
4033
+ others and insert `H` at the front. Every kept entry is already mounted because
4034
+ Harness writers never target a head before the current range: resets and
4035
+ handoffs head themselves, compaction summaries target a kept entry of the
4036
+ current range, and head writes that reach further back are stale (section 6). A raw head write that targets further back shows only the
4037
+ mounted entries (section 12).
4038
+ Document creation and retirement set and delete the `docs` key. Parent entries
4039
+ through `parent.at` are immutable, so a child's mount follows only its own
4040
+ conversation. A publication that touches nothing mounted creates no revision; a
4041
+ redundant nonempty batch remains a real publication. Mounted document revisions
4042
+ are structurally shared under the trusted immutability contract. The mount
4043
+ performs no semantic projection and owns no persistence.
4044
+
4045
+ `Conversation.viewState()` exposes the mount as a disposable read-only Chord
4046
+ state. `Conversation.watch()` exposes the same mount through the serialized
4047
+ exact-frame watch of section 9.2, with bounded pending frames and full-value
4048
+ overflow replacements.
4049
+
4050
+ ### 9.4 Agent events (experimental)
4051
+
4052
+ `watchEvents(harness, conversationId, context): Promise<AgentEventStream>`,
4053
+ exported from the package root, is an experimental adapter that
4054
+ translates one conversation's committed publications into agent events shaped
4055
+ like the coding agent's `AgentSessionEvent`s. It owns no tracker or persistence,
4056
+ covers exactly one conversation (not its owned subtree), and emits an event only
4057
+ after the commit that makes it true. Its protocol may change without notice.
4058
+
4059
+ ```ts
4060
+ type AgentEvent =
4061
+ | {
4062
+ type: "snapshot";
4063
+ entries: readonly EntryRecord[];
4064
+ run?: { inputs: readonly SubmissionId[] };
4065
+ /** Current generation attempt: its in-flight partial, retry backoff, or deferred poll. */
4066
+ generation?: { attempt: number; message?: AssistantMessage; retry?: { at: number; error: string }; deferred?: { pollAt: number } };
4067
+ tools: readonly ToolSlot[];
4068
+ /** `pi.live.compactions` (section 8.2). */
4069
+ compactions: readonly CompactionStatus[];
4070
+ inbox: readonly { id: SubmissionId; mode: InboxItem["mode"] }[];
4071
+ /** `pi.agent`; `{}` when absent. */
4072
+ agent: AgentState;
4073
+ usage: UsageState;
4074
+ }
4075
+ | { type: "run_start"; inputs: readonly SubmissionId[] }
4076
+ | { type: "run_end"; inputs: readonly SubmissionId[] }
4077
+ | { type: "turn_start" }
4078
+ | { type: "turn_end" }
4079
+ | { type: "message_start"; message: Message }
4080
+ | { type: "message_update"; usage: Usage; changes: readonly MessageChange[] }
4081
+ | { type: "message_end"; entry: EntryRecord }
4082
+ | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: JsonObject }
4083
+ | {
4084
+ type: "tool_execution_update";
4085
+ toolCallId: string;
4086
+ toolName: string;
4087
+ /** A front trim and then an append of the retained window, or its replacement. */
4088
+ output?: { trimStart?: number; append?: string } | { set: string };
4089
+ details?: JsonValue;
4090
+ diagnostics?: readonly ToolDiagnostic[];
4091
+ }
4092
+ /** `entry` is absent when the tool task faulted or was orphaned. */
4093
+ | { type: "tool_execution_end"; toolCallId: string; toolName: string; entry?: EntryRecord }
4094
+ | { type: "inbox_update"; items: readonly { id: SubmissionId; mode: InboxItem["mode"] }[] }
4095
+ | { type: "submission"; record: SubmissionRecord }
4096
+ | { type: "auto_retry_start"; attempt: number; at: number; errorMessage: string }
4097
+ | { type: "auto_retry_end"; attempt: number }
4098
+ | { type: "deferred_poll"; pollAt: number }
4099
+ | { type: "entry_appended"; entry: EntryRecord }
4100
+ | { type: "agent_changed"; agent: AgentState }
4101
+ | { type: "usage_changed"; usage: UsageState }
4102
+ | { type: "task_failed"; taskId: TaskId; kind: string; message: string }
4103
+ | { type: "compaction_start"; taskId: TaskId; reason: CompactionReason; blocking: boolean }
4104
+ /** The task's receipt tells whether it produced a summary; the summary entry has its own events. */
4105
+ | { type: "compaction_end"; taskId: TaskId; reason: CompactionReason };
4106
+
4107
+ /** One change to the in-flight assistant message, relative to that message. */
4108
+ type MessageChange =
4109
+ | { type: "text_start" | "thinking_start" | "toolcall_start"; contentIndex: number; block: AssistantMessage["content"][number] }
4110
+ | { type: "text_delta" | "thinking_delta"; contentIndex: number; delta: string }
4111
+ | { type: "toolcall_delta"; contentIndex: number; path: readonly (string | number)[]; delta: string }
4112
+ | { type: "block"; contentIndex: number; block: AssistantMessage["content"][number] }
4113
+ | { type: "message"; message: AssistantMessage };
4114
+
4115
+ interface AgentEventStream {
4116
+ /** The `snapshot` event at attachment. */
4117
+ readonly snapshot: Extract<AgentEvent, { type: "snapshot" }>;
4118
+ start(listener: (events: readonly AgentEvent[], context: Context) => Promise<void>): void;
4119
+ stop(): Promise<WatchEnd>;
4120
+ readonly closed: Promise<WatchEnd>;
4121
+ }
4122
+ ```
4123
+
4124
+ Every event is relative to the `snapshot` and the events before it, never to
4125
+ Pico's document layout. `snapshot` replaces everything a consumer holds; every
4126
+ other event applies on top of it. Attachment captures the snapshot and
4127
+ registers for later publications atomically on the Session line.
4128
+
4129
+ Events derive from committed changes:
4130
+
4131
+ - `run_start`/`run_end`: `pi.live.run` appears, is removed, or is replaced by a
4132
+ successor run whose first input differs. Steers joining the current run and
4133
+ handovers between its tasks are not run events. The inputs' outcomes are
4134
+ `submission` events.
4135
+ - `turn_start`: a `pi.generation` task is created. `turn_end`: a generation's
4136
+ outcome is committed, when it holds `completing` or becomes terminal,
4137
+ whichever comes first, so a successor created at hold starts after it.
4138
+ - `message_start`: the first committed partial of an attempt, or, for a message
4139
+ entry without a committed partial, the entry itself; `message_end` follows for
4140
+ every entry with `model` messages. Generation commits a partial only once it has
4141
+ content, and every built-in path that clears one appends its entry, so each
4142
+ started message ends with its entry. Entries without messages are
4143
+ `entry_appended`.
4144
+ - `message_update`: the Chord operations on the committed partial, translated:
4145
+ an insertion into `content` starts blocks, an append to a block's `text` or
4146
+ `thinking` is a delta, an append to a string inside a tool call's `arguments`
4147
+ is a `toolcall_delta` with the path relative to `arguments`, any other change
4148
+ inside a block sends that `block`, which already holds the batch's later
4149
+ changes to it, and any other change to `content` sends the whole `message`.
4150
+ Each update carries the partial's current `usage`, as the coding agent's JSON
4151
+ mode does; a change of only `usage` sends no changes. Only deltas travel, so throttled
4152
+ partials cause no write amplification on the wire.
4153
+ - `tool_execution_start`: a slot becomes `running`; `args` come from the tool
4154
+ task's intent checkpoint in the same commit. A slot that becomes `done`
4155
+ without running (a call not offered, invalid arguments, a block, a fault
4156
+ before intent) gets only `tool_execution_end`. `tool_execution_update`: output
4157
+ appends, front trims, and replacements of the slot's retained window, and the
4158
+ slot's current `details` and `diagnostics` when they change; removed ones, as
4159
+ when a safe replay restarts the tool, send `null` and `[]`.
4160
+ `tool_execution_end`: the slot becomes `done`, with its `pi.tool-result`
4161
+ entry, which carries the diagnostics, or without one after a fault or
4162
+ orphan. An unfinished slot that disappears because its run ended ends with the
4163
+ result entry appended in the same commit, as for the unstarted calls of an
4164
+ aborted round, directly before its `message_start`, or without an entry.
4165
+ - `inbox_update`, `agent_changed`, `usage_changed`: the document changed; a
4166
+ retired one reads as its initial value, as in a snapshot. Registry installs
4167
+ and settings changes make no commit and emit nothing; a UI showing resolved
4168
+ tools or a resolved model resolves again through `Conversation.agent()`.
4169
+ - `auto_retry_start`/`auto_retry_end`, `deferred_poll`: `pi.live.generation`
4170
+ gains or drops `retry`, or gains `deferred` or moves its `pollAt`.
4171
+ - `task_failed`: a task of the conversation settles `faulted` or `orphaned`.
4172
+ - `compaction_start`/`compaction_end`: a status appears in or disappears from
4173
+ `pi.live.compactions`. A retry backoff shows only in the snapshot's
4174
+ `compactions`. A queued summary is placed later with its own
4175
+ `message_start`/`message_end` and `submission` events.
4176
+
4177
+ One commit produces one batch, in this order: `tool_execution_start`,
4178
+ `message_start` of a first partial, `message_update`, `tool_execution_update`,
4179
+ and retry/deferred events; then entries in append order with their
4180
+ `message_start`/`message_end` or `entry_appended`, where a tool result's
4181
+ `tool_execution_end` directly precedes its `message_start`, as in the coding
4182
+ agent; then the `tool_execution_end` of tools ending without an entry;
4183
+ then `compaction_end`, `task_failed`, `turn_end`, `run_end`; then `submission`
4184
+ events in ID order; then `inbox_update`, `agent_changed`, and `usage_changed`;
4185
+ and last `compaction_start`, `run_start`, and `turn_start`. A stream buffers at most 100 undelivered
4186
+ batches; adding another replaces every undelivered batch with one `snapshot` of
4187
+ the newest committed state. The stream therefore converges but does not promise
4188
+ every transition; `Submission.wait()` reports exact outcomes. A reconnecting
4189
+ consumer attaches again and starts from its `snapshot`; nothing is replayed.
4190
+ Transport backpressure and disconnect policy belong to the consumer.
4191
+
4192
+ Print mode awaits its own input `Submission` and prints its answer. A TUI
4193
+ renders `ConversationView`; events may drive transient animation.
4194
+
4195
+ ### 9.5 Task graph view
4196
+
4197
+ The task graph view shows every live task of the Session as one structural
4198
+ value, for UIs and debugging: what runs, what waits for what, and which
4199
+ conversations each task owns. It is to the Session's tasks what the
4200
+ conversation view (section 9.3) is to one conversation.
4201
+
4202
+ ```ts
4203
+ type TaskGraphState =
4204
+ | { readonly status: "pending" | "running"; readonly phase: string }
4205
+ | {
4206
+ readonly status: "waiting";
4207
+ readonly phase: string;
4208
+ readonly on: readonly TaskId[];
4209
+ readonly policy: JoinPolicy;
4210
+ }
4211
+ /** Outcome held until its ordinary owned work drains (section 5.5). */
4212
+ | { readonly status: "completing"; readonly outcome: TaskOutcome<JsonValue>["status"] };
4213
+
4214
+ type TaskGraphNode = {
4215
+ readonly id: TaskId;
4216
+ readonly kind: string;
4217
+ readonly conversationId: ConversationId;
4218
+ /** Owner task; absent for a conversation-owned task. */
4219
+ readonly owner?: TaskId;
4220
+ readonly background: boolean;
4221
+ readonly abortRequested: boolean;
4222
+ readonly state: TaskGraphState;
4223
+ /** Conversations this task owns, in ID order. */
4224
+ readonly conversations: readonly ConversationId[];
4225
+ };
4226
+
4227
+ type TaskGraph = {
4228
+ /** Every live task, keyed by its decimal ID. */
4229
+ readonly tasks: Readonly<Record<string, TaskGraphNode>>;
4230
+ };
4231
+
4232
+ type TaskGraphWatch = WatchHandle<TaskGraph>;
4233
+
4234
+ interface Harness {
4235
+ taskGraph(context: Context): Promise<AttachedReplicatedState<TaskGraph>>;
4236
+ watchTaskGraph(context: Context): Promise<TaskGraphWatch>;
4237
+ }
4238
+ ```
4239
+
4240
+ A node holds the committed task record without its input, checkpoint payload,
4241
+ outcome payload, and memos: `phase` is the checkpoint's phase, `on` and
4242
+ `policy` are the stored wait, and `outcome` is the held outcome's status.
4243
+ `running` is the durable status: open changes surviving `running` tasks to
4244
+ `pending` (section 5.4), so they show as `pending` until the scheduler reserves
4245
+ them again, while `waiting` and `completing` tasks keep their status. Whether a
4246
+ pending task is blocked, and which of the tasks in `on` are still live, depend
4247
+ on the registry and the rest of the graph; `inspect()` derives them. A node is
4248
+ present from the commit that creates its task until the commit that makes the
4249
+ task terminal. Owner edges are immutable, so a node's `conversations` only grow
4250
+ while it lives.
4251
+
4252
+ The graph holds live tasks only. A conversation whose owner task is terminal,
4253
+ such as a background subagent's conversation after its anchor finished, has no
4254
+ node that lists it; a later task there is a top-level node of that conversation.
4255
+ A UI that places it under its parent reads the conversation's owner edge from
4256
+ `ConversationRecord.owner`, which `ConversationView.conversation` also carries.
4257
+
4258
+ The Harness keeps at most one graph mount. The first `taskGraph()` or
4259
+ `watchTaskGraph()` builds its revision on the Session line from the committed
4260
+ live tasks and the conversations they own, one owner scan per live task, and
4261
+ holds the line while it does; the mount is dropped when its last observer
4262
+ detaches. It advances from `subscribeCommits()` publications like the
4263
+ conversation view: one publication that changes a node yields one Chord batch
4264
+ that sets or deletes `tasks[id]`, or sets a node's `conversations`. A
4265
+ publication that changes no node creates no revision. Task and conversation
4266
+ records are immutable values; a node shares nothing with them.
4267
+
4268
+ `taskGraph()` returns the mount as a disposable read-only Chord state.
4269
+ `watchTaskGraph()` returns it through the serialized exact-frame watch of
4270
+ section 9.2. Both read only: neither enables scheduling. Close ends them at its
4271
+ seal like every other state and watch (section 2.2).
4272
+
4273
+ ```ts
4274
+ const graph = await harness.taskGraph(context);
4275
+ graph.subscribe((value) => {
4276
+ for (const node of Object.values(value.tasks)) {
4277
+ const where = node.owner === undefined ? `conversation ${node.conversationId}` : `task ${node.owner}`;
4278
+ render(`${node.id} ${node.kind} ${node.state.status} under ${where}`);
4279
+ }
4280
+ });
4281
+ ```
4282
+
4283
+ ## 10. Storage contract
4284
+
4285
+ Ordered scans are cursor-based. Exact identity lookups are keyed.
4286
+
4287
+ ```ts
4288
+ type Page<T, C> = {
4289
+ readonly items: readonly T[];
4290
+ readonly next?: C;
4291
+ };
4292
+
4293
+ type Cursor = Readonly<Record<string, JsonValue>>;
4294
+
4295
+ type ConversationQuery = {
4296
+ readonly ownerConversationId?: ConversationId;
4297
+ readonly ownerTaskId?: TaskId;
4298
+ };
4299
+
4300
+ type EntryQuery = {
4301
+ readonly conversationId: ConversationId;
4302
+ readonly minEntryId?: EntryId; // inclusive
4303
+ readonly maxEntryId?: EntryId; // inclusive
4304
+ };
4305
+
4306
+ type TaskQuery = {
4307
+ readonly conversationId?: ConversationId;
4308
+ readonly kind?: string;
4309
+ readonly status?: "pending" | "running" | "waiting" | "completing" | "terminal";
4310
+ readonly abortRequested?: boolean;
4311
+ readonly background?: boolean;
4312
+ };
4313
+
4314
+ type SubmissionQuery = {
4315
+ readonly conversationId?: ConversationId;
4316
+ readonly status?: SubmissionRecord["status"];
4317
+ };
4318
+
4319
+ type DocumentPoint = Seq | "current";
4320
+
4321
+ type DocumentAddress = {
4322
+ readonly kind: string;
4323
+ readonly scope: DocumentRecord["scope"];
4324
+ readonly key?: string;
4325
+ };
4326
+
4327
+ type DocumentQuery = {
4328
+ readonly scope: DocumentRecord["scope"];
4329
+ readonly at: DocumentPoint;
4330
+ readonly kind?: string;
4331
+ };
4332
+
4333
+ type DocumentContent =
4334
+ | { readonly version: number; readonly kind: "base"; readonly value: JsonObject }
4335
+ | { readonly version: number; readonly kind: "delta"; readonly ops: readonly Op[] };
4336
+
4337
+ type StoredDocument = {
4338
+ readonly record: DocumentRecord;
4339
+ readonly version: number;
4340
+ readonly value: JsonObject;
4341
+ /** Deltas replayed after the selected base to materialize `value`. */
4342
+ readonly deltasSinceBase: number;
4343
+ };
4344
+
4345
+ type StorageWrite =
4346
+ | { readonly type: "conversation"; readonly value: ConversationRecord }
4347
+ | { readonly type: "entry"; readonly value: EntryRecord }
4348
+ | { readonly type: "task"; readonly value: TaskRecord<JsonValue, JsonValue, JsonValue> }
4349
+ | { readonly type: "submission"; readonly value: SubmissionRecord }
4350
+ | {
4351
+ readonly type: "document.create";
4352
+ readonly record: DocumentCreate;
4353
+ readonly content: Extract<DocumentContent, { kind: "base" }>;
4354
+ }
4355
+ | {
4356
+ readonly type: "document.copy";
4357
+ readonly record: DocumentCreate;
4358
+ readonly source: { readonly id: DocumentId; readonly at: DocumentPoint };
4359
+ }
4360
+ | {
4361
+ readonly type: "document.change";
4362
+ readonly id: DocumentId;
4363
+ readonly content: DocumentContent;
4364
+ }
4365
+ | { readonly type: "document.retire"; readonly id: DocumentId };
4366
+
4367
+ /**
4368
+ * Trusts the owning Session to supply semantically valid records, references,
4369
+ * ancestry, and transitions. Enforces atomicity, global ID ownership, immutable
4370
+ * conversation/entry creation, document record consistency, and detached
4371
+ * values; Session serializes commits. Sequences strictly increase but may have
4372
+ * gaps. Once commit() resolves, later reads through that Storage observe it.
4373
+ */
4374
+ interface Storage {
4375
+ commit(writes: readonly StorageWrite[], context: Context): Promise<Seq>;
4376
+ /** Allocate from the one global numeric namespace; the generic brand is compile-time only. */
4377
+ mintId<I extends Id<string>>(): Promise<I>;
4378
+
4379
+ conversation(id: ConversationId, context: Context): Promise<ConversationRecord | undefined>;
4380
+ scanConversations(query: ConversationQuery, limit: number, cursor: Cursor | undefined, context: Context): Promise<Page<ConversationRecord, Cursor>>;
4381
+
4382
+ entry(id: EntryId, context: Context): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined>;
4383
+ entry(conversationId: ConversationId, id: EntryId, context: Context): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined>;
4384
+ findLatestHeadMarker(conversationId: ConversationId, atOrBeforeEntryId: EntryId | undefined, context: Context): Promise<(EntryRecord & { readonly head: EntryId }) | undefined>;
4385
+ scanEntries(query: EntryQuery, limit: number, cursor: Cursor | undefined, context: Context): Promise<Page<EntryRecord, Cursor>>;
4386
+
4387
+ task(id: TaskId, context: Context): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
4388
+ scanTasks(query: TaskQuery, limit: number, cursor: Cursor | undefined, context: Context): Promise<Page<TaskRecord<JsonValue, JsonValue, JsonValue>, Cursor>>;
4389
+
4390
+ submission(id: SubmissionId, context: Context): Promise<SubmissionRecord | undefined>;
4391
+ scanSubmissions(query: SubmissionQuery, limit: number, cursor: Cursor | undefined, context: Context): Promise<Page<SubmissionRecord, Cursor>>;
4392
+ submissionByRequest(conversationId: ConversationId, requestId: string, context: Context): Promise<SubmissionRecord | undefined>;
4393
+
4394
+ findDocument(address: DocumentAddress, at: DocumentPoint, context: Context): Promise<DocumentRecord | undefined>;
4395
+ document(id: DocumentId, at: DocumentPoint, context: Context): Promise<StoredDocument | undefined>;
4396
+ scanDocuments(query: DocumentQuery, limit: number, cursor: Cursor | undefined, context: Context): Promise<Page<DocumentRecord, Cursor>>;
4397
+
4398
+ close(context: Context): Promise<void>;
4399
+ }
4400
+ ```
4401
+
4402
+ `StorageRejected` means a batch was rejected before any durable effect and is
4403
+ guaranteed not to have committed. Session rolls such a batch back normally;
4404
+ unknown failures after Storage admission remain fatal because their commit state
4405
+ is uncertain. Backends use `StorageRejected` for deterministic `document.copy`
4406
+ source, replay, and consistency failures only when rollback is guaranteed.
4407
+
4408
+ A `document.copy` reads committed pre-batch source state independent of command
4409
+ order. The source must be an alive conversation document at the selected point,
4410
+ and kind/key/history/fork must match the child create record. Storage persists
4411
+ one independent complete child base at the source's stored version. A batch may
4412
+ not create, change, or retire a selected source. Later source changes,
4413
+ reclamation, retirement, or backend reopen cannot affect the child.
4414
+
4415
+ Cursors are backend-owned JSON objects. Callers only round-trip them to the same
4416
+ scan on the same storage; cross-storage or cross-query use is unsupported. The
4417
+ Session owns the mutation line, so storage implementations do not add a second
4418
+ caller-facing commit mutex. Each backend still makes one admitted batch atomic.
4419
+
4420
+ `findLatestHeadMarker()` returns the newest visible entry carrying `head` at or
4421
+ below its optional inclusive cutoff. The returned entry is the marker; its
4422
+ `head` value is the actual lower bound for context. `scanEntries()` pages the
4423
+ inclusive ID range in newest-first order while applying every conversation
4424
+ ancestry cap. With no bounds it pages complete visible history. To read context
4425
+ through entry `E`, find the marker at or before `E`, then scan from
4426
+ `marker?.head` through `E`. For current context the upper bound is omitted.
4427
+ Conversation owner filters are indexed and conjunctive. They support ownership
4428
+ traversal without an all-conversation scan; application-maintained registries
4429
+ are not a substitute for these kernel indexes.
4430
+
4431
+ `entry(id)` combines exact global lookup with the commit sequence required by
4432
+ historical document reads. `entry(conversationId, id)` returns that pair only
4433
+ when the entry is visible through the requested conversation's ancestry. `limit`
4434
+ is always the maximum page size.
4435
+ `findDocument()` resolves one exact logical kind/scope/key address at current or
4436
+ historical membership. A missing key means the singleton, not every family
4437
+ member. `scanDocuments()` enumerates only the incarnations alive in one exact
4438
+ scope at its selected point and may restrict one family/singleton kind. It uses
4439
+ ascending incarnation IDs. There is no ordinary open-time all-document scan.
4440
+ Task queries support conversation, kind, live/terminal status, abort mark, and
4441
+ background status.
4442
+
4443
+ `document(id, at)` materializes one specific incarnation and never follows a
4444
+ replacement at the same logical address. Callers resolve an address with
4445
+ `findDocument()` when they do not already hold an incarnation ID. It selects the
4446
+ newest applicable base, applies its ordered Chord delta tail, and returns the detached materialized value, stored
4447
+ definition version, and number of replayed deltas after that base. Base/delta records are backend-private. The lookup never
4448
+ scans unrelated documents. An unknown ID returns `undefined`. At `"current"`, a
4449
+ retired incarnation returns `undefined`. A numeric lookup of a rewindable
4450
+ conversation incarnation returns `undefined` outside its half-open lifetime and
4451
+ reconstructs the selected value inside it. A numeric lookup of a known current-
4452
+ only incarnation rejects rather than depending on reclaimed content. Metadata
4453
+ membership remains queryable historically. A missing required base, a version
4454
+ change inside a delta tail, or an operation that cannot be applied inside an
4455
+ addressable lifetime is storage corruption, not absence.
4456
+
4457
+ One normalized batch contains at most one create/change content command per
4458
+ incarnation and may also retire that incarnation. Storage applies content before
4459
+ retirement independent of write-array order. Create plus retire stamps both
4460
+ lifetime bounds with the batch sequence. Retire plus create at one logical
4461
+ address makes the new incarnation current at that sequence. Deltas cannot cross
4462
+ a stored version boundary; a version transition must be a base.
4463
+
4464
+ The semantic conformance suite covers memory, SQLite, and JSONL.
4465
+
4466
+ ## 11. Backends
4467
+
4468
+ ### 11.1 Memory
4469
+
4470
+ Memory storage is the reference semantics. It copies retained write values and
4471
+ all read results. This deliberately simulates the ownership boundary naturally
4472
+ created by SQLite encoding/decoding and JSONL serialization; it is not defensive
4473
+ validation. It preserves rewindable records and reclaims latest records only
4474
+ after a committed base or retirement.
4475
+
4476
+ ### 11.2 SQLite
4477
+
4478
+ One SQL transaction is one Session commit. SQLite stores:
4479
+
4480
+ - conversation, entry, task, and submission records;
4481
+ - document records;
4482
+ - indexed document bases/deltas by document and commit sequence.
4483
+
4484
+ Live task transitions replace one row. Terminal tasks remain as small records.
4485
+ Document reads use indexed base-plus-tail ranges. The first implementation stores
4486
+ Chord records directly; it does not translate generic operations to SQLite JSON
4487
+ functions.
4488
+
4489
+ Schema shape, WAL checkpoint cadence, and synchronous defaults are backend
4490
+ implementation choices validated by conformance, reopen, query-plan, and storage
4491
+ benchmarks.
4492
+
4493
+ ### 11.3 JSONL
4494
+
4495
+ JSONL depends on the portable `FileSystem` capability, not the broader
4496
+ `ExecutionEnv`. It uses reclaimable sidecars without exposing them to the
4497
+ harness. Persistence alone does not provide the ownership boundary: any decoded
4498
+ indexes, materialized values, or caches retained in memory must be detached from
4499
+ commit arguments and must not be exposed directly by reads. A JSONL backend
4500
+ cannot simply add file appends around aliasing memory tables.
4501
+
4502
+ ```text
4503
+ main.jsonl table writes, document records, and one marker per commit
4504
+ doc-<id>.jsonl one document incarnation
4505
+ task-<id>.jsonl live task replacements
4506
+ ```
4507
+
4508
+ Publication protocol:
4509
+
4510
+ 1. Append complete prepared records to every affected sidecar.
4511
+ 2. Append one complete main marker listing those records.
4512
+ 3. Publish in memory only after the marker write succeeds.
4513
+
4514
+ Every commit uses this protocol; there is no standalone-sidecar fast path.
4515
+ JSONL creation accepts an `fsync` option that defaults to `false`. Without
4516
+ `fsync`, it guarantees ordinary process-crash consistency, not survival of
4517
+ power, host, kernel, or filesystem failure. With `fsync: true`, the backend
4518
+ appends all affected sidecar records, flushes each affected sidecar, and only
4519
+ then appends the main marker. Ordinary publication does not explicitly flush
4520
+ `main.jsonl`; an acknowledged tail commit may therefore still disappear, but a
4521
+ marker that survives should not overtake its sidecar data. A main-only commit has
4522
+ no sidecars to flush. Before destructive reclamation with `fsync: true`, the
4523
+ backend flushes `main.jsonl` once so the authorizing marker cannot disappear
4524
+ while its replacement or removal survives. If that flush fails, the committed
4525
+ state remains published and reclamation is deferred. A non-empty temporary
4526
+ replacement is also flushed before rename.
4527
+
4528
+ Recovery:
4529
+
4530
+ - Remove torn final lines.
4531
+ - Ignore and remove unconfirmed sidecar tails.
4532
+ - Apply confirmed records only.
4533
+ - Missing required confirmed data is corruption and opening fails.
4534
+ - A later committed latest base or retirement may prove an earlier physical
4535
+ record unnecessary.
4536
+ - Any uncertain append failure poisons the open backend.
4537
+
4538
+ Reclamation starts only after the authorizing base/retirement commits. When no
4539
+ sidecar records remain, it removes the sidecar directly. Otherwise, it writes a
4540
+ temporary replacement, renames it, and invalidates cached file descriptors so
4541
+ future appends cannot target an unlinked inode. Flushing `main.jsonl` to
4542
+ authorize reclamation does not compact it. `main.jsonl` is not compacted in the
4543
+ initial implementation.
4544
+
4545
+ ## 12. API footguns
4546
+
4547
+ These are contracts, not invitations to add defensive machinery:
4548
+
4549
+ - **Detached draft work:** draft reads, draft writes, and all `Tx` operations
4550
+ reject after the Session callback settles. Fire-and-forget work that runs
4551
+ before callback settlement can still mutate the active transaction and is
4552
+ unsupported.
4553
+ - **Read after write:** read every required table row before the first table
4554
+ write. Document drafts remain usable afterward; table reads do not.
4555
+ - **Writing to a busy conversation:** only the Harness appends to a conversation
4556
+ with an active run. Raw entries appended by `Harness.commit()` or a custom
4557
+ task while a generation prepares its request can misplace its system prompt entries;
4558
+ use a write submission.
4559
+ - **Queued items after a failed run:** failure and task abort leave the inbox alone.
4560
+ Queued follow-ups wait in `pi.inbox`, and their `wait()` does not settle, until
4561
+ the next submission's boundary places them or the host withdraws them. A
4562
+ compaction summary is such a submission: when a manual or background
4563
+ compaction finishes in that idle conversation, its final boundary places the
4564
+ waiting follow-ups and starts a run.
4565
+ - **Compaction spend:** every manual and background compaction pays for one
4566
+ summarization request per attempt, also when its summary ends stale because a
4567
+ later compaction cut further.
4568
+ - **Heads and edits during compaction:** a summary reflects the context when its
4569
+ compaction selected the range. An application edit placed while it
4570
+ summarizes, such as a write replacing entry 20, is lost when the summary cuts
4571
+ past its target: the summary still describes the old entry 20, and the edit's
4572
+ target leaves the range. An application head placed meanwhile, for example
4573
+ one cutting at 85 to forget entries 10-84, is undone by a summary cutting at 90:
4574
+ that summary still describes 10-84. Place such writes before compacting.
4575
+ - **Summary timestamps:** a queued summary's user message carries the time its
4576
+ compaction finished, not its placement. Code that judges usage staleness by
4577
+ message timestamps, such as pi-ai `estimateContextTokens()` over view
4578
+ messages, can misjudge; the Harness estimate uses entry order (section 8.3).
4579
+ - **Raw head writes into the past:** a head written directly with `tx.appendEntry()`
4580
+ that targets an entry before the conversation's active range changes model
4581
+ context, but a mounted view keeps only the entries it already holds until the
4582
+ mount is rebuilt. Use a write submission, whose stale check rejects it.
4583
+ - **Double-counted subagent spend:** a tool that runs an owned conversation must
4584
+ not report that conversation's usage in its result; the child's `pi.usage`
4585
+ already counts it, and subtree sums would count it twice.
4586
+ - **Owned work holds its owner:** a task that owns live ordinary work stays
4587
+ `completing` until that work ends (section 5.5). Foreground work an extension
4588
+ starts in a subagent's conversation holds the calling tool, and with it the
4589
+ run; interrogating a subagent while its tool is completing extends the hold.
4590
+ Esc or a host timeout ends it; work that should not hold its owner is created
4591
+ conversation-owned and `background`.
4592
+ - **Work created by hooks:** hooks run inside the asking task's invocation, so
4593
+ work they create owned by that task holds it. Work that must delay a task's
4594
+ finish has to be created before the task commits its outcome; a listener
4595
+ reacting to published events afterwards cannot extend it. A run task's owned
4596
+ work must not write the transcript after the run was released.
4597
+ - **Compensation in abort handlers:** an abort handler cannot create owned
4598
+ children, because its task is abort-marked. Compensate at the level
4599
+ that did the effect, in that task's own abort handler, or inline, or through
4600
+ background conversation-owned tasks awaited with `waitForTask()`; a
4601
+ non-background one would be aborted by any cascade that reaches its
4602
+ conversation.
4603
+ - **Hook memo names:** hooks share the asking task's memo namespace with the
4604
+ task and other hooks. Prefix memo names.
4605
+ - **Long transactions:** an async commit callback holds the Session mutation
4606
+ line. Never await models, tools, processes, network calls, humans, a nested
4607
+ Session commit, or a Session waiter inside it. Use methods on the current `Tx`.
4608
+ - **Explicit creation:** only typed `tx.doc()` creates an absent document. Snapshot,
4609
+ state, and watch lookup return `undefined` instead.
4610
+ - **Family initialization:** the first acquisition of an absent family address
4611
+ selects its seed. Existing instances and later calls ignore seeds; a seed is
4612
+ neither identity nor an update.
4613
+ - **Checkpoint starvation:** if `checkpointWhen()` never returns true, replay
4614
+ and current-only document storage can grow without bound while the document is
4615
+ live.
4616
+ - **Wrong fork setting:** `current`, `initial`, and `asOf` are product semantics,
4617
+ not optimizations. Changing one changes child conversation behavior.
4618
+ - **Schema stability:** document kinds and visible mount paths are
4619
+ persisted/public protocol. Value migration cannot rename a kind; a kind
4620
+ change requires explicit copy and retirement. Passing incompatible definition
4621
+ tokens that claim the same kind is unsupported caller misuse; Session does not
4622
+ maintain a document-definition registry to detect it.
4623
+ - **Trusted immutable revisions:** `snapshot()`, state values, watch values, and
4624
+ their descendants may share tracker-owned containers. Never mutate them; copy
4625
+ first when mutable ownership is required. Runtime freezing is not provided.
4626
+ - **Watch activation:** before `start()`, `watch.value` remains the immutable
4627
+ acquisition revision. Initialize the consumer from it first. After start, the
4628
+ property advances to each delivered immutable revision before its callback;
4629
+ every earlier reference remains stable.
4630
+ - **Buffered watches:** slow or unstarted watches retain up to 100 exact committed
4631
+ frames. Overflow replaces the undelivered suffix with one full-value root
4632
+ replacement, so intermediate committed states may be omitted. A consumer that
4633
+ must audit every transition must persist each fact in an immutable entry or
4634
+ journal and scan that history explicitly.
4635
+ - **Watch stop:** a listener may call and await `stop()`; it prevents future
4636
+ callbacks but neither aborts nor joins the callback already running.
4637
+ - **Durable progress cadence:** clients see only committed progress. A crash may
4638
+ lose the current uncommitted throttle window.
4639
+ - **Large terminal results:** terminal task records remain queryable. Put large
4640
+ results in entries or longer-lived documents and retain only their IDs in the
4641
+ outcome. Never reference a task-scoped document retired by that same outcome.
4642
+ - **Raw transcript:** view entries are not model context. Rendering edits,
4643
+ display-only entries, and model filtering require the appropriate reducer.
4644
+ - **Reserved `pi.` names:** task names, document kinds, and entry kinds starting
4645
+ with `pi.` belong to built-ins by convention. Nothing enforces it; reusing one
4646
+ collides with Harness behavior, such as `pi.system` entries being replayed as
4647
+ system messages.
4648
+ - **Early resource disposal:** uninstalling or replacing an extension only stops
4649
+ new use. Freeing resources immediately can fail calls that are still running.
4650
+ - **Guards left out of a selection:** a conversation with an array selection
4651
+ that omits a guard extension, such as a permissions hook, runs without it.
4652
+ Extensions are selected whole; there is no registry-wide hook or wrapper.
4653
+ - **Selection edits and copies:** `{ add, remove }` always edits the host default
4654
+ selection. Written to a child that copied any selection from its owner, an
4655
+ array or an `{ add, remove }` object, it replaces that selection, so the child
4656
+ gets the host default with the edit, not the owner's selection. Compute the child's array from the resolved agent
4657
+ instead (section 7.3).
4658
+ - **Copies are one-time:** a new task-owned conversation copies its owner's
4659
+ `pi.agent` at creation; later owner changes do not reach it. A fork keeps its
4660
+ `asOf` copy instead (section 2.2).
4661
+ - **Environment on recovery:** a tool rerun after recovery builds its
4662
+ environment again, with the conversation's current `cwd`, which may differ
4663
+ from the first attempt's.
4664
+ - **Moving default selections:** a conversation on the default selection changes
4665
+ whenever the settings or the installed extensions change; each change appends
4666
+ `pi.system` entries and misses the provider prompt cache.
4667
+ - **Unstable prompt text:** a section renderer whose output changes without a
4668
+ real content change, for example by embedding the time, appends system deltas
4669
+ and defeats provider prompt caching.
4670
+ - **Non-cooperative code at close:** close joins every invocation. A task
4671
+ handler, tool, or hook that ignores its signal keeps `close()` pending and
4672
+ Storage open until it returns; cancelling the close wait does not end it.
4673
+ - **Services outliving the Harness:** withdraw Chord services and detach clients
4674
+ before closing the Harness. A state ended by close keeps its last value and
4675
+ never updates again.
4676
+ - **Fatal storage errors:** after an uncertain storage failure the Session is
4677
+ poisoned. Do not catch the error and continue using it.
4678
+ - **JSONL durability:** default JSONL ordering handles ordinary process crashes;
4679
+ without durable mode it does not promise acknowledged commits survive power or
4680
+ host failure.
4681
+
4682
+ ## 13. Non-goals
4683
+
4684
+ Pico5 initially has no:
4685
+
4686
+ - whole-Session DOM;
4687
+ - visible-undurable publication;
4688
+ - Session-kernel semantic event journal or independently maintained event state;
4689
+ - session-scoped rewindable documents;
4690
+ - automatic checkpoint heuristic;
4691
+ - automatic third-party view mounting;
4692
+ - CRDT/offline multi-writer merge;
4693
+ - SQL translation of arbitrary Chord operations;
4694
+ - JSONL global compaction or automatic corruption repair;
4695
+ - compatibility layer for removed Pico prototypes;
4696
+ - forced termination of non-cooperative extension code inside one process.