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,256 @@
1
+ // Server-Sent Event streams. Three scopes share one frame envelope (see dpi-sdk/src/api.ts):
2
+ // device (tree + activity), session (agents, tasks, usage) and agent (pi-durable agent events).
3
+ // Streams are viewers only: closing one never aborts work or closes a harness.
4
+ import { BACKEND_SSE_VIEWER_BYTES, CONNECTION_BUFFER_BUDGET_BYTES, LOGICAL_MESSAGE_MAX_BYTES } from "../../dpi-sdk/src/protocol/limits.js";
5
+ import { ByteBudget } from "../../dpi-sdk/src/node/chunk.js";
6
+ import { createPacedSseWriter } from "../../dpi-sdk/src/node/sse-writer.js";
7
+ import type { Request, Response } from "express";
8
+ import { watchEvents, type Conversation, type Harness, type WatchEnd } from "@earendil-works/pi-durable";
9
+ import { BACKGROUND_CONTEXT as ctx } from "@earendil-works/chord/context";
10
+ import type { Activity, DeviceTree, Frame, StreamScope } from "../../dpi-sdk/src/api.js";
11
+ import { AGENT_DOC_KINDS, DEVICE_DOC_KINDS, type SessionRow, type Store } from "./store.js";
12
+ import { invalid } from "./errors.js";
13
+
14
+ const sseBudget = new ByteBudget("backend-sse", CONNECTION_BUFFER_BUDGET_BYTES);
15
+
16
+ /**
17
+ * pi-durable 1.2+ ends a Session (and every watch on it) with `session_failed` when a Storage call fails, without
18
+ * waiting for the next agent event. Tell a viewer that is still open and end its response; a viewer that already
19
+ * closed, or any other end reason, needs nothing. Never writes to an ended response.
20
+ */
21
+ export function onWatchEnd(sse: Sse, end: WatchEnd): void {
22
+ if (end.reason !== "session_failed" || sse.closed) return;
23
+ sse.send({
24
+ type: "fault",
25
+ error: { code: "unavailable", message: "The session failed and was closed; reconnect after it is reopened" },
26
+ });
27
+ // F17: the fault is queued by the paced writer — end ONLY after it has
28
+ // actually been written, never while it is still sitting in the chain.
29
+ sse.end();
30
+ }
31
+
32
+ export interface Sse {
33
+ send(frame: Frame): void;
34
+ /** Flush every queued frame, then end the response (F17). */
35
+ end(): void;
36
+ readonly closed: boolean;
37
+ onClose(fn: () => void): void;
38
+ sendIfChanged(key: string, frame: Frame): void;
39
+ }
40
+
41
+ /**
42
+ * `pingMs` is deployment/test policy (default 15 s) — constructor-injected,
43
+ * never request-overridable.
44
+ */
45
+ export function openSse(res: Response, scope: StreamScope, opts: { pingMs?: number; budget?: ByteBudget } = {}): Sse {
46
+ res.status(200).set({
47
+ "content-type": "text/event-stream; charset=utf-8",
48
+ "cache-control": "no-cache, no-transform",
49
+ connection: "keep-alive",
50
+ "x-accel-buffering": "no",
51
+ });
52
+ res.flushHeaders();
53
+ res.socket?.setNoDelay(true);
54
+ let seq = 0;
55
+ const cleanups: Array<() => void> = [];
56
+ const last = new Map<string, string>();
57
+ let ping: NodeJS.Timeout | undefined;
58
+ const writer = createPacedSseWriter(res, {
59
+ maxBufferedBytes: BACKEND_SSE_VIEWER_BYTES,
60
+ budget: opts.budget ?? sseBudget,
61
+ onClose: () => {
62
+ clearInterval(ping);
63
+ last.clear();
64
+ for (const fn of cleanups.splice(0)) {
65
+ try {
66
+ fn();
67
+ } catch {}
68
+ }
69
+ },
70
+ });
71
+ // Heartbeats use the same queue; they never split an event mid-JSON.
72
+ ping = setInterval(() => writer.write(": ping\n\n"), opts.pingMs ?? 15_000);
73
+ const send = (frame: Frame) => {
74
+ if (writer.closed) return;
75
+ const event = `id: ${++seq}\nevent: ${frame.type}\ndata: ${JSON.stringify(frame)}\n\n`;
76
+ if (writer.write(event) === "too-large") {
77
+ send({
78
+ type: "fault",
79
+ error: { code: "invalid", message: `Stream event exceeds ${LOGICAL_MESSAGE_MAX_BYTES} bytes; reduce the scope or payload` },
80
+ });
81
+ }
82
+ };
83
+ send({ type: "hello", apiVersion: 1, serverTime: Date.now(), scope });
84
+ return {
85
+ send,
86
+ end: writer.end,
87
+ get closed() {
88
+ return writer.closed;
89
+ },
90
+ onClose(fn) {
91
+ if (writer.closed) fn();
92
+ else cleanups.push(fn);
93
+ },
94
+ sendIfChanged(key, frame) {
95
+ const json = JSON.stringify(frame);
96
+ if (last.get(key) === json) return;
97
+ last.set(key, json);
98
+ send(frame);
99
+ },
100
+ };
101
+ }
102
+
103
+ /** Run `fn` at most once per `ms`, never concurrently, always catching up on the latest trigger. */
104
+ export function coalesce(fn: () => Promise<void>, ms: number) {
105
+ let timer: ReturnType<typeof setTimeout> | undefined;
106
+ let running = false;
107
+ let again = false;
108
+ const run = async () => {
109
+ timer = undefined;
110
+ if (running) {
111
+ again = true;
112
+ return;
113
+ }
114
+ running = true;
115
+ try {
116
+ await fn();
117
+ } catch (e) {
118
+ console.error("[dpi-backend] stream update failed:", e instanceof Error ? e.message : e);
119
+ } finally {
120
+ running = false;
121
+ if (again) {
122
+ again = false;
123
+ trigger();
124
+ }
125
+ }
126
+ };
127
+ const trigger = () => {
128
+ if (!timer) timer = setTimeout(() => void run(), ms);
129
+ };
130
+ return Object.assign(trigger, { now: run, cancel: () => timer && clearTimeout(timer) });
131
+ }
132
+
133
+ export function parseDocs(raw: unknown, allowed: readonly string[]): string[] {
134
+ if (raw === undefined || raw === "") return [];
135
+ if (typeof raw !== "string") throw invalid("docs must be a comma-separated list");
136
+ const kinds = [...new Set(raw.split(",").map((k) => k.trim()).filter(Boolean))];
137
+ for (const kind of kinds)
138
+ if (!allowed.includes(kind)) throw invalid(`Unknown document ${kind}. Allowed: ${allowed.join(", ")}`);
139
+ return kinds;
140
+ }
141
+
142
+ export function diffActivity(prev: DeviceTree | undefined, next: DeviceTree): Activity[] {
143
+ if (!prev) return [];
144
+ const before = new Map(prev.sessions.flatMap((s) => s.agents.map((a) => [`${s.id}/${a.id}`, a.busy] as const)));
145
+ return next.sessions.flatMap((s) =>
146
+ s.agents.flatMap((a): Activity[] => {
147
+ const was = before.get(`${s.id}/${a.id}`) ?? false;
148
+ if (was === a.busy) return [];
149
+ return [{ type: a.busy ? "agent.started" : "agent.finished", sessionId: s.id, agentId: a.id, at: next.at }];
150
+ }),
151
+ );
152
+ }
153
+
154
+ // ---------------------------------------------------------------- device scope
155
+
156
+ export function deviceStream(store: Store, req: Request, res: Response) {
157
+ const docs = parseDocs(req.query.docs, DEVICE_DOC_KINDS);
158
+ const sse = openSse(res, { kind: "device" });
159
+ let previous: DeviceTree | undefined;
160
+ const push = coalesce(async () => {
161
+ if (sse.closed) return;
162
+ const tree = await store.tree();
163
+ const { at: _at, ...stable } = tree;
164
+ const items = diffActivity(previous, tree);
165
+ previous = tree;
166
+ sse.sendIfChanged("tree", { type: "tree", tree: { ...stable, at: 0 } });
167
+ if (items.length) {
168
+ for (const item of items)
169
+ if (item.type === "agent.finished") {
170
+ const row = store.list().find((r) => r.id === item.sessionId);
171
+ if (row) item.preview = await store.preview(row, item.agentId).catch(() => undefined);
172
+ }
173
+ sse.send({ type: "activity", items });
174
+ }
175
+ for (const kind of docs) {
176
+ const doc = await store.deviceDoc(kind);
177
+ sse.sendIfChanged(`doc:${kind}`, { type: "doc", kind, value: doc.value, revision: doc.revision });
178
+ }
179
+ }, 250);
180
+ sse.onClose(store.subscribe(push));
181
+ sse.onClose(() => push.cancel());
182
+ void push.now();
183
+ }
184
+
185
+ // ---------------------------------------------------------------- session scope
186
+
187
+ export async function sessionStream(store: Store, row: SessionRow, res: Response) {
188
+ const h = store.harness(row);
189
+ const sse = openSse(res, { kind: "session", sessionId: row.id });
190
+ const watch = await h.watchTaskGraph(ctx);
191
+ const tasksOf = (value: { tasks: Readonly<Record<string, unknown>> }) =>
192
+ Object.values(value.tasks).map((t) => store.taskNode(t));
193
+ let tasks = await store.labelTasks(h, tasksOf(watch.value));
194
+ sse.send({ type: "session", agents: await store.agents(row, tasks), tasks, usage: await store.usage(h) });
195
+ watch.start(async (value) => {
196
+ if (sse.closed) return;
197
+ tasks = await store.labelTasks(h, tasksOf(value));
198
+ sse.sendIfChanged("tasks", { type: "tasks", tasks });
199
+ push();
200
+ });
201
+ const push = coalesce(async () => {
202
+ if (sse.closed || !store.opened.has(row.id)) return;
203
+ sse.sendIfChanged("agents", { type: "agents", agents: await store.agents(row, tasks) });
204
+ sse.sendIfChanged("usage", { type: "usage", usage: await store.usage(h) });
205
+ }, 250);
206
+ const off = h.subscribeCommits(() => push());
207
+ // Agent names live in the device catalog, not the harness: a rename only fires the store's change feed.
208
+ const offCatalog = store.subscribe(() => push());
209
+ // pi-durable 1.2+: a failed Storage call ends the Session (and every watch) with `session_failed`, without
210
+ // waiting for the next event. See onWatchEnd for the viewer-facing behavior.
211
+ void watch.closed.then((end) => onWatchEnd(sse, end)).catch(() => {});
212
+ sse.onClose(() => {
213
+ off();
214
+ offCatalog();
215
+ push.cancel();
216
+ void watch.stop().catch(() => {});
217
+ });
218
+ }
219
+
220
+ // ---------------------------------------------------------------- agent scope
221
+
222
+ export async function agentStream(
223
+ store: Store,
224
+ target: { row: SessionRow; h: Harness; c: Conversation },
225
+ req: Request,
226
+ res: Response,
227
+ ) {
228
+ const { row, h, c } = target;
229
+ const docs = parseDocs(req.query.docs, AGENT_DOC_KINDS);
230
+ const stream = await watchEvents(h, c.id, ctx);
231
+ const sse = openSse(res, { kind: "agent", sessionId: row.id, agentId: c.id as number });
232
+ sse.send({
233
+ type: "snapshot",
234
+ snapshot: JSON.parse(JSON.stringify(stream.snapshot)),
235
+ inbox: await store.inbox(h, c),
236
+ });
237
+ const pushDocs = coalesce(async () => {
238
+ for (const kind of docs)
239
+ sse.sendIfChanged(`doc:${kind}`, { type: "doc", kind, value: await store.agentDoc(h, c, kind) });
240
+ }, 150);
241
+ await pushDocs.now();
242
+ stream.start(async (events) => {
243
+ if (sse.closed) return;
244
+ sse.send({ type: "batch", events: JSON.parse(JSON.stringify(events)) });
245
+ if (events.some((e) => e.type === "inbox_update" || e.type === "snapshot"))
246
+ sse.send({ type: "inbox", items: await store.inbox(h, c) });
247
+ });
248
+ const offDocs = docs.length ? h.subscribeCommits(() => pushDocs()) : () => {};
249
+ // A failed Storage call ends the session and this watch with `session_failed`; an open viewer learns at once.
250
+ void stream.closed.then((end) => onWatchEnd(sse, end)).catch(() => {});
251
+ sse.onClose(() => {
252
+ offDocs();
253
+ pushDocs.cancel();
254
+ void stream.stop().catch(() => {});
255
+ });
256
+ }