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,1240 @@
1
+ import { SharedState } from "./shared-state.js";
2
+ import type { StateChange } from "../../dpi-sdk/src/app-state.js";
3
+ import type {
4
+ AgentDetail,
5
+ AgentExtensions,
6
+ AgentRef,
7
+ AgentSummary,
8
+ CreateSessionBody,
9
+ DeviceInfo,
10
+ DeviceTree,
11
+ DirListing,
12
+ InboxItem,
13
+ ModelInfo,
14
+ Page,
15
+ SendResult,
16
+ SessionSummary,
17
+ TaskNode,
18
+ TaskOutcome,
19
+ ThinkingLevel,
20
+ UsageTotals,
21
+ WhenBusy,
22
+ WireEntry,
23
+ } from "../../dpi-sdk/src/api.js";
24
+ import {
25
+ compactionFits,
26
+ DEFAULT_COMPACTION,
27
+ defaultCompaction,
28
+ THINKING_LEVELS,
29
+ type CompactionSettings,
30
+ } from "../../dpi-sdk/src/api.js";
31
+ import { lastAssistantText, messageText, toMarkdown, toTranscript } from "../../dpi-sdk/src/transcript.js";
32
+ import { ApiFault, invalid, notFound } from "./errors.js";
33
+ import { DatabaseSync } from "node:sqlite";
34
+ import { mkdir, readdir, realpath, rename as renamePath, stat } from "node:fs/promises";
35
+ import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
36
+ import { ensurePrivateDir } from "../../dpi-sdk/src/node/private-dir.js";
37
+ import { join, dirname, resolve } from "node:path";
38
+ import { homedir, hostname, platform } from "node:os";
39
+ import { randomUUID } from "node:crypto";
40
+ import { createRequire } from "node:module";
41
+ import { BACKGROUND_CONTEXT as ctx, withAbortSignal } from "@earendil-works/chord/context";
42
+ import { createModels } from "@earendil-works/pi-ai/models";
43
+ import { openrouterProvider } from "@earendil-works/pi-ai/providers/openrouter";
44
+ import { curatedModels, defaultModelId } from "./model-catalog.js";
45
+ import { fauxModels, installFaux } from "./faux.js";
46
+ import {
47
+ Harness,
48
+ createRegistry,
49
+ InboxDoc,
50
+ type Conversation,
51
+ type ConversationId,
52
+ type ConversationRecord,
53
+ type EntryId,
54
+ type TaskId,
55
+ type SubmissionId,
56
+ type ModelRef,
57
+ type ToolRegistration,
58
+ } from "@earendil-works/pi-durable";
59
+ import { CodingTools } from "@earendil-works/pi-durable/tools";
60
+ import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
61
+ import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
62
+ import { createPlayground, Workspace, type SpawnSession } from "./extensions.js";
63
+ import { createJobs, JobRuns, JOB_DEFS, jobDef, resolveJobInput, type Jobs } from "./jobs.js";
64
+ import { ADDON_INFO, createAddons, PROMPTS, PromptChecklist, type Addons } from "./addons.js";
65
+ import { JOB_RUNS_DOC } from "../../dpi-sdk/src/jobs.js";
66
+
67
+ // The Durable version this device runs, read from the installed artifact so an upgrade cannot leave a stale
68
+ // literal here. `/v1/device` reports it; the hub's monitor compares apiVersion separately.
69
+ export const DURABLE_VERSION = (() => {
70
+ try {
71
+ return (createRequire(import.meta.url)("@earendil-works/pi-durable/package.json") as { version: string }).version;
72
+ } catch {
73
+ return "unknown";
74
+ }
75
+ })();
76
+
77
+ /** Tools that create, update or delete: shell, files, App State, job runs, sessions, pages and subagents. */
78
+ export const WRITE_TOOLS = new Set([
79
+ "bash",
80
+ "write",
81
+ "edit",
82
+ "update_state",
83
+ "run_durable_job",
84
+ "ping_on_call_engineer",
85
+ "subagent",
86
+ "start_session",
87
+ ]);
88
+
89
+ export type SessionRow = {
90
+ id: string;
91
+ name: string;
92
+ cwd: string;
93
+ dbPath: string;
94
+ createdAt: number;
95
+ model: string;
96
+ provider: string;
97
+ };
98
+
99
+ export interface StoreOptions {
100
+ dataDir: string;
101
+ startDirs: string[];
102
+ name: string;
103
+ provider: "openrouter" | "faux";
104
+ }
105
+
106
+ /** Agent documents a client may read or stream, by kind. */
107
+ /** apps/dpi-backend/src → the checkout root. */
108
+ const CODE_DIR = resolve(import.meta.dirname, "../../..");
109
+ const AGENT_DOCS = { [JOB_RUNS_DOC]: JobRuns, "demo.workspace": Workspace } as const;
110
+ export const AGENT_DOC_KINDS = Object.keys(AGENT_DOCS);
111
+ export const DEVICE_DOC_KINDS = ["app.shared-state"];
112
+
113
+ export function lockFile(path: string) {
114
+ mkdirSync(dirname(path), { recursive: true });
115
+ const take = () => writeFileSync(path, String(process.pid), { flag: "wx", mode: 0o600 });
116
+ try {
117
+ take();
118
+ } catch (err) {
119
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
120
+ const pid = Number(readFileSync(path, "utf8"));
121
+ let alive = false;
122
+ try {
123
+ process.kill(pid, 0);
124
+ alive = true;
125
+ } catch (e) {
126
+ if ((e as NodeJS.ErrnoException).code !== "ESRCH") alive = true;
127
+ }
128
+ if (alive && pid !== process.pid) throw Error(`Database already owned by process ${pid}: ${path}`);
129
+ unlinkSync(path);
130
+ take();
131
+ }
132
+ return () => {
133
+ try {
134
+ if (readFileSync(path, "utf8") === String(process.pid)) unlinkSync(path);
135
+ } catch {}
136
+ };
137
+ }
138
+
139
+ type Opened = { harness: Harness; unlock: () => void; unsubscribe: () => void; jobs: Jobs; addons: Addons };
140
+ type Resolved = { row: SessionRow; h: Harness; c: Conversation };
141
+
142
+ /**
143
+ * Bounded, secret-free description of the error that failed a Session. It names the error class only: never the
144
+ * cause message, so no SQL values, tokens or transcript can reach a REST error message. The library reports the
145
+ * full cause through the Harness `onReport` (server-side) for diagnostics.
146
+ */
147
+ function sessionFailureMessage(error: unknown): string {
148
+ const kind = (error instanceof Error && error.name ? error.name : "unknown").replace(/[^A-Za-z0-9_]/g, "").slice(0, 40);
149
+ return `Session failed and is unavailable (${kind || "unknown"}); reconnect after it is reopened`;
150
+ }
151
+
152
+ const encodeCursor = (cursor: unknown) =>
153
+ cursor === undefined ? undefined : Buffer.from(JSON.stringify(cursor)).toString("base64url");
154
+ const decodeCursor = (cursor?: string) => {
155
+ if (!cursor) return undefined;
156
+ try {
157
+ return JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
158
+ } catch {
159
+ throw invalid("Invalid cursor");
160
+ }
161
+ };
162
+
163
+ export class Store {
164
+ readonly models = createModels();
165
+ readonly opened = new Map<string, Opened>();
166
+ readonly errors = new Map<string, string>();
167
+ readonly catalog: DatabaseSync;
168
+ readonly startedAt = Date.now();
169
+ shared!: SharedState;
170
+ uid!: string;
171
+ modelList: ModelInfo[] = [];
172
+ private unlockCatalog: () => void;
173
+ private unlockShared?: () => void;
174
+ private listeners = new Set<() => void>();
175
+ /** Saved thresholds per session; null when the session uses the defaults for its model. */
176
+ private compactions = new Map<string, Partial<CompactionSettings> | null>();
177
+ /** Task labels never change once known: tool names of pi.tool tasks, job titles of job runs. */
178
+ private taskLabels = new Map<string, { label?: string; callId?: string }>();
179
+
180
+ get catalogPath() {
181
+ return join(this.options.dataDir, "catalog.sqlite");
182
+ }
183
+ get sharedStatePath() {
184
+ return join(this.options.dataDir, "app-state.sqlite");
185
+ }
186
+ get sessionsDir() {
187
+ return join(this.options.dataDir, "sessions");
188
+ }
189
+ get archiveDir() {
190
+ return join(this.options.dataDir, "archive");
191
+ }
192
+ get name() {
193
+ return this.options.name;
194
+ }
195
+ get startDirs() {
196
+ return this.options.startDirs;
197
+ }
198
+
199
+ private constructor(readonly options: StoreOptions) {
200
+ if (options.provider === "faux") installFaux(this.models);
201
+ else {
202
+ const provider = openrouterProvider();
203
+ const catalog = provider.getModels();
204
+ for (const model of curatedModels)
205
+ if (!catalog.some((m) => m.id === model.id)) throw Error(`OpenRouter catalog missing ${model.id}`);
206
+ this.models.setProvider({
207
+ ...provider,
208
+ getModels: () => catalog.filter((m) => curatedModels.some((c) => c.id === m.id)),
209
+ });
210
+ }
211
+ ensurePrivateDir(options.dataDir);
212
+ this.unlockCatalog = lockFile(`${this.catalogPath}.lock`);
213
+ this.catalog = new DatabaseSync(this.catalogPath);
214
+ this.catalog.exec(`PRAGMA journal_mode=WAL;
215
+ CREATE TABLE IF NOT EXISTS sessions(id TEXT PRIMARY KEY,name TEXT NOT NULL,cwd TEXT NOT NULL,dbPath TEXT NOT NULL UNIQUE,createdAt INTEGER NOT NULL,model TEXT NOT NULL,provider TEXT NOT NULL);
216
+ CREATE TABLE IF NOT EXISTS conversation_names(sessionId TEXT NOT NULL,conversationId INTEGER NOT NULL,name TEXT NOT NULL,PRIMARY KEY(sessionId,conversationId));
217
+ CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY,value TEXT NOT NULL);
218
+ CREATE TABLE IF NOT EXISTS requests(key TEXT PRIMARY KEY,route TEXT NOT NULL,status INTEGER NOT NULL,body TEXT NOT NULL,createdAt INTEGER NOT NULL);
219
+ CREATE TABLE IF NOT EXISTS session_spawns(key TEXT PRIMARY KEY,sessionId TEXT NOT NULL);
220
+ CREATE TABLE IF NOT EXISTS session_compaction(sessionId TEXT PRIMARY KEY,settings TEXT NOT NULL);`);
221
+ const uid = this.catalog.prepare("SELECT value FROM meta WHERE key='device_uid'").get() as
222
+ | { value: string }
223
+ | undefined;
224
+ this.uid = uid?.value ?? randomUUID();
225
+ if (!uid) this.catalog.prepare("INSERT INTO meta(key,value) VALUES('device_uid',?)").run(this.uid);
226
+ this.catalog.prepare("DELETE FROM requests WHERE createdAt < ?").run(Date.now() - 24 * 3600_000);
227
+ }
228
+
229
+ static async open(options: StoreOptions) {
230
+ const store = new Store({ ...options, dataDir: resolve(options.dataDir) });
231
+ // Start dirs are where new sessions begin; create any that do not exist yet.
232
+ await Promise.all(options.startDirs.map((dir) => mkdir(dir, { recursive: true }).catch(() => {})));
233
+ try {
234
+ store.unlockShared = lockFile(`${store.sharedStatePath}.lock`);
235
+ store.shared = await SharedState.open(store.sharedStatePath);
236
+ store.shared.harness.subscribeCommits(() => store.notify());
237
+ for (const row of store.list()) {
238
+ try {
239
+ await store.openAgent(row);
240
+ } catch (e) {
241
+ store.errors.set(row.id, String(e instanceof Error ? e.message : e));
242
+ }
243
+ }
244
+ if (!(await store.shared.initialized()))
245
+ await store.shared.initialize({ goal: "Explore durable agents", counter: 0, notes: [] });
246
+ store.modelList = await store.loadModels();
247
+ for (const { harness } of store.opened.values()) harness.resume();
248
+ return store;
249
+ } catch (e) {
250
+ await store.close().catch(() => {});
251
+ throw e;
252
+ }
253
+ }
254
+
255
+ // ------------------------------------------------------------ change feed
256
+
257
+ subscribe(fn: () => void) {
258
+ this.listeners.add(fn);
259
+ return () => void this.listeners.delete(fn);
260
+ }
261
+ notify = () => {
262
+ for (const fn of this.listeners) fn();
263
+ };
264
+
265
+ // ------------------------------------------------------------ device
266
+
267
+ deviceInfo(): DeviceInfo {
268
+ return {
269
+ uid: this.uid,
270
+ name: this.name,
271
+ hostname: hostname(),
272
+ platform: `${platform()}-${process.arch} node ${process.version}`,
273
+ apiVersion: 1,
274
+ durableVersion: DURABLE_VERSION,
275
+ startedAt: this.startedAt,
276
+ startDirs: this.startDirs,
277
+ models: this.modelList,
278
+ defaultModel: this.defaultModel(),
279
+ storage: { dataDir: this.options.dataDir, catalogPath: this.catalogPath, appStatePath: this.sharedStatePath },
280
+ codeDir: CODE_DIR,
281
+ capabilities: ["events", "docs", "demo", "jobs"],
282
+ jobs: JOB_DEFS,
283
+ };
284
+ }
285
+
286
+ private defaultModel() {
287
+ return this.options.provider === "faux" ? fauxModels[0].id : defaultModelId;
288
+ }
289
+
290
+ private async loadModels(): Promise<ModelInfo[]> {
291
+ if (this.options.provider === "faux")
292
+ return fauxModels.map((m) => ({
293
+ id: m.id,
294
+ provider: "faux",
295
+ name: m.name,
296
+ tier: "T",
297
+ icon: "lab.png",
298
+ contextWindow: 128000,
299
+ reasoning: false,
300
+ cost: { input: 0, output: 0 },
301
+ }));
302
+ const available = await this.models.getAvailable("openrouter", { signal: AbortSignal.timeout(10000) });
303
+ return curatedModels.flatMap((c) => {
304
+ const m = available.find((m) => m.id === c.id);
305
+ return m
306
+ ? [
307
+ {
308
+ id: m.id,
309
+ provider: "openrouter",
310
+ name: c.name,
311
+ tier: c.tier,
312
+ icon: c.icon,
313
+ contextWindow: m.contextWindow,
314
+ reasoning: m.reasoning,
315
+ cost: { input: m.cost.input, output: m.cost.output },
316
+ },
317
+ ]
318
+ : [];
319
+ });
320
+ }
321
+
322
+ routedModel(model?: ModelRef): ModelRef {
323
+ if (this.options.provider === "faux") {
324
+ const id = fauxModels.some((m) => m.id === model?.modelId) ? model!.modelId : fauxModels[0].id;
325
+ return { provider: "faux", modelId: id };
326
+ }
327
+ const exact = model?.provider === "openrouter" ? model.modelId : `${model?.provider}/${model?.modelId}`;
328
+ return {
329
+ provider: "openrouter",
330
+ modelId: curatedModels.some((m) => m.id === exact) ? exact : defaultModelId,
331
+ };
332
+ }
333
+
334
+ validateModel(model: unknown): ModelRef {
335
+ const m = model as ModelRef | undefined;
336
+ if (!m || typeof m.provider !== "string" || typeof m.modelId !== "string")
337
+ throw invalid("model must be { provider, modelId }");
338
+ const known =
339
+ this.options.provider === "faux"
340
+ ? m.provider === "faux" && fauxModels.some((f) => f.id === m.modelId)
341
+ : m.provider === "openrouter" &&
342
+ curatedModels.some((c) => c.id === m.modelId) &&
343
+ !!this.models.getModel(m.provider, m.modelId);
344
+ if (!known) throw invalid(`Unknown model ${m.provider}/${m.modelId}. Choose one from GET /v1/device models`);
345
+ return { provider: m.provider, modelId: m.modelId };
346
+ }
347
+
348
+ // ------------------------------------------------------------ directories
349
+
350
+ async directories(): Promise<DirListing> {
351
+ const dirs: { name: string; path: string }[] = [];
352
+ for (const root of this.startDirs) {
353
+ try {
354
+ dirs.push({ name: root, path: await realpath(root) });
355
+ } catch {}
356
+ }
357
+ return { path: "", dirs };
358
+ }
359
+
360
+ async browse(path: string): Promise<DirListing> {
361
+ if (!path) return this.directories();
362
+ const expanded = path === "~" ? homedir() : path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
363
+ let real: string;
364
+ try {
365
+ real = await realpath(resolve(expanded));
366
+ if (!(await stat(real)).isDirectory()) throw Error();
367
+ } catch {
368
+ throw notFound(`Not a directory on ${this.name}: ${path}`);
369
+ }
370
+ const dirs: { name: string; path: string }[] = [];
371
+ try {
372
+ for (const entry of await readdir(real, { withFileTypes: true })) {
373
+ if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
374
+ dirs.push({ name: entry.name, path: join(real, entry.name) });
375
+ }
376
+ } catch (e) {
377
+ throw new ApiFault("unavailable", `Cannot list ${real}: ${(e as Error).message}`);
378
+ }
379
+ dirs.sort((a, b) => a.name.localeCompare(b.name));
380
+ return { path: real, parent: dirname(real) !== real ? dirname(real) : undefined, dirs: dirs.slice(0, 500) };
381
+ }
382
+
383
+ // ------------------------------------------------------------ sessions
384
+
385
+ list(): SessionRow[] {
386
+ return this.catalog.prepare("SELECT * FROM sessions ORDER BY createdAt DESC").all() as SessionRow[];
387
+ }
388
+
389
+ row(id: string): SessionRow {
390
+ const row = this.catalog.prepare("SELECT * FROM sessions WHERE id=?").get(id) as SessionRow | undefined;
391
+ if (!row) throw notFound(`No session ${id}`);
392
+ return row;
393
+ }
394
+
395
+ /** A session by UUID or unique case-insensitive name. */
396
+ resolveSession(ref: string): SessionRow {
397
+ const byId = this.catalog.prepare("SELECT * FROM sessions WHERE id=?").get(ref) as SessionRow | undefined;
398
+ if (byId) return byId;
399
+ const matches = this.catalog
400
+ .prepare("SELECT * FROM sessions WHERE lower(name)=lower(?)")
401
+ .all(ref) as SessionRow[];
402
+ if (matches.length === 1) return matches[0];
403
+ if (matches.length > 1)
404
+ throw new ApiFault("ambiguous", `${matches.length} sessions are named "${ref}"; use the session id`);
405
+ throw notFound(`No session "${ref}" on ${this.name}`);
406
+ }
407
+
408
+ harness(row: SessionRow) {
409
+ const h = this.opened.get(row.id)?.harness;
410
+ if (!h) throw new ApiFault("unavailable", this.errors.get(row.id) ?? `Session ${row.name} is unavailable`);
411
+ return h;
412
+ }
413
+
414
+ /**
415
+ * A Session that failed its own Storage call is closed by pi-durable. Drop its handles and record a bounded,
416
+ * secret-free reason so every later call answers `unavailable` instead of touching a closed Session.
417
+ */
418
+ private failSession(id: string, error: unknown) {
419
+ const opened = this.opened.get(id);
420
+ if (!opened) return; // already cleaned up (normal close in removeSession, or an earlier failure)
421
+ this.opened.delete(id);
422
+ try {
423
+ opened.unsubscribe();
424
+ } catch {}
425
+ try {
426
+ opened.unlock();
427
+ } catch {}
428
+ this.errors.set(id, sessionFailureMessage(error));
429
+ this.taskLabels.clear();
430
+ this.notify();
431
+ }
432
+
433
+ /** `spawnKey` records which tool call created the session, in the same transaction as the session row. */
434
+ async create(body: CreateSessionBody, spawnKey?: string): Promise<SessionSummary> {
435
+ if (typeof body?.name !== "string") throw invalid("name is required");
436
+ if (typeof body.cwd !== "string" || !body.cwd.trim()) throw invalid("cwd is required");
437
+ const path = body.cwd.trim();
438
+ const expanded = path === "~" ? homedir() : path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
439
+ let cwd: string;
440
+ try {
441
+ cwd = await realpath(resolve(this.startDirs[0] ?? homedir(), expanded));
442
+ if (!(await stat(cwd)).isDirectory()) throw Error();
443
+ } catch {
444
+ throw invalid(`cwd must be an existing folder on ${this.name}`);
445
+ }
446
+ const model = this.validateModel(body.model);
447
+ if (body.thinkingLevel !== undefined && !THINKING_LEVELS.includes(body.thinkingLevel))
448
+ throw invalid("Invalid thinking level");
449
+ const id = randomUUID();
450
+ const row: SessionRow = {
451
+ id,
452
+ name: body.name.trim().slice(0, 100) || "Untitled session",
453
+ cwd,
454
+ dbPath: join(this.sessionsDir, id, "agent.sqlite"),
455
+ createdAt: Date.now(),
456
+ model: model.modelId,
457
+ provider: model.provider,
458
+ };
459
+ await this.openAgent(row, true, body.thinkingLevel ?? "low");
460
+ this.catalog.exec("BEGIN");
461
+ try {
462
+ this.catalog
463
+ .prepare("INSERT INTO sessions VALUES(?,?,?,?,?,?,?)")
464
+ .run(id, row.name, cwd, row.dbPath, row.createdAt, row.model, row.provider);
465
+ if (spawnKey) this.catalog.prepare("INSERT OR REPLACE INTO session_spawns VALUES(?,?)").run(spawnKey, id);
466
+ this.catalog.exec("COMMIT");
467
+ } catch (e) {
468
+ this.catalog.exec("ROLLBACK");
469
+ throw e;
470
+ }
471
+ this.notify();
472
+ return this.sessionSummary(row);
473
+ }
474
+
475
+ /**
476
+ * The start_session tool. A replay of the same tool call (same `key`) finds the session it created, and the first
477
+ * prompt reuses its requestId, so a crash never starts a second session or sends the prompt twice.
478
+ */
479
+ spawnSession: SpawnSession = async (input) => {
480
+ const found = this.catalog.prepare("SELECT sessionId FROM session_spawns WHERE key=?").get(input.key) as
481
+ | { sessionId: string }
482
+ | undefined;
483
+ const existing = found && this.list().find((r) => r.id === found.sessionId);
484
+ const session = existing
485
+ ? await this.sessionSummary(existing)
486
+ : await this.create(
487
+ { name: input.name, cwd: input.cwd, model: input.model, thinkingLevel: input.thinkingLevel },
488
+ input.key,
489
+ );
490
+ if (!input.prompt) return { session };
491
+ const { c } = await this.resolveAgent(session.id, "main");
492
+ const submission = await this.send(c, { content: input.prompt, requestId: `${input.key}:prompt` });
493
+ return { session, submissionId: submission.id as number };
494
+ };
495
+
496
+ rename(row: SessionRow, name: unknown) {
497
+ if (typeof name !== "string" || !name.trim()) throw invalid("A name is required");
498
+ this.catalog.prepare("UPDATE sessions SET name=? WHERE id=?").run(name.trim().slice(0, 100), row.id);
499
+ this.notify();
500
+ return this.sessionSummary(this.row(row.id));
501
+ }
502
+
503
+ /** Stop all work, close the harness and move its files to archive/. Nothing is deleted. */
504
+ async archive(row: SessionRow) {
505
+ const opened = this.opened.get(row.id);
506
+ if (opened) {
507
+ for (const record of await this.scanAll(opened.harness)) {
508
+ const c = await opened.harness.conversation(record.id, ctx);
509
+ if (c) await c.abort(ctx, { background: true }).catch(() => {});
510
+ }
511
+ opened.unsubscribe();
512
+ try {
513
+ await opened.harness.close(ctx);
514
+ } finally {
515
+ opened.unlock();
516
+ }
517
+ this.opened.delete(row.id);
518
+ }
519
+ await mkdir(this.archiveDir, { recursive: true });
520
+ const target = join(this.archiveDir, `${row.id}-${Date.now()}`);
521
+ try {
522
+ await renamePath(dirname(row.dbPath), target);
523
+ } catch (e) {
524
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
525
+ }
526
+ this.catalog.prepare("DELETE FROM conversation_names WHERE sessionId=?").run(row.id);
527
+ this.catalog.prepare("DELETE FROM sessions WHERE id=?").run(row.id);
528
+ this.errors.delete(row.id);
529
+ this.notify();
530
+ return { archived: target };
531
+ }
532
+
533
+ async openAgent(row: SessionRow, create = false, thinkingLevel: ThinkingLevel = "low") {
534
+ const current = this.opened.get(row.id);
535
+ if (current) return current.harness;
536
+ if (!create) await stat(row.dbPath);
537
+ await mkdir(dirname(row.dbPath), { recursive: true });
538
+ const unlock = lockFile(`${row.dbPath}.lock`);
539
+ try {
540
+ const shellEnv = Object.fromEntries(
541
+ ["PATH", "HOME", "USER", "SHELL", "TMPDIR", "LANG", "TERM"].flatMap((k) =>
542
+ process.env[k] ? [[k, process.env[k]]] : [],
543
+ ),
544
+ );
545
+ const registry = createRegistry();
546
+ registry.install(CodingTools);
547
+ const jobs = createJobs({ shared: this.shared, sessionId: row.id });
548
+ const compaction = () => this.compactionOf(row.id);
549
+ const playground = createPlayground(this.shared, row.id, this.spawnSession, jobs);
550
+ registry.install(playground);
551
+ // Custom extensions are installed but not selected: an agent turns them on in its own selection.
552
+ const addons = createAddons({ offline: this.options.provider === "faux" });
553
+ for (const extension of addons.list) registry.install(extension);
554
+ const harness = await Harness.open(
555
+ await openNodeSqliteStorage(row.dbPath),
556
+ {
557
+ models: this.models,
558
+ registry,
559
+ env: ({ cwd }) => new NodeExecutionEnv({ cwd: cwd ?? row.cwd, shellEnv }),
560
+ conversationCreated: async (tx, c) => {
561
+ await tx.doc(Workspace, c.id);
562
+ await tx.doc(JobRuns, c.id);
563
+ },
564
+ settings: {
565
+ extensions: [CodingTools, playground],
566
+ // Read at every use, so a PATCH .../compaction applies from the next request.
567
+ get compaction() {
568
+ return compaction();
569
+ },
570
+ progress: { partialIntervalMs: 100, outputIntervalMs: 100 },
571
+ },
572
+ onReport: (e) => console.error("[durable]", String(e)),
573
+ },
574
+ ctx,
575
+ );
576
+ await harness.root(ctx, {
577
+ agent: {
578
+ cwd: row.cwd,
579
+ model: { provider: row.provider, modelId: row.model },
580
+ thinkingLevel,
581
+ },
582
+ });
583
+ // Route stored agent choices to this device's provider before recovery issues any request.
584
+ for (const record of await this.scanAll(harness)) {
585
+ const conversation = (await harness.conversation(record.id, ctx))!;
586
+ const previous = (await conversation.agent(ctx)).model;
587
+ const routed = this.routedModel(previous);
588
+ if (previous?.provider !== routed.provider || previous.modelId !== routed.modelId)
589
+ await conversation.configure({ model: routed }, ctx);
590
+ }
591
+ const rootModel = (await (await harness.root(ctx)).agent(ctx)).model!;
592
+ row.provider = rootModel.provider;
593
+ row.model = rootModel.modelId;
594
+ this.catalog.prepare("UPDATE sessions SET provider=?,model=? WHERE id=?").run(row.provider, row.model, row.id);
595
+ const unsubscribe = harness.subscribeCommits(() => this.notify());
596
+ this.opened.set(row.id, { harness, unlock, unsubscribe, jobs, addons });
597
+ this.errors.delete(row.id);
598
+ // pi-durable 1.2+: any failed Storage call closes the Session, without waiting for the next event.
599
+ // Surface it as unavailable and clean up the handle/listener/lock; never auto-reopen in a loop —
600
+ // a later request reopens it once from committed storage (see openAgent), and fails cleanly if it still fails.
601
+ void harness.closed
602
+ .then((end) => {
603
+ if (end.reason === "failed") this.failSession(row.id, end.error);
604
+ })
605
+ .catch(() => {});
606
+ if (create) harness.resume();
607
+ return harness;
608
+ } catch (e) {
609
+ unlock();
610
+ throw e;
611
+ }
612
+ }
613
+
614
+ async scanAll(h: Harness): Promise<ConversationRecord[]> {
615
+ return h.commit(async (tx) => {
616
+ let page = await tx.scanConversations({}, 1000);
617
+ const records = [...page.items];
618
+ while (page.next) {
619
+ page = await tx.scanConversations({}, 1000, page.next);
620
+ records.push(...page.items);
621
+ }
622
+ return records;
623
+ }, ctx);
624
+ }
625
+
626
+ // ------------------------------------------------------------ agents
627
+
628
+ private names(sessionId: string) {
629
+ return new Map(
630
+ (
631
+ this.catalog
632
+ .prepare("SELECT conversationId,name FROM conversation_names WHERE sessionId=?")
633
+ .all(sessionId) as { conversationId: number; name: string }[]
634
+ ).map((r) => [r.conversationId, r.name]),
635
+ );
636
+ }
637
+
638
+ private agentRef(record: ConversationRecord, names: Map<number, string>): AgentRef {
639
+ const id = record.id as number;
640
+ const kind = record.parent ? "fork" : record.owner ? "subagent" : "root";
641
+ const custom = names.get(id);
642
+ const derived =
643
+ id === 1 ? "Main" : kind === "fork" ? `Fork #${id}` : kind === "subagent" ? `Subagent #${id}` : `Root #${id}`;
644
+ return {
645
+ id,
646
+ name: custom ?? derived,
647
+ named: custom !== undefined,
648
+ kind,
649
+ ...(record.parent ? { parentId: record.parent.conversationId as number, forkedAt: record.parent.at as number } : {}),
650
+ ...(record.owner
651
+ ? { ownerAgentId: record.owner.conversationId as number, ownerTaskId: record.owner.taskId as number }
652
+ : {}),
653
+ };
654
+ }
655
+
656
+ async agents(row: SessionRow, tasks?: TaskNode[]): Promise<AgentSummary[]> {
657
+ const h = this.harness(row);
658
+ const names = this.names(row.id);
659
+ const records = await this.scanAll(h);
660
+ const live = tasks ?? (await this.liveTasks(h));
661
+ return records.map((record) => {
662
+ const own = live.filter((t) => t.conversationId === (record.id as number));
663
+ return {
664
+ ...this.agentRef(record, names),
665
+ busy: own.some((t) => !t.background),
666
+ background: own.filter((t) => t.background).length,
667
+ };
668
+ });
669
+ }
670
+
671
+ async liveTasks(h: Harness): Promise<TaskNode[]> {
672
+ const graph = await h.taskGraph(ctx);
673
+ try {
674
+ return await this.labelTasks(h, Object.values(graph.value.tasks).map((t) => this.taskNode(t)));
675
+ } finally {
676
+ graph.dispose();
677
+ }
678
+ }
679
+
680
+ taskNode(t: unknown): TaskNode {
681
+ const n = t as {
682
+ id: number;
683
+ kind: string;
684
+ conversationId: number;
685
+ owner?: number;
686
+ background: boolean;
687
+ abortRequested?: boolean;
688
+ state: { status: string; phase?: string; on?: readonly number[]; policy?: string; outcome?: string };
689
+ conversations?: readonly number[];
690
+ };
691
+ return {
692
+ id: n.id,
693
+ kind: n.kind,
694
+ conversationId: n.conversationId,
695
+ ...(n.owner !== undefined ? { owner: n.owner } : {}),
696
+ background: !!n.background,
697
+ ...(n.abortRequested ? { aborting: true } : {}),
698
+ state: {
699
+ status: n.state.status,
700
+ ...(n.state.phase ? { phase: n.state.phase } : {}),
701
+ ...(n.state.on ? { on: [...n.state.on] } : {}),
702
+ ...(n.state.policy ? { policy: n.state.policy } : {}),
703
+ },
704
+ conversations: [...(n.conversations ?? [])],
705
+ };
706
+ }
707
+
708
+ /** Agent by numeric id, `main`, or unique name (custom or derived, case-insensitive). */
709
+ async resolveAgent(sessionRef: string, agentRef: string): Promise<Resolved> {
710
+ const row = this.resolveSession(sessionRef);
711
+ const h = this.harness(row);
712
+ let id: number | undefined;
713
+ if (/^\d+$/.test(agentRef)) id = Number(agentRef);
714
+ else if (agentRef.toLowerCase() === "main") id = 1;
715
+ else {
716
+ const wanted = agentRef.toLowerCase();
717
+ const names = this.names(row.id);
718
+ const matches = (await this.scanAll(h))
719
+ .map((r) => this.agentRef(r, names))
720
+ .filter((a) => a.name.toLowerCase() === wanted);
721
+ if (matches.length > 1) throw new ApiFault("ambiguous", `${matches.length} agents are named "${agentRef}"`);
722
+ id = matches[0]?.id;
723
+ }
724
+ const c = id === undefined ? undefined : await h.conversation(id as ConversationId, ctx);
725
+ if (!c) throw notFound(`No agent "${agentRef}" in session ${row.name}`);
726
+ return { row, h, c };
727
+ }
728
+
729
+ async agentSummary(row: SessionRow, c: Conversation): Promise<AgentSummary> {
730
+ const agents = await this.agents(row);
731
+ return agents.find((a) => a.id === (c.id as number))!;
732
+ }
733
+
734
+ /** Every tool of the agent's selected extensions, first definition per name, and the names it is offered. */
735
+ private async toolsOf(c: Conversation) {
736
+ const agent = await c.agent(ctx);
737
+ const all = new Map<string, ToolRegistration>();
738
+ for (const extension of agent.extensions)
739
+ for (const tool of extension.tools ?? []) if (!all.has(tool.name)) all.set(tool.name, tool as ToolRegistration);
740
+ return { agent, all: [...all.values()], offered: new Set(agent.tools.map((t) => t.name)) };
741
+ }
742
+
743
+ async agentDetail(row: SessionRow, c: Conversation): Promise<AgentDetail> {
744
+ const { agent, all, offered } = await this.toolsOf(c);
745
+ const writers = all.filter((t) => WRITE_TOOLS.has(t.name));
746
+ return {
747
+ ...(await this.agentSummary(row, c)),
748
+ sessionId: row.id,
749
+ config: {
750
+ model: agent.model,
751
+ thinkingLevel: agent.thinkingLevel as ThinkingLevel | undefined,
752
+ instructions: agent.instructions,
753
+ cwd: agent.cwd,
754
+ readOnly: writers.length > 0 && writers.every((t) => !offered.has(t.name)),
755
+ },
756
+ tools: all.map((t) => ({
757
+ name: t.name,
758
+ description: t.description,
759
+ replay: (t.replay ?? "unsafe") as "safe" | "unsafe",
760
+ parameters: JSON.parse(JSON.stringify(t.parameters ?? {})),
761
+ writes: WRITE_TOOLS.has(t.name),
762
+ offered: offered.has(t.name),
763
+ })),
764
+ extensions: agent.extensions.map((e) => e.name),
765
+ };
766
+ }
767
+
768
+ setAgentName(row: SessionRow, agentId: number, name: unknown) {
769
+ if (typeof name !== "string" || !name.trim()) throw invalid("A name is required");
770
+ const clean = name.trim().slice(0, 100);
771
+ if (/^\d+$/.test(clean) || clean.toLowerCase() === "main" && agentId !== 1)
772
+ throw invalid("Agent names cannot be numbers or 'main'");
773
+ this.catalog
774
+ .prepare(
775
+ "INSERT INTO conversation_names VALUES(?,?,?) ON CONFLICT(sessionId,conversationId) DO UPDATE SET name=excluded.name",
776
+ )
777
+ .run(row.id, agentId, clean);
778
+ this.notify();
779
+ }
780
+
781
+ async createAgent(row: SessionRow, body: { from?: unknown; name?: unknown }) {
782
+ const h = this.harness(row);
783
+ const source = await this.resolveAgent(row.id, String(body.from ?? 1));
784
+ const agent = await source.c.agent(ctx);
785
+ const other = await h.createConversation(
786
+ {
787
+ ownership: { kind: "ownerless" },
788
+ agent: {
789
+ model: agent.model,
790
+ cwd: agent.cwd,
791
+ thinkingLevel: agent.thinkingLevel,
792
+ instructions: agent.instructions,
793
+ },
794
+ },
795
+ ctx,
796
+ );
797
+ if (typeof body.name === "string" && body.name.trim()) this.setAgentName(row, other.id as number, body.name);
798
+ this.notify();
799
+ return this.agentSummary(row, other);
800
+ }
801
+
802
+ async fork(row: SessionRow, c: Conversation, body: { entryId?: unknown; name?: unknown }) {
803
+ const at = Number(body.entryId);
804
+ if (!Number.isSafeInteger(at)) throw invalid("entryId is required");
805
+ if (body.name !== undefined && typeof body.name !== "string") throw invalid("Invalid fork name");
806
+ const fork = await c.fork(at as EntryId, { ownership: { kind: "ownerless" } }, ctx);
807
+ const agent = await fork.agent(ctx);
808
+ await fork.configure({ model: this.routedModel(agent.model) }, ctx);
809
+ if (typeof body.name === "string" && body.name.trim()) this.setAgentName(row, fork.id as number, body.name);
810
+ this.notify();
811
+ return this.agentSummary(row, fork);
812
+ }
813
+
814
+ async configure(row: SessionRow, c: Conversation, body: Record<string, unknown>) {
815
+ const model = body.model === undefined ? undefined : this.validateModel(body.model);
816
+ if (body.thinkingLevel !== undefined && !THINKING_LEVELS.includes(body.thinkingLevel as ThinkingLevel))
817
+ throw invalid("Invalid thinking level");
818
+ if (body.instructions !== undefined && typeof body.instructions !== "string")
819
+ throw invalid("instructions must be a string");
820
+ let cwd: string | undefined;
821
+ if (body.cwd !== undefined) {
822
+ if (typeof body.cwd !== "string") throw invalid("cwd must be a string");
823
+ cwd = (await this.browse(body.cwd)).path;
824
+ }
825
+ if (body.readOnly !== undefined && typeof body.readOnly !== "boolean") throw invalid("readOnly must be a boolean");
826
+ // Read only filters the agent's tools: the model is never offered the ones that create, update or delete.
827
+ const tools =
828
+ body.readOnly === true
829
+ ? { tools: { remove: (await this.toolsOf(c)).all.filter((t) => WRITE_TOOLS.has(t.name)) } }
830
+ : body.readOnly === false
831
+ ? { tools: null }
832
+ : {};
833
+ await c.configure(
834
+ {
835
+ ...tools,
836
+ ...(model ? { model } : {}),
837
+ ...(body.thinkingLevel ? { thinkingLevel: body.thinkingLevel as ThinkingLevel } : {}),
838
+ ...(typeof body.instructions === "string" ? { instructions: body.instructions.slice(0, 12000) } : {}),
839
+ ...(cwd ? { cwd } : {}),
840
+ },
841
+ ctx,
842
+ );
843
+ if (model && (c.id as number) === 1)
844
+ this.catalog.prepare("UPDATE sessions SET model=?,provider=? WHERE id=?").run(model.modelId, model.provider, row.id);
845
+ // Thresholds are shared by the session's agents; refit them when this model's window is too small for them.
846
+ const window = model ? this.windowOf(model.modelId) : 0;
847
+ if (window && !compactionFits(window, this.compactionOf(row.id))) {
848
+ const fitted = defaultCompaction(window);
849
+ this.setCompaction(row, { reserveTokens: fitted.reserveTokens, backgroundTokens: fitted.backgroundTokens });
850
+ }
851
+ this.notify();
852
+ return (await this.agentDetail(row, c)).config;
853
+ }
854
+
855
+ async send(c: Conversation, body: Record<string, unknown>) {
856
+ if (typeof body.content !== "string" || !body.content.trim() || body.content.length > 100000)
857
+ throw invalid("content must be a message under 100,000 characters");
858
+ const whenBusy = (body.whenBusy ?? "followUp") as WhenBusy;
859
+ if (!["steer", "followUp", "reject"].includes(whenBusy)) throw invalid("whenBusy must be followUp, steer or reject");
860
+ if (typeof body.requestId !== "string" || !body.requestId || body.requestId.length > 128)
861
+ throw invalid("requestId is required (≤128 chars) so retries never submit twice");
862
+ return c.submit({ type: "input", content: body.content, requestId: body.requestId, whenBusy }, ctx);
863
+ }
864
+
865
+ async settled(h: Harness, submissionId: number, waitMs = 0, signal?: AbortSignal): Promise<SendResult> {
866
+ const submission = await h.submission(submissionId as SubmissionId, ctx);
867
+ if (!submission) throw notFound(`No submission ${submissionId}`);
868
+ let record = await submission.status(ctx);
869
+ if (waitMs > 0 && record.status !== "done" && record.status !== "unanswered") {
870
+ const timeout = AbortSignal.timeout(Math.min(waitMs, 300_000));
871
+ const stop = signal ? AbortSignal.any([timeout, signal]) : timeout;
872
+ try {
873
+ record = await submission.wait(withAbortSignal(stop, ctx));
874
+ } catch (e) {
875
+ if (!stop.aborted) throw e;
876
+ }
877
+ }
878
+ if (record.status === "done" && record.answer !== undefined) {
879
+ const entry = await h.commit((tx) => tx.entry(record.answer as EntryId), ctx);
880
+ const text = (entry?.model ?? []).map((m) => messageText(m as never)).join("").trim();
881
+ return { submissionId, status: "done", answer: { entryId: record.answer as number, text } };
882
+ }
883
+ if (record.status === "unanswered") return { submissionId, status: "unanswered", reason: record.reason };
884
+ return { submissionId, status: record.status === "queued" ? "queued" : "pending" };
885
+ }
886
+
887
+ async abort(c: Conversation, background: boolean) {
888
+ await c.abort(ctx, { background });
889
+ return { ok: true as const };
890
+ }
891
+
892
+ async withdraw(h: Harness, c: Conversation, submissionId: number) {
893
+ return { status: await h.abortSubmission(submissionId as SubmissionId, ctx, c.id) };
894
+ }
895
+
896
+ async compact(c: Conversation, instructions?: unknown) {
897
+ return { taskId: (await c.compact(typeof instructions === "string" && instructions.trim() ? instructions : undefined, ctx)) as number };
898
+ }
899
+
900
+ async reset(c: Conversation, note?: unknown) {
901
+ await c.reset(typeof note === "string" && note.trim() ? note : undefined, ctx);
902
+ return { ok: true as const };
903
+ }
904
+
905
+ async tasks(h: Harness, status: string, limit: number): Promise<TaskOutcome[]> {
906
+ const items = await h.commit(async (tx) => {
907
+ let page = await tx.scanTasks({}, 500);
908
+ let all = [...page.items];
909
+ while (page.next) {
910
+ page = await tx.scanTasks({}, 500, page.next);
911
+ all = [...all, ...page.items].slice(-2000);
912
+ }
913
+ return all;
914
+ }, ctx);
915
+ const wanted = items.filter((t) =>
916
+ status === "terminal" ? t.state.status === "terminal" : t.state.status !== "terminal",
917
+ );
918
+ return wanted.slice(-Math.min(Math.max(limit, 1), 200)).reverse().map((t) => ({
919
+ id: t.id as number,
920
+ kind: (t as { kind?: string }).kind ?? (t as { definition?: string }).definition ?? "task",
921
+ conversationId: (t as { conversationId?: number }).conversationId,
922
+ state: JSON.parse(JSON.stringify(t.state)),
923
+ }));
924
+ }
925
+
926
+ async abortTask(h: Harness, taskId: number) {
927
+ const task = await h.getTask(taskId as TaskId, ctx);
928
+ if (!task) throw notFound(`No task ${taskId}`);
929
+ return { status: await h.abortTask(task.id, ctx) };
930
+ }
931
+
932
+ async inspect(h: Harness) {
933
+ return h.inspect(ctx);
934
+ }
935
+
936
+ async entries(c: Conversation, cursor: string | undefined, limit: number): Promise<Page<WireEntry>> {
937
+ const page = await c.entries({}, Math.min(Math.max(limit, 1), 2000), decodeCursor(cursor), ctx);
938
+ return { items: page.items as unknown as WireEntry[], next: encodeCursor(page.next) };
939
+ }
940
+
941
+ async activeEntries(c: Conversation): Promise<WireEntry[]> {
942
+ const view = await c.viewState(ctx);
943
+ try {
944
+ return JSON.parse(JSON.stringify(view.value.entries)) as WireEntry[];
945
+ } finally {
946
+ view.dispose();
947
+ }
948
+ }
949
+
950
+ async transcript(c: Conversation, format: string) {
951
+ const items = toTranscript(await this.activeEntries(c));
952
+ return format === "markdown" ? { format: "markdown", text: toMarkdown(items) } : { format: "json", items };
953
+ }
954
+
955
+ async preview(row: SessionRow, agentId: number) {
956
+ const h = this.opened.get(row.id)?.harness;
957
+ const c = h ? await h.conversation(agentId as ConversationId, ctx) : undefined;
958
+ if (!c) return undefined;
959
+ return lastAssistantText(await this.activeEntries(c))?.slice(0, 200);
960
+ }
961
+
962
+ async inbox(h: Harness, c: Conversation): Promise<InboxItem[]> {
963
+ const doc = await h.snapshot(InboxDoc, c.id, ctx);
964
+ return (doc?.items ?? []).map((item) => {
965
+ if (item.mode === "write") return { id: item.id as number, mode: "write" as const, content: "Context update" };
966
+ const content = item.content as unknown;
967
+ const text =
968
+ typeof content === "string"
969
+ ? content
970
+ : Array.isArray(content)
971
+ ? content.map((b: { type?: string; text?: string }) => (b.type === "text" ? b.text : "")).join("")
972
+ : undefined;
973
+ return { id: item.id as number, mode: item.mode, content: text };
974
+ });
975
+ }
976
+
977
+ async agentDoc(h: Harness, c: Conversation, kind: string) {
978
+ const token = AGENT_DOCS[kind as keyof typeof AGENT_DOCS];
979
+ if (!token) throw notFound(`Unknown agent document ${kind}. Known: ${AGENT_DOC_KINDS.join(", ")}`);
980
+ const value = await h.snapshot(token as typeof JobRuns, c.id, ctx);
981
+ return JSON.parse(JSON.stringify(value ?? (kind === JOB_RUNS_DOC ? { counts: {}, items: {} } : {}))) as unknown;
982
+ }
983
+
984
+ async deviceDoc(kind: string) {
985
+ if (kind !== "app.shared-state") throw notFound(`Unknown device document ${kind}`);
986
+ const { value, revision } = await this.shared.snapshot();
987
+ return { value, revision };
988
+ }
989
+
990
+ async usage(h: Harness): Promise<UsageTotals> {
991
+ return JSON.parse(JSON.stringify(await h.usage(ctx))) as UsageTotals;
992
+ }
993
+
994
+ // ------------------------------------------------------------ custom extensions (per agent)
995
+
996
+ async extensionsOf(row: SessionRow, c: Conversation): Promise<AgentExtensions> {
997
+ const active = new Set((await c.agent(ctx)).extensions.map((e) => e.name));
998
+ const checked = (await this.harness(row).snapshot(PromptChecklist, c.id, ctx))?.enabled ?? [PROMPTS[0].id];
999
+ return {
1000
+ builtIn: [...active].filter((name) => !ADDON_INFO.some((a) => a.name === name)),
1001
+ extensions: ADDON_INFO.map((info) => ({
1002
+ ...info,
1003
+ enabled: active.has(info.name),
1004
+ ...(info.name === "system-prompts"
1005
+ ? { prompts: PROMPTS.map(({ id, title, description, text }) => ({ id, title, description, text, enabled: checked.includes(id) })) }
1006
+ : {}),
1007
+ })),
1008
+ };
1009
+ }
1010
+
1011
+ /** `{name, enabled}` edits the agent's extension selection; `{prompt, enabled}` edits its prompt checklist. */
1012
+ async setExtension(row: SessionRow, c: Conversation, body: Record<string, unknown>) {
1013
+ if (typeof body.enabled !== "boolean") throw invalid("enabled must be a boolean");
1014
+ const enabled = body.enabled;
1015
+ if (typeof body.name === "string") {
1016
+ const extension = this.opened.get(row.id)?.addons.all[body.name];
1017
+ if (!extension) throw invalid(`Unknown extension ${body.name}. Known: ${ADDON_INFO.map((a) => a.name).join(", ")}`);
1018
+ // An array is the agent's whole selection. An { add | remove } object would edit the host default instead and
1019
+ // replace the previous edit, so turning one extension on would turn the other off.
1020
+ const rank = (name: string) => ADDON_INFO.findIndex((a) => a.name === name);
1021
+ const others = (await c.agent(ctx)).extensions.filter((e) => e.name !== extension.name);
1022
+ const next = (enabled ? [...others, extension] : others).sort((a, b) => rank(a.name) - rank(b.name));
1023
+ await c.configure({ extensions: next }, ctx);
1024
+ } else if (typeof body.prompt === "string") {
1025
+ const prompt = body.prompt;
1026
+ if (!PROMPTS.some((p) => p.id === prompt)) throw invalid(`Unknown prompt ${prompt}. Known: ${PROMPTS.map((p) => p.id).join(", ")}`);
1027
+ await c.commit(async (tx) => {
1028
+ const doc = await tx.doc(PromptChecklist, c.id);
1029
+ const next = new Set(doc.enabled);
1030
+ if (enabled) next.add(prompt);
1031
+ else next.delete(prompt);
1032
+ doc.enabled = PROMPTS.map((p) => p.id).filter((id) => next.has(id));
1033
+ }, ctx);
1034
+ } else throw invalid("name or prompt is required");
1035
+ this.notify();
1036
+ return this.extensionsOf(row, c);
1037
+ }
1038
+
1039
+ // ------------------------------------------------------------ compaction settings (per session)
1040
+
1041
+ private windowOf(modelId: string) {
1042
+ return this.modelList.find((m) => m.id === modelId)?.contextWindow ?? 0;
1043
+ }
1044
+
1045
+ /** Saved thresholds, or the defaults for the session model's window (200k background, 250k wait when it fits). */
1046
+ compactionOf(sessionId: string): CompactionSettings {
1047
+ if (!this.compactions.has(sessionId)) {
1048
+ const row = this.catalog.prepare("SELECT settings FROM session_compaction WHERE sessionId=?").get(sessionId) as
1049
+ | { settings: string }
1050
+ | undefined;
1051
+ this.compactions.set(sessionId, row ? (JSON.parse(row.settings) as Partial<CompactionSettings>) : null);
1052
+ }
1053
+ const saved = this.compactions.get(sessionId);
1054
+ if (saved) return { ...DEFAULT_COMPACTION, ...saved };
1055
+ const session = this.catalog.prepare("SELECT model FROM sessions WHERE id=?").get(sessionId) as { model: string } | undefined;
1056
+ return defaultCompaction(this.windowOf(session?.model ?? ""));
1057
+ }
1058
+
1059
+ /** Validate and store new thresholds; pi-durable reads them at its next use, so they apply from the next request. */
1060
+ setCompaction(row: SessionRow, body: Record<string, unknown>): CompactionSettings {
1061
+ const next = this.compactionOf(row.id);
1062
+ const int = (key: "reserveTokens" | "backgroundTokens" | "keepRecentTokens", min: number, max: number) => {
1063
+ if (body[key] === undefined) return;
1064
+ const n = Number(body[key]);
1065
+ if (!Number.isInteger(n) || n < min || n > max) throw invalid(`${key} must be an integer from ${min} to ${max}`);
1066
+ next[key] = n;
1067
+ };
1068
+ if (body.enabled !== undefined) {
1069
+ if (typeof body.enabled !== "boolean") throw invalid("enabled must be a boolean");
1070
+ next.enabled = body.enabled;
1071
+ }
1072
+ int("reserveTokens", 1024, 1_000_000);
1073
+ int("backgroundTokens", 0, 1_000_000);
1074
+ int("keepRecentTokens", 500, 200_000);
1075
+ this.catalog
1076
+ .prepare("INSERT INTO session_compaction VALUES(?,?) ON CONFLICT(sessionId) DO UPDATE SET settings=excluded.settings")
1077
+ .run(row.id, JSON.stringify(next));
1078
+ this.compactions.set(row.id, next);
1079
+ this.notify();
1080
+ return { ...next };
1081
+ }
1082
+
1083
+ /** Name every pi.tool task by its tool and every job run by its title, so a task list reads like the transcript. */
1084
+ async labelTasks(h: Harness, nodes: TaskNode[]): Promise<TaskNode[]> {
1085
+ return Promise.all(
1086
+ nodes.map(async (node) => {
1087
+ const key = `${node.conversationId}:${node.id}`;
1088
+ let known = this.taskLabels.get(key);
1089
+ if (!known) {
1090
+ known = await this.taskLabel(h, node).catch(() => undefined);
1091
+ if (!known) return node;
1092
+ this.taskLabels.set(key, known);
1093
+ if (this.taskLabels.size > 5000) this.taskLabels.delete(this.taskLabels.keys().next().value!);
1094
+ }
1095
+ return { ...node, ...known };
1096
+ }),
1097
+ );
1098
+ }
1099
+
1100
+ private async taskLabel(h: Harness, node: TaskNode): Promise<{ label?: string; callId?: string } | undefined> {
1101
+ if (node.kind.startsWith("demo.job.")) {
1102
+ const run = (await h.snapshot(JobRuns, node.conversationId as ConversationId, ctx))?.items[String(node.id)];
1103
+ const def = jobDef(node.kind.slice("demo.job.".length));
1104
+ return def ? { label: run ? `${def.title} #${run.run}` : def.title } : undefined;
1105
+ }
1106
+ if (node.kind !== "pi.tool") return undefined;
1107
+ const task = await h.getTask(node.id as TaskId, ctx);
1108
+ // pi-durable 1.2+ tagged union: only a model-issued call reads its assistant entry. A nested call
1109
+ // (kind "nested") carries no assistant/callId, so it gets no label rather than a fabricated one.
1110
+ const input = task?.input as { kind?: string; assistant?: number; callId?: string } | undefined;
1111
+ if (input?.kind !== "model" || input.callId === undefined || input.assistant === undefined) return undefined;
1112
+ const entry = await h.commit((tx) => tx.entry(input.assistant as EntryId), ctx);
1113
+ for (const m of (entry?.model ?? []) as unknown as { role: string; content?: { type: string; id?: string; name?: string }[] }[])
1114
+ for (const b of m.role === "assistant" ? (m.content ?? []) : [])
1115
+ if (b.type === "toolCall" && b.id === input.callId) return { label: b.name, callId: input.callId };
1116
+ return { callId: input.callId };
1117
+ }
1118
+
1119
+ // ------------------------------------------------------------ tree
1120
+
1121
+ sessionSummaryBase(row: SessionRow) {
1122
+ return {
1123
+ id: row.id,
1124
+ name: row.name,
1125
+ cwd: row.cwd,
1126
+ model: row.model,
1127
+ createdAt: row.createdAt,
1128
+ dbPath: row.dbPath,
1129
+ compaction: this.compactionOf(row.id),
1130
+ };
1131
+ }
1132
+
1133
+ async sessionSummary(row: SessionRow): Promise<SessionSummary> {
1134
+ if (!this.opened.has(row.id))
1135
+ return { ...this.sessionSummaryBase(row), busy: false, agents: [], error: this.errors.get(row.id) ?? "Session unavailable" };
1136
+ const agents = await this.agents(row);
1137
+ return { ...this.sessionSummaryBase(row), busy: agents.some((a) => a.busy), agents };
1138
+ }
1139
+
1140
+ async tree(): Promise<DeviceTree> {
1141
+ const sessions = await Promise.all(this.list().map((row) => this.sessionSummary(row)));
1142
+ return { uid: this.uid, name: this.name, sessions, at: Date.now() };
1143
+ }
1144
+
1145
+ // ------------------------------------------------------------ demo extension
1146
+
1147
+ async demoState(c: Conversation, body: Record<string, unknown>) {
1148
+ let change = body as unknown as StateChange;
1149
+ if (body.operation === undefined) {
1150
+ const { value } = await this.shared.snapshot();
1151
+ if (typeof body.goal === "string")
1152
+ change = { operation: Object.hasOwn(value, "goal") ? "update" : "create", path: "/goal", value: body.goal };
1153
+ else if (body.increment === true) change = { operation: "increment", path: "/counter", value: 1 };
1154
+ else if (typeof body.note === "string") change = { operation: "create", path: "/notes/-", value: body.note };
1155
+ else throw invalid("A state operation is required");
1156
+ }
1157
+ await this.shared.change(
1158
+ change,
1159
+ undefined,
1160
+ typeof body.expectedRevision === "number" ? body.expectedRevision : undefined,
1161
+ );
1162
+ await c.commit(
1163
+ (tx) =>
1164
+ tx.appendEntry(c.id, {
1165
+ kind: "demo.state-change",
1166
+ data: { text: `Shared App State: ${change.operation} ${change.path || "/"}` },
1167
+ }),
1168
+ ctx,
1169
+ );
1170
+ return { ok: true as const, ...(await this.deviceDoc("app.shared-state")) };
1171
+ }
1172
+
1173
+ /** Start a durable job from a client: the conversation owns the run, and a transcript entry anchors its card. */
1174
+ async startJob(row: SessionRow, h: Harness, c: Conversation, body: Record<string, unknown>) {
1175
+ const def = typeof body.job === "string" ? jobDef(body.job) : undefined;
1176
+ if (!def) throw invalid(`job must be one of ${JOB_DEFS.map((d) => d.id).join(", ")}`);
1177
+ let input;
1178
+ try {
1179
+ input = resolveJobInput(def, body.input ?? {});
1180
+ } catch (e) {
1181
+ throw invalid((e as Error).message);
1182
+ }
1183
+ const jobs = this.opened.get(row.id)?.jobs;
1184
+ if (!jobs) throw new ApiFault("unavailable", "Session is not open");
1185
+ const taskId = await c.commit(async (tx) => {
1186
+ const id = await jobs.start(tx, c.id, { def, input, startedBy: "you", now: Date.now() });
1187
+ await tx.appendEntry(c.id, { kind: "demo.job", data: { taskId: id as number, job: def.id, text: `Started ${def.title}` } });
1188
+ return id;
1189
+ }, ctx);
1190
+ h.resume();
1191
+ return { taskId: taskId as number };
1192
+ }
1193
+
1194
+ async approveJob(c: Conversation, taskId: number, approved: unknown) {
1195
+ if (typeof approved !== "boolean") throw invalid("approved must be a boolean");
1196
+ await c.commit(async (tx) => {
1197
+ const run = (await tx.doc(JobRuns, c.id)).items[String(taskId)];
1198
+ if (!run || run.approval !== "pending") throw new ApiFault("conflict", "No pending approval for that run");
1199
+ run.approval = approved ? "approved" : "declined";
1200
+ }, ctx);
1201
+ return { ok: true as const };
1202
+ }
1203
+
1204
+ // ------------------------------------------------------------ idempotency
1205
+
1206
+ replay(key: string, route: string) {
1207
+ const row = this.catalog.prepare("SELECT * FROM requests WHERE key=?").get(key) as
1208
+ | { route: string; status: number; body: string }
1209
+ | undefined;
1210
+ if (!row) return undefined;
1211
+ if (row.route !== route) throw invalid("Idempotency-Key was already used for a different request");
1212
+ return { status: row.status, body: JSON.parse(row.body) as unknown };
1213
+ }
1214
+
1215
+ remember(key: string, route: string, status: number, body: unknown) {
1216
+ this.catalog
1217
+ .prepare("INSERT OR IGNORE INTO requests VALUES(?,?,?,?,?)")
1218
+ .run(key, route, status, JSON.stringify(body), Date.now());
1219
+ }
1220
+
1221
+ // ------------------------------------------------------------ lifecycle
1222
+
1223
+ async close() {
1224
+ for (const { harness, unlock, unsubscribe } of this.opened.values()) {
1225
+ unsubscribe();
1226
+ try {
1227
+ await harness.close(ctx);
1228
+ } finally {
1229
+ unlock();
1230
+ }
1231
+ }
1232
+ this.opened.clear();
1233
+ await this.shared?.close();
1234
+ this.unlockShared?.();
1235
+ this.catalog.close();
1236
+ this.unlockCatalog();
1237
+ }
1238
+ }
1239
+
1240
+ export type { Resolved };