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,373 @@
1
+ // App-wide state: one hub stream, the network, the selected agent and its session/device feeds,
2
+ // plus the command helpers every component shares.
3
+ import { computed, reactive, ref, shallowRef, watch } from "vue";
4
+ import type { AgentDetail, ModelInfo, NetworkDevice, TaskNode } from "@sdk/api";
5
+ import { sumUsage } from "@sdk/transcript";
6
+ import { lineage } from "@sdk/models";
7
+ import { HubStream } from "./stream";
8
+ import { iconPath } from "./icon-path";
9
+ import { deviceClient, hub, message } from "./hub";
10
+ import { useAgent, useDevice, useNetwork, useSession, type AgentTarget } from "./composables";
11
+ import type { PairHandoff } from "./pair-handoff";
12
+
13
+ export const stream = new HubStream();
14
+ export const streamState = ref(stream.state);
15
+ stream.onState((s) => (streamState.value = s));
16
+ export const network = useNetwork(stream);
17
+ export const configFile = ref<string>();
18
+
19
+ // ------------------------------------------------------------------ selection + persisted UI state
20
+
21
+ export const selected = ref<AgentTarget>();
22
+ export const expanded = ref(new Set<string>());
23
+ export const treeWidth = ref(300);
24
+ /** Which side panels are expanded (persisted). Collapsed panels shrink to an icon rail. */
25
+ export const layout = reactive({ tree: true, inspector: true });
26
+ const uiLoaded = ref(false);
27
+
28
+ export const deviceId = computed(() => selected.value?.deviceId);
29
+ export const device = computed(() => network.devices.value.find((d) => d.id === deviceId.value));
30
+ export const online = computed(() => device.value?.status === "online");
31
+ export const sessionTarget = computed(() =>
32
+ selected.value
33
+ ? { deviceId: selected.value.deviceId, sessionId: selected.value.sessionId }
34
+ : undefined,
35
+ );
36
+ export const session = useSession(stream, sessionTarget);
37
+ export const agent = useAgent(stream, selected);
38
+ export const deviceFeed = useDevice(stream, deviceId);
39
+
40
+ export const sessionSummary = computed(() =>
41
+ device.value?.tree?.sessions.find((s) => s.id === selected.value?.sessionId),
42
+ );
43
+ export const client = computed(() => (deviceId.value ? deviceClient(deviceId.value) : undefined));
44
+ export const agentHandle = computed(() =>
45
+ selected.value && client.value
46
+ ? client.value.agent(selected.value.sessionId, selected.value.agentId)
47
+ : undefined,
48
+ );
49
+ export const sessionHandle = computed(() =>
50
+ selected.value && client.value ? client.value.session(selected.value.sessionId) : undefined,
51
+ );
52
+
53
+ export function select(target: AgentTarget | undefined) {
54
+ selected.value = target;
55
+ ui.drawer = false;
56
+ pendingReveal = !!target;
57
+ reveal();
58
+ }
59
+
60
+ // Expand the selected agent's device, session and ancestors once it shows up in the tree (a new
61
+ // fork or agent appears a moment after the call that created it).
62
+ let pendingReveal = false;
63
+ function reveal() {
64
+ const t = selected.value;
65
+ if (!pendingReveal || !t) return;
66
+ const agents = network.devices.value
67
+ .find((d) => d.id === t.deviceId)
68
+ ?.tree?.sessions.find((s) => s.id === t.sessionId)?.agents;
69
+ const byId = new Map((agents ?? []).map((a) => [a.id, a]));
70
+ const keys = [
71
+ t.deviceId,
72
+ `${t.deviceId}/s/${t.sessionId}`,
73
+ `${t.deviceId}/s/${t.sessionId}/a/${t.agentId}`,
74
+ ];
75
+ let current = byId.get(t.agentId);
76
+ const seen = new Set<number>();
77
+ while (current && !seen.has(current.id)) {
78
+ seen.add(current.id);
79
+ const parent = current.parentId ?? current.ownerAgentId;
80
+ if (parent === undefined) break;
81
+ keys.push(`${t.deviceId}/s/${t.sessionId}/a/${parent}`);
82
+ current = byId.get(parent);
83
+ }
84
+ if (keys.some((k) => !expanded.value.has(k)))
85
+ expanded.value = new Set([...expanded.value, ...keys]);
86
+ if (byId.has(t.agentId)) pendingReveal = false;
87
+ }
88
+ watch(() => network.devices.value, reveal);
89
+
90
+ export function toggle(key: string, open?: boolean) {
91
+ const next = new Set(expanded.value);
92
+ if (open ?? !next.has(key)) next.add(key);
93
+ else next.delete(key);
94
+ expanded.value = next;
95
+ }
96
+
97
+ const saveLater = (() => {
98
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
99
+ return (key: string, value: unknown) => {
100
+ clearTimeout(timers.get(key));
101
+ timers.set(
102
+ key,
103
+ setTimeout(() => void hub.uiSet(key, value).catch(() => {}), 300),
104
+ );
105
+ };
106
+ })();
107
+ // Selection saves at once so a quick reload restores it; drags (width) debounce.
108
+ watch(
109
+ selected,
110
+ (v) => uiLoaded.value && void hub.uiSet("tree.selected", v ?? null).catch(() => {}),
111
+ );
112
+ watch(treeWidth, (v) => uiLoaded.value && saveLater("tree.width", v));
113
+ watch(layout, (v) => uiLoaded.value && saveLater("layout.panels", { ...v }));
114
+
115
+ export async function boot() {
116
+ stream.start();
117
+ try {
118
+ const [net, sel, width, panels] = await Promise.all([
119
+ hub.network(),
120
+ hub.uiGet<AgentTarget>("tree.selected"),
121
+ hub.uiGet<number>("tree.width"),
122
+ hub.uiGet<{ tree: boolean; inspector: boolean }>("layout.panels"),
123
+ ]);
124
+ if (panels) Object.assign(layout, panels);
125
+ configFile.value = net.configFile;
126
+ if (!network.model.devices.length)
127
+ network.model.apply({ type: "network", devices: net.devices });
128
+ // Every device starts folded; select() opens only the active device, workspace and agent.
129
+ expanded.value = new Set();
130
+ if (width) treeWidth.value = width;
131
+ if (sel && net.devices.some((d) => d.id === sel.deviceId)) select(sel);
132
+ else autoSelect(net.devices);
133
+ } catch (e) {
134
+ ui.error = message(e);
135
+ } finally {
136
+ uiLoaded.value = true;
137
+ }
138
+ }
139
+
140
+ /** With nothing saved, open the first agent on the first online device that has a session. */
141
+ function autoSelect(devices: NetworkDevice[]) {
142
+ for (const d of devices) {
143
+ const s = d.status === "online" ? d.tree?.sessions[0] : undefined;
144
+ const a = s?.agents.find((x) => x.kind === "root") ?? s?.agents[0];
145
+ if (s && a) return select({ deviceId: d.id, sessionId: s.id, agentId: a.id });
146
+ }
147
+ }
148
+
149
+ // The device answered "not found" for the selected agent and its session left the tree: it was archived.
150
+ watch(
151
+ () => [agent.status.value, sessionSummary.value] as const,
152
+ ([status, summary]) => {
153
+ if (status !== "fault" || summary || !online.value) return;
154
+ notify("That session was archived on its device.");
155
+ select(undefined);
156
+ },
157
+ );
158
+
159
+ // ------------------------------------------------------------------ derived agent views
160
+
161
+ export const models = computed<ModelInfo[]>(() => device.value?.info?.models ?? []);
162
+ export const agentState = agent.state;
163
+ export const sessionAgents = computed(() =>
164
+ session.state.value.agents.length
165
+ ? session.state.value.agents
166
+ : (sessionSummary.value?.agents ?? []),
167
+ );
168
+ export const agentSummary = computed(() =>
169
+ sessionAgents.value.find((a) => a.id === selected.value?.agentId),
170
+ );
171
+ export const activeLineage = computed(() =>
172
+ selected.value ? lineage(sessionAgents.value, selected.value.agentId) : [],
173
+ );
174
+
175
+ /** Tasks of this agent and every subagent it owns (transitively), with inherited background. */
176
+ export const tasks = computed(() => {
177
+ const id = selected.value?.agentId;
178
+ if (id === undefined) return [] as (TaskNode & { effectiveBackground: boolean })[];
179
+ const agents = sessionAgents.value;
180
+ const owned = new Set([id]);
181
+ for (let changed = true; changed;) {
182
+ changed = false;
183
+ for (const a of agents)
184
+ if (a.ownerAgentId !== undefined && owned.has(a.ownerAgentId) && !owned.has(a.id)) {
185
+ owned.add(a.id);
186
+ changed = true;
187
+ }
188
+ }
189
+ const all = session.state.value.tasks;
190
+ const byId = new Map(all.map((t) => [t.id, t]));
191
+ const ownerTask = new Map(agents.map((a) => [a.id, a.ownerTaskId]));
192
+ return all
193
+ .filter((t) => owned.has(t.conversationId))
194
+ .map((task) => {
195
+ let current: TaskNode | undefined = task;
196
+ let effectiveBackground = false;
197
+ const seen = new Set<number>();
198
+ while (current && !seen.has(current.id)) {
199
+ seen.add(current.id);
200
+ if (current.background) {
201
+ effectiveBackground = true;
202
+ break;
203
+ }
204
+ const ownerId: number | undefined = current.owner ?? ownerTask.get(current.conversationId);
205
+ current = ownerId === undefined ? undefined : byId.get(ownerId);
206
+ }
207
+ return { ...task, effectiveBackground };
208
+ });
209
+ });
210
+ export const busy = computed(
211
+ () =>
212
+ agentState.value.running ||
213
+ tasks.value.some((t) => !t.effectiveBackground && t.conversationId === selected.value?.agentId),
214
+ );
215
+ export const usage = computed(() => sumUsage(agentState.value.usage));
216
+ export const currentModel = computed(() => {
217
+ const m = agentState.value.agent.model;
218
+ return models.value.find((x) => x.provider === m?.provider && x.id === m?.modelId);
219
+ });
220
+
221
+ /** Tools and extensions come from GET agent (not streamed); refresh on selection and config changes. */
222
+ export const detail = shallowRef<AgentDetail>();
223
+ let detailSeq = 0;
224
+ export async function refreshDetail() {
225
+ const handle = agentHandle.value;
226
+ const seq = ++detailSeq;
227
+ if (!handle || !online.value) return void (detail.value = undefined);
228
+ try {
229
+ const d = await handle.get();
230
+ if (seq === detailSeq) detail.value = d;
231
+ } catch {
232
+ if (seq === detailSeq) detail.value = undefined;
233
+ }
234
+ }
235
+ watch([agentHandle, online], () => void refreshDetail(), { immediate: true });
236
+
237
+ // ------------------------------------------------------------------ UI chrome
238
+
239
+ export const ui = reactive({
240
+ error: "",
241
+ toast: "",
242
+ drawer: false,
243
+ /** Narrow screens: the inspector opens over the chat instead of taking a column. */
244
+ inspectorOverlay: false,
245
+ tab: "session",
246
+ composer: "",
247
+ dialog: undefined as Dialog | undefined,
248
+ });
249
+
250
+ export type Dialog =
251
+ | { kind: "new-session"; deviceId?: string }
252
+ | { kind: "pair"; handoff?: PairHandoff }
253
+ | { kind: "credentials"; deviceId: string }
254
+ | { kind: "models" }
255
+ | { kind: "help" }
256
+ | { kind: "path"; path: string }
257
+ | { kind: "compaction" }
258
+ | {
259
+ kind: "prompt";
260
+ title: string;
261
+ label?: string;
262
+ value?: string;
263
+ placeholder?: string;
264
+ hint?: string;
265
+ multiline?: boolean;
266
+ optional?: boolean;
267
+ submit: string;
268
+ danger?: boolean;
269
+ run: (value: string) => Promise<unknown>;
270
+ };
271
+
272
+ let toastTimer: ReturnType<typeof setTimeout>;
273
+ export function notify(text: string) {
274
+ ui.toast = text;
275
+ clearTimeout(toastTimer);
276
+ toastTimer = setTimeout(() => (ui.toast = ""), 4500);
277
+ }
278
+
279
+ export async function attempt<T>(fn: () => Promise<T>): Promise<T | undefined> {
280
+ ui.error = "";
281
+ try {
282
+ return await fn();
283
+ } catch (e) {
284
+ ui.error = message(e);
285
+ return undefined;
286
+ }
287
+ }
288
+
289
+ export function preset(text: string) {
290
+ ui.composer = text;
291
+ setTimeout(() => document.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus());
292
+ }
293
+
294
+ // ------------------------------------------------------------------ formatting
295
+
296
+ export const number = (n: number) =>
297
+ Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(n);
298
+ export const json = (v: unknown) => JSON.stringify(v, null, 2);
299
+ export const icon = (modelId = "") => iconPath(models.value.find((m) => m.id === modelId)?.icon);
300
+ export const shortPath = (s = "", n = 34) => (s.length > n ? "…" + s.slice(-(n - 1)) : s);
301
+ export function ago(ms?: number) {
302
+ if (!ms) return "never";
303
+ const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
304
+ if (s < 60) return `${s}s ago`;
305
+ if (s < 3600) return `${Math.round(s / 60)}m ago`;
306
+ if (s < 86400) return `${Math.round(s / 3600)}h ago`;
307
+ return `${Math.round(s / 86400)}d ago`;
308
+ }
309
+
310
+ // ------------------------------------------------------------------ shared agent commands
311
+
312
+ export const interrupting = ref(false);
313
+ /** Escape and the Interrupt button: stop foreground work and owned subagents; background jobs continue. */
314
+ export async function interrupt() {
315
+ if (!busy.value || interrupting.value || !agentHandle.value) return;
316
+ interrupting.value = true;
317
+ const done = await attempt(() => agentHandle.value!.abort(false));
318
+ if (done) notify("Foreground work interrupted. Background jobs continue.");
319
+ interrupting.value = false;
320
+ }
321
+
322
+ // ------------------------------------------------------------------ panel layout
323
+
324
+ const narrowQuery = window.matchMedia("(max-width: 1100px)");
325
+ /** Below 1100px the inspector never takes a column; it opens over the chat. */
326
+ export const narrow = ref(narrowQuery.matches);
327
+ narrowQuery.addEventListener("change", (e) => (narrow.value = e.matches));
328
+ export const inspectorExpanded = computed(() =>
329
+ narrow.value ? ui.inspectorOverlay : layout.inspector,
330
+ );
331
+
332
+ /** Show the inspector, optionally on a tab: expand its column, or open it over the chat when narrow. */
333
+ export function showInspector(tab?: string) {
334
+ if (tab) ui.tab = tab;
335
+ if (narrow.value) {
336
+ ui.drawer = false;
337
+ ui.inspectorOverlay = true;
338
+ } else layout.inspector = true;
339
+ }
340
+
341
+ export function hideInspector() {
342
+ if (narrow.value) ui.inspectorOverlay = false;
343
+ else layout.inspector = false;
344
+ }
345
+
346
+ export function toggleTree() {
347
+ if (!narrow.value) layout.tree = !layout.tree;
348
+ else if ((ui.drawer = !ui.drawer)) {
349
+ ui.inspectorOverlay = false;
350
+ layout.tree = true;
351
+ }
352
+ }
353
+
354
+ export function toggleInspector() {
355
+ if (inspectorExpanded.value) hideInspector();
356
+ else showInspector();
357
+ }
358
+
359
+ // Panel hotkeys. macOS browsers use Cmd for new window/tab, so Ctrl+N and Ctrl+T reach the page;
360
+ // elsewhere browsers reserve Ctrl+N and Ctrl+T, so Alt+Shift takes their place.
361
+ const mac = /Mac|iPhone|iPad/.test(navigator.userAgent);
362
+ export const panelKeys = mac
363
+ ? { tree: "Ctrl+N", inspector: "Ctrl+T" }
364
+ : { tree: "Alt+Shift+N", inspector: "Alt+Shift+T" };
365
+
366
+ export function panelHotkey(e: KeyboardEvent): "tree" | "inspector" | undefined {
367
+ const held = mac
368
+ ? e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey
369
+ : e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
370
+ if (!held || e.repeat || e.isComposing) return;
371
+ if (e.code === "KeyN") return "tree";
372
+ if (e.code === "KeyT") return "inspector";
373
+ }
@@ -0,0 +1,183 @@
1
+ // One browser connection to the hub: EventSource('/api/stream'). The first frame names the
2
+ // stream; named slots (device, session, agent) map to subscription keys that a debounced
3
+ // PUT /api/stream/:id sends. Every (re)connect opens fresh upstreams, so each model restarts
4
+ // from its server snapshot.
5
+ import type { Frame, MuxFrame, NetworkDevice } from "@sdk/api";
6
+
7
+ export type Slot = "device" | "session" | "agent";
8
+ export type StreamState = "connecting" | "live" | "offline";
9
+
10
+ const MAX_BACKOFF_MS = 30_000;
11
+
12
+ /** Delay before retry number `failures` (0-based) of a rejected PUT; a numeric Retry-After (seconds) wins. */
13
+ export function retryDelay(failures: number, retryAfter?: string | null) {
14
+ if (retryAfter && /^\d+$/.test(retryAfter.trim()))
15
+ return Math.min(MAX_BACKOFF_MS, Math.max(1000, Number(retryAfter) * 1000));
16
+ return Math.min(MAX_BACKOFF_MS, 1000 * 2 ** Math.min(failures, 10));
17
+ }
18
+
19
+ /** Delay before reconnect number `attempt` (0-based) after the browser gave up on the EventSource. */
20
+ export function reconnectDelay(attempt: number) {
21
+ return Math.min(MAX_BACKOFF_MS, 2000 * 2 ** Math.min(attempt, 10));
22
+ }
23
+
24
+ export class HubStream {
25
+ state: StreamState = "connecting";
26
+ private source?: EventSource;
27
+ private streamId = "";
28
+ private slots = new Map<Slot, string>();
29
+ private fresh = new Set<string>();
30
+ private applied: string[] = [];
31
+ private routes = new Map<Slot, Set<(frame: Frame) => void>>();
32
+ private networkFns = new Set<(devices: NetworkDevice[]) => void>();
33
+ private stateFns = new Set<(state: StreamState) => void>();
34
+ private timer?: ReturnType<typeof setTimeout>;
35
+ private reconnectTimer?: ReturnType<typeof setTimeout>;
36
+ private reconnects = 0;
37
+ private failures = 0;
38
+ private flushing = Promise.resolve();
39
+
40
+ start() {
41
+ clearTimeout(this.reconnectTimer);
42
+ this.source?.close();
43
+ this.streamId = "";
44
+ this.source = new EventSource("/api/stream");
45
+ this.source.onmessage = (e) => this.receive(JSON.parse(e.data) as MuxFrame);
46
+ // EventSource reconnects on its own; the next hello re-sends the subscriptions.
47
+ this.source.onerror = () => {
48
+ this.streamId = "";
49
+ this.setState(this.source?.readyState === EventSource.CLOSED ? "offline" : "connecting");
50
+ if (this.source?.readyState === EventSource.CLOSED) {
51
+ clearTimeout(this.reconnectTimer);
52
+ this.reconnectTimer = setTimeout(() => this.start(), reconnectDelay(this.reconnects++));
53
+ }
54
+ };
55
+ }
56
+
57
+ stop() {
58
+ clearTimeout(this.timer);
59
+ clearTimeout(this.reconnectTimer);
60
+ this.source?.close();
61
+ // An in-flight PUT that settles later sees a different stream and does not retry.
62
+ this.streamId = "";
63
+ }
64
+
65
+ /** Point a slot at a subscription key (or clear it). A changed key always gets a fresh snapshot. */
66
+ set(slot: Slot, key?: string) {
67
+ if (this.slots.get(slot) === key) return;
68
+ if (key) {
69
+ this.slots.set(slot, key);
70
+ this.fresh.add(key);
71
+ } else this.slots.delete(slot);
72
+ this.schedule();
73
+ }
74
+
75
+ /** Retry a failed slot without discarding the other scopes or their cached models. */
76
+ reopen(slot: Slot) {
77
+ const key = this.slots.get(slot);
78
+ if (!key) return;
79
+ this.fresh.add(key);
80
+ this.schedule(0);
81
+ }
82
+
83
+ route(slot: Slot, fn: (frame: Frame) => void) {
84
+ const set = this.routes.get(slot) ?? new Set();
85
+ set.add(fn);
86
+ this.routes.set(slot, set);
87
+ return () => void set.delete(fn);
88
+ }
89
+
90
+ onNetwork(fn: (devices: NetworkDevice[]) => void) {
91
+ this.networkFns.add(fn);
92
+ return () => void this.networkFns.delete(fn);
93
+ }
94
+
95
+ onState(fn: (state: StreamState) => void) {
96
+ this.stateFns.add(fn);
97
+ return () => void this.stateFns.delete(fn);
98
+ }
99
+
100
+ private setState(state: StreamState) {
101
+ if (state === this.state) return;
102
+ this.state = state;
103
+ for (const fn of this.stateFns) fn(state);
104
+ }
105
+
106
+ private receive(m: MuxFrame) {
107
+ const frame = m.frame;
108
+ if (frame.type === "stream") {
109
+ if (m.sub !== "hello") return;
110
+ this.streamId = frame.streamId;
111
+ this.applied = [];
112
+ this.reconnects = 0;
113
+ this.failures = 0;
114
+ this.setState("live");
115
+ this.schedule(0);
116
+ return;
117
+ }
118
+ if (frame.type === "network") {
119
+ if (m.sub !== "network") return;
120
+ for (const fn of this.networkFns) fn(frame.devices);
121
+ return;
122
+ }
123
+ if (m.sub === "hello" || m.sub === "network") return;
124
+ for (const [slot, key] of this.slots)
125
+ if (key === m.sub) for (const fn of this.routes.get(slot) ?? []) fn(frame);
126
+ }
127
+
128
+ private schedule(ms = 30) {
129
+ clearTimeout(this.timer);
130
+ this.timer = setTimeout(() => {
131
+ this.flushing = this.flushing.then(() => this.flush());
132
+ }, ms);
133
+ }
134
+
135
+ private async flush() {
136
+ const streamId = this.streamId;
137
+ if (!streamId) return;
138
+ const subs = [...new Set(this.slots.values())];
139
+ // A key re-selected while its upstream is still open would get no new snapshot: drop it once.
140
+ const reopen = subs.filter((k) => this.fresh.has(k) && this.applied.includes(k));
141
+ this.fresh.clear();
142
+ if (
143
+ reopen.length &&
144
+ !(await this.put(
145
+ streamId,
146
+ subs.filter((k) => !reopen.includes(k)),
147
+ ))
148
+ ) {
149
+ for (const k of reopen) this.fresh.add(k);
150
+ return;
151
+ }
152
+ if (subs.join("\n") !== this.applied.join("\n") || reopen.length)
153
+ await this.put(streamId, subs);
154
+ }
155
+
156
+ private async put(streamId: string, subs: string[]) {
157
+ let retryAfter: string | null = null;
158
+ try {
159
+ const r = await fetch(`/api/stream/${streamId}`, {
160
+ method: "PUT",
161
+ headers: { "content-type": "application/json" },
162
+ body: JSON.stringify({ subs }),
163
+ });
164
+ // The reply belongs to a stream that has since been replaced or stopped.
165
+ if (streamId !== this.streamId) return false;
166
+ if (r.ok) {
167
+ this.applied = subs;
168
+ this.failures = 0;
169
+ return true;
170
+ }
171
+ if (r.status === 404) {
172
+ this.start();
173
+ return false;
174
+ }
175
+ console.warn("subscription rejected", r.status, await r.text());
176
+ retryAfter = r.headers.get("retry-after");
177
+ } catch {
178
+ if (streamId !== this.streamId) return false;
179
+ }
180
+ this.schedule(retryDelay(this.failures++, retryAfter));
181
+ return false;
182
+ }
183
+ }