@ny4n/durable 0.0.0-stage → 1.0.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 (303) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +711 -2
  3. package/dist/documents.d.ts +66 -0
  4. package/dist/documents.d.ts.map +1 -0
  5. package/dist/documents.js +98 -0
  6. package/dist/documents.js.map +1 -0
  7. package/dist/entries.d.ts +31 -0
  8. package/dist/entries.d.ts.map +1 -0
  9. package/dist/entries.js +31 -0
  10. package/dist/entries.js.map +1 -0
  11. package/dist/env/decode.d.ts +16 -0
  12. package/dist/env/decode.d.ts.map +1 -0
  13. package/dist/env/decode.js +28 -0
  14. package/dist/env/decode.js.map +1 -0
  15. package/dist/env/index.d.ts +263 -0
  16. package/dist/env/index.d.ts.map +1 -0
  17. package/dist/env/index.js +49 -0
  18. package/dist/env/index.js.map +1 -0
  19. package/dist/env/line-scan.d.ts +13 -0
  20. package/dist/env/line-scan.d.ts.map +1 -0
  21. package/dist/env/line-scan.js +147 -0
  22. package/dist/env/line-scan.js.map +1 -0
  23. package/dist/env/node-watch.d.ts +29 -0
  24. package/dist/env/node-watch.d.ts.map +1 -0
  25. package/dist/env/node-watch.js +485 -0
  26. package/dist/env/node-watch.js.map +1 -0
  27. package/dist/env/node.d.ts +56 -0
  28. package/dist/env/node.d.ts.map +1 -0
  29. package/dist/env/node.js +1166 -0
  30. package/dist/env/node.js.map +1 -0
  31. package/dist/errors.d.ts +32 -0
  32. package/dist/errors.d.ts.map +1 -0
  33. package/dist/errors.js +53 -0
  34. package/dist/errors.js.map +1 -0
  35. package/dist/harness/agent.d.ts +33 -0
  36. package/dist/harness/agent.d.ts.map +1 -0
  37. package/dist/harness/agent.js +235 -0
  38. package/dist/harness/agent.js.map +1 -0
  39. package/dist/harness/assistant.d.ts +15 -0
  40. package/dist/harness/assistant.d.ts.map +1 -0
  41. package/dist/harness/assistant.js +23 -0
  42. package/dist/harness/assistant.js.map +1 -0
  43. package/dist/harness/compaction.d.ts +56 -0
  44. package/dist/harness/compaction.d.ts.map +1 -0
  45. package/dist/harness/compaction.js +384 -0
  46. package/dist/harness/compaction.js.map +1 -0
  47. package/dist/harness/context.d.ts +63 -0
  48. package/dist/harness/context.d.ts.map +1 -0
  49. package/dist/harness/context.js +221 -0
  50. package/dist/harness/context.js.map +1 -0
  51. package/dist/harness/define.d.ts +18 -0
  52. package/dist/harness/define.d.ts.map +1 -0
  53. package/dist/harness/define.js +25 -0
  54. package/dist/harness/define.js.map +1 -0
  55. package/dist/harness/events.d.ts +180 -0
  56. package/dist/harness/events.d.ts.map +1 -0
  57. package/dist/harness/events.js +370 -0
  58. package/dist/harness/events.js.map +1 -0
  59. package/dist/harness/generation.d.ts +54 -0
  60. package/dist/harness/generation.d.ts.map +1 -0
  61. package/dist/harness/generation.js +514 -0
  62. package/dist/harness/generation.js.map +1 -0
  63. package/dist/harness/harness.d.ts +17 -0
  64. package/dist/harness/harness.d.ts.map +1 -0
  65. package/dist/harness/harness.js +332 -0
  66. package/dist/harness/harness.js.map +1 -0
  67. package/dist/harness/inbox.d.ts +59 -0
  68. package/dist/harness/inbox.d.ts.map +1 -0
  69. package/dist/harness/inbox.js +97 -0
  70. package/dist/harness/inbox.js.map +1 -0
  71. package/dist/harness/json.d.ts +10 -0
  72. package/dist/harness/json.d.ts.map +1 -0
  73. package/dist/harness/json.js +33 -0
  74. package/dist/harness/json.js.map +1 -0
  75. package/dist/harness/live.d.ts +142 -0
  76. package/dist/harness/live.d.ts.map +1 -0
  77. package/dist/harness/live.js +149 -0
  78. package/dist/harness/live.js.map +1 -0
  79. package/dist/harness/output.d.ts +69 -0
  80. package/dist/harness/output.d.ts.map +1 -0
  81. package/dist/harness/output.js +318 -0
  82. package/dist/harness/output.js.map +1 -0
  83. package/dist/harness/prompt.d.ts +27 -0
  84. package/dist/harness/prompt.d.ts.map +1 -0
  85. package/dist/harness/prompt.js +131 -0
  86. package/dist/harness/prompt.js.map +1 -0
  87. package/dist/harness/provider.d.ts +14 -0
  88. package/dist/harness/provider.d.ts.map +1 -0
  89. package/dist/harness/provider.js +30 -0
  90. package/dist/harness/provider.js.map +1 -0
  91. package/dist/harness/registry.d.ts +6 -0
  92. package/dist/harness/registry.d.ts.map +1 -0
  93. package/dist/harness/registry.js +101 -0
  94. package/dist/harness/registry.js.map +1 -0
  95. package/dist/harness/run.d.ts +17 -0
  96. package/dist/harness/run.d.ts.map +1 -0
  97. package/dist/harness/run.js +26 -0
  98. package/dist/harness/run.js.map +1 -0
  99. package/dist/harness/scheduler.d.ts +106 -0
  100. package/dist/harness/scheduler.d.ts.map +1 -0
  101. package/dist/harness/scheduler.js +1743 -0
  102. package/dist/harness/scheduler.js.map +1 -0
  103. package/dist/harness/submissions.d.ts +29 -0
  104. package/dist/harness/submissions.d.ts.map +1 -0
  105. package/dist/harness/submissions.js +175 -0
  106. package/dist/harness/submissions.js.map +1 -0
  107. package/dist/harness/task-graph.d.ts +49 -0
  108. package/dist/harness/task-graph.d.ts.map +1 -0
  109. package/dist/harness/task-graph.js +168 -0
  110. package/dist/harness/task-graph.js.map +1 -0
  111. package/dist/harness/tool.d.ts +76 -0
  112. package/dist/harness/tool.d.ts.map +1 -0
  113. package/dist/harness/tool.js +736 -0
  114. package/dist/harness/tool.js.map +1 -0
  115. package/dist/harness/types.d.ts +713 -0
  116. package/dist/harness/types.d.ts.map +1 -0
  117. package/dist/harness/types.js +2 -0
  118. package/dist/harness/types.js.map +1 -0
  119. package/dist/harness/usage.d.ts +18 -0
  120. package/dist/harness/usage.d.ts.map +1 -0
  121. package/dist/harness/usage.js +57 -0
  122. package/dist/harness/usage.js.map +1 -0
  123. package/dist/harness/util.d.ts +19 -0
  124. package/dist/harness/util.d.ts.map +1 -0
  125. package/dist/harness/util.js +58 -0
  126. package/dist/harness/util.js.map +1 -0
  127. package/dist/harness/view.d.ts +47 -0
  128. package/dist/harness/view.d.ts.map +1 -0
  129. package/dist/harness/view.js +206 -0
  130. package/dist/harness/view.js.map +1 -0
  131. package/dist/ids.d.ts +6 -0
  132. package/dist/ids.d.ts.map +1 -0
  133. package/dist/ids.js +9 -0
  134. package/dist/ids.js.map +1 -0
  135. package/dist/images/cloudflare.d.ts +4 -0
  136. package/dist/images/cloudflare.d.ts.map +1 -0
  137. package/dist/images/cloudflare.js +9 -0
  138. package/dist/images/cloudflare.js.map +1 -0
  139. package/dist/images/exif.d.ts +3 -0
  140. package/dist/images/exif.d.ts.map +1 -0
  141. package/dist/images/exif.js +71 -0
  142. package/dist/images/exif.js.map +1 -0
  143. package/dist/images/index.d.ts +18 -0
  144. package/dist/images/index.d.ts.map +1 -0
  145. package/dist/images/index.js +125 -0
  146. package/dist/images/index.js.map +1 -0
  147. package/dist/images/node-worker.d.ts +17 -0
  148. package/dist/images/node-worker.d.ts.map +1 -0
  149. package/dist/images/node-worker.js +16 -0
  150. package/dist/images/node-worker.js.map +1 -0
  151. package/dist/images/node.d.ts +21 -0
  152. package/dist/images/node.d.ts.map +1 -0
  153. package/dist/images/node.js +121 -0
  154. package/dist/images/node.js.map +1 -0
  155. package/dist/index.d.ts +24 -0
  156. package/dist/index.d.ts.map +1 -0
  157. package/dist/index.js +20 -0
  158. package/dist/index.js.map +1 -0
  159. package/dist/session/forks.d.ts +10 -0
  160. package/dist/session/forks.d.ts.map +1 -0
  161. package/dist/session/forks.js +42 -0
  162. package/dist/session/forks.js.map +1 -0
  163. package/dist/session/observation.d.ts +42 -0
  164. package/dist/session/observation.d.ts.map +1 -0
  165. package/dist/session/observation.js +281 -0
  166. package/dist/session/observation.js.map +1 -0
  167. package/dist/session/session.d.ts +133 -0
  168. package/dist/session/session.d.ts.map +1 -0
  169. package/dist/session/session.js +811 -0
  170. package/dist/session/session.js.map +1 -0
  171. package/dist/session/transaction.d.ts +119 -0
  172. package/dist/session/transaction.d.ts.map +1 -0
  173. package/dist/session/transaction.js +795 -0
  174. package/dist/session/transaction.js.map +1 -0
  175. package/dist/storage/jsonl/index.d.ts +2 -0
  176. package/dist/storage/jsonl/index.d.ts.map +1 -0
  177. package/dist/storage/jsonl/index.js +2 -0
  178. package/dist/storage/jsonl/index.js.map +1 -0
  179. package/dist/storage/jsonl/node.d.ts +6 -0
  180. package/dist/storage/jsonl/node.d.ts.map +1 -0
  181. package/dist/storage/jsonl/node.js +8 -0
  182. package/dist/storage/jsonl/node.js.map +1 -0
  183. package/dist/storage/jsonl/storage.d.ts +143 -0
  184. package/dist/storage/jsonl/storage.d.ts.map +1 -0
  185. package/dist/storage/jsonl/storage.js +682 -0
  186. package/dist/storage/jsonl/storage.js.map +1 -0
  187. package/dist/storage/memory.d.ts +62 -0
  188. package/dist/storage/memory.d.ts.map +1 -0
  189. package/dist/storage/memory.js +733 -0
  190. package/dist/storage/memory.js.map +1 -0
  191. package/dist/storage/scan.d.ts +18 -0
  192. package/dist/storage/scan.d.ts.map +1 -0
  193. package/dist/storage/scan.js +29 -0
  194. package/dist/storage/scan.js.map +1 -0
  195. package/dist/storage/sqlite/cloudflare.d.ts +46 -0
  196. package/dist/storage/sqlite/cloudflare.d.ts.map +1 -0
  197. package/dist/storage/sqlite/cloudflare.js +106 -0
  198. package/dist/storage/sqlite/cloudflare.js.map +1 -0
  199. package/dist/storage/sqlite/database.d.ts +37 -0
  200. package/dist/storage/sqlite/database.d.ts.map +1 -0
  201. package/dist/storage/sqlite/database.js +2 -0
  202. package/dist/storage/sqlite/database.js.map +1 -0
  203. package/dist/storage/sqlite/index.d.ts +4 -0
  204. package/dist/storage/sqlite/index.d.ts.map +1 -0
  205. package/dist/storage/sqlite/index.js +3 -0
  206. package/dist/storage/sqlite/index.js.map +1 -0
  207. package/dist/storage/sqlite/migrations.d.ts +11 -0
  208. package/dist/storage/sqlite/migrations.d.ts.map +1 -0
  209. package/dist/storage/sqlite/migrations.js +111 -0
  210. package/dist/storage/sqlite/migrations.js.map +1 -0
  211. package/dist/storage/sqlite/node.d.ts +41 -0
  212. package/dist/storage/sqlite/node.d.ts.map +1 -0
  213. package/dist/storage/sqlite/node.js +179 -0
  214. package/dist/storage/sqlite/node.js.map +1 -0
  215. package/dist/storage/sqlite/storage.d.ts +65 -0
  216. package/dist/storage/sqlite/storage.d.ts.map +1 -0
  217. package/dist/storage/sqlite/storage.js +670 -0
  218. package/dist/storage/sqlite/storage.js.map +1 -0
  219. package/dist/tasks.d.ts +6 -0
  220. package/dist/tasks.d.ts.map +1 -0
  221. package/dist/tasks.js +5 -0
  222. package/dist/tasks.js.map +1 -0
  223. package/dist/testing/assertions.d.ts +5 -0
  224. package/dist/testing/assertions.d.ts.map +1 -0
  225. package/dist/testing/assertions.js +25 -0
  226. package/dist/testing/assertions.js.map +1 -0
  227. package/dist/testing/env-conformance.d.ts +7 -0
  228. package/dist/testing/env-conformance.d.ts.map +1 -0
  229. package/dist/testing/env-conformance.js +431 -0
  230. package/dist/testing/env-conformance.js.map +1 -0
  231. package/dist/testing/index.d.ts +7 -0
  232. package/dist/testing/index.d.ts.map +1 -0
  233. package/dist/testing/index.js +6 -0
  234. package/dist/testing/index.js.map +1 -0
  235. package/dist/testing/runner.d.ts +12 -0
  236. package/dist/testing/runner.d.ts.map +1 -0
  237. package/dist/testing/runner.js +23 -0
  238. package/dist/testing/runner.js.map +1 -0
  239. package/dist/testing/storage-benchmark.d.ts +41 -0
  240. package/dist/testing/storage-benchmark.d.ts.map +1 -0
  241. package/dist/testing/storage-benchmark.js +350 -0
  242. package/dist/testing/storage-benchmark.js.map +1 -0
  243. package/dist/testing/storage-conformance.d.ts +4 -0
  244. package/dist/testing/storage-conformance.d.ts.map +1 -0
  245. package/dist/testing/storage-conformance.js +1213 -0
  246. package/dist/testing/storage-conformance.js.map +1 -0
  247. package/dist/testing/types.d.ts +37 -0
  248. package/dist/testing/types.d.ts.map +1 -0
  249. package/dist/testing/types.js +2 -0
  250. package/dist/testing/types.js.map +1 -0
  251. package/dist/tools/bash.d.ts +55 -0
  252. package/dist/tools/bash.d.ts.map +1 -0
  253. package/dist/tools/bash.js +138 -0
  254. package/dist/tools/bash.js.map +1 -0
  255. package/dist/tools/edit-diff.d.ts +89 -0
  256. package/dist/tools/edit-diff.d.ts.map +1 -0
  257. package/dist/tools/edit-diff.js +386 -0
  258. package/dist/tools/edit-diff.js.map +1 -0
  259. package/dist/tools/edit.d.ts +18 -0
  260. package/dist/tools/edit.d.ts.map +1 -0
  261. package/dist/tools/edit.js +117 -0
  262. package/dist/tools/edit.js.map +1 -0
  263. package/dist/tools/env.d.ts +5 -0
  264. package/dist/tools/env.d.ts.map +1 -0
  265. package/dist/tools/env.js +7 -0
  266. package/dist/tools/env.js.map +1 -0
  267. package/dist/tools/file-mutation-queue.d.ts +9 -0
  268. package/dist/tools/file-mutation-queue.d.ts.map +1 -0
  269. package/dist/tools/file-mutation-queue.js +52 -0
  270. package/dist/tools/file-mutation-queue.js.map +1 -0
  271. package/dist/tools/image-processor.d.ts +47 -0
  272. package/dist/tools/image-processor.d.ts.map +1 -0
  273. package/dist/tools/image-processor.js +30 -0
  274. package/dist/tools/image-processor.js.map +1 -0
  275. package/dist/tools/image.d.ts +20 -0
  276. package/dist/tools/image.d.ts.map +1 -0
  277. package/dist/tools/image.js +178 -0
  278. package/dist/tools/image.js.map +1 -0
  279. package/dist/tools/index.d.ts +46 -0
  280. package/dist/tools/index.d.ts.map +1 -0
  281. package/dist/tools/index.js +23 -0
  282. package/dist/tools/index.js.map +1 -0
  283. package/dist/tools/path-utils.d.ts +5 -0
  284. package/dist/tools/path-utils.d.ts.map +1 -0
  285. package/dist/tools/path-utils.js +26 -0
  286. package/dist/tools/path-utils.js.map +1 -0
  287. package/dist/tools/read.d.ts +25 -0
  288. package/dist/tools/read.d.ts.map +1 -0
  289. package/dist/tools/read.js +235 -0
  290. package/dist/tools/read.js.map +1 -0
  291. package/dist/tools/write.d.ts +10 -0
  292. package/dist/tools/write.d.ts.map +1 -0
  293. package/dist/tools/write.js +31 -0
  294. package/dist/tools/write.js.map +1 -0
  295. package/dist/truncate.d.ts +64 -0
  296. package/dist/truncate.d.ts.map +1 -0
  297. package/dist/truncate.js +154 -0
  298. package/dist/truncate.js.map +1 -0
  299. package/dist/types.d.ts +932 -0
  300. package/dist/types.d.ts.map +1 -0
  301. package/dist/types.js +3 -0
  302. package/dist/types.js.map +1 -0
  303. package/package.json +140 -5
package/README.md CHANGED
@@ -1,3 +1,712 @@
1
- # Temporary Holding Version
1
+ # @ny4n/durable
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > **Experimental.** The API changes without notice between releases.
4
+
5
+ A durable agent harness. Conversations, model turns, tool calls, and your own state are committed to storage before anything is shown. If the process dies mid-turn, reopening the storage picks the work up where it stopped.
6
+
7
+ Built on [`@ny4n/ai`](../ai/README.md) for model access and `@ny4n/chord` for document state.
8
+ The durable runtime consumes the provider-neutral model operations from the `@ny4n/ai/contracts` entry point; provider
9
+ catalogs, credentials, and OAuth remain owned by the host that supplies the model runtime.
10
+
11
+ ## Table of Contents
12
+
13
+ - [Installation](#installation)
14
+ - [Quick Start](#quick-start)
15
+ - [Concepts](#concepts)
16
+ - [Persist and Resume](#persist-and-resume)
17
+ - [Extensions](#extensions)
18
+ - [Tools](#tools)
19
+ - [System Prompt](#system-prompt)
20
+ - [Per-Conversation Agent](#per-conversation-agent)
21
+ - [Settings](#settings)
22
+ - [Environment](#environment)
23
+ - [Reload](#reload)
24
+ - [Watching a Conversation](#watching-a-conversation)
25
+ - [Busy Conversations](#busy-conversations)
26
+ - [Reset and Handoff](#reset-and-handoff)
27
+ - [Compaction](#compaction)
28
+ - [Agent Events (Experimental)](#agent-events-experimental)
29
+ - [Hooks](#hooks)
30
+ - [More Conversations and Forks](#more-conversations-and-forks)
31
+ - [Abort and Subagents](#abort-and-subagents)
32
+ - [Child Tasks](#child-tasks)
33
+ - [Task Graph](#task-graph)
34
+ - [Your Own State](#your-own-state)
35
+ - [Usage and Cost](#usage-and-cost)
36
+ - [Storage](#storage)
37
+ - [Errors](#errors)
38
+ - [Examples](#examples)
39
+ - [Design Documents](#design-documents)
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ npm install @ny4n/durable @ny4n/ai @ny4n/chord
45
+ ```
46
+
47
+ ## Quick Start
48
+
49
+ ```typescript
50
+ import { BACKGROUND_CONTEXT } from "@ny4n/chord/context";
51
+ import { createModels } from "@ny4n/ai/models";
52
+ import { openaiProvider } from "@ny4n/ai/providers/openai";
53
+ import { AssistantEntry, createRegistry, Harness, MemoryStorage } from "@ny4n/durable";
54
+
55
+ const context = BACKGROUND_CONTEXT;
56
+
57
+ const models = createModels();
58
+ models.setProvider(openaiProvider()); // reads OPENAI_API_KEY
59
+
60
+ const harness = await Harness.open(new MemoryStorage(), { models, registry: createRegistry() }, context);
61
+ const root = await harness.root(context, { agent: { model: { provider: "openai", modelId: "gpt-6-sol" } } });
62
+
63
+ const submission = await root.submit({ type: "input", content: "What is the capital of France?" }, context);
64
+ const settled = await submission.wait(context);
65
+ if (settled.status === "done" && settled.type === "input") {
66
+ const answer = await root.commit((tx) => tx.entry(AssistantEntry, settled.answer), context);
67
+ console.log(answer?.model?.[0]);
68
+ }
69
+ await harness.close(context);
70
+ ```
71
+
72
+ What happened:
73
+
74
+ - `Harness.open()` opens a Session over a storage backend. `MemoryStorage` keeps everything in memory.
75
+ - `root()` returns the root conversation, creating it on first use with the given agent choices. A conversation is a transcript of immutable entries.
76
+ - `submit()` durably admits your input and returns a `Submission`. A built-in generation task calls the model and appends the answer.
77
+ - `wait()` resolves once the input is answered (`done`) or has failed (`unanswered`, with a reason).
78
+
79
+ Every async call takes a Chord `Context`, which carries cancellation. `BACKGROUND_CONTEXT` never cancels. Cancelling a wait only cancels that wait, never the work.
80
+
81
+ ## Concepts
82
+
83
+ - **Harness**: one open storage plus the machinery that runs agents on it. All changes go through one line of atomic commits, and nothing is shown before its commit is stored.
84
+ - **Conversation**: a transcript. `root()` creates the root conversation on first use; you can create more and fork them. A `Conversation` handle holds no state; compare handles by `id`.
85
+ - **Entry**: one immutable transcript record, such as a user message (`nyan.user`), a model response (`nyan.assistant`), a tool result (`nyan.tool-result`), a system prompt change (`nyan.system`), a reset (`nyan.reset`), or your own kind. The model sees the entries from the newest reset onward.
86
+ - **Commit**: an atomic write. `conversation.commit((tx) => ...)` can append entries, edit documents, and create tasks together; either all of it is stored or none of it.
87
+ - **Document**: typed JSON state stored next to the transcript and changed in commits. Built-in ones hold each conversation's agent choices (`nyan.agent`), provider-facing session identity (`nyan.provider`), running generation and tools (`nyan.live`), queued submissions (`nyan.inbox`), and spend (`nyan.usage`).
88
+ - **Task**: a durable state machine that saves a checkpoint at every step, so a restarted process continues from the last one. Every task has an owner: its conversation, or another task. The Harness runs answers as built-in tasks: `nyan.generation` calls the model and owns the `nyan.tool` tasks of its tool calls, waits for them, and hands the run to the next generation.
89
+ - **Submission**: something you hand to a conversation, either user input or an entry to write, which you can wait for.
90
+ - **Turn and run**: a turn is one model response and its tool calls; a run is the turns from an input to its final answer. A conversation is busy while a run is going.
91
+ - **Extension**: a named bundle of tools, system prompt sections, hooks, wrappers, and tasks.
92
+ - **Registry**: the extensions this process installed. It can change while the Harness runs; new work uses the new state.
93
+ - **Agent**: what a conversation runs with: model, thinking level, selected extensions, tools, instructions, and working directory. Stored per conversation as names in `nyan.agent`, resolved against the registry at each use.
94
+
95
+ One answered input, as entries and tasks:
96
+
97
+ ```text
98
+ submit(input) → nyan.user
99
+ nyan.generation → nyan.system (only if the prompt or tools changed), nyan.assistant (tool calls)
100
+ nyan.tool × n → nyan.tool-result × n (owned by the generation, which waits for them)
101
+ nyan.generation → nyan.assistant (answer) → submission done
102
+ ```
103
+
104
+ ## Persist and Resume
105
+
106
+ Use SQLite or JSONL storage to keep conversations across restarts:
107
+
108
+ ```typescript
109
+ import { openNodeSqliteStorage } from "@ny4n/durable/storage/sqlite/node";
110
+
111
+ const harness = await Harness.open(await openNodeSqliteStorage("./session.sqlite"), { models, registry }, context);
112
+ const root = await harness.root(context); // the same root as last time
113
+ harness.resume(); // continue any run the last process left unfinished
114
+ ```
115
+
116
+ Work interrupted by a crash or close stays pending. `resume()` starts the task scheduler; submitting or waiting starts it too. Each conversation has its own persisted UUIDv7 in `nyan.provider`, forwarded to @ny4n/ai as `sessionId` for provider prompt-cache and session affinity. It survives reopen, retries, reset, compaction, and model changes; a child or fork receives a fresh identity. A legacy conversation receives and persists one before its first generation or compaction request.
117
+
118
+ A retried submission with the same `requestId` returns the existing submission instead of submitting twice:
119
+
120
+ ```typescript
121
+ const submission = await root.submit({ type: "input", content: "Hello", requestId: "greeting-1" }, context);
122
+ // After a restart: the same request ID finds the same submission.
123
+ const again = await root.submit({ type: "input", content: "Hello", requestId: "greeting-1" }, context);
124
+ // again.id === submission.id
125
+ ```
126
+
127
+ `harness.submission(id)` reacquires a submission by ID, for example to wait for it after a restart.
128
+
129
+ ## Extensions
130
+
131
+ Code the Harness runs, other than its built-in tasks, comes in named extensions installed in a registry your process owns:
132
+
133
+ ```typescript
134
+ import { createRegistry, defineExtension, defineTool, hook, section, ToolTask } from "@ny4n/durable";
135
+ import { CodingTools } from "@ny4n/durable/tools";
136
+
137
+ const Coding = defineExtension({
138
+ name: "coding",
139
+ sections: [section("preamble", () => "You are a concise coding assistant.", { tag: false })],
140
+ hooks: [hook(ToolTask, { beforeTool: (call) => (isDangerous(call) ? { block: "Needs approval" } : undefined) })],
141
+ });
142
+
143
+ const registry = createRegistry();
144
+ registry.install(CodingTools);
145
+ registry.install(Coding);
146
+ ```
147
+
148
+ An extension may bring `tools`, `sections`, `hooks`, `wraps` (decorators of a tool or section by name), and `tasks`. By default every conversation selects every installed extension, in install order. Nothing in the registry is stored; conversations store extension names.
149
+
150
+ ## Tools
151
+
152
+ `@ny4n/durable/tools` provides `read`, `write`, `edit`, and `bash`, and the `CodingTools` extension with all four. They touch files and processes only through the call's environment (see [Environment](#environment)).
153
+
154
+ `read` returns an image file as one image block, which programs calling it get as an `ImageContent`. Limits are the model's `inputLimits.images.resize`, by default 2000x2000 pixels and 4.5 MB of base64. Without an image processor it sends PNG, JPEG, GIF, and WebP files within them (dimensions read from the file's header) as they are, without decoding them, and refuses larger ones and other formats. With one, it decodes every image, turns it upright by its EXIF orientation, converts BMP to PNG, and shrinks it to fit. The Photon-based processor runs on WebAssembly and is only loaded when you import it:
155
+
156
+ ```typescript
157
+ import { createNodePhotonImages } from "@ny4n/durable/images/node"; // or /images/cloudflare, or createPhotonImages(wasm) from /images
158
+ import { createCodingTools } from "@ny4n/durable/tools";
159
+
160
+ registry.install(createCodingTools({ images: await createNodePhotonImages() }));
161
+ ```
162
+
163
+ A model without image input sees a placeholder instead of the image, and `read` says so in a diagnostic. `/images/node` prepares images in a worker thread, so a large photo (a second or two of work) never blocks the event loop; the worker exits after 30 s without images, freeing Photon's WebAssembly memory (`createNodePhotonImages({ worker: false, idleMs })`). Elsewhere, `createPhotonImages()` and `/images/cloudflare` work on the calling thread, and decoding takes the image's full size in WebAssembly memory, which does not shrink again; leave the processor out on memory-constrained hosts such as Cloudflare Workers unless images stay small.
164
+
165
+ Define your own tool with a TypeBox schema. `defineTool()` types `args` from `parameters`, which the Harness validates before `execute()`. `api.output()` streams running output, which becomes the result when `execute()` returns no `output`:
166
+
167
+ ```typescript
168
+ import { Type } from "@ny4n/ai";
169
+
170
+ const count = defineTool({
171
+ name: "count",
172
+ description: "Count from 1 to n",
173
+ parameters: Type.Object({ n: Type.Number() }),
174
+ execute: async (args, api) => {
175
+ for (let i = 1; i <= args.n; i++) api.output(`${i}\n`);
176
+ return {};
177
+ },
178
+ });
179
+ registry.install(defineExtension({ name: "count", tools: [count] }));
180
+ ```
181
+
182
+ Each call runs as its own durable task. Its intent is committed before `execute()` runs. If the process dies mid-call, the tool reruns on reopen only when it is declared `replay: "safe"`; otherwise the model gets an `interrupted` error result with the output committed so far. Throwing from `execute()` gives the model an error result. A result can also return `usage`, which is added to the conversation's [usage](#usage-and-cost). It can also return `control: { terminate: true }`: when every result of the round asks for it, the run ends without another model request.
183
+
184
+ A result has three channels: `output` for the model, `structuredOutput` for programs that call the tool (see [Calling tools from tools](#calling-tools-from-tools)), and `details` for UIs. A tool that declares `structuredOutputSchema` returns a matching `structuredOutput`, which the Harness validates. Programs calling a tool without one get its bounded output: one text item as a string, one image as its `ImageContent`, nothing as `""`, and anything else as the content list, for errors too. The transcript stores only `output` and `details`:
185
+
186
+ ```typescript
187
+ const lines = defineTool({
188
+ name: "lines",
189
+ description: "Count the lines of a file",
190
+ parameters: Type.Object({ path: Type.String() }),
191
+ structuredOutputSchema: Type.Object({ lines: Type.Number() }),
192
+ execute: async (args, api, context) => {
193
+ const text = await readText(api, args.path, context);
194
+ const count = text.split("\n").length;
195
+ return { output: [{ type: "text", text: `${count} lines` }], structuredOutput: { lines: count } };
196
+ },
197
+ });
198
+ ```
199
+
200
+ A missing, undeclared, or invalid `structuredOutput` turns a nested call's result into an error result with an `invalid_structured_output` diagnostic; for a call the model made, which never sees it, it is dropped and reported through `onReport`. An error result may omit it; error results the Harness writes itself (blocked, invalid, interrupted, aborted) carry none, and their `diagnostics` say what went wrong. An `afterTool` hook that redacts a schema tool's `output` must redact its `structuredOutput` too. The built-in `bash` returns `{ output, truncated, fullOutputPath?, exitCode }`, where `output` is the retained tail the model sees, read with `api.retainedOutput()`, and answers a nonzero exit with an error result that still carries it.
201
+
202
+ A later extension's tool with the same name replaces an earlier one where both are selected, and `wrapTool()` decorates whichever tool won:
203
+
204
+ ```typescript
205
+ const Venv = defineExtension({ name: "venv", tools: [createBashTool({ commandPrefix: "source .venv/bin/activate" })] });
206
+ const Timing = defineExtension({
207
+ name: "timing",
208
+ wraps: [wrapTool(createBashTool(), (bash) => ({ ...bash, execute: (args, api, ctx) => timed(() => bash.execute(args, api, ctx)) }))],
209
+ });
210
+ ```
211
+
212
+ ### Calling tools from tools
213
+
214
+ A tool calls another tool with `api.executeTool()`, as a code mode script or an MCP bridge does. The nested call is its own tool task, owned by the calling one: it is validated, runs the `ToolTask` hooks, keeps its own output limits and replay policy, and returns its result to the caller instead of the transcript:
215
+
216
+ ```typescript
217
+ const testsPass = defineTool({
218
+ name: "tests_pass",
219
+ description: "Run the test suite and say whether it passed",
220
+ parameters: Type.Object({}),
221
+ execute: async (_args, api, context) => {
222
+ const run = await api.executeTool("bash", { command: "npm test" }, context, { progress: false });
223
+ // Absent when bash did not finish, for example on a timeout.
224
+ const exitCode = (run.structuredOutput as { exitCode: number } | undefined)?.exitCode;
225
+ return { output: [{ type: "text", text: exitCode === 0 ? "passed" : "failed" }] };
226
+ },
227
+ });
228
+ ```
229
+
230
+ - The caller gets the nested call's `taskId`, `structuredOutput`, `details`, `diagnostics`, `usage`, and `isError`, not the `output` meant for the model. A blocked, invalid, failed, or aborted nested call returns an `isError` result. `executeTool()` itself rejects once `execute()` has returned, when the caller is aborted, or when the Harness closes; cancelling its `context` stops only the wait.
231
+ - Hooks see a nested call's `call.parent`, so a guard can treat calls from scripts differently.
232
+ - While the caller runs, its nested calls show in `docs["nyan.live"].nestedTools`, with the arguments they run with and a `summary` (error, duration, usage) once done, and as tool events with `parentToolCallId` and `parentTaskId`. `progress: false` keeps a nested call's running output out of `nyan.live`. Before the caller settles, nested calls it left running are aborted, also those an earlier attempt made. The caller's result message does not list its nested calls; their task records keep them.
233
+ - Each nested call has a key, its position among the caller's nested calls unless the caller passes `{ key }`. A replay-safe caller that reruns after a crash and calls in the same order, or passes the same keys, gets the nested calls it already made back, finished or still running, instead of starting them again. A nested call the crash interrupted before its result was committed follows its own `replay` policy, so a replay-safe tool with external effects still needs its own idempotency. A caller that is not replay-safe never reruns, so after a restart its unfinished nested calls are abandoned, with everything they own, before they run again.
234
+ - A nested call's `usage` is counted under its own tool; passing it on in the caller's result counts it twice.
235
+ - Nested results are not kept: they live in the caller's task documents (`NestedCallDoc`, one per nested call, keyed by its key, holding `{ taskId, result? }`) only until the caller settles. The nested call's task record keeps only a small receipt. A stored result is exactly what `executeTool()` returns, without the model's `output`, so an image or a `bash` tail is stored once.
236
+ - Nothing bounds how deeply calls nest; a tool with the default `callers` may even call itself. Restrict `callers` where that matters (below).
237
+
238
+ ### Who may call a tool
239
+
240
+ `callers` on a tool says who may call it: the model, other tools through `executeTool()`, or both, the default. A conversation's `modelTools` narrows which of its tools the model is offered, without taking them away from tools:
241
+
242
+ ```typescript
243
+ const codemode = defineTool({ name: "codemode", callers: ["model"], ... }); // scripts cannot start scripts
244
+ const search = defineTool({ name: "mcp__github__search", callers: ["tools"], ... }); // never offered to the model
245
+
246
+ // The model is offered only codemode; its scripts call read, bash, and the MCP tools.
247
+ await root.configure({ modelTools: [codemode] }, context);
248
+ // The model is offered everything but bash, which scripts may still call.
249
+ await root.configure({ modelTools: { remove: [bash] } }, context);
250
+ ```
251
+
252
+ `conversation.agent()` resolves both lists: `tools`, offered to the model, and `callable`, which nested calls resolve among. `tools` still decides what is enabled at all; `modelTools` cannot add to it.
253
+
254
+ ### Tasks a restart abandons
255
+
256
+ A task that its creator awaits only in memory, and that the creator never resumes after a restart, can be created with `abandonOnRestart: true`. When a later Harness starts scheduling and finds it still live, it is aborted with `abortReason: "restart"`, together with everything it owns, before any of it runs again. Nested calls of tools that are not replay-safe get this automatically, and so do the child tasks such a tool creates with `api.createTask()`. A task abandoned this way whose definition is missing waits until its extension is installed, so its abort handler still cleans up. Its caller, and the conversation, wait with it; `harness.inspect()` shows it as blocked. Aborting it then orphans it, as any abort does.
257
+
258
+ ## System Prompt
259
+
260
+ The system prompt is built from the selected extensions' sections, rendered in order before each request. A section sees the resolved agent, the environment built for the request, and committed documents:
261
+
262
+ ```typescript
263
+ section("cwd", (input) => input.env?.cwd); // rendered as <cwd>\n...\n</cwd>; undefined omits it
264
+ ```
265
+
266
+ A conversation's `instructions` render last, as the section `instructions`. Sections and tool changes are stored as positional system entries in the transcript. Only what changed is sent again, which keeps provider prompt caches warm. A section that returns something different every time, such as the current time, defeats that.
267
+
268
+ ## Per-Conversation Agent
269
+
270
+ Each conversation stores what it runs with in its `nyan.agent` document. `configure()` changes it in one commit; unset fields follow the host:
271
+
272
+ ```typescript
273
+ await root.configure(
274
+ {
275
+ model: { provider: "openai", modelId: "gpt-6-sol" },
276
+ thinkingLevel: "high",
277
+ extensions: { remove: [Coding] }, // edits the host default; an array selects exactly these, in order
278
+ tools: [readTool, bashTool], // an array enables exactly these; { remove: [...] } drops some
279
+ modelTools: [readTool], // of those, offer the model only read; tools may still call bash
280
+ instructions: "Only read; never edit files.",
281
+ cwd: "/work/repo",
282
+ },
283
+ context,
284
+ );
285
+ await root.configure({ tools: null }, context); // null clears a field back to the host default
286
+ const agent = await root.agent(context); // resolved: model, extensions, tools, sections, cwd
287
+ ```
288
+
289
+ Extensions and tools are passed as objects and stored by name, so a stored name outlives its code: after an extension is uninstalled, conversations that select it just stop getting it until it is installed again. `createConversation()`, `fork()`, and `root()` take the same change as `agent`. A task-owned conversation, such as a subagent's, starts as a copy of its owner's conversation's agent. A fork starts with the agent its parent had at the fork entry. The model, prompt, and offered tools of a request are fixed when it is prepared; a change applies from the next request. Tool calls and hooks use the agent as their task phase resolves it, and the environment is built from the current `cwd` at each use, so a `cwd` or extension change can reach calls the model already made.
290
+
291
+ ## Settings
292
+
293
+ Run policy shared by every conversation is passed as `settings`. It is read at every use and never stored, so getters make it live, for example backed by a settings file:
294
+
295
+ ```typescript
296
+ const harness = await Harness.open(storage, {
297
+ models,
298
+ registry,
299
+ settings: {
300
+ extensions: [CodingTools, Coding], // default selection; absent: every installed extension
301
+ stream: { timeoutMs: 120_000 },
302
+ retry: { maxRetries: 3 },
303
+ compaction: { reserveTokens: 16384 },
304
+ progress: { partialIntervalMs: 100, outputIntervalMs: 100 },
305
+ toolExecution: "parallel",
306
+ contextRetentionMs: 600_000, // idle conversations keep their context in memory this long (checked lazily where timers cannot be unreferenced)
307
+ documentRetentionMs: 600_000, // loaded documents nothing observes stay in memory this long after their last use; observed ones always
308
+ get followUpMode() {
309
+ return userSettings.followUpMode;
310
+ },
311
+ },
312
+ }, context);
313
+ ```
314
+
315
+ ## Environment
316
+
317
+ `env` builds the execution environment for each tool call, section rendering, and `runtime.env()`. It receives the conversation's ID, its agent `cwd`, and committed reads, so one function serves a directory per conversation or a container per conversation:
318
+
319
+ ```typescript
320
+ import { NodeExecutionEnv } from "@ny4n/durable/env/node";
321
+
322
+ const harness = await Harness.open(storage, {
323
+ models,
324
+ registry,
325
+ env: ({ cwd }) => new NodeExecutionEnv({ cwd: cwd ?? process.cwd() }),
326
+ }, context);
327
+ ```
328
+
329
+ A throw from `env` becomes the call's error result. Without an environment, the built-in tools fail with an error result. A fresh environment object per call is fine: `edit` and `write` serialize changes to one file by the environment's `id` and path. A custom `ExecutionEnv` sets `id` so that equal ids see the same files at the same paths, for example one id per container.
330
+
331
+ Hosts can use the environment directly too, for example to show a project's files. `openBinaryReader()` reads byte ranges of one opened file, `openDirReader()` pages a directory, and `exec()` with an argv array runs a program without a shell, reporting which stream each output chunk came from:
332
+
333
+ ```typescript
334
+ const status = { stdout: "", stderr: "" };
335
+ await env.exec(["git", "status", "--porcelain=v2", "-z"], {
336
+ onOutput: (text, _context, { stream }) => {
337
+ status[stream] += text;
338
+ },
339
+ }, context);
340
+ ```
341
+
342
+ Abort the context to stop one call; `cleanup()` is for shutting the environment down. A custom environment can check itself with `registerEnvConformance()` from `@ny4n/durable/testing`, like storage below.
343
+
344
+ ## Reload
345
+
346
+ Installing an extension with an installed name replaces it in place, in one step:
347
+
348
+ ```typescript
349
+ registry.install(await loadCodingExtension()); // same name "coding": replaces the installed one
350
+ ```
351
+
352
+ `registry.uninstall(extension)` removes the installed extension with that name, whichever object it is.
353
+
354
+ Work that already started keeps the code it took: a running tool call finishes under its old implementation, and each task phase resolves hooks and the agent once, from the registry at the phase's start. The next phase, request, or call uses the new code. After a restart, install the same extensions again; pending tasks of an extension's `tasks` resume once it is installed.
355
+
356
+ ## Watching a Conversation
357
+
358
+ Everything a UI needs is committed state. `viewState()` returns the conversation's structural view as a read-only Chord state, updated after every commit that touches it:
359
+
360
+ ```typescript
361
+ const view = await root.viewState(context);
362
+ view.subscribe((value) => {
363
+ // value.entries: the active transcript
364
+ // value.docs["nyan.live"]: the running generation (streamed partial, retry, deferred) and tool calls (output, details)
365
+ // value.docs["nyan.inbox"], value.docs["nyan.usage"], value.docs["nyan.agent"], value.docs["nyan.provider"]
366
+ render(value);
367
+ });
368
+ // later: view.dispose();
369
+ ```
370
+
371
+ `watch()` delivers the same view with the exact Chord operations of each commit, one callback at a time:
372
+
373
+ ```typescript
374
+ const watch = await root.watch(context);
375
+ render(watch.value); // the state at attachment
376
+ watch.start(async (value, ops) => {
377
+ await send(ops); // for example to a remote client that applies them
378
+ });
379
+ // later: await watch.stop();
380
+ ```
381
+
382
+ A slow watch keeps at most 100 undelivered frames. After that, the pending frames are replaced by one frame holding the whole newest view. A client that joins late or reconnects starts from the current view; nothing is replayed.
383
+
384
+ Partial answers and tool output are committed at most every 100 ms by default, so a crash loses at most that window. `settings.progress` changes the intervals; a host whose storage is remote can commit less often, for example `{ partialIntervalMs: 500, outputIntervalMs: 500 }`.
385
+
386
+ ## Busy Conversations
387
+
388
+ A conversation is busy while a run is working on an input. Submitting to a busy conversation queues the submission in the conversation's inbox, `docs["nyan.inbox"]` in the view:
389
+
390
+ ```typescript
391
+ await root.submit({ type: "input", content: "Also run the tests" }, context); // follow-up (default)
392
+ await root.submit({ type: "input", content: "Use pnpm, not npm", whenBusy: "steer" }, context);
393
+ await root.submit({ type: "input", content: "Only if idle", whenBusy: "reject" }, context); // throws ConversationBusy
394
+ await root.submit({ type: "write", entry: { kind: "app.note", data: "user opened a file" } }, context);
395
+ ```
396
+
397
+ - **Steers** are placed after the current tool round and join the running work.
398
+ - **Follow-ups** are placed when the run answers, and start the next run.
399
+ - **Writes** append an entry without asking the model anything.
400
+ - `await submission.abort(context)` withdraws a queued submission.
401
+ - The [settings](#settings) `steeringMode: "all"` and `followUpMode: "all"` place every queued item at once instead of one per turn.
402
+
403
+ If a run fails, queued items stay in the inbox until the next submission places them, oldest first.
404
+
405
+ ## Reset and Handoff
406
+
407
+ `reset()` starts a new context. The model no longer sees older entries, but they stay in storage:
408
+
409
+ ```typescript
410
+ await root.reset(undefined, context); // start from nothing
411
+ await root.reset("We were fixing the flaky login test. Continue.", context); // start from a handoff note
412
+ ```
413
+
414
+ While busy, the reset is queued like a write. When it is placed during a tool round, the current run ends. A tool can request the same with `control: { handoff: "..." }`.
415
+
416
+ ## Compaction
417
+
418
+ Compaction shrinks what the model sees: it summarizes older entries and appends a `nyan.compaction` entry that holds the summary and heads the first entry it keeps. Older entries stay in storage.
419
+
420
+ ```typescript
421
+ const id = await root.compact("Keep the failing test names", context); // manual, with optional instructions
422
+ const { outcome } = (await harness.waitForTask(id, context)).state;
423
+ if (outcome.status === "completed" && outcome.result.submissionId !== undefined) {
424
+ const placed = await (await harness.submission(outcome.result.submissionId, context))!.wait(context);
425
+ console.log(placed.status); // "done", or "unanswered" with reason "stale"
426
+ }
427
+ ```
428
+
429
+ The conversation keeps working while the summary is made. The summary is placed at once when the conversation is idle, otherwise at the next turn boundary. Esc (`abort()`) cancels a manual compaction.
430
+
431
+ Generation also compacts on its own, controlled by the [settings](#settings):
432
+
433
+ ```typescript
434
+ settings: {
435
+ compaction: {
436
+ enabled: true, // automatic compaction; manual compact() always works
437
+ reserveTokens: 16384, // above contextWindow - reserveTokens, the next request waits for a compaction
438
+ keepRecentTokens: 20000, // roughly how much recent context stays verbatim
439
+ backgroundTokens: 32768, // this far below that, a compaction starts in the background; 0 disables it
440
+ },
441
+ }
442
+ ```
443
+
444
+ When a provider rejects a request because the context is too long, generation compacts and retries once. A summary that would cut before the start of the current context settles as `stale` when it is placed, so when several are in flight, the furthest cut stays in effect. Summarization spend counts in `nyan.usage`. A `beforeCompact` hook on `CompactionTask` can decline or supply its own summary.
445
+
446
+ Running compactions are listed in `docs["nyan.live"].compactions` with their reason, attempt, and retry backoff. The agent events add `compaction_start` and `compaction_end`, and a `compactions` field in the snapshot.
447
+
448
+ ## Agent Events (Experimental)
449
+
450
+ For consumers that want coding-agent style events (`message_start`, `message_update`, `tool_execution_start`, ...) instead of structural state:
451
+
452
+ ```typescript
453
+ import { watchEvents } from "@ny4n/durable";
454
+
455
+ const stream = await watchEvents(harness, root.id, context);
456
+ initialize(stream.snapshot); // entries, run, in-flight generation, tools, compactions, inbox, agent, usage
457
+ stream.start(async (events) => {
458
+ for (const event of events) console.log(JSON.stringify(event));
459
+ });
460
+ ```
461
+
462
+ Events are derived from commits, one batch per commit, and apply on top of the snapshot. Message and tool updates carry deltas: text and thinking appends, appended tool-call argument text, and output trims and appends. When a consumer falls more than 100 batches behind, it receives a fresh `snapshot` event instead. See `test/examples/19-json.ts` for the full stream of one run.
463
+
464
+ ## Hooks
465
+
466
+ Hooks let extensions observe or adjust the built-in tasks, in the conversations that select them:
467
+
468
+ ```typescript
469
+ import { GenerationTask, hook, ToolTask } from "@ny4n/durable";
470
+
471
+ const Guard = defineExtension({
472
+ name: "guard",
473
+ hooks: [
474
+ hook(ToolTask, { beforeTool: (call) => (call.name === "bash" ? { block: "bash is disabled here" } : undefined) }),
475
+ hook(GenerationTask, { onYield: (answer) => (needsMoreWork(answer) ? { continue: "Keep going." } : undefined) }),
476
+ ],
477
+ });
478
+ ```
479
+
480
+ - **Generation:** `beforeRequest` (replace the messages of one request), `afterResponse`, `onYield` (continue the run with another user message), and `afterTools` (runs once a round's tools are done).
481
+ - **Tools:** `beforeTool` (block or rewrite arguments) and `afterTool` (replace the result).
482
+
483
+ To limit a hook to some conversations, select its extension only there, for example with `configure({ extensions: { add: [Guard] } })`.
484
+
485
+ ## More Conversations and Forks
486
+
487
+ ```typescript
488
+ const other = await harness.createConversation({ ownership: { kind: "ownerless" } }, context);
489
+ const fork = await root.fork(entryId, { ownership: { kind: "ownerless" } }, context);
490
+ ```
491
+
492
+ A fork sees its parent's entries up to `entryId` and continues independently. It keeps the parent's agent as of that entry but receives a fresh provider session identity. Both take `agent` and `init`, applied in the creating commit.
493
+
494
+ ## Abort and Subagents
495
+
496
+ `await root.abort(context)` stops a conversation: queued inputs are withdrawn (queued writes stay), every task of its current work is aborted, and the call resolves once the conversation is idle.
497
+
498
+ A conversation can be **owned** by a task. A subagent tool creates its child inside `api.commit()` with `ownership: { kind: "task", taskId: api.taskId }`, then drives it through `api.conversation(id)`:
499
+
500
+ ```typescript
501
+ const Subagent: Extension = defineExtension({
502
+ name: "subagent",
503
+ tools: [
504
+ defineTool({
505
+ name: "subagent",
506
+ description: "Delegate a self-contained task to a subagent and get its answer back.",
507
+ parameters: Type.Object({ task: Type.String() }),
508
+ replay: "safe", // a rerun after a crash finds the same child and submission
509
+ execute: async (args, api, context) => {
510
+ const child = await api.commit(async (tx) => {
511
+ // The ownership index remembers the child, so a rerun reuses it.
512
+ const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
513
+ if (existing !== undefined) return existing.id;
514
+ // Starts as a copy of this conversation's agent: model, extensions, tools, cwd.
515
+ const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
516
+ // A cheaper model, and no subagents of its own.
517
+ await configure(tx, created.id, { model: haiku, extensions: { remove: [Subagent] } });
518
+ return created.id;
519
+ }, context);
520
+ await api.details({ conversationId: child }, context); // lets a UI attach to the child
521
+ const request = { type: "input", content: args.task, requestId: `subagent:${api.taskId}` } as const;
522
+ const settled = await (await (await api.conversation(child, context))!.submit(request, context)).wait(context);
523
+ return { output: [{ type: "text", text: settled.status }] };
524
+ },
525
+ }),
526
+ ],
527
+ });
528
+ ```
529
+
530
+ Owned work belongs to its owner:
531
+
532
+ - Aborting the call aborts the child. So does the call failing: `execute()` throwing, or a crash that interrupts a call that is not replay-safe.
533
+ - The parent is idle only once the child is.
534
+ - A task created with `{ background: true }` is a boundary: work it owns survives the parent's abort and does not keep the parent busy. `root.abort(context, { background: true })` aborts it too.
535
+
536
+ The examples show both patterns as product code:
537
+
538
+ - [`22-subagent-foreground.ts`](test/examples/22-subagent-foreground.ts): the tool above, returning the child's answer. The UI finds the child through the call's `details` and prints the child's events indented under the call.
539
+ - [`23-subagent-background.ts`](test/examples/23-subagent-background.ts): persistent subagents behind one `subagent` tool that spawns, messages (steer or follow-up), waits for, stops, and lists them. Each child is owned by a background anchor task, so the parent's Esc and idle waits never reach it. Each message is delivered by a background reporter task that posts the answer back to the parent as a follow-up input once it arrives; request IDs keep a restart from sending a message or a report twice.
540
+
541
+ ## Child Tasks
542
+
543
+ A task can own child tasks, created with `ownership: { kind: "task", taskId }`, and wait for them by committing a `waiting` state:
544
+
545
+ ```typescript
546
+ pay: async (task, runtime, context) => {
547
+ await runtime.commit(async (tx) => {
548
+ const payments = [];
549
+ for (const card of task.input.cards) {
550
+ payments.push(await tx.createTask(Payment, { card }, { ownership: { kind: "task", taskId: task.id } }));
551
+ }
552
+ // Resume in `decide` once every payment is done; the first failure aborts the rest.
553
+ return { status: "waiting", checkpoint: { phase: "decide", payments }, on: payments, policy: "failFast" };
554
+ }, context);
555
+ },
556
+ decide: async (task, runtime, context) => {
557
+ const outcomes = await runtime.outcomes(task.state.checkpoint.payments, context);
558
+ // ...commit the checkout's own outcome
559
+ },
560
+ ```
561
+
562
+ - **Waiting:** the task runs no code while it waits. With `allSettled` it resumes once every task in `on` is done; with `failFast` the first failed child also aborts the others. `on` may name other tasks too, with `allSettled`.
563
+ - **Finishing:** a task that finishes while work it owns is still running is `completing`: its outcome is decided, but it becomes terminal, and `waitForTask()` returns, only once that work is done. A failed or aborted outcome aborts that work first.
564
+ - **Aborting:** abort runs bottom-up. Aborting a task aborts the work it owns first, and its own abort handler starts only once that work is done, so each task undoes its own effects.
565
+
566
+ [`24-child-tasks.ts`](test/examples/24-child-tasks.ts) runs a checkout with four payments: a declined card, a cancelled checkout, and a restart while the payments run.
567
+
568
+ ## Task Graph
569
+
570
+ `harness.taskGraph(context)` shows every live task of the Session as one Chord state, for a task panel or debugging. Each node has its owner edge (`owner` task, or none for a task its conversation owns), its status, whether it is `background` or abort-marked, and the conversations it owns. `harness.watchTaskGraph(context)` delivers the same value as a watch, like a conversation's `watch()`.
571
+
572
+ ```typescript
573
+ const graph = await harness.taskGraph(context);
574
+ graph.subscribe((value) => {
575
+ for (const node of Object.values(value.tasks)) {
576
+ const status = node.state.status === "waiting" ? `waiting on ${node.state.on.join(", ")}` : node.state.status;
577
+ console.log(`${node.id} ${node.kind} ${status}`, node.owner ?? `conversation ${node.conversationId}`);
578
+ }
579
+ });
580
+ ```
581
+
582
+ A task appears with the commit that creates it and leaves with the commit that makes it terminal. Statuses are the committed ones: `pending`, `running`, `waiting` (with `on` and `policy`), and `completing` (with the held outcome's status). After a restart, tasks that were `running` show as `pending` until they run again. Whether a pending task is blocked by a missing definition is not part of the graph; `harness.inspect()` reports that. The graph lists live tasks only: once a subagent's owner task is terminal, a later task in its conversation is a top-level node, and the conversation's `ConversationRecord.owner` (also in its view's `conversation`) links it to its parent. [`24-child-tasks.ts`](test/examples/24-child-tasks.ts) prints the checkout's tree while its payments run.
583
+
584
+ ## Your Own State
585
+
586
+ Documents are typed JSON objects committed together with entries. Define one, and edit it in a commit:
587
+
588
+ ```typescript
589
+ import { defineDoc } from "@ny4n/durable";
590
+
591
+ const Todos = defineDoc<{ items: string[] }>({
592
+ kind: "app.todos",
593
+ version: 1,
594
+ scope: "conversation",
595
+ history: "latest", // or "rewindable" to read old values with snapshotAsOf()
596
+ fork: "initial", // what a fork starts with: "initial", "current", or "asOf"
597
+ initial: () => ({ items: [] }),
598
+ });
599
+
600
+ await root.commit(async (tx) => {
601
+ (await tx.doc(Todos, root.id)).items.push("write docs");
602
+ }, context);
603
+ console.log(await harness.snapshot(Todos, root.id, context));
604
+ ```
605
+
606
+ `harness.watchDoc()` and `harness.documentState()` observe one document like the view above. `HarnessOptions.conversationCreated(tx, conversation)` runs in every commit that creates or forks a conversation, including a tool's raw `tx.createConversation()`, so every conversation gets your documents; `init` in `createConversation()`, `fork()`, and `root()` writes per-call data in the same commit. An extension's tools, sections, and hooks read their own documents through `api` or `input.read`, and treat an absent one as its default ([`11-extension-state.ts`](test/examples/11-extension-state.ts)).
607
+
608
+ ## Usage and Cost
609
+
610
+ Each conversation keeps token and cost totals in `docs["nyan.usage"]`: per `provider/model` for model responses, and per tool name for tool results that report usage. Failed and aborted attempts count too. For the whole Session:
611
+
612
+ ```typescript
613
+ const usage = await harness.usage(context); // { models: { "openai/gpt-6-sol": Usage }, tools: {...} }
614
+ ```
615
+
616
+ ## Storage
617
+
618
+ | Backend | Import | Notes |
619
+ |---|---|---|
620
+ | Memory | `MemoryStorage` from the package root | Nothing is persisted. |
621
+ | SQLite | `openNodeSqliteStorage(file)` from `@ny4n/durable/storage/sqlite/node` | One database file. WAL mode with `synchronous = NORMAL`: commits survive process crashes; the newest may be lost on power or host failure. |
622
+ | JSONL | `openNodeJsonlStorage(directory, context)` from `@ny4n/durable/storage/jsonl/node` | Append-only files in one directory. Pass `{ fsync: true }` to flush before each commit marker. Keeps the whole history in memory too, so memory grows with it; use SQLite for long-running agents. |
623
+ | Cloudflare Durable Object | `openDurableObjectSqliteStorage(ctx.storage)` from `@ny4n/durable/storage/sqlite/cloudflare` | The SQLite storage of one SQLite-backed Durable Object; commits are its storage transactions. |
624
+
625
+ One process owns a storage at a time; there is no cross-process locking. The portable SQLite and JSONL cores (`/storage/sqlite`, `/storage/jsonl`) run without Node APIs, for example on Bun or in Cloudflare Durable Objects, given an asynchronous `SqliteDatabase` facade or a `FileSystem` from `@ny4n/durable/env`.
626
+
627
+ SQLite adapters implement promise-based `exec`, `run`, `get`, `all`, `transaction`, and `close`. `run`, `get`, and `all` take SQL text plus positional bindings; adapters may cache prepared statements by SQL text. A transaction callback receives a transaction handle; all work in the transaction must use it, and the handle expires when the callback settles. Adapters must queue unrelated operations and other transactions until the transaction finishes, so calling `database` itself inside the callback never settles:
628
+
629
+ ```typescript
630
+ await database.transaction(async (transaction) => {
631
+ await transaction.exec("CREATE TABLE example (value TEXT)");
632
+ await transaction.run("INSERT INTO example (value) VALUES (?)", "stored atomically");
633
+ });
634
+ ```
635
+
636
+ Custom backends can run the shared conformance suite with any Vitest- or Jest-compatible runner:
637
+
638
+ ```typescript
639
+ import { registerStorageConformance } from "@ny4n/durable/testing";
640
+ import { describe, expect, it } from "vitest";
641
+
642
+ registerStorageConformance({ describe, expect, it }, "My Storage", async (use) => {
643
+ const storage = await openMyStorage();
644
+ try {
645
+ await use(storage);
646
+ } finally {
647
+ await closeMyStorage(storage);
648
+ }
649
+ });
650
+ ```
651
+
652
+ The package root loads TypeBox, because the tool task validates arguments with @ny4n/ai's `validateToolArguments()`. That costs about 23 MB of peak RSS unbundled, about 4 MB in a tree-shaken bundle.
653
+
654
+ ## Errors
655
+
656
+ Three rules decide what an error does:
657
+
658
+ 1. **A storage error ends the Harness.** The call that hit it rejects with the error. Everything after it, and every pending wait, rejects with `SessionFailed` (its `cause` is that error); watches and event streams end with `session_failed`; running tasks get their abort signal; `onReport` gets the error once. The Harness then closes itself. Nothing is retried; a custom storage retries its own transient failures. Reopen to recover: commits are atomic, so the store is consistent, and resubmitting with the same `requestId` finds a submission that was already admitted.
659
+ 2. **An invalid request fails only that call:** a storage throws `StorageRequestError` for a bad cursor, an unknown conversation, or history a document does not keep. A read you cancelled fails only itself too.
660
+ 3. **Your code fails only its own unit:** a throwing task phase faults that task, a throwing tool gets an error result, a throwing hook is reported and skipped (`beforeTool` blocks the call), and a throwing commit callback rolls back that commit. Listeners that throw, or return a rejected promise, are reported and the others still run. The exception is a host `RegistryReader` whose `snapshot()` throws: the Harness cannot schedule without it, so it fails the Harness like a storage error.
661
+
662
+ `harness.closed` settles once the Harness has closed, by `close()` or after a storage error, so a host restarts on failure like this:
663
+
664
+ ```typescript
665
+ harness.closed.then((end) => {
666
+ if (end.reason === "failed") reopen(end.error);
667
+ });
668
+ ```
669
+
670
+ A failure closes the Harness by itself; `closed` settles once that close is done, even if the storage could not close cleanly. Closing waits for running task code to return, so code that ignores its abort signal delays `closed`.
671
+
672
+ ## Examples
673
+
674
+ Runnable examples live in [`test/examples`](test/examples). Run one from this package directory with:
675
+
676
+ ```bash
677
+ node --conditions=source --experimental-strip-types test/examples/14-chat.ts
678
+ ```
679
+
680
+ | Example | Shows |
681
+ |---|---|
682
+ | [14-chat](test/examples/14-chat.ts) | One question and answer |
683
+ | [16-real-model](test/examples/16-real-model.ts) | Streaming an answer from OpenAI |
684
+ | [17-coding-tools](test/examples/17-coding-tools.ts) | A tool-using turn on JSONL storage |
685
+ | [18-print](test/examples/18-print.ts) | Print mode: submit a prompt, print the answer |
686
+ | [19-json](test/examples/19-json.ts) | JSON mode: agent events or raw view operations, on SQLite, JSONL, or memory |
687
+ | [20-inbox](test/examples/20-inbox.ts) | Steers, follow-ups, writes, and withdrawal while busy |
688
+ | [21-late-join](test/examples/21-late-join.ts) | Attaching a view and an event stream mid-run |
689
+ | [22-subagent-foreground](test/examples/22-subagent-foreground.ts) | A replay-safe subagent tool whose child the call owns, with the child's events under the call |
690
+ | [23-subagent-background](test/examples/23-subagent-background.ts) | Persistent subagents: spawn, steer, stop, list, answers reported back, restart-safe |
691
+ | [24-child-tasks](test/examples/24-child-tasks.ts) | A checkout that owns and waits for four payments: failFast, abort, restart |
692
+ | [25-compaction](test/examples/25-compaction.ts) | A long chat compacted in the background, manually, and after a context overflow |
693
+ | [26-coding-agent](test/examples/26-coding-agent.ts) | CodingTools, live settings from a settings object, an environment that follows the conversation's directory |
694
+ | [27-plan-mode](test/examples/27-plan-mode.ts) | A read-only plan mode as an extension with its own document, switched with `configure()` |
695
+ | [28-reviewer](test/examples/28-reviewer.ts) | A reviewer conversation with its own model, extensions, tools, directory, and review loop |
696
+ | [29-sandbox-per-conversation](test/examples/29-sandbox-per-conversation.ts) | An environment per conversation, looked up from an app document |
697
+ | [30-tool-override](test/examples/30-tool-override.ts) | A same-name bash for some conversations, and a wrapper that times whichever bash won |
698
+ | [31-reload-and-restart](test/examples/31-reload-and-restart.ts) | Reloading an extension mid-call, and stored choices surviving a restart |
699
+ | [00](test/examples/00-conversation.ts)–[13](test/examples/13-recovery.ts) | The layers underneath: sessions, documents, forks, watches, the Harness, agent configuration, reload, extension state, tasks, recovery |
700
+
701
+ Examples that call OpenAI need `OPENAI_API_KEY`; most use the faux provider otherwise.
702
+
703
+ ## Design Documents
704
+
705
+ - [`docs/spec.md`](https://github.com/blxzer77/nyan/blob/main/packages/durable/docs/spec.md): the normative specification
706
+ - [`docs/pico-v5-chord-usage.md`](https://github.com/blxzer77/nyan/blob/main/packages/durable/docs/pico-v5-chord-usage.md): how the package uses Chord
707
+
708
+ Benchmarks: `npm run bench:storage`, `npm run bench:storage:memory`, `npm run bench:tool-output`, and `npm run bench:nested-tools`.
709
+
710
+ ## License
711
+
712
+ MIT