my-durable 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/.env.example +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +328 -2
  4. package/ai_docs/durable_pi/announcement.md +860 -0
  5. package/ai_docs/durable_pi/changelog.md +51 -0
  6. package/ai_docs/durable_pi/chord-delta-findings.md +362 -0
  7. package/ai_docs/durable_pi/coding-agent-integration.md +70 -0
  8. package/ai_docs/durable_pi/pico-v5-chord-usage.md +391 -0
  9. package/ai_docs/durable_pi/pico-v5-handoff.md +921 -0
  10. package/ai_docs/durable_pi/readme.md +617 -0
  11. package/ai_docs/durable_pi/sources.md +22 -0
  12. package/ai_docs/durable_pi/spec.md +4696 -0
  13. package/ai_docs/durable_pi/vacation-planner.md +57 -0
  14. package/ai_docs/pi/chord.md +268 -0
  15. package/ai_docs/pi/cli-integration.md +110 -0
  16. package/ai_docs/pi/cli.md +325 -0
  17. package/ai_docs/pi/codemode.md +211 -0
  18. package/ai_docs/pi/compaction.md +467 -0
  19. package/ai_docs/pi/configuration.md +51 -0
  20. package/ai_docs/pi/containerization.md +187 -0
  21. package/ai_docs/pi/custom-provider.md +177 -0
  22. package/ai_docs/pi/environment-variables.md +102 -0
  23. package/ai_docs/pi/extensions.md +278 -0
  24. package/ai_docs/pi/how-pi-works.md +53 -0
  25. package/ai_docs/pi/index.md +43 -0
  26. package/ai_docs/pi/json.md +230 -0
  27. package/ai_docs/pi/keybindings.md +198 -0
  28. package/ai_docs/pi/llama-cpp.md +118 -0
  29. package/ai_docs/pi/mcp.md +274 -0
  30. package/ai_docs/pi/message-types.md +265 -0
  31. package/ai_docs/pi/models.md +209 -0
  32. package/ai_docs/pi/overview.md +166 -0
  33. package/ai_docs/pi/packages.md +133 -0
  34. package/ai_docs/pi/pi-agent-core.md +568 -0
  35. package/ai_docs/pi/pi-ai.md +1942 -0
  36. package/ai_docs/pi/prompt-templates.md +63 -0
  37. package/ai_docs/pi/providers.md +204 -0
  38. package/ai_docs/pi/quickstart.md +140 -0
  39. package/ai_docs/pi/readme.md +99 -0
  40. package/ai_docs/pi/rpc-commands.md +864 -0
  41. package/ai_docs/pi/rpc-extension-ui.md +204 -0
  42. package/ai_docs/pi/rpc.md +197 -0
  43. package/ai_docs/pi/sdk.md +151 -0
  44. package/ai_docs/pi/security.md +103 -0
  45. package/ai_docs/pi/session-format.md +297 -0
  46. package/ai_docs/pi/sessions.md +70 -0
  47. package/ai_docs/pi/settings.md +173 -0
  48. package/ai_docs/pi/shell-aliases.md +97 -0
  49. package/ai_docs/pi/skills.md +97 -0
  50. package/ai_docs/pi/slash-commands.md +64 -0
  51. package/ai_docs/pi/sources.md +57 -0
  52. package/ai_docs/pi/terminal-setup.md +223 -0
  53. package/ai_docs/pi/termux.md +121 -0
  54. package/ai_docs/pi/themes.md +140 -0
  55. package/ai_docs/pi/tmux.md +59 -0
  56. package/ai_docs/pi/toolkit.md +168 -0
  57. package/ai_docs/pi/tui.md +129 -0
  58. package/ai_docs/pi/usage.md +98 -0
  59. package/ai_docs/pi/virtual-models.md +118 -0
  60. package/ai_docs/pi/windows.md +71 -0
  61. package/apps/dpi-backend/.nvmrc +1 -0
  62. package/apps/dpi-backend/API.md +99 -0
  63. package/apps/dpi-backend/README.md +119 -0
  64. package/apps/dpi-backend/deploy/backend.env.example +9 -0
  65. package/apps/dpi-backend/deploy/dpi-backend.plist +26 -0
  66. package/apps/dpi-backend/deploy/dpi-backend.service +22 -0
  67. package/apps/dpi-backend/package.json +33 -0
  68. package/apps/dpi-backend/prompts/one_sentence.md +3 -0
  69. package/apps/dpi-backend/prompts/smart_ass_opus_fix.md +169 -0
  70. package/apps/dpi-backend/scripts/check-contract.mjs +151 -0
  71. package/apps/dpi-backend/scripts/migrate-playground.ts +66 -0
  72. package/apps/dpi-backend/scripts/record-nested-fixture.mts +154 -0
  73. package/apps/dpi-backend/scripts/token.ts +57 -0
  74. package/apps/dpi-backend/src/addons.ts +139 -0
  75. package/apps/dpi-backend/src/auth.ts +20 -0
  76. package/apps/dpi-backend/src/config.ts +54 -0
  77. package/apps/dpi-backend/src/contract-check.ts +118 -0
  78. package/apps/dpi-backend/src/errors.ts +50 -0
  79. package/apps/dpi-backend/src/extensions.ts +510 -0
  80. package/apps/dpi-backend/src/faux.ts +80 -0
  81. package/apps/dpi-backend/src/history.ts +165 -0
  82. package/apps/dpi-backend/src/idempotency.ts +20 -0
  83. package/apps/dpi-backend/src/jev.ts +43 -0
  84. package/apps/dpi-backend/src/jobs.ts +790 -0
  85. package/apps/dpi-backend/src/main.ts +76 -0
  86. package/apps/dpi-backend/src/model-catalog.ts +180 -0
  87. package/apps/dpi-backend/src/pairing-files.ts +142 -0
  88. package/apps/dpi-backend/src/pairing.ts +459 -0
  89. package/apps/dpi-backend/src/routes.ts +217 -0
  90. package/apps/dpi-backend/src/shared-state.ts +85 -0
  91. package/apps/dpi-backend/src/store.ts +1240 -0
  92. package/apps/dpi-backend/src/streams.ts +256 -0
  93. package/apps/dpi-backend/src/tunnel.ts +955 -0
  94. package/apps/dpi-backend/tsconfig.json +13 -0
  95. package/apps/dpi-playground/README.md +83 -0
  96. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +77 -0
  97. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +1 -0
  98. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/anthropic.png +0 -0
  99. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/deepseek.png +0 -0
  100. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/durable-pi.svg +14 -0
  101. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/gemini.png +0 -0
  102. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/grok.png +0 -0
  103. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/kimi.png +0 -0
  104. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/lab.png +0 -0
  105. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/meta.png +0 -0
  106. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/minimax.png +0 -0
  107. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/openai.png +0 -0
  108. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/qwen.png +0 -0
  109. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/xiaomi.png +0 -0
  110. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/zai.png +0 -0
  111. package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +16 -0
  112. package/apps/dpi-playground/apps/dpi-playground-fe/index.html +15 -0
  113. package/apps/dpi-playground/apps/dpi-playground-fe/package.json +5 -0
  114. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/anthropic.png +0 -0
  115. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/deepseek.png +0 -0
  116. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/durable-pi.svg +14 -0
  117. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/gemini.png +0 -0
  118. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/grok.png +0 -0
  119. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/kimi.png +0 -0
  120. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/lab.png +0 -0
  121. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/meta.png +0 -0
  122. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/minimax.png +0 -0
  123. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/openai.png +0 -0
  124. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/qwen.png +0 -0
  125. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/xiaomi.png +0 -0
  126. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/zai.png +0 -0
  127. package/apps/dpi-playground/apps/dpi-playground-fe/src/App.vue +180 -0
  128. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/commands.ts +119 -0
  129. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/composables.ts +129 -0
  130. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/hub.ts +40 -0
  131. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/icon-path.ts +7 -0
  132. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/ids.ts +2 -0
  133. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/jobs.ts +80 -0
  134. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/link-policy.ts +16 -0
  135. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown-policy.ts +115 -0
  136. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown.ts +60 -0
  137. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-close.ts +23 -0
  138. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +56 -0
  139. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +166 -0
  140. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +323 -0
  141. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/signature.ts +146 -0
  142. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/store.ts +373 -0
  143. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/stream.ts +183 -0
  144. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +342 -0
  145. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Composer.vue +198 -0
  146. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ContextBar.vue +254 -0
  147. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceCredentialsDialog.vue +40 -0
  148. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceIcon.vue +56 -0
  149. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DirBrowser.vue +95 -0
  150. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ExtensionsPanel.vue +96 -0
  151. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/HelpDialog.vue +69 -0
  152. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +425 -0
  153. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/JobCard.vue +221 -0
  154. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/MobileNav.vue +39 -0
  155. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ModelPicker.vue +71 -0
  156. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NetworkSidebar.vue +236 -0
  157. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +114 -0
  158. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/OfflinePanel.vue +128 -0
  159. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +433 -0
  160. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PromptDialog.vue +89 -0
  161. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/StateEditor.vue +179 -0
  162. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TasksPanel.vue +280 -0
  163. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Toast.vue +11 -0
  164. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TokenManager.vue +175 -0
  165. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TopBar.vue +114 -0
  166. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TranscriptItem.vue +152 -0
  167. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +195 -0
  168. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TunnelStatusBar.vue +107 -0
  169. package/apps/dpi-playground/apps/dpi-playground-fe/src/extensions.css +105 -0
  170. package/apps/dpi-playground/apps/dpi-playground-fe/src/jobs.css +655 -0
  171. package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +22 -0
  172. package/apps/dpi-playground/apps/dpi-playground-fe/src/network.css +675 -0
  173. package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +3203 -0
  174. package/apps/dpi-playground/apps/dpi-playground-fe/tsconfig.json +16 -0
  175. package/apps/dpi-playground/apps/dpi-playground-fe/vite.config.ts +14 -0
  176. package/apps/dpi-playground/apps/dpi-playground-hub/package.json +5 -0
  177. package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +406 -0
  178. package/apps/dpi-playground/apps/dpi-playground-hub/src/client-ip.ts +72 -0
  179. package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +265 -0
  180. package/apps/dpi-playground/apps/dpi-playground-hub/src/guard.ts +43 -0
  181. package/apps/dpi-playground/apps/dpi-playground-hub/src/http-errors.ts +156 -0
  182. package/apps/dpi-playground/apps/dpi-playground-hub/src/identity.ts +60 -0
  183. package/apps/dpi-playground/apps/dpi-playground-hub/src/limiter.ts +165 -0
  184. package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +460 -0
  185. package/apps/dpi-playground/apps/dpi-playground-hub/src/monitor.ts +320 -0
  186. package/apps/dpi-playground/apps/dpi-playground-hub/src/mux.ts +277 -0
  187. package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +816 -0
  188. package/apps/dpi-playground/apps/dpi-playground-hub/src/proxy.ts +128 -0
  189. package/apps/dpi-playground/apps/dpi-playground-hub/src/rotation.ts +583 -0
  190. package/apps/dpi-playground/apps/dpi-playground-hub/src/seed.ts +11 -0
  191. package/apps/dpi-playground/apps/dpi-playground-hub/src/transport.ts +108 -0
  192. package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-token.ts +11 -0
  193. package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-transport.ts +560 -0
  194. package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +873 -0
  195. package/apps/dpi-playground/apps/dpi-playground-hub/tsconfig.json +13 -0
  196. package/apps/dpi-playground/package.json +37 -0
  197. package/apps/dpi-sdk/README.md +74 -0
  198. package/apps/dpi-sdk/package.json +20 -0
  199. package/apps/dpi-sdk/scripts/net.ts +73 -0
  200. package/apps/dpi-sdk/src/api.ts +631 -0
  201. package/apps/dpi-sdk/src/app-state.ts +111 -0
  202. package/apps/dpi-sdk/src/client.ts +330 -0
  203. package/apps/dpi-sdk/src/errors.ts +16 -0
  204. package/apps/dpi-sdk/src/jobs.ts +99 -0
  205. package/apps/dpi-sdk/src/models.ts +472 -0
  206. package/apps/dpi-sdk/src/node/chunk.ts +488 -0
  207. package/apps/dpi-sdk/src/node/config.ts +175 -0
  208. package/apps/dpi-sdk/src/node/drivers.ts +402 -0
  209. package/apps/dpi-sdk/src/node/ops.ts +114 -0
  210. package/apps/dpi-sdk/src/node/private-dir.ts +48 -0
  211. package/apps/dpi-sdk/src/node/registry-migration.ts +549 -0
  212. package/apps/dpi-sdk/src/node/registry.ts +390 -0
  213. package/apps/dpi-sdk/src/node/sse-writer.ts +151 -0
  214. package/apps/dpi-sdk/src/protocol/backoff.ts +31 -0
  215. package/apps/dpi-sdk/src/protocol/limits.ts +199 -0
  216. package/apps/dpi-sdk/src/protocol/pairing.ts +311 -0
  217. package/apps/dpi-sdk/src/protocol/rest-routes.ts +105 -0
  218. package/apps/dpi-sdk/src/protocol/tunnel.ts +331 -0
  219. package/apps/dpi-sdk/src/sse.ts +117 -0
  220. package/apps/dpi-sdk/src/transcript.ts +291 -0
  221. package/apps/dpi-sdk/tsconfig.json +13 -0
  222. package/apps/dpi-sdk/w +20 -0
  223. package/apps/dpi-tui/README.md +54 -0
  224. package/apps/dpi-tui/bin/dpi-tui.mjs +5 -0
  225. package/apps/dpi-tui/package.json +20 -0
  226. package/apps/dpi-tui/src/app.ts +433 -0
  227. package/apps/dpi-tui/src/commands.ts +151 -0
  228. package/apps/dpi-tui/src/main.ts +122 -0
  229. package/apps/dpi-tui/src/pairing.ts +287 -0
  230. package/apps/dpi-tui/src/pickers.ts +238 -0
  231. package/apps/dpi-tui/src/render.ts +164 -0
  232. package/apps/dpi-tui/src/theme.ts +57 -0
  233. package/apps/dpi-tui/tsconfig.json +14 -0
  234. package/bin/device.mjs +5 -0
  235. package/bin/hub.mjs +5 -0
  236. package/bin/tui.mjs +5 -0
  237. package/deploy/Caddyfile +120 -0
  238. package/docs/verification.md +102 -0
  239. package/dpi-network.example.yaml +62 -0
  240. package/images/01_durable_agent_network.svg +185 -0
  241. package/images/02_pi_made_durable.svg +204 -0
  242. package/images/03_restart_mid_tool.svg +168 -0
  243. package/images/07_one_api_every_client.svg +123 -0
  244. package/images/08_device_session_agent.svg +123 -0
  245. package/images/09_compact_keep_history.svg +143 -0
  246. package/images/10_shared_app_state.svg +177 -0
  247. package/images/11_durable_jobs_wait.svg +141 -0
  248. package/images/12_tools_change_mid_run.svg +231 -0
  249. package/images/13_hook_blocks_tool.svg +122 -0
  250. package/images/14_fork_at_any_entry.svg +119 -0
  251. package/images/15_web_client.png +0 -0
  252. package/images/16_terminal_client.svg +43 -0
  253. package/images/17_devices_are_data.svg +182 -0
  254. package/images/18_should_every_agent_be_durable.svg +118 -0
  255. package/images/19_sqlite_on_every_device.svg +155 -0
  256. package/images/20_admission_and_effects.svg +134 -0
  257. package/justfile +33 -0
  258. package/package.json +41 -4
  259. package/scripts/verify.mjs +210 -0
@@ -0,0 +1,860 @@
1
+ > Source: [https://earendil.com/posts/pi-durable/](https://earendil.com/posts/pi-durable/)
2
+ > Retrieved with Firecrawl: 2026-10-05.
3
+
4
+ # Pi Durable
5
+
6
+ Date:Thu, 01 Oct 2026
7
+
8
+ From:Earendil Engineering < [rfc@earendil.com](mailto:rfc@earendil.com) >
9
+
10
+ To:You
11
+
12
+ Subject:Pi Durable
13
+
14
+ Today Earendil and the Pi community [shipped Pi 1.0](https://earendil.com/posts/pi-1-0/). This
15
+ reflects our belief that after countless hours of hardening, maintenance, and
16
+ active development, Pi is now a solid foundation on which to build. Pi also
17
+ continues to evolve. Together with Pi 1.0, we are shipping an experimental new
18
+ package called Pi Durable. Pi Durable was built specifically for long-running,
19
+ durable, and malleable agents that can run anywhere. We would like you to join
20
+ in the fun and help us make it the best durable harness there is.
21
+
22
+ ## Why Pi Durable?
23
+
24
+ Pi the coding agent is built to run on your (remote) machine, inside a terminal,
25
+ driven by one person. If the process dies, you look at what happened and tell it
26
+ to continue. That is what Pi 1.0 focuses on and excels at, and that is not
27
+ changing.
28
+
29
+ At Earendil, we want to bring this technology to everyone, in whatever form fits
30
+ their needs best. For that, we need a harness that runs anywhere, can be reached
31
+ from different surfaces, supports infinitely long conversations, survives
32
+ catastrophic internal and external failures, and lets multiple humans steer the
33
+ same agents.
34
+
35
+ Pi Durable is that harness. It does not replace the Pi coding agent. It is a
36
+ framework for building any agentic application, coding agents included. It
37
+ shares not only code with the Pi coding agent, like pi-ai, but also its
38
+ principles: minimalism and malleability.
39
+
40
+ It also lets us explore designs in this space without disrupting Pi the coding
41
+ agent. Lessons we learn building agentic applications on Pi Durable will flow
42
+ back into Pi the coding agent as they prove themselves valuable.
43
+
44
+ ## What is a harness?
45
+
46
+ Everybody has their own definition of a harness. We [wrote about this\\
47
+ previously](https://earendil.com/posts/what-is-a-harness/), but let us reintroduce the concept of
48
+ the harness for Pi Durable.
49
+
50
+ A harness is storage plus the machinery needed to run one or more conversations
51
+ with large language models in parallel. It provides the tools those models call,
52
+ and the execution environments the tools run in.
53
+
54
+ A conversation is an interaction between you and an agent, recorded as a
55
+ transcript. The agent is the large language model together with its settings,
56
+ like the thinking level, and the tools it can call.
57
+
58
+ Tools do their work through an execution environment, which can be your laptop,
59
+ a remote VM, or an in-memory sandbox. Which tools and which execution
60
+ environment an agent gets is up to each conversation.
61
+
62
+ Everything the harness runs, from calling the model to executing a tool, is a
63
+ task.
64
+
65
+ Like everything in Pi, Pi Durable is built so your agent can understand it. The
66
+ entire source code, without tests, is about 15,000 lines, which comes out to
67
+ about 150,000 tokens with GPT and about 250,000 with Claude. That's the worst
68
+ case. To build on Pi Durable, your agent rarely needs all of it; the storage
69
+ backends alone are 3,000 lines it can usually skip.
70
+
71
+ Now let us give you a little tour of Pi Durable, to illustrate what we built and
72
+ why we built it.
73
+
74
+ ## Long runs anywhere
75
+
76
+ We want agents to run for a long time and to be able to run anywhere, where
77
+ anywhere currently means anywhere there is a JavaScript runtime.
78
+
79
+ In Pi Durable, a harness opens over a storage backend. Pi Durable ships memory,
80
+ SQLite, and JSONL storage, plus a conformance suite and benchmarks for your own
81
+ backend. The SQLite and JSONL storage code uses no Node APIs, so with a small
82
+ adapter it runs on Bun or inside a Cloudflare Durable Object. The storage
83
+ interface is small and easy to implement on top of whatever you have, like a
84
+ key-value store or Postgres. One process owns a storage at a time, and other
85
+ clients attach to that process.
86
+
87
+ On SQLite, the harness only keeps the working set in memory: the active
88
+ transcripts, live tasks, and pending submissions. Everything else stays on disk
89
+ until it is needed. Active transcripts are naturally bounded by the model's
90
+ context window, because compaction summarizes older messages before they
91
+ overflow it. So even a conversation with tens of thousands of messages fits
92
+ snugly into memory.
93
+
94
+ Tools that need files or a shell get them from an execution environment. Pi
95
+ Durable ships a Node execution environment, which gives tools access to your
96
+ local files. Like storage, the execution environment interface is small and easy
97
+ to implement, so you can also expose remote execution environments to your
98
+ tools. That allows the harness to run on one machine while its tools run on
99
+ another. Your `env` function builds the environment for every tool call, from
100
+ the conversation's working directory, so each conversation can run in a
101
+ different place.
102
+
103
+ ```typescript
104
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
105
+ import { createModels } from "@earendil-works/pi-ai/models";
106
+ import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
107
+ import { createRegistry, Harness } from "@earendil-works/pi-durable";
108
+ import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
109
+ import {
110
+ openNodeSqliteStorage,
111
+ } from "@earendil-works/pi-durable/storage/sqlite/node";
112
+ import { CodingTools } from "@earendil-works/pi-durable/tools";
113
+
114
+ const context = BACKGROUND_CONTEXT; // every call takes a context for cancellation
115
+ const models = createModels();
116
+ models.setProvider(openaiProvider());
117
+
118
+ const registry = createRegistry();
119
+ registry.install(CodingTools); // read, write, edit, bash
120
+
121
+ const env = ({ cwd }: { cwd?: string }) =>
122
+ new NodeExecutionEnv({ cwd: cwd ?? process.cwd() });
123
+ const harness = await Harness.open(
124
+ await openNodeSqliteStorage("./agent.sqlite"),
125
+ { models, registry, env },
126
+ context,
127
+ );
128
+ // The root conversation: created on first use, and the same one after every
129
+ // restart.
130
+ const root = await harness.root(context, {
131
+ agent: {
132
+ model: { provider: "openai", modelId: "gpt-6.1-sol" },
133
+ cwd: "/work/repo",
134
+ },
135
+ });
136
+ ```
137
+
138
+ ## Survives crashes
139
+
140
+ We want an agent to survive its process dying, whether the laptop sleeps, the
141
+ container is redeployed, or the machine runs out of memory, and to pick up where
142
+ it left off.
143
+
144
+ In Pi Durable, every step of a run is a task that stores a checkpoint before it
145
+ moves on. If the process dies, a new process opens the same storage, finds the
146
+ unfinished tasks, and continues each one from its last checkpoint. A model
147
+ request that was cut off is sent again; the partial answer stays in the
148
+ transcript, marked as aborted. A tool call that was cut off reruns if it is safe
149
+ to; otherwise the model is told it was interrupted. Pi Durable has no built-in
150
+ subagents, but they take a few lines of code to build, as the triage tool below
151
+ shows. A subagent runs in a conversation of its own, so it continues from where
152
+ it left off too, and a subagent tool that is safe to rerun finds its subagent
153
+ again and waits for its answer. Queued messages are still queued. A `requestId`
154
+ makes a submission exactly-once, so a client that retries after a crash gets the
155
+ original submission back instead of asking twice.
156
+
157
+ ```typescript
158
+ const job = {
159
+ type: "input",
160
+ content: "Fix the flaky login test",
161
+ requestId: "job-42",
162
+ } as const;
163
+ await root.submit(job, context);
164
+ // The process dies here, in the middle of a tool call.
165
+
166
+ // A new process opens the same storage.
167
+ const harness = await Harness.open(
168
+ await openNodeSqliteStorage("./agent.sqlite"),
169
+ { models, registry, env },
170
+ context,
171
+ );
172
+ harness.resume(); // continue the interrupted run
173
+ const root = await harness.root(context);
174
+ // the same submission, answered
175
+ const settled = await (await root.submit(job, context)).wait(context);
176
+ ```
177
+
178
+ ## Many conversations at once
179
+
180
+ We want one harness to run many conversations at the same time, without one
181
+ blocking another.
182
+
183
+ In Pi Durable, one harness runs as many conversations as you need, concurrently,
184
+ all with the same guarantees. A conversation starts fresh or forks another one
185
+ at any point in its transcript, and sees the parent's history up to that point
186
+ without copying it.
187
+
188
+ Think of a Slack channel where your agent answers mentions from anyone. Then
189
+ somebody opens a thread. The channel can be one conversation, and the thread a
190
+ fork of it at the message the thread replies to. Both run at the same time and
191
+ neither blocks the other.
192
+
193
+ ```typescript
194
+ const channel = await harness.root(context);
195
+ const question = await channel.submit(
196
+ { type: "input", content: "@agent why did the deploy fail?" },
197
+ context,
198
+ );
199
+ const answered = await question.wait(context);
200
+
201
+ // Someone replies to the agent's answer in a thread. Every conversation names
202
+ // its owner, which decides what an abort reaches (more on that under Tasks).
203
+ // The thread has none.
204
+ const thread = await channel.fork(
205
+ answered.answer!,
206
+ { ownership: { kind: "ownerless" } },
207
+ context,
208
+ );
209
+
210
+ // Both conversations work at the same time.
211
+ const inThread = await thread.submit(
212
+ { type: "input", content: "@agent can we roll it back?" },
213
+ context,
214
+ );
215
+ const inChannel = await channel.submit(
216
+ { type: "input", content: "@agent who is on call today?" },
217
+ context,
218
+ );
219
+ await Promise.all([inThread.wait(context), inChannel.wait(context)]);
220
+ ```
221
+
222
+ Each conversation also stores its own agent: the model, the thinking level, the
223
+ selected extensions and which of their tools are active, extra instructions, and
224
+ the working directory in its execution environment. A reviewer next to the main
225
+ agent can use a cheaper model, read-only tools, and its own checkout.
226
+
227
+ ## Extensions
228
+
229
+ We want everything an agent can do to be pluggable, and every plugged-in piece
230
+ to take part in durability.
231
+
232
+ In Pi Durable, an extension is a named bundle of system prompt sections, tools,
233
+ hooks, and tasks. The application installs extensions in a registry. Each
234
+ conversation selects which extensions and tools it uses, and stores only their
235
+ names.
236
+
237
+ ### System prompt sections
238
+
239
+ The system prompt is rebuilt from the sections of the conversation's extensions
240
+ before every request, so a changed section is picked up by the next request. Pi
241
+ Durable records what changed in the transcript, at the position where it
242
+ changed, so a restart or a fork sees exactly what the model saw. On models that
243
+ support system prompt and tool changes in the middle of a conversation, only the
244
+ change is sent, so the prompt cache stays valid.
245
+
246
+ ```typescript
247
+ import { defineExtension, section } from "@earendil-works/pi-durable";
248
+
249
+ const ProjectContext = defineExtension({
250
+ name: "project-context",
251
+ sections: [\
252
+ // Read from the conversation's execution environment. The files can be\
253
+ // loaded and watched in the background; every request renders the\
254
+ // latest state.\
255
+ section("agents_md", (input) => agentsMd.latest(input.env)),\
256
+ section("skills", (input) => skills.latest(input.env)),\
257
+ ],
258
+ });
259
+ ```
260
+
261
+ ### Tools
262
+
263
+ Every tool call runs as its own durable task, and its intent is stored before it
264
+ runs. After a crash, a tool reruns only if it says that is safe. Otherwise the
265
+ model is told the call was interrupted, with the output stored so far, and
266
+ decides what to do. Each conversation can also get its own set of tools, like
267
+ the Slack thread from earlier, which may search but not deploy.
268
+
269
+ ```typescript
270
+ import { Type } from "@earendil-works/pi-ai";
271
+ import { defineTool } from "@earendil-works/pi-durable";
272
+
273
+ const searchIssues = defineTool({
274
+ name: "search_issues",
275
+ description: "Search the issue tracker",
276
+ parameters: Type.Object({ query: Type.String() }),
277
+ replay: "safe", // only reads, so a rerun after a crash is fine
278
+ execute: async (args, api) => {
279
+ // streamed to every client watching
280
+ api.output(`searching for ${args.query}\n`);
281
+ return {
282
+ content: [{ type: "text", text: await tracker.search(args.query) }],
283
+ };
284
+ },
285
+ });
286
+
287
+ const deploy = defineTool({
288
+ name: "deploy",
289
+ description: "Deploy a version to production",
290
+ parameters: Type.Object({ version: Type.String() }),
291
+ // No replay: a deploy interrupted by a crash is reported to the model,
292
+ // never repeated.
293
+ execute: async (args) => ({
294
+ content: [{ type: "text", text: await ci.deploy(args.version) }],
295
+ }),
296
+ });
297
+
298
+ registry.install(defineExtension({ name: "ops", tools: [searchIssues, deploy] }));
299
+
300
+ // The thread may search, but not deploy.
301
+ await thread.configure({ tools: { remove: [deploy] } }, context);
302
+ ```
303
+
304
+ A tool gets the harness API for its call: it can commit entries and documents,
305
+ start tasks and conversations, and talk to other conversations. That makes a
306
+ subagent a few lines of code. A tool creates a conversation it owns, gives it a
307
+ smaller model and its own instructions, and waits for its answer. The subagent
308
+ is a conversation like any other, so it survives a crash, counts its own cost,
309
+ and a UI can show it under the call.
310
+
311
+ ```typescript
312
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
313
+ import { AssistantEntry, configure } from "@earendil-works/pi-durable";
314
+
315
+ const triage = defineTool({
316
+ name: "triage",
317
+ description: "Label an incoming issue as bug, feature, or question",
318
+ parameters: Type.Object({ issue: Type.String() }),
319
+ // a rerun after a crash finds the same subagent and the same submission
320
+ replay: "safe",
321
+ execute: async (args, api, context) => {
322
+ const child = await api.commit(async (tx) => {
323
+ const existing = (
324
+ await tx.scanConversations({ ownerTaskId: api.taskId }, 1)
325
+ ).items[0];
326
+ if (existing !== undefined) return existing.id;
327
+ // Owned by this call, so aborting the call aborts the subagent.
328
+ const created = await tx.createConversation({
329
+ ownership: { kind: "task", taskId: api.taskId },
330
+ });
331
+ // It starts as a copy of this conversation's agent. Make it a small
332
+ // model without tools.
333
+ await configure(tx, created.id, {
334
+ model: { provider: "openai", modelId: "gpt-6-luna" },
335
+ tools: [],
336
+ instructions: "Answer with one word: bug, feature, or question.",
337
+ });
338
+ return created.id;
339
+ }, context);
340
+ // lets a UI show the subagent under the call
341
+ await api.details({ conversationId: child }, context);
342
+ const subagent = await api.conversation(child, context);
343
+ const request = {
344
+ type: "input",
345
+ content: args.issue,
346
+ requestId: `triage:${api.taskId}`,
347
+ } as const;
348
+ const settled = await (
349
+ await subagent!.submit(request, context)
350
+ ).wait(context);
351
+ // The answer is an entry in the subagent's transcript. Read it and take
352
+ // its text.
353
+ const entry = await api.commit(
354
+ (tx) => tx.entry(AssistantEntry, settled.answer!),
355
+ context,
356
+ );
357
+ const message = entry?.model?.[0] as AssistantMessage;
358
+ const text = message.content
359
+ .flatMap((content) => (content.type === "text" ? [content.text] : []))
360
+ .join("");
361
+ return { content: [{ type: "text", text }] };
362
+ },
363
+ });
364
+ ```
365
+
366
+ Extensions can also change other extensions' tools. A tool with the same name in
367
+ a later extension replaces the earlier one, for example a bash that runs inside
368
+ a Python virtualenv. A wrap decorates whichever tool won, wherever the wrapping
369
+ extension is selected.
370
+
371
+ ```typescript
372
+ import { wrapTool } from "@earendil-works/pi-durable";
373
+ import { createBashTool } from "@earendil-works/pi-durable/tools";
374
+
375
+ // Times every bash call, whichever bash the conversation ends up with.
376
+ const Timing = defineExtension({
377
+ name: "timing",
378
+ wraps: [\
379
+ wrapTool(createBashTool(), (bash) => ({\
380
+ ...bash,\
381
+ execute: async (args, api, context) => {\
382
+ const start = Date.now();\
383
+ try {\
384
+ return await bash.execute(args, api, context);\
385
+ } finally {\
386
+ metrics.record("bash", Date.now() - start);\
387
+ }\
388
+ },\
389
+ })),\
390
+ ],
391
+ });
392
+ ```
393
+
394
+ ### Hooks
395
+
396
+ Hooks let extensions step into tasks, including the built-in tasks for
397
+ generating a model response, invoking a tool, or performing compaction. They can
398
+ rewrite a request before it goes to the model, block or rewrite a tool call,
399
+ replace a result, keep a run going, or write a summary themselves. A hook can
400
+ run again after a crash, so a hook that makes a decision stores it in a memo: a
401
+ small value stored with the task, where the first write wins.
402
+
403
+ ```typescript
404
+ import { hook, ToolTask } from "@earendil-works/pi-durable";
405
+
406
+ const Approval = defineExtension({
407
+ name: "approval",
408
+ hooks: [\
409
+ hook(ToolTask, {\
410
+ beforeTool: async (call, api, context) => {\
411
+ if (call.name !== "deploy") return undefined;\
412
+ // After a restart, the hook finds the stored answer instead of\
413
+ // asking again.\
414
+ let approved = await api.memo<boolean>(\
415
+ "approval:deploy",\
416
+ context,\
417
+ );\
418
+ approved ??= await api.memo(\
419
+ "approval:deploy",\
420
+ await askInSlack(call),\
421
+ context,\
422
+ );\
423
+ return approved\
424
+ ? undefined\
425
+ : { block: "Nobody approved the deploy." };\
426
+ },\
427
+ }),\
428
+ ],
429
+ });
430
+ ```
431
+
432
+ Several extensions can hook the same thing. Their hooks run as a chain, in the
433
+ order the conversation selects the extensions, and each hook defines how its
434
+ chain runs. `beforeTool` passes rewritten arguments down the chain, and the
435
+ first block stops it. `afterTool` passes the result down the chain. `onYield`
436
+ stops at the first hook that keeps the run going. Observers like `afterResponse`
437
+ always run them all. A hook that throws is reported, and the chain continues,
438
+ except in `beforeTool`, where a throw blocks the call.
439
+
440
+ ### Tasks
441
+
442
+ The harness runs conversations with built-in tasks: one for each model request,
443
+ one for each tool call, and one for compaction. Extensions bring their own tasks
444
+ and get the same machinery: a checkpoint after every step, timers that survive
445
+ restarts, and waiting on other tasks.
446
+
447
+ A checkout that splits the bill across several cards charges every card at once.
448
+ If one card is declined, the other payments are aborted and refund themselves:
449
+
450
+ ```typescript
451
+ import { defineTask, type TaskId } from "@earendil-works/pi-durable";
452
+
453
+ const Payment = defineTask<{ card: string }, { phase: "charge" }, string>({
454
+ name: "shop.payment",
455
+ version: 1,
456
+ initial: () => ({ phase: "charge" }),
457
+ phases: {
458
+ charge: async (task, runtime, context) => {
459
+ // The key makes the charge idempotent: if a crash reruns this
460
+ // phase, the card is only charged once.
461
+ const charge = await bank.charge(
462
+ task.input.card,
463
+ `payment-${task.id}`,
464
+ );
465
+ await runtime.commit(
466
+ () => ({
467
+ status: "terminal",
468
+ outcome: charge.ok
469
+ ? { status: "completed", result: charge.receipt }
470
+ : { status: "failed", error: { message: charge.error } },
471
+ }),
472
+ context,
473
+ );
474
+ },
475
+ },
476
+ // Another payment failed, or the checkout was cancelled: undo this one.
477
+ abort: async (task, runtime, context) => {
478
+ await bank.refund(`payment-${task.id}`);
479
+ await runtime.commit(
480
+ () => ({ status: "terminal", outcome: { status: "aborted" } }),
481
+ context,
482
+ );
483
+ },
484
+ });
485
+
486
+ type CheckoutState =
487
+ | { phase: "pay" }
488
+ | { phase: "decide"; payments: TaskId<string>[] };
489
+ const Checkout = defineTask<{ cards: string[] }, CheckoutState, string>({
490
+ name: "shop.checkout",
491
+ version: 1,
492
+ initial: () => ({ phase: "pay" }),
493
+ phases: {
494
+ pay: async (task, runtime, context) => {
495
+ await runtime.commit(async (tx) => {
496
+ const payments: TaskId<string>[] = [];
497
+ for (const card of task.input.cards) {
498
+ payments.push(
499
+ await tx.createTask(Payment, { card }, {
500
+ ownership: { kind: "task", taskId: task.id },
501
+ }),
502
+ );
503
+ }
504
+ // Run no code until every payment is done. The first failed
505
+ // payment aborts the others.
506
+ return {
507
+ status: "waiting",
508
+ checkpoint: { phase: "decide", payments },
509
+ on: payments,
510
+ policy: "failFast",
511
+ };
512
+ }, context);
513
+ },
514
+ decide: async (task, runtime, context) => {
515
+ const outcomes = await runtime.outcomes(
516
+ task.state.checkpoint.payments,
517
+ context,
518
+ );
519
+ const paid = outcomes.every(
520
+ (outcome) => outcome.status === "completed",
521
+ );
522
+ await runtime.commit(
523
+ () => ({
524
+ status: "terminal",
525
+ outcome: paid
526
+ ? { status: "completed", result: "Order placed." }
527
+ : {
528
+ status: "failed",
529
+ error: { message: "A payment failed." },
530
+ },
531
+ }),
532
+ context,
533
+ );
534
+ },
535
+ },
536
+ abort: (_task, runtime, context) =>
537
+ runtime.commit(
538
+ () => ({ status: "terminal", outcome: { status: "aborted" } }),
539
+ context,
540
+ ),
541
+ });
542
+
543
+ // The agent starts a checkout with a tool.
544
+ const checkout = defineTool({
545
+ name: "checkout",
546
+ description: "Pay for the cart, split across several cards",
547
+ parameters: Type.Object({ cards: Type.Array(Type.String()) }),
548
+ execute: async (args, api, context) => {
549
+ // Owned by this call: aborting the call aborts the checkout and refunds
550
+ // its payments.
551
+ const owner = {
552
+ ownership: { kind: "task", taskId: api.taskId },
553
+ } as const;
554
+ const id = await api.createTask(
555
+ Checkout,
556
+ { cards: args.cards },
557
+ owner,
558
+ context,
559
+ );
560
+ const { outcome } = (await api.waitForTask(id, context)).state;
561
+ const text =
562
+ outcome.status === "completed" ? outcome.result : outcome.status;
563
+ return { content: [{ type: "text", text }] };
564
+ },
565
+ });
566
+
567
+ registry.install(defineExtension({
568
+ name: "shop",
569
+ tools: [checkout],
570
+ tasks: [Payment, Checkout],
571
+ }));
572
+ ```
573
+
574
+ Tasks and conversations form one ownership tree. Aborting a task aborts what it
575
+ owns, bottom-up, so every task cleans up its own effects first, and a task only
576
+ finishes once the work it owns has finished. A subagent is the same pattern: a
577
+ conversation owned by the tool call that started it.
578
+
579
+ Tasks are foreground by default: they are part of the conversation's current
580
+ work. The conversation is idle only once they are done, and aborting the
581
+ conversation, for example when the user presses Esc, aborts them and everything
582
+ they own. A background task belongs to the conversation, but not to its current
583
+ work. The conversation goes idle while it runs, and an ordinary abort leaves it
584
+ and everything it owns alone. That fits a subagent that should outlive the turn
585
+ that started it, or a reminder that fires tomorrow. Aborting the task itself, or
586
+ the conversation with `{ background: true }`, still stops it.
587
+
588
+ ```typescript
589
+ // Part of the current work: Esc aborts it, and the conversation waits for it.
590
+ await api.createTask(
591
+ Checkout,
592
+ input,
593
+ { ownership: { kind: "task", taskId: api.taskId } },
594
+ context,
595
+ );
596
+
597
+ // Side work: the conversation goes idle while it runs, and Esc leaves it alone.
598
+ await api.createTask(
599
+ Reminder,
600
+ input,
601
+ { ownership: { kind: "conversation" }, background: true },
602
+ context,
603
+ );
604
+ ```
605
+
606
+ ## Compaction
607
+
608
+ We want long conversations to keep going without the agent stopping to
609
+ summarize.
610
+
611
+ In Pi Durable, compaction is a task like any other, and it runs while the
612
+ conversation keeps going. When the context gets close to the model's limit, a
613
+ background compaction summarizes the older messages, and the summary is placed
614
+ at the next turn boundary. The conversation only waits for a summary when the
615
+ next request would not fit otherwise. If the provider still rejects a request as
616
+ too long, the harness compacts and retries once. You can also compact manually
617
+ at any time, with your own instructions. The older messages always stay in
618
+ storage.
619
+
620
+ ```typescript
621
+ const harness = await Harness.open(storage, {
622
+ models,
623
+ registry,
624
+ settings: {
625
+ compaction: {
626
+ // past contextWindow - reserveTokens, the next request waits for a
627
+ // summary
628
+ reserveTokens: 16384,
629
+ // this far before that, a summary starts in the background
630
+ backgroundTokens: 32768,
631
+ },
632
+ },
633
+ }, context);
634
+
635
+ // Manual, also while the agent is working.
636
+ await root.compact("Keep the names of the failing tests", context);
637
+ ```
638
+
639
+ `reset()` goes further: it starts a new context, optionally from a handoff note,
640
+ and a tool can ask for the same by returning `control: { handoff }`. Because
641
+ nothing is deleted, a second tool can still search everything before the
642
+ handoff. That is all it takes to build an agent that hands off to itself and
643
+ looks things up later.
644
+
645
+ ```typescript
646
+ const handoff = defineTool({
647
+ name: "handoff",
648
+ description:
649
+ "Start over from a handoff note. " +
650
+ "Older messages stay searchable with search_history.",
651
+ parameters: Type.Object({ note: Type.String() }),
652
+ execute: async (args, api, context) => {
653
+ // Queued behind the handoff, so it starts the next run in the new
654
+ // context.
655
+ const self = await api.conversation(api.conversationId, context);
656
+ await self!.submit(
657
+ {
658
+ type: "input",
659
+ content: "Continue.",
660
+ requestId: `handoff:${api.taskId}`,
661
+ },
662
+ context,
663
+ );
664
+ // Ends this run and starts a new context from the note, like
665
+ // reset(note).
666
+ return {
667
+ content: [{ type: "text", text: "Handing off." }],
668
+ control: { handoff: args.note },
669
+ };
670
+ },
671
+ });
672
+
673
+ const searchHistory = defineTool({
674
+ name: "search_history",
675
+ description: "Search older messages, including those before a handoff",
676
+ parameters: Type.Object({ text: Type.String() }),
677
+ replay: "safe",
678
+ execute: async (args, api, context) => {
679
+ // Tools read records through a transaction too. One that writes
680
+ // nothing stores nothing.
681
+ const page = await api.commit(
682
+ (tx) => tx.scanEntries({ conversationId: api.conversationId }, 200),
683
+ context,
684
+ );
685
+ const hits = page.items.filter((entry) =>
686
+ JSON.stringify(entry.model ?? []).includes(args.text),
687
+ );
688
+ const text = hits.map((entry) => JSON.stringify(entry.model)).join("\n");
689
+ return { content: [{ type: "text", text }] };
690
+ },
691
+ });
692
+ ```
693
+
694
+ ## Durable application state
695
+
696
+ We want the state of the application built on the agent to be as durable as the
697
+ conversation itself.
698
+
699
+ In Pi Durable, application state, like a todo list, a plan, a ticket, or the
700
+ sandbox a conversation runs in, lives in documents. Documents are typed JSON
701
+ stored next to the transcript and changed in the same atomic commits, so the
702
+ state never disagrees with the transcript that produced it. Each document says
703
+ what a fork starts with: the parent's value at the fork point, its current
704
+ value, or a fresh one.
705
+
706
+ ```typescript
707
+ import { defineDoc } from "@earendil-works/pi-durable";
708
+
709
+ const Todos = defineDoc<{ items: string[] }>({
710
+ kind: "app.todos",
711
+ version: 1,
712
+ scope: "conversation",
713
+ history: "rewindable",
714
+ fork: "asOf", // a fork starts with the todos its parent had at the fork entry
715
+ initial: () => ({ items: [] }),
716
+ });
717
+
718
+ const Todo = defineExtension({
719
+ name: "todo",
720
+ tools: [\
721
+ defineTool({\
722
+ name: "todo",\
723
+ description: "Add an item to your todo list",\
724
+ parameters: Type.Object({ item: Type.String() }),\
725
+ execute: async (args, api, context) => {\
726
+ await api.commit(async (tx) => {\
727
+ const todos = await tx.doc(Todos, api.conversationId);\
728
+ todos.items.push(args.item);\
729
+ }, context);\
730
+ const text = `Added ${args.item}`;\
731
+ return { content: [{ type: "text", text }] };\
732
+ },\
733
+ }),\
734
+ ],
735
+ // The model sees the list before every request.
736
+ sections: [\
737
+ section("todos", async (input, context) => {\
738
+ const todos = await input.read.snapshot(\
739
+ Todos,\
740
+ input.conversationId,\
741
+ context,\
742
+ );\
743
+ return todos?.items.join("\n") || undefined;\
744
+ }),\
745
+ ],
746
+ });
747
+
748
+ // A UI subscribes to the committed value.
749
+ const todos = await harness.documentState(Todos, channel.id, context);
750
+ todos?.subscribe((value) => renderTodos(value?.items ?? []));
751
+ ```
752
+
753
+ ## Malleable
754
+
755
+ We want to change the code of a running agent without stopping it.
756
+
757
+ In Pi Durable, the registry can change while conversations run. Installing an
758
+ extension under a name that is already installed replaces it in one step. A tool
759
+ call that is already running finishes on the code it started with; the next call
760
+ uses the new code. Conversations store extension and tool names, never code, so
761
+ after a restart they pick up whatever the new process installs.
762
+
763
+ ```typescript
764
+ // The extension's file changed on disk.
765
+ // same name "ops": replaces the installed one
766
+ registry.install(await loadExtension("./ops.ts"));
767
+ ```
768
+
769
+ ## Multiplayer
770
+
771
+ We want many people and clients to work with the same conversations at once:
772
+ watch them, join late, and steer them.
773
+
774
+ In Pi Durable, everything a UI needs is committed state, so any number of
775
+ clients can attach to any conversation in the harness. A client gets the current
776
+ view first: the transcript, the answer being streamed, running tools and their
777
+ output, queued messages, the agent, and usage. After that it only gets what
778
+ changes. A client that joins late or reconnects starts from the current view.
779
+ Any client can steer a running conversation or queue a follow-up.
780
+
781
+ ```typescript
782
+ // A second client joins the thread while the agent is working.
783
+ const view = await thread.viewState(context);
784
+ render(view.value);
785
+ view.subscribe((value) => render(value));
786
+
787
+ // And steers it. The message joins the running work after the current tool
788
+ // calls.
789
+ await thread.submit(
790
+ { type: "input", content: "Check the staging logs first", whenBusy: "steer" },
791
+ context,
792
+ );
793
+ ```
794
+
795
+ For a remote client, `thread.watch()` delivers the exact operations of every
796
+ commit, small enough to send over a socket. If you prefer the coding agent's
797
+ familiar events, `watchEvents()` turns the commits into those, at the cost of
798
+ more bytes on the wire.
799
+
800
+ ## Try it
801
+
802
+ You can try Pi Durable today. Pi Durable is experimental, and the API might
803
+ still change. Point your agent at `packages/durable` in a Pi checkout, have it
804
+ read the
805
+ [README](https://github.com/earendil-works/pi/blob/main/packages/durable/README.md),
806
+ the over thirty
807
+ [examples](https://github.com/earendil-works/pi/tree/main/packages/durable/test/examples),
808
+ the [small coding agent on Pi\\
809
+ Durable](https://github.com/earendil-works/pi/tree/main/packages/coding-agent/src/experimental/durable),
810
+ or this beautiful [vacation planning\\
811
+ agent](https://github.com/earendil-works/pi/tree/main/packages/coding-agent/src/experimental/vacation),
812
+ and get building.
813
+
814
+ The vacation planner is about 1,300 lines of TypeScript, most of them the TUI.
815
+ If it looks like a coding agent, that's only because it borrows its TUI
816
+ components from the Pi coding agent.
817
+
818
+ ```
819
+ A vacation planner with a TUI, built on Pi Durable. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ { "task": "Research a weekend trip to Vienna for two adults. Identify a strong 2-day first-time itinerary, current practical visitor information, notable restaurants/cafés, transit tips, and any booking considerations. Dates are not yet provided, so flag date-dependent items." } Research started in the background. Research is underway. What weekend are you visiting, and what’s your rough budget and interests—art, history, food, classical music, nightlife Tasks (3 live, /tasks to hide) vacation.research #12: running deliver [background] owns conversation 13 pi.generation #21: running request pi.generation #22: running request ── ⠧ Working... (esc to abort) ───────────────────────────────────────────────── ──────────────────────────────────────────────────────────────────────────────── ↑243 ↓96 $0.003 0.1%/500k /private/tmp/vacation-trip main · radius/gpt-5.6-sol · thinking:low (shift+tab) · ctrl+l or /model · /agents · /compact · /tasks · option+enter follow-up · ctrl+c exit
820
+ ```
821
+
822
+ 00:00-01:2700:17 - A subagent runs three searches in parallel, each a durable task.00:23 - Meanwhile, the main agent is free to chat.00:38 - The process dies. Weather and museums are done, trains is not.00:44 - Restarted. search is safe to rerun: only trains runs again.00:48 - Switch to the subagent and steer it.01:02 - Back in main: ask, compact, and steer, all while it works.01:27 - The report arrives as a message; main turns it into a plan.Keyboard shortcuts (?)Fullscreen (f)
823
+
824
+ 1. A vacation planner with a TUI, built on Pi Durable.
825
+ 2. A subagent runs three searches in parallel, each a durable task.
826
+ 3. Meanwhile, the main agent is free to chat.
827
+ 4. The process dies. Weather and museums are done, trains is not.
828
+ 5. Restarted. `search` is safe to rerun: only trains runs again.
829
+ 6. Switch to the subagent and steer it.
830
+ 7. Back in main: ask, compact, and steer, all while it works.
831
+ 8. The report arrives as a message; main turns it into a plan.
832
+
833
+ A vacation planner with a TUI, built on Pi Durable.
834
+
835
+ To run both demos from a Pi checkout:
836
+
837
+ ```bash
838
+ npm install && npm run build
839
+ node packages/coding-agent/src/experimental/durable/main.ts
840
+ node packages/coding-agent/src/experimental/vacation/main.ts
841
+ ```
842
+
843
+ To build on Pi Durable in your own project:
844
+
845
+ ```bash
846
+ npm install @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord
847
+ ```
848
+
849
+ In the coming weeks, we will talk more about Pi Durable and show you the small
850
+ agentic tools we build with it to help us work, like a Slack bot or a GitHub
851
+ triage bot. We don't want to spill the beans yet. There is more coming as we use
852
+ Pi Durable ourselves, just like we use Pi.
853
+
854
+ ## FAQ
855
+
856
+ ### Why TypeScript again?
857
+
858
+ Because it is the easiest way to bootstrap this. But as everybody knows by now,
859
+ it's very easy to port everything to Rust or assembler. We're not ruling this
860
+ out in the future, but at the moment we are focusing on TypeScript.