@earendil-works/pi-durable 1.4.0 → 1.5.1

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 (270) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/README.md +195 -85
  3. package/dist/codemode/codemode.d.ts +6 -5
  4. package/dist/codemode/codemode.d.ts.map +1 -1
  5. package/dist/codemode/codemode.js +10 -49
  6. package/dist/codemode/codemode.js.map +1 -1
  7. package/dist/codemode/models.d.ts +2 -1
  8. package/dist/codemode/models.d.ts.map +1 -1
  9. package/dist/codemode/models.js +4 -4
  10. package/dist/codemode/models.js.map +1 -1
  11. package/dist/documents.d.ts +1 -1
  12. package/dist/documents.d.ts.map +1 -1
  13. package/dist/documents.js +1 -1
  14. package/dist/documents.js.map +1 -1
  15. package/dist/entries.d.ts +1 -1
  16. package/dist/entries.d.ts.map +1 -1
  17. package/dist/entries.js.map +1 -1
  18. package/dist/env/index.d.ts +13 -11
  19. package/dist/env/index.d.ts.map +1 -1
  20. package/dist/env/index.js.map +1 -1
  21. package/dist/env/node-watch.d.ts +4 -4
  22. package/dist/env/node-watch.d.ts.map +1 -1
  23. package/dist/env/node-watch.js +18 -16
  24. package/dist/env/node-watch.js.map +1 -1
  25. package/dist/env/node.d.ts +3 -3
  26. package/dist/env/node.d.ts.map +1 -1
  27. package/dist/env/node.js +1 -1
  28. package/dist/env/node.js.map +1 -1
  29. package/dist/errors.d.ts +18 -2
  30. package/dist/errors.d.ts.map +1 -1
  31. package/dist/errors.js +26 -3
  32. package/dist/errors.js.map +1 -1
  33. package/dist/harness/agent.d.ts.map +1 -1
  34. package/dist/harness/agent.js +37 -3
  35. package/dist/harness/agent.js.map +1 -1
  36. package/dist/harness/compaction.d.ts +8 -3
  37. package/dist/harness/compaction.d.ts.map +1 -1
  38. package/dist/harness/compaction.js +40 -11
  39. package/dist/harness/compaction.js.map +1 -1
  40. package/dist/harness/context.d.ts +24 -8
  41. package/dist/harness/context.d.ts.map +1 -1
  42. package/dist/harness/context.js +71 -25
  43. package/dist/harness/context.js.map +1 -1
  44. package/dist/harness/define.d.ts +8 -4
  45. package/dist/harness/define.d.ts.map +1 -1
  46. package/dist/harness/define.js +4 -0
  47. package/dist/harness/define.js.map +1 -1
  48. package/dist/harness/generation.d.ts +39 -6
  49. package/dist/harness/generation.d.ts.map +1 -1
  50. package/dist/harness/generation.js +451 -100
  51. package/dist/harness/generation.js.map +1 -1
  52. package/dist/harness/harness.d.ts +3 -1
  53. package/dist/harness/harness.d.ts.map +1 -1
  54. package/dist/harness/harness.js +87 -62
  55. package/dist/harness/harness.js.map +1 -1
  56. package/dist/harness/inbox.d.ts +7 -2
  57. package/dist/harness/inbox.d.ts.map +1 -1
  58. package/dist/harness/inbox.js +5 -2
  59. package/dist/harness/inbox.js.map +1 -1
  60. package/dist/harness/json.d.ts +21 -5
  61. package/dist/harness/json.d.ts.map +1 -1
  62. package/dist/harness/json.js +23 -5
  63. package/dist/harness/json.js.map +1 -1
  64. package/dist/harness/live.d.ts +124 -17
  65. package/dist/harness/live.d.ts.map +1 -1
  66. package/dist/harness/live.js +93 -12
  67. package/dist/harness/live.js.map +1 -1
  68. package/dist/harness/partial-changes.d.ts +54 -0
  69. package/dist/harness/partial-changes.d.ts.map +1 -0
  70. package/dist/harness/partial-changes.js +163 -0
  71. package/dist/harness/partial-changes.js.map +1 -0
  72. package/dist/harness/prompt.d.ts +1 -1
  73. package/dist/harness/prompt.d.ts.map +1 -1
  74. package/dist/harness/prompt.js.map +1 -1
  75. package/dist/harness/provider.d.ts +1 -1
  76. package/dist/harness/provider.d.ts.map +1 -1
  77. package/dist/harness/provider.js.map +1 -1
  78. package/dist/harness/registry-reader.d.ts +11 -0
  79. package/dist/harness/registry-reader.d.ts.map +1 -0
  80. package/dist/harness/registry-reader.js +25 -0
  81. package/dist/harness/registry-reader.js.map +1 -0
  82. package/dist/harness/scheduler.d.ts +48 -20
  83. package/dist/harness/scheduler.d.ts.map +1 -1
  84. package/dist/harness/scheduler.js +1006 -380
  85. package/dist/harness/scheduler.js.map +1 -1
  86. package/dist/harness/submissions.d.ts +18 -11
  87. package/dist/harness/submissions.d.ts.map +1 -1
  88. package/dist/harness/submissions.js +63 -26
  89. package/dist/harness/submissions.js.map +1 -1
  90. package/dist/harness/task-graph.d.ts +14 -9
  91. package/dist/harness/task-graph.d.ts.map +1 -1
  92. package/dist/harness/task-graph.js +38 -28
  93. package/dist/harness/task-graph.js.map +1 -1
  94. package/dist/harness/telemetry.d.ts +11 -11
  95. package/dist/harness/telemetry.d.ts.map +1 -1
  96. package/dist/harness/telemetry.js +7 -7
  97. package/dist/harness/telemetry.js.map +1 -1
  98. package/dist/harness/tool.d.ts +3 -1
  99. package/dist/harness/tool.d.ts.map +1 -1
  100. package/dist/harness/tool.js +172 -56
  101. package/dist/harness/tool.js.map +1 -1
  102. package/dist/harness/transcript.d.ts +72 -0
  103. package/dist/harness/transcript.d.ts.map +1 -0
  104. package/dist/harness/transcript.js +264 -0
  105. package/dist/harness/transcript.js.map +1 -0
  106. package/dist/harness/types.d.ts +237 -61
  107. package/dist/harness/types.d.ts.map +1 -1
  108. package/dist/harness/types.js.map +1 -1
  109. package/dist/harness/usage.d.ts +2 -1
  110. package/dist/harness/usage.d.ts.map +1 -1
  111. package/dist/harness/usage.js +1 -1
  112. package/dist/harness/usage.js.map +1 -1
  113. package/dist/harness/util.d.ts +4 -2
  114. package/dist/harness/util.d.ts.map +1 -1
  115. package/dist/harness/util.js +9 -0
  116. package/dist/harness/util.js.map +1 -1
  117. package/dist/harness/view.d.ts +44 -22
  118. package/dist/harness/view.d.ts.map +1 -1
  119. package/dist/harness/view.js +130 -71
  120. package/dist/harness/view.js.map +1 -1
  121. package/dist/index.d.ts +9 -7
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/index.js +4 -3
  124. package/dist/index.js.map +1 -1
  125. package/dist/search.d.ts +19 -3
  126. package/dist/search.d.ts.map +1 -1
  127. package/dist/search.js +107 -44
  128. package/dist/search.js.map +1 -1
  129. package/dist/session/cached-storage.d.ts +72 -0
  130. package/dist/session/cached-storage.d.ts.map +1 -0
  131. package/dist/session/cached-storage.js +269 -0
  132. package/dist/session/cached-storage.js.map +1 -0
  133. package/dist/session/conversation-tails.d.ts +33 -0
  134. package/dist/session/conversation-tails.d.ts.map +1 -0
  135. package/dist/session/conversation-tails.js +101 -0
  136. package/dist/session/conversation-tails.js.map +1 -0
  137. package/dist/session/forks.d.ts +1 -1
  138. package/dist/session/forks.d.ts.map +1 -1
  139. package/dist/session/forks.js.map +1 -1
  140. package/dist/session/keys.d.ts +69 -0
  141. package/dist/session/keys.d.ts.map +1 -0
  142. package/dist/session/keys.js +161 -0
  143. package/dist/session/keys.js.map +1 -0
  144. package/dist/session/session.d.ts +169 -26
  145. package/dist/session/session.d.ts.map +1 -1
  146. package/dist/session/session.js +1029 -171
  147. package/dist/session/session.js.map +1 -1
  148. package/dist/session/transaction.d.ts +84 -5
  149. package/dist/session/transaction.d.ts.map +1 -1
  150. package/dist/session/transaction.js +227 -40
  151. package/dist/session/transaction.js.map +1 -1
  152. package/dist/session/writer.d.ts +74 -0
  153. package/dist/session/writer.d.ts.map +1 -0
  154. package/dist/session/writer.js +294 -0
  155. package/dist/session/writer.js.map +1 -0
  156. package/dist/storage/indexeddb.d.ts +12 -3
  157. package/dist/storage/indexeddb.d.ts.map +1 -1
  158. package/dist/storage/indexeddb.js +91 -41
  159. package/dist/storage/indexeddb.js.map +1 -1
  160. package/dist/storage/jsonl/index.d.ts +1 -1
  161. package/dist/storage/jsonl/index.d.ts.map +1 -1
  162. package/dist/storage/jsonl/index.js +1 -1
  163. package/dist/storage/jsonl/index.js.map +1 -1
  164. package/dist/storage/jsonl/node.d.ts +3 -3
  165. package/dist/storage/jsonl/node.d.ts.map +1 -1
  166. package/dist/storage/jsonl/node.js +2 -2
  167. package/dist/storage/jsonl/node.js.map +1 -1
  168. package/dist/storage/jsonl/storage.d.ts +46 -5
  169. package/dist/storage/jsonl/storage.d.ts.map +1 -1
  170. package/dist/storage/jsonl/storage.js +158 -43
  171. package/dist/storage/jsonl/storage.js.map +1 -1
  172. package/dist/storage/memory.d.ts +9 -3
  173. package/dist/storage/memory.d.ts.map +1 -1
  174. package/dist/storage/memory.js +28 -2
  175. package/dist/storage/memory.js.map +1 -1
  176. package/dist/storage/sqlite/commit.d.ts +52 -0
  177. package/dist/storage/sqlite/commit.d.ts.map +1 -0
  178. package/dist/storage/sqlite/commit.js +371 -0
  179. package/dist/storage/sqlite/commit.js.map +1 -0
  180. package/dist/storage/sqlite/rows.d.ts +33 -0
  181. package/dist/storage/sqlite/rows.d.ts.map +1 -0
  182. package/dist/storage/sqlite/rows.js +53 -0
  183. package/dist/storage/sqlite/rows.js.map +1 -0
  184. package/dist/storage/sqlite/statements.d.ts +13 -0
  185. package/dist/storage/sqlite/statements.d.ts.map +1 -0
  186. package/dist/storage/sqlite/statements.js +88 -0
  187. package/dist/storage/sqlite/statements.js.map +1 -0
  188. package/dist/storage/sqlite/storage.d.ts +23 -10
  189. package/dist/storage/sqlite/storage.d.ts.map +1 -1
  190. package/dist/storage/sqlite/storage.js +120 -261
  191. package/dist/storage/sqlite/storage.js.map +1 -1
  192. package/dist/storage/store/commit.d.ts +33 -0
  193. package/dist/storage/store/commit.d.ts.map +1 -0
  194. package/dist/storage/store/commit.js +286 -0
  195. package/dist/storage/store/commit.js.map +1 -0
  196. package/dist/storage/store/documents.d.ts +43 -0
  197. package/dist/storage/store/documents.d.ts.map +1 -0
  198. package/dist/storage/store/documents.js +102 -0
  199. package/dist/storage/store/documents.js.map +1 -0
  200. package/dist/storage/store/index.d.ts +2 -0
  201. package/dist/storage/store/index.d.ts.map +1 -0
  202. package/dist/storage/store/index.js +2 -0
  203. package/dist/storage/store/index.js.map +1 -0
  204. package/dist/storage/store/queries.d.ts +49 -0
  205. package/dist/storage/store/queries.d.ts.map +1 -0
  206. package/dist/storage/store/queries.js +93 -0
  207. package/dist/storage/store/queries.js.map +1 -0
  208. package/dist/storage/store/storage.d.ts +60 -0
  209. package/dist/storage/store/storage.d.ts.map +1 -0
  210. package/dist/storage/store/storage.js +438 -0
  211. package/dist/storage/store/storage.js.map +1 -0
  212. package/dist/storage/store/tables.d.ts +54 -0
  213. package/dist/storage/store/tables.d.ts.map +1 -0
  214. package/dist/storage/store/tables.js +108 -0
  215. package/dist/storage/store/tables.js.map +1 -0
  216. package/dist/testing/commit-writes.d.ts +5 -0
  217. package/dist/testing/commit-writes.d.ts.map +1 -0
  218. package/dist/testing/commit-writes.js +12 -0
  219. package/dist/testing/commit-writes.js.map +1 -0
  220. package/dist/testing/env-conformance.js +7 -7
  221. package/dist/testing/env-conformance.js.map +1 -1
  222. package/dist/testing/index.d.ts +1 -0
  223. package/dist/testing/index.d.ts.map +1 -1
  224. package/dist/testing/index.js +1 -0
  225. package/dist/testing/index.js.map +1 -1
  226. package/dist/testing/storage-benchmark.d.ts.map +1 -1
  227. package/dist/testing/storage-benchmark.js +18 -17
  228. package/dist/testing/storage-benchmark.js.map +1 -1
  229. package/dist/testing/storage-conformance.d.ts.map +1 -1
  230. package/dist/testing/storage-conformance.js +354 -69
  231. package/dist/testing/storage-conformance.js.map +1 -1
  232. package/dist/tool-search/tool-search.d.ts +8 -14
  233. package/dist/tool-search/tool-search.d.ts.map +1 -1
  234. package/dist/tool-search/tool-search.js +25 -13
  235. package/dist/tool-search/tool-search.js.map +1 -1
  236. package/dist/tools/bash.d.ts +13 -13
  237. package/dist/tools/bash.d.ts.map +1 -1
  238. package/dist/tools/bash.js +16 -15
  239. package/dist/tools/bash.js.map +1 -1
  240. package/dist/tools/edit.d.ts +7 -7
  241. package/dist/tools/edit.d.ts.map +1 -1
  242. package/dist/tools/edit.js +9 -9
  243. package/dist/tools/edit.js.map +1 -1
  244. package/dist/tools/file-mutation-queue.d.ts +1 -1
  245. package/dist/tools/file-mutation-queue.d.ts.map +1 -1
  246. package/dist/tools/file-mutation-queue.js.map +1 -1
  247. package/dist/tools/index.d.ts +32 -32
  248. package/dist/tools/path-utils.d.ts +1 -1
  249. package/dist/tools/path-utils.d.ts.map +1 -1
  250. package/dist/tools/path-utils.js.map +1 -1
  251. package/dist/tools/read.d.ts +6 -6
  252. package/dist/tools/read.d.ts.map +1 -1
  253. package/dist/tools/read.js +19 -8
  254. package/dist/tools/read.js.map +1 -1
  255. package/dist/tools/write.d.ts +4 -4
  256. package/dist/tools/write.d.ts.map +1 -1
  257. package/dist/tools/write.js +5 -5
  258. package/dist/tools/write.js.map +1 -1
  259. package/dist/types.d.ts +228 -78
  260. package/dist/types.d.ts.map +1 -1
  261. package/dist/types.js.map +1 -1
  262. package/package.json +19 -12
  263. package/dist/harness/events.d.ts +0 -186
  264. package/dist/harness/events.d.ts.map +0 -1
  265. package/dist/harness/events.js +0 -373
  266. package/dist/harness/events.js.map +0 -1
  267. package/dist/session/observation.d.ts +0 -42
  268. package/dist/session/observation.d.ts.map +0 -1
  269. package/dist/session/observation.js +0 -281
  270. package/dist/session/observation.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,209 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.5.1] - 2026-11-09
4
+
5
+ ### Added
6
+
7
+ - `Tx.submission(id)` reads a committed submission record by ID, so a tool can find the entries of its run's inputs (`pi.live.run.inputs`) without scanning the transcript.
8
+ - Per-conversation compaction settings: `configure({ compaction })` stores a `CompactionSettings` (the shape of `HarnessSettings.compaction`, `modelOverrides` included) in `pi.agent`, inherited by children and forks. Each field resolves from the conversation's model override, then the harness's, then the conversation's own fields, then the harness-wide policy; a conversation's `enabled: false` also beats the harness's model overrides, so it turns off threshold, background and overflow compaction before a compaction task is created ([#11771](https://github.com/earendil-works/pi/issues/11771))
9
+ - JSONL storage opens read-only with `{ readOnly: true }` (`JsonlStorage.open()`, `openNodeJsonlStorage()`), for a viewer of a directory another process writes. A writable open repairs files on recovery: it truncates torn lines and unconfirmed sidecar records and reclaims sidecars, which can cut records a running writer just appended. The read-only open creates and writes nothing, skips those records in memory, holds the state committed at open, and `commit()` rejects with `JsonlStorageReadOnlyError`. A recovery that fails because it raced a writer's commit is retried. The README's read-only viewer guidance now says to open JSONL storage this way, since `HarnessOptions.readOnly` does not reach the storage open ([#11939](https://github.com/earendil-works/pi/issues/11939))
10
+
11
+ ### Changed
12
+
13
+ - `SqliteStorage.commit()` costs a few SQL statements per call instead of about eight per batch: it reads the check state of all batches up front, checks them in memory, and writes the accepted batches per table in multi-row statements (rows as one JSON array parameter; rows with much text bound column by column), so a group commit over a remote SQLite takes a few round trips, not hundreds. A stored document record that cannot be parsed now rejects the call as a storage failure instead of refusing the batch.
14
+
15
+ ### Fixed
16
+
17
+ - Fixed `tool_search` not finding a deferred tool by property names and descriptions behind a local `$ref` in its parameters, as in MCP input schemas that put their models in `$defs`. Referenced definitions are read once, so recursive schemas do not loop, and definitions nothing references stay out of the search text ([#11803](https://github.com/earendil-works/pi/issues/11803))
18
+ - Fixed a task staying `waiting`, or `completing` with idle waits never resolving, when the last task it waited for or held ended while a scheduler pass was planning, as with a generation waiting on parallel tool calls: the pass's cleanup erased the new notification. A pass now takes its candidates and finalize checks out of the pending sets before it plans, so notifications that arrive meanwhile stay for the next pass ([#11892](https://github.com/earendil-works/pi/issues/11892))
19
+ - Fixed every code mode run failing with `URI malformed` when any callable tool had a `$ref` with a malformed percent escape (`#/$defs/%E0`) in its schema, even scripts that do not call that tool. The parameter now renders as `unknown` in the tool's declaration ([#11803](https://github.com/earendil-works/pi/issues/11803))
20
+
21
+ ## [1.5.0] - 2026-11-02
22
+
23
+ ### Breaking Changes
24
+
25
+ - Tool schemas are JSON Schema typed through `@earendil-works/pi-schema`: `defineTool()` and `ToolRegistration` take any JSON Schema object as `parameters` and `structuredOutputSchema`, and type `args` with pi-schema's `Static` (`ToolArgs<TParameters>`; TypeBox schemas give TypeBox's static type for the common kinds, as pi-schema's `Static` describes; other schemas give `unknown`). The built-in tools (`read`, `write`, `edit`, `bash`, `powershell`, `codemode`, `tool_search`, the model tools) are built with pi-schema, with the same JSON Schema up to key order. `structuredOutput` is validated with pi-schema, so `invalid_structured_output` messages use its wording. `typebox` is no longer a dependency. Migration: replace `Type.Object({ n: Type.Number() })` from `@earendil-works/pi-ai` with `obj({ n: num() })` from `@earendil-works/pi-schema` (closed by default; `{ open: true }` allows other properties).
26
+ - `SqliteExecutor` (re-exported from Chord) gains `runCounted(sql, ...params)`, resolving with the rows the statement changed; custom `SqliteDatabase` implementations add it.
27
+ - `Storage.commit()` stores a group of commits in one call: `commit(batches, receive, context)` resolves with a `CommitResult` (`{ stored, refused? }`) and replaces `commit(writes, adopt, context)`. Each batch is one atomic commit with its own seq, checked against the state the batches before it left. The first batch Durable's own checks refuse (global IDs, document actions, copy sources) is not stored, nor anything after it; the batches before it are, and the call resolves with their number in `stored` and the refusal in `refused`. Any other failure (a driver or I/O error, a failed `COMMIT`) rejects the whole call as an unknown outcome and stores nothing the backend could roll back. `receive(index, seq)` is called once per stored batch, in order, synchronously, once they are durable and before the next call starts; receipts made before a rejection stand. Every batch has at least one write. The optional `publishing(publish, context)` runs each commit's publication inside the storage's own scope (`StoreStorage`: the attached host's batch). Memory, SQLite, JSONL, IndexedDB and `StoreStorage` store a call in one transaction or append, and the storage conformance suite checks the contract. Migration for a custom Storage:
28
+
29
+ ```ts
30
+ // Before
31
+ async commit(writes, adopt, context) {
32
+ const seq = await this.transaction((tx) => {
33
+ check(tx, writes); // throws when Durable's rules refuse the commit
34
+ return apply(tx, writes);
35
+ });
36
+ adopt(seq);
37
+ return seq;
38
+ }
39
+
40
+ // After
41
+ async commit(batches, receive, context) {
42
+ let refused: { error: unknown } | undefined;
43
+ const seqs = await this.transaction((tx) => {
44
+ const stored: Seq[] = [];
45
+ for (const writes of batches) {
46
+ try {
47
+ check(tx, writes); // only Durable's own checks; reads the earlier batches' writes
48
+ } catch (error) {
49
+ refused = { error };
50
+ break; // keep the batches before it
51
+ }
52
+ stored.push(apply(tx, writes)); // a throw rejects the call and rolls all of it back
53
+ }
54
+ return stored;
55
+ });
56
+ seqs.forEach((seq, index) => receive(index, seq));
57
+ return refused === undefined ? { stored: seqs.length } : { stored: seqs.length, refused: refused.error };
58
+ }
59
+ ```
60
+
61
+ A database error during the checks is not a refusal: it must reject the call. A wrapper forwards `commit()` and `publishing()`. Code that called `storage.commit(writes, adopt, context)` directly, such as a test seeding a storage, calls `commitWrites(storage, writes, context)` from `@earendil-works/pi-durable/testing` (one batch; resolves with its seq, throws the refusal).
62
+ - `Storage.mintId()`'s contract is narrowed to IDs greater than every ID minted since the storage was opened and every ID stored, also across reopen. The bundled storages already behave so.
63
+ - Watches use Chord's `WatchHandle` and `WatchEnd` (Durable re-exports both, with `WatchChange` and `NativeWatchChange`). End reasons are `stopped`, `cancelled`, `ended` (was `session_closed`), `listener_error`, and `failed` (was `session_failed`); `retired` is gone (see below). An ended watch drops its listener and every reference into its Session, so a stopped watch that is still referenced keeps only its last value.
64
+ - Watch listeners take one change object before the context: `watch.start(async ({ value, ops, retired }, context) => …)` for document, conversation view (`Conversation.watch()`), and task graph watches. Migration: `start(async (value, ops, ctx) => …)` becomes `start(async ({ value, ops }, ctx) => …)`.
65
+ - Document watches follow their address (kind, scope, and key) instead of one incarnation: `DocumentWatch<T>` is `WatchHandle<Readonly<T>>` (no `null`). A retirement no longer ends the watch with a last frame `[["r", null]]` and `retired`: the next call has `retired: true`, the last value the watch was given, and no ops (folded with what is pending; a change made in the retiring commit itself is not shown), and a document created at the address later arrives on the same watch as `[["r", value]]`, possibly in the same call. A task document's watch ends `ended` once the retirement call of the commit that makes its task terminal has returned; Session close or failure still ends it first if that call was not delivered yet (a watch never started). Acquiring an absent document still gives `undefined`. A watch pins the incarnation at its address, nothing while it is absent. Migration: test `change.retired` instead of a `null` value or a `retired` end, and stop a watch whose document may be retired for good, as it otherwise stays until the Session ends.
66
+ - Document, conversation view, and task graph watches deliver the commits made while their listener runs (or before `start()`) in one callback: the newest value, the commits' operations folded (appends to one string join, sets of one field keep the last) and the newest commit's Context. Past 10,000 pending operations the callback gets one root replacement `[["r", newest]]`. Before, each commit was its own callback and more than 100 pending commits became a root replacement.
67
+ - Removed the Chord state getters `Session.documentState()`, `Conversation.viewState()`, and `Harness.taskGraph()`, and the `DocumentState` type; use `watchDoc()`, `Conversation.watch()`, and `Harness.watchTaskGraph()`. Durable no longer uses Chord's `replicatedState()`.
68
+ - Partial answers and running tool output are stored later than they are shown: their throttled commits are deferred (`CommitPersistence`, spec §4.1), so watches and views see each at once, while Storage gets them together with the next commit that reads `pi.live` (the end of the answer, a tool settling, a run ending) and at the latest after `settings.progress.persistWithinMs` (default 1,000 ms). With 32 streams over SQLite this cuts storage transactions about 5.7x and disk writes about 2.9x for turns with parallel tools, 27x and 9x for long answers, and CPU by about a quarter. A crash now loses up to about a second of streamed content instead of 100 ms; a stream that crashes before its first store leaves no aborted entry, and that attempt's usage is not recorded. `subscribeCommits()` gets these commits only once they are stored, with exactly what was stored (held operations first; a document the commit retires shows as its retirement only, without the operations stored before it), and gets a publication for a deadline's store too; it no longer sees every partial. Migration: a `subscribeCommits()` listener that showed live progress should use `watchDoc()` or `Conversation.watch()`; one that mirrors Storage keeps working; set `settings.progress.persistWithinMs: 0` to store progress right after each commit, about as before (one storage transaction per partial).
69
+ - The change type of `FileSystem.watch()` in `@earendil-works/pi-durable/env` is renamed from `WatchChange` to `FileChange` (same shape: `{ paths }`, `{ overflow: true }`, or `{ error }`), so `WatchChange` names only a watch listener's change. Migration: import `type FileChange` from `@earendil-works/pi-durable/env` where `WatchChange` was imported from it.
70
+ - `HostCallback` gains `"telemetry"`: a `host_error` report about a telemetry adapter that threw while a commit was published. Exhaustive switches over `HostCallback` need a case for it.
71
+ - `ConversationView.entries` is a window over the transcript instead of the active transcript: the fork-aware history `Conversation.entries()` scans, in entry ID order, with head markers (compaction summaries, resets, handoffs) appended in place instead of cutting the entries before them. A watch starts with the newest 50 entries; new entries are appends, and past 100 the oldest leave in one splice down to 50, which also sets the new `ConversationView.before`, the ID of the entry just before the window (absent when the window starts the transcript). First values and overflow replacements (`[["r", view]]`) hold at most `max` entries, never the whole transcript. Migration: read the active transcript the model sees from `Conversation.context()`; render a transcript longer than the window with `ConversationTranscript`, or page older entries with `Conversation.entries()`; a client that rebuilt the head cut from the view's operations no longer gets one ([#11327](https://github.com/earendil-works/pi/issues/11327)).
72
+ - `Conversation.watch(context, window?)` and `Harness.observeConversation(id, observer, context, window?)` take a `ConversationWindow` (`from`, `min` default 50, `max` default `2 * min`): `min`/`max` bound the window, and `from` starts the first value at that entry ID when at most `max` entries are at or after it, otherwise at the newest `min`; it picks only that first value, so every entry committed after the attachment is appended, and a `from` past the newest entry starts empty. Bounds that are not integers, or `max` below `min`, reject with a `RangeError`. Each watch and observer has its own window, so the observers of one conversation no longer share one change object or one value; they share the document values ([#11327](https://github.com/earendil-works/pi/issues/11327)).
73
+ - `pi.live` is no longer empty after a run that ended without an answer: it keeps `runFailure` (below) until a later run starts. Code that compares an idle `pi.live` with `{}`, or tests for idleness that way, checks `run === undefined` instead ([#11327](https://github.com/earendil-works/pi/issues/11327)).
74
+ - Removed `watchEvents()` and its types `AgentEvent`, `AgentEventStream`, `SnapshotEvent`, `MessageChange`, and `ToolEventCall`, and spec §9.4. A renderer renders state instead of assembling it from events: everything the events carried that a renderer draws is in the conversation view, typed for producer and consumer, and new information becomes a field there rather than an event type every client must learn ([#11327](https://github.com/earendil-works/pi/issues/11327)). Migration, for a client of `watchEvents()`:
75
+
76
+ Before:
77
+
78
+ ```ts
79
+ const stream = await watchEvents(harness, conversation.id, context);
80
+ initialize(stream.snapshot); // entries, run, generation, tools, compactions, inbox, agent, usage
81
+ stream.start(async ({ value: events }) => {
82
+ for (const event of events) {
83
+ if (event.type === "message_update") applyChanges(event.changes); // streamed text
84
+ else if (event.type === "message_end") appendEntry(event.entry);
85
+ else if (event.type === "tool_execution_update") applyOutput(event.toolCallId, event.output);
86
+ else if (event.type === "run_end") showIdle();
87
+ // ... and every other event type
88
+ }
89
+ });
90
+ // After a drop: attach again; the new snapshot replaces everything held.
91
+ ```
92
+
93
+ After:
94
+
95
+ ```ts
96
+ const transcript = new ConversationTranscript((query, limit, ctx) => conversation.entries(query, limit, undefined, ctx));
97
+ const watch = await conversation.watch(context);
98
+ render(transcript.merge(watch.value));
99
+ watch.start(async ({ value }) => render(transcript.merge(value)));
100
+
101
+ function render(view: TranscriptView): void {
102
+ const live = view.docs["pi.live"] as LiveState;
103
+ drawItems(view.items); // entries, a "load more" row per gap, "load earlier" at the top
104
+ if (live.generation?.message !== undefined) drawStreaming(live.generation.message);
105
+ for (const slot of live.tools ?? []) drawTool(slot); // status, running output, details, result entry
106
+ if (live.runFailure !== undefined) drawFailure(live.runFailure); // why the last run ended without an answer
107
+ setBusy(live.run !== undefined);
108
+ }
109
+ // A "load earlier" or "load more" row scrolled into view:
110
+ render(await transcript.loadEarlier(context));
111
+ render(await transcript.fillGap(gap, context));
112
+ // After a drop: only what was missed when it fits the window, otherwise the newest entries and a gap.
113
+ const resumed = await conversation.watch(context, transcript.resume);
114
+ ```
115
+
116
+ The watch's operations carry the same deltas the events did (text and thinking appends, tool-call argument appends, output trims and appends), for a client that forwards them, for example to a remote replica. Where each event went:
117
+
118
+ | event | state |
119
+ |---|---|
120
+ | `snapshot` | the watch's first value, `watch.value`, its window bounded by `max` |
121
+ | `run_start`, `run_end` | `pi.live.run` appears, disappears |
122
+ | `turn_start`, `turn_end` | `pi.live.run.taskId` moves to the next generation; the task graph's `pi.generation` nodes, `completing` at their hold |
123
+ | `message_start`, `message_update` | `pi.live.generation.message`, the streamed answer, with its `usage` |
124
+ | `message_end`, `entry_appended` | the entry appended to `entries` |
125
+ | `tool_execution_start`, `tool_execution_update`, `tool_execution_end` | the slot in `pi.live.tools` or `nestedTools`: `status` pending, running, done; `output`, `details`, `diagnostics`; `entry` once done (absent after a fault); a nested call's `arguments`, its `summary`, and its result in the caller's `NestedCallDoc` |
126
+ | `auto_retry_start`, `auto_retry_end` | `pi.live.generation.retry` set, removed |
127
+ | `deferred_poll` | `pi.live.generation.deferred.pollAt` |
128
+ | `compaction_start`, `compaction_end` | a status in `pi.live.compactions` appears, disappears; `pi.live.compactionFailure` when it failed; the summary entry when it is placed |
129
+ | `inbox_update` | `pi.inbox` |
130
+ | `agent_changed`, `usage_changed` | `pi.agent`, `pi.usage` |
131
+ | `submission` | `Submission.status()` and `wait()`, `Harness.submission(id)`; queued items in `pi.inbox`, the run's inputs in `pi.live.run.inputs` |
132
+ | `task_failed` | the task graph (`completing` with the outcome's status), `Harness.getTask()`, and `onReport`'s `task_faulted`; for a run, `pi.live.runFailure`; for a compaction, `compactionFailure`; for a tool, a done slot without `entry` |
133
+ | a run that ended without an answer (`run_end` with an `unanswered` `submission`) | `pi.live.runFailure`: `reason` (`model_error`, `no_model`, `aborted`, `reset`, `faulted`, `orphaned`), `detail`, `taskId`, `inputs` |
134
+
135
+ Some event data has no state in the view. A tool's arguments after repair and `beforeTool` at its start: in the nested slot's `arguments` for a nested call, only in the tool task's checkpoint for a model-issued one. A submission's settlement: `Submission.status()`/`wait()` and `Harness.submission(id)`; an input settled without a run (withdrawn, `stale`, aborted while queued, `run_failed`) just leaves `pi.inbox`. A failed task's error: `Harness.getTask()` and `onReport`; the task graph keeps only live tasks, not terminal outcomes. A nested call's full result: its caller's `NestedCallDoc` (`watchDoc()`), which retires when the caller settles. Exact turn boundaries: the run's `taskId` and the task graph, with no explicit event. Whether a compaction did anything: its `CompactionResult` (`waitForTask()`), and the summary entry when it is placed.
136
+ - Restart-limited tasks no longer park: a task cut off `settings.restartLimit` times in a row ends instead, visibly. A tool call ends with an error result (diagnostic code `restart_limit`) and its task fails with detail `{ reason: "restart_limit" }`, a compaction fails into `pi.live.compactionFailure`, a generation ends its run with `pi.live.runFailure` reason `restart_limit` (its inputs settle `unanswered` with that reason), and a task of an extension's kind ends `faulted` with detail `{ reason: "restart_limit" }`, reported as `task_faulted` with reason `restart_limit`. The built-in tasks are reported as `task_interrupted` (below). An abort handler cut off at the limit still faults its task, but a run someone aborted now ends with `runFailure` reason `aborted`, the cut-off as `detail`, and an aborted compaction leaves no `compactionFailure`. `Harness.retryTask()`, the `restart_limit` blocked reason of `TaskInspection` and `TaskGraphNode`, and the `task_parked` report (and its telemetry value) are removed. Migration: a parked task kept its conversation busy until `retryTask()`; it now ends, so show `runFailure`, `compactionFailure`, the tool's error result, or the `task_interrupted` and `task_faulted` reports instead, and to run the work again, submit it again (a new input, `compact()`, or a new task); raise `restartLimit` before the task is next reserved to let it run longer ([#11139](https://github.com/earendil-works/pi/issues/11139)).
137
+ - A generation whose model request was in flight when the host died no longer resends it on open: the attempt counts as failed under `settings.retry`. Its partial becomes an aborted entry, as before; the next attempt waits for the backoff, shown in `pi.live.generation.retry`, and with no retry left the run ends with `pi.live.runFailure` reason `interrupted` and the inputs settle `unanswered` with that reason. A deferred poll whose fetch was in flight is charged the same way, even when the replacement host's clock is behind, and its next attempt polls the same deferred response again instead of submitting a new request; a run that ends instead cancels it. A close still resends. `restartLimit` still bounds these phases: a charge that itself keeps being cut off ends the run `restart_limit`, past the limit without converting the partial ([#11139](https://github.com/earendil-works/pi/issues/11139)).
138
+ - Watch listeners and synchronous observers take Chord's tagged watch shapes. Durable re-exports `WatchCall` and `NativeWatchCall` instead of `WatchChange` and `NativeWatchChange`; a listener gets `{ kind: "value", value, ops, retired }`. Chord's `{ kind: "failed", error }` call, for a source that recovers, never comes from Durable, whose failures end the watch. `WatchEnd` is tagged by `kind`, and a watch whose acquisition context is aborted ends `aborted` (was `cancelled`). Before: `watch.start(async ({ value, ops }) => render(value, ops))`, `if (end.reason === "cancelled")`. After: `watch.start(async (call) => { if (call.kind === "value") render(call.value, call.ops); })`, `if (end.kind === "aborted")`.
139
+ - `Observer<T>` (`observeDoc()`, `observeConversation()`, `observeTaskGraph()`) has the watch's shapes: `advance(call: WatchCall<T>, context)` (always a `value` call) and `end(end: WatchEnd)`: `ended` at close or after a task document's retirement with its task (was `undefined`), `failed` with the error that failed the Session (was `{ error }`), and `listener_error` with the error for an observer that threw (was `{ error }`), now also for one whose `advance` returns a promise that rejects. Before: `{ advance: ({ value }) => push(value), end: (failure) => failure && fail(failure.error) }`. After: `{ advance: (call) => call.kind === "value" && push(call.value), end: (end) => end.kind === "failed" && fail(end.error) }`.
140
+ - `subscribeCommits(listener)` is `observeCommits(observer)` with the observer shape: `{ advance(publication, context), end(end) }`, returning `{ detach() }`. Its observation has no `value`: commits are events, and the Session does not know Storage's newest `seq` before its own first commit. `end` comes once, `ended` after the last publication at close or `failed` when the Session fails; an observer that throws (or whose `advance` rejects) is reported, detached, and ended `listener_error` (a throwing listener stayed subscribed). Before: `const unsubscribe = harness.subscribeCommits((publication) => apply(publication)); unsubscribe();`. After: `const observation = harness.observeCommits({ advance: (publication) => apply(publication), end: () => {} }); observation.detach();`.
141
+ - `HarnessOptions.onReport(report)` takes one `Report`: the fields of `ReportInfo`, tagged by `kind` (was `type`), and the `error` (was the first argument). `session_failed` is `storage_failed`, and `task_outdated`'s reason `incompatible_task` is the scheduler's own word for why the replacement cannot take the task: `task_too_old` (older than the task) or `migration_failed` (newer without a `migrate`), as a blocked task shows. The `pi.durable.report` event and `pi.durable.reports` metric keep their `type` attribute, with the new values. Before: `onReport: (error, info) => { if (info.type === "session_failed") restart(error); }`. After: `onReport: (report) => { if (report.kind === "storage_failed") restart(report.error); }`.
142
+ - `DocumentCommitChange` is tagged: a document change is `{ type: "document", kind: "change", record, conversationId, version, value, ops }` and a retirement `{ type: "document", kind: "retire", record, conversationId }` (was `value: null` and `version: undefined`). Before: `if (change.type === "document" && change.value !== null) use(change.value)`. After: `if (change.type === "document" && change.kind === "change") use(change.value)`.
143
+ - `CompactionStatus.reason` and `CompactionFailure.reason` in `pi.live` are `trigger` (what started the compaction; a run failure's `reason` is why it failed). `pi.live` is version 2: its `migrate` renames the field in a stored version 1 value when it is read, and the next commit that writes `pi.live` stores it at version 2, so code built against version 1 cannot read a conversation this version wrote to. Before: `live.compactionFailure?.reason`. After: `live.compactionFailure?.trigger`.
144
+ - `TaskInspection.state` uses `TaskState`'s word: `{ kind: "pending", migrates }` (was `ready`). Before: `state.kind === "ready"`. After: `state.kind === "pending"`.
145
+ - `FileChange` (`FileSystem.watch()` in `@earendil-works/pi-durable/env`) is tagged by `kind`: `{ kind: "paths", paths }`, `{ kind: "overflow" }`, or `{ kind: "failed", error }` (was `{ paths }`, `{ overflow: true }`, `{ error }`), and `FileWatcher.close(context)` is `stop(context)`. Before: `if ("overflow" in change) rescan(); else if ("paths" in change) reload(change.paths); await watcher.close(ctx);`. After: `if (change.kind === "overflow") rescan(); else if (change.kind === "paths") reload(change.paths); await watcher.stop(ctx);`.
146
+ - `SessionEnd` (`Session.closed`, `Harness.closed`) is tagged by `kind`: `{ kind: "closed" }` or `{ kind: "failed", error }` (was `reason`). Before: `harness.closed.then((end) => { if (end.reason === "failed") reopen(end.error); })`. After: `harness.closed.then((end) => { if (end.kind === "failed") reopen(end.error); })`.
147
+ - `ReportFrom`, the second argument of a task's `runtime.report(error, from)`, is tagged by `kind`: `{ kind: "extension", extension? }` or `{ kind: "host", callback }` (was `type`). Before: `runtime.report(error, { type: "host", callback: "env" })`. After: `runtime.report(error, { kind: "host", callback: "env" })`.
148
+ - `afterTool` runs on every result a tool task writes, not only on an executed call's: also on the error results the Harness writes itself, for a tool that does not resolve (`tool_unavailable`), invalid arguments (`invalid_arguments`, with `call.arguments` as the model sent them), a block (`blocked`), an interruption (`interrupted`), the restart limit (`restart_limit`), and an abort (`aborted`, `abandoned`); the diagnostic code tells them apart. Such a result is bounded with the tool's output limits after the hooks, never keeps a `structuredOutput`, and applies `control` only when the call completes; a nested call a hook turns into a success gets its bounded output as value unless the tool declares a schema. Once a tool task was cut off more than `settings.restartLimit` times its hooks no longer run, nor in the last attempt the scheduler gives its abort handler, and an abort's hooks delay `Conversation.abort()`. A non-replay-safe tool interrupted that often now ends with `restart_limit` too. Hooks that assumed the tool ran (cast `call.arguments`, read `structuredOutput`) check `result.diagnostics` first ([#11043](https://github.com/earendil-works/pi/issues/11043))
149
+ - A call to a tool its request did not offer gets a `pi.tool` task like any call, flagged `unoffered: true` in its input and its `pi.live.tools` slot, instead of a result the generation wrote without one: the task resolves nothing (a deferred tool the agent has still never runs), settles `tool_unavailable`, and runs `afterTool`, whose `control` then counts toward the round (one such call no longer prevents `terminate`). Its slot starts `pending` with a `taskId` instead of `done`, and its result entry lands in a later commit than the tool-calling answer. Controls are applied in call order. `pi.tool` is version 3 and `pi.generation` version 2 (a sequential round keeps such calls pending until their turn): an older Harness leaves both blocked (`task_too_old`) instead of resolving the flagged name ([#11043](https://github.com/earendil-works/pi/issues/11043))
150
+ - `SubmissionRecord` gains a `type: "continue"` variant (`placed` without `entry`, `done` with `answer`, `unanswered`), so narrowing a record by `status` alone no longer guarantees an `entry`: check `type === "input"` too. A generation whose context does not end in a user message or tool results, system messages aside, now ends its run `unanswered` with the new `RunFailureReason` `nothing_to_answer` without a request; an empty conversation no longer faults its generation. `ConversationHandle.submit()` takes any `SubmissionDraft` (was `InputSubmissionDraft`), as `Conversation.submit()` does ([#11043](https://github.com/earendil-works/pi/issues/11043))
151
+ - `RunFailureReason` (`pi.live.runFailure.reason`) gains `nothing_to_answer` and `turn_limit`, also an `unanswered` submission's `reason`: exhaustive switches over it need cases ([#11043](https://github.com/earendil-works/pi/issues/11043))
152
+ - `pi.live.run` has a required `turns` field, the run's tool rounds so far; code that writes `run` itself sets it (`turns: 0`). `pi.live` is version 3: its `migrate` adds `turns: 0` to a stored run without it, version 1 or a version 2 an earlier unreleased build wrote, whose run in flight otherwise failed its next tool round with "Value contains a non-finite number" ([#11043](https://github.com/earendil-works/pi/issues/11043))
153
+
154
+ - A Session's close ends synchronous observers (`observeDoc()`, `observeConversation()`, `observeTaskGraph()`, `observeCommits()`) `failed` with the error "session closed" instead of `ended`, so an adapter following a Session (Chord's `self.follow`) fails and follows the next one; `ended` stays for an observed owner that ended (a task document's task), and watches still end `ended`. Before: `end: (end) => { if (end.kind === "ended") done(); }`. After: `end: (end) => { if (end.kind === "ended" || end.kind === "failed") done(); }`.
155
+
156
+ ### Added
157
+
158
+ - `StoreStorage` (`@earendil-works/pi-durable/storage/store`): Durable's `Storage` on Chord v2's store. `StoreStorage.open(store, owner, context)` keeps a Session's records as declared tables of the store under `owner` and its documents in the store's documents engine, takes its ids from the store's one allocator, and stores each `commit()` call in one store transaction (`ChordStore.commitSeries`), one seq and outbox row per batch, publishing each commit in the batch of the host the store serves, so a Chord object and a Durable Session commit through one queue and one outbox. It passes the storage conformance suite; `close()` leaves the store open (the app owns it).
159
+ - Deferred commits: `Session.commit()`, `Conversation.commit()`, `TaskRuntime.commit()`, and a tool's `api.commit()` take `{ persistence: { kind: "deferred", withinMs? } }`. A commit that changes only existing conversation documents with `history: "latest"` and `fork: "initial"` is then adopted in memory and shown to watches at once, and stored with the next commit that reads or writes the document, before the document leaves memory, at close, or within `withinMs` (default `settings.progress.persistWithinMs`, or `createSession(storage, { persistWithinMs })`); any other write makes it immediate. A crash loses what is held. See spec §4.1.
160
+ - Synchronous observers for adapters, such as a Chord source over a Durable view: `Session.observeDoc(token, ...scope, observer, context)`, `Harness.observeConversation(id, observer, context)`, and `Harness.observeTaskGraph(observer, context)` attach an `Observer<T>` (`advance(change, context)`, `end(failure)`) atomically on the Session line, like a watch, and return an `Observation<T>` (`value`, `detach()`; `observeDoc` gives `undefined` for an absent document). `advance` gets every change unfolded as memory adopts it, for a stored commit when it is published after its storage call settled, and for a deferred one when memory adopts it, before it is stored, so it must only record it: no awaits, no Session calls, no throws. A document observer gets one call per change at its address (two for a commit that retires and recreates it), the task graph also a call outside any commit when a registry change or a failed migration changes a `blocked` reason, and a change can arrive before the attaching promise resolves. The attaching context governs only the attachment; a closed Session or an invalid argument rejects the promise. One that throws is reported as `listener_error`, detached, and ended with the error. `end` comes once, at Session close or failure, or after a task document's retirement with its task. Document, view, and task graph watches are built on them (spec §9.1).
161
+ - `ConversationTranscript` keeps what a client has of one conversation over any `ConversationView`: `merge(view)` returns the view with `items`, the client's whole transcript (entries, a `gap` where a later window did not continue it, and an `earlier` marker until the start is loaded), instead of the window; `loadEarlier(context)` and `fillGap(gap, context)` page entries through a loader such as `Conversation.entries()`, and `resume` is the window that continues it after a drop: `conversation.watch(context, transcript.resume)`. A merge costs O(items) and returns the same `items` array when it adds nothing; concurrent loads of the same page are shared ([#11327](https://github.com/earendil-works/pi/issues/11327)).
162
+ - `pi.live.runFailure` (`RunFailure`, `RunFailureReason`) records why the newest run ended without an answer, `model_error`, `no_model`, `aborted`, `reset`, `faulted`, or `orphaned`, with the run's task and inputs and a `detail` where one exists (the error text, the missing model, the fault, the orphan reason). The commit that ends the run sets it; a later commit that starts a run removes it ([#11327](https://github.com/earendil-works/pi/issues/11327)).
163
+ - `RunFailureReason` gains `interrupted` (the host stopped while the model was answering, with no retry left) and `restart_limit` (the run's task was cut off `settings.restartLimit` times in a row) ([#11139](https://github.com/earendil-works/pi/issues/11139)).
164
+ - `onReport` gets `task_interrupted` (`reason` `restart_limit` or `interrupted`, `restarts`, and the task) when crashes end a built-in task: a tool call, a compaction, or a generation at the restart limit, or a run that ends `interrupted`. The telemetry report attributes gain the type `task_interrupted` and the reason `interrupted` ([#11139](https://github.com/earendil-works/pi/issues/11139)).
165
+ - `HarnessOptions.readOnly` opens a Harness as a viewer of a session another process drives: open leaves running tasks as committed instead of recovering them, every commit with writes rejects with `ReadOnlySession` before it reaches Storage, and `resume()` and every call that would run tasks reject with it.
166
+ - Continue submissions: `submit({ type: "continue" })` starts a run on the context as it is, without a user message, for example after a run that ended without an answer (`pi.live.runFailure`). Only an idle conversation admits one (a busy one throws `ConversationBusy`; it never queues); items a failed run left queued are placed first and join the run. A context that ends in an answer, also a text answer cut off at its output limit, has nothing to continue (`nothing_to_answer`); a cut-off answer with tool calls is continued, as its calls end in error results. Tasks and tools submit one through `ConversationHandle`. The `pi.durable.submit` span starts a root trace for it, as for an input ([#11043](https://github.com/earendil-works/pi/issues/11043))
167
+ - Per-conversation turn limits: `AgentState.maxTurns` (`configure({ maxTurns })`, `null` clears; a `RangeError` for anything but an integer 1 or more) bounds the tool rounds one run takes. The round that reaches it runs its tools, then the run ends `unanswered` with the new `RunFailureReason` `turn_limit`, like a failed run, so `settings.afterFailedRun` decides the queued inputs and a continue resumes it on the round's results. The count is `pi.live.run.turns`; every run starts at 0, and an `onYield` continuation starts it over. Task-owned children and forks inherit the limit; `Agent.maxTurns` exposes it ([#11043](https://github.com/earendil-works/pi/issues/11043))
168
+ - `whenIdle: "reject"` on input submissions: an idle conversation rejects the input with the new `ConversationIdle` and writes nothing, so it never starts a run. With `whenBusy: "steer"` it steers the current run or is refused, in one admission instead of a host's check and a separate submit that can race the run's end. A known request ID still returns its submission ([#11149](https://github.com/earendil-works/pi/issues/11149))
169
+ - `Conversation.submit(tx, draft)` admits a submission inside the caller's commit (`Harness.commit()`, `Conversation.commit()`) with the same rules as `submit(draft, context)`, and returns its ID: a host checks its own documents and `pi.live`, admits, and writes its own state in one commit, which stores all of it or none. `ConversationHandle.submit(tx, draft)` does the same inside a task's or tool's own commit. The Harness supplies the clock, queue modes, the `pi.durable.submit` span (covering the admission only; its `result` is provisional until the commit is stored), and the request for progress. It must come before the commit's first table write, once per commit, and throws `ReadAfterWrite` otherwise on every path ([#11149](https://github.com/earendil-works/pi/issues/11149))
170
+ - `HookApi.entry()` and `entries()`, and `TaskRuntime.entries()`: a hook or task reads the conversation's raw history, which compaction never changes, so a range bounded by entry IDs reads the same before and after a compaction and on a rerun ([#11141](https://github.com/earendil-works/pi/issues/11141))
171
+ - Run-end tasks: a `GenerationHooks.afterRun(run, api)` hook returns `{ tasks }`, built with the typed `runEndTask(task, input)`, after a run answered (a final answer or a `terminate` round; not when an `onYield` continuation is taken, not after a failure or an abort). The answer is committed and the run's inputs settle `done` first, so `Submission.wait()` resolves at the answer; the generation then creates the tasks as its children and waits for them, keeping `pi.live.run` (with `run.afterRun`) and the conversation busy, so idle waits include them, Stop aborts them, and a crash resumes them. Input queued meanwhile, steers included, is placed when they are terminal and starts the next run; a task admits a model-visible write or a fix-run follow-up in its own commit with `submit(tx, draft)`. A crash before the tasks exist asks the hooks again; a broken request (an uninstalled or built-in task, a non-JSON input, a throwing `initial()`) is reported and skipped. `run` names the run's entries: `first` (`pi.live.run.first`, carried into `runFailure.first` so a continue covers a failed run's history), `answer`, and `last` ([#11141](https://github.com/earendil-works/pi/issues/11141))
172
+
173
+ - `registryReader(value, context)`: a `RegistryReader` over a Chord v2 registry value whose composition is a registry snapshot (`RegistryValue<RegistrySnapshot>`): `snapshot()` reads the current composition, `subscribe` observes its compositions synchronously inside Chord's batch, so a Harness opened in a Chord object takes its extensions from a Chord registry.
174
+ - `GenerationHooks.beforeProviderRequest({ model, payload }, api)` replaces the provider request body of a generation request, for that request only, e.g. to set a provider-specific field. The body is in the provider API's format; the hook returns `{ payload }` or `undefined`. Hooks chain in extension order and run each time pi-ai builds a body; a throw is reported and the chain continues. Compaction requests do not use it ([#10804](https://github.com/earendil-works/pi/issues/10804))
175
+
176
+ ### Changed
177
+
178
+ - Document, conversation view, and task graph watches use Chord's watch handles (`Watch`).
179
+ - Group commit: a Session no longer holds its line while a commit is stored. Commits that arrive while one storage call is in flight go out together in the next call, each with its own seq and its own outcome, so the conversations of one Session share storage round trips instead of queueing behind each other's. A job runs while earlier commits are being stored until it touches a key one of them wrote (a record, a document, a conversation's entries, a task's membership or cancellation state); then it waits until that commit is stored and published. Outcomes are delivered in line order, and every job after a commit that fails the Session gets `SessionFailed`. One Harness serving 128 conversations over storage with a 4 ms round trip went from about 5 to about 440 agent runs per second (median run 25 s to 0.28 s); with local SQLite or the Chord store, throughput at 128 conversations rises by about 30%, while with in-memory storage and no latency each commit costs somewhat more CPU than before (up to 25% fewer runs per second). At most 256 jobs wait for their outcome. See `docs/group-commit.md` and spec §4.
180
+ - A stored commit is published after its storage call settled, in line order, the commits of one call a macrotask apart (inside the storage's `publishing()` scope if it has one): the read caches and memory adopt it, then its telemetry events and Durable's own observers (document watches, conversation views, the task graph, the scheduler, and waits) run, so two views never show one commit apart. `observeCommits()` observers run one microtask later, before every wait the commit resolves and every abort signal it fires; they have no order relative to watch listeners and `onWake`. A call that settles after something else failed the Session publishes nothing; what it stored is durable and appears after reopen.
181
+ - A normal agent run makes no storage reads: the Session keeps recently read conversation, task and submission records (bounded by count, and by live work for tasks), the newest entry of each conversation, and the task documents of live tasks, and the scheduler keeps the context range of a running conversation. Read results (`tx.task()`, `tx.conversation()`, `getTask()`, `runtime.entry()`, published entries) are frozen objects shared with these caches; mutating one throws.
182
+ - A tool slot's `running` status and a nested call's final arguments, and a generation's attempt, are stored with the next commit, like streamed progress: a run with one tool call stores 12 commits instead of 14. After a crash a slot may show `pending` while its task's checkpoint is at execution; the rerun sets it again.
183
+ - A wait whose commit is still being stored when the Harness closes ends with the closed error; the commit is still stored and published before close completes.
184
+ - Cancellation is checked when a job starts, which can be earlier than before: an invocation-bound submit queued behind an abort mark that is not stored yet lands, and the abort's cascade then aborts its run.
185
+ - The scheduler's per-commit work no longer grows with the number of conversations: after a commit it re-checks idleness only for the conversations above the tasks that became live or ended, expires idle context ranges from an expiry-ordered index, and `wake()` takes the earliest sleep from the sleeping tasks and scans live tasks only while nothing runs. Before, every commit re-walked every conversation read in the last `contextRetentionMs` (default 10 minutes). One Session with 2048 conversations went from 100 to 805 agent runs per second (4096: 46 to 772; local SQLite at 2048: 88 to 459), with CPU per run flat at about 1.6 ms instead of growing linearly.
186
+ - `subscribeCommits()` listeners are read when a commit's dispatch runs: a listener added between publication and the dispatch gets the commit only when one was registered at adoption; one added after the dispatch ran misses it.
187
+ - The abort signal of a newly marked running task fires after `subscribeCommits()` listeners instead of before them.
188
+ - A throwing Durable commit observer fails the Session after `Storage.commit()` returned, after the other observers and the host listeners got the commit; the commit still stands. When Storage also rejects that commit after adopting it, the storage error fails the Session and the observer's error is reported as `listener_error`.
189
+
190
+ ### Fixed
191
+
192
+ - Fixed a tool round with `control.handoff` ending the run on the tool-calling answer: the handoff's `pi.reset` entry is written and the same run continues with a new generation in the new context, so the submission settles with the eventual answer. Queued writes and selected steers are placed after the handoff text, follow-ups wait for the run's final boundary, and a queued reset still ends the run `unanswered` with reason `reset`. `terminate` remains the way to end a round without another model request.
193
+ - Fixed a conversation watcher (conversation views) holding a copy of a streamed text or tool call per partial commit, through Chord append ops that kept the whole string alive; a 140 KB answer followed by a 140 KB tool call held well over 100 MB ([#9265](https://github.com/earendil-works/pi/issues/9265))
194
+ - Fixed a nested call's error summary keeping the call's whole output alive through a slice of it ([#9265](https://github.com/earendil-works/pi/issues/9265))
195
+ - Fixed task span error messages and codemode call previews keeping the whole text they were cut from alive ([#9265](https://github.com/earendil-works/pi/issues/9265))
196
+ - Fixed partial commits of a streamed answer copying every growing text, thinking and tool-call argument string in full on each commit (about 20 s of CPU and a 100 MB heap peak for a 500 KB tool call): they now append new text with Chord's `appendText()`, and apply the argument changes of their own parse of the streamed argument text ([#9265](https://github.com/earendil-works/pi/issues/9265))
197
+ - Fixed stored assistant messages lacking `thinkingLevel`: a generation records the Pi thinking level its request asked for on the response, also after polling a deferred one, as pi-agent-core's agent loop does
198
+ - Fixed partial commits polluting `Object.prototype` when a streamed tool call's arguments gained a `__proto__` member after an earlier commit: the merge read the missing member through to `Object.prototype` and wrote into it
199
+ - Fixed a commit that Storage rejects after adopting it (a failed JSONL reclaim) publishing nothing: it is published and its waits resolve before the Session fails, so a replica built from `subscribeCommits()` stays equal to Storage and `pi.durable.tasks.active` stays balanced.
200
+ - Fixed a throwing telemetry adapter (a commit's `pi.durable.entry` or `pi.durable.submission` event, `pi.durable.tasks.active`) rejecting a durable commit and leaving the views and the scheduler without it: it is reported as `host_error` with callback `"telemetry"`, and the commit resolves. A throwing report metric or report event is dropped.
201
+ - Fixed a host that crashes during a model request resending it forever, adding an aborted entry each time: the interrupted request is a failed attempt under the retry policy, and the run ends with `runFailure` reason `interrupted` once no retry is left; other work cut off `restartLimit` times ends instead of keeping its conversation busy ([#11139](https://github.com/earendil-works/pi/issues/11139))
202
+ - Fixed the built-in `read` resolving to `""` for an image in Code Mode scripts, so `image(await tools.read(...))` threw: `read` declares a `structuredOutputSchema`, a text file's text or an image's `ImageContent`, as the coding agent's `read` does ([#11015](https://github.com/earendil-works/pi/issues/11015))
203
+ - Fixed the Code Mode extension loading pi-codemode's WASM QuickJS runtime (`quickjs-wasi`) although it only runs scripts in the sandbox the host passes: it imports `toCodemodeIdentifier` from `@earendil-works/pi-codemode/declarations` instead of `/portable` ([#11254](https://github.com/earendil-works/pi/issues/11254))
204
+ - Fixed `tool_search` and Code Mode's `searchTools()` ranking a tool below tools that only mention its name, as the coding agent did before [#11440](https://github.com/earendil-works/pi/pull/11440) and [#11625](https://github.com/earendil-works/pi/pull/11625): `thread_read` with `limit: 1` returned `queue_read`, and `peekaboo app` missed `mcp__peekaboo__app`. Both now rank like the coding agent: a query that is exactly a tool's name ranks it first, a tool matching more of the query's words ranks above one matching fewer, a query word in the name outweighs any number of mentions in the description, and a tool whose whole name is in the query gets a large bonus. `searchTools()` also searches namespace instructions, as a low-weight field that does not lower a tool's score ([#11331](https://github.com/earendil-works/pi/issues/11331))
205
+ - Fixed `tool_search` and Code Mode's `searchTools()` finding nothing for Chinese, Japanese, Thai, and other non-ASCII queries, because the tokenizer kept only ASCII letters and digits. Words in any script now match, text without spaces between words such as `搜索记录` is split into words, and canonically equivalent text such as `café` and `cafe\u0301` matches ([#10816](https://github.com/earendil-works/pi/issues/10816), [#11037](https://github.com/earendil-works/pi/pull/11037) by [@haoqixu](https://github.com/haoqixu))
206
+
3
207
  ## [1.4.0] - 2026-10-21
4
208
 
5
209
  ### Breaking Changes