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,433 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * P2-24 — PairDeviceWizard: create code → device proof → FULL fingerprint
4
+ * approval → issued → (monitor probe) online.
5
+ *
6
+ * Security contract (P-8/P-14, plan §P2-24):
7
+ * - Approve is enabled ONLY after proofVerified AND the operator ticks the
8
+ * explicit confirmation; approval always sends the hub-computed fingerprint
9
+ * from the codeState DTO — never the device-declared one.
10
+ * - Name/UID/code alone can never enable approval.
11
+ * - Key change (re-pair with a different key) requires replaceKey=true and a
12
+ * separate warning; 409 already_issued leads to an explicit Revoke offer,
13
+ * never a "cancelled" toast.
14
+ * - The code is RAM-only: closing/cancelling the wizard cancels the code and
15
+ * clears every secret-adjacent field.
16
+ * - /pair handoff: reads ?code/&origin once, replaces the URL (no query left),
17
+ * pre-fills — never auto-approves.
18
+ */
19
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
20
+ import { CheckCircle2, Copy, RefreshCw, ShieldAlert, X } from "lucide-vue-next";
21
+ import { createPairingSession, pairingAdmin, pollCode, type PairingSession } from "../client/pair";
22
+ import { qrSvg } from "../client/qrcode";
23
+ import type { PairCodeIssue, PairCodeState } from "@sdk/api";
24
+ import { DpiError } from "@sdk/errors";
25
+ import { settledPairPath, type PairHandoff } from "../client/pair-handoff";
26
+ import { CANCEL_TIMEOUT_MS, closeWizard, type CancelResult } from "../client/pair-close";
27
+ import { notify, network } from "../client/store";
28
+
29
+ const emit = defineEmits<{ close: [] }>();
30
+ const props = defineProps<{ handoff?: PairHandoff }>();
31
+
32
+ type Step = "boot" | "code" | "proof" | "approve" | "issued" | "cancelled" | "expired";
33
+
34
+ const step = ref<Step>("boot");
35
+ const session = ref<PairingSession>();
36
+ const issue = ref<PairCodeIssue>();
37
+ const state = ref<PairCodeState>();
38
+ const error = ref("");
39
+ const busy = ref(false);
40
+ const closing = ref(false);
41
+ const confirmed = ref(false);
42
+ const replaceKey = ref(false);
43
+ const copied = ref(false);
44
+ const watcher = ref<(() => void) | undefined>();
45
+
46
+ const grouped = computed(() => {
47
+ const code = issue.value?.code ?? "";
48
+ return code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;
49
+ });
50
+ const qrData = computed(() => {
51
+ if (!issue.value) return "";
52
+ return issue.value.pairUrl;
53
+ });
54
+ const qr = computed(() => (qrData.value ? qrSvg(qrData.value, 148) : ""));
55
+ const fingerprint = computed(() => state.value?.enrollment?.keyFingerprint ?? "");
56
+ const deviceId = computed(() => state.value?.deviceId ?? "");
57
+
58
+ /** Online toast fires only after the MONITOR probe succeeds — never on WSS alone. */
59
+ watch(deviceId, (id) => {
60
+ watcher.value?.();
61
+ if (!id) return;
62
+ watcher.value = watch(
63
+ () => network.devices.value.find((d) => d.id === id)?.status,
64
+ (status) => {
65
+ if (status === "online") {
66
+ notify("Device paired and online.");
67
+ // F12: the STOP HANDLE is what watch() RETURNS — the callback's third
68
+ // argument is the onCleanup REGISTRAR (needs a function argument).
69
+ watcher.value?.();
70
+ watcher.value = undefined;
71
+ }
72
+ },
73
+ { immediate: true },
74
+ );
75
+ });
76
+
77
+ async function createCode(handoffCode?: string): Promise<void> {
78
+ step.value = "boot";
79
+ error.value = "";
80
+ confirmed.value = false;
81
+ replaceKey.value = false;
82
+ state.value = undefined;
83
+ issue.value = undefined;
84
+ session.value?.close();
85
+ const s = createPairingSession();
86
+ session.value = s;
87
+ try {
88
+ const created = handoffCode
89
+ ? await pairingAdmin.resolveCode(handoffCode)
90
+ : await pairingAdmin.createCode();
91
+ if (s.closed()) {
92
+ await pairingAdmin.cancel(created.codeId).catch(() => {
93
+ notify(
94
+ "The late pairing code could not be cancelled. Check device credentials before retrying.",
95
+ );
96
+ });
97
+ return;
98
+ }
99
+ issue.value = created;
100
+ step.value = "code";
101
+ if (handoffCode) {
102
+ const path = settledPairPath(new URL(window.location.href));
103
+ if (path) window.history.replaceState(window.history.state, "", path);
104
+ }
105
+ void pollCode(s, created.codeId, {
106
+ intervalMs: 1_000,
107
+ onState: (st) => {
108
+ state.value = st;
109
+ if (st.phase === "awaiting_proof") step.value = "proof";
110
+ else if (st.phase === "awaiting_approval" || st.phase === "approved")
111
+ step.value = "approve";
112
+ else if (st.phase === "issued" || st.phase === "connected") step.value = "issued";
113
+ else if (st.phase === "cancelled") step.value = "cancelled";
114
+ else if (st.phase === "expired") step.value = "expired";
115
+ },
116
+ }).catch((e) => {
117
+ if (s.closed()) return;
118
+ error.value = e instanceof Error ? e.message : String(e);
119
+ step.value = "expired";
120
+ });
121
+ } catch (e) {
122
+ error.value = e instanceof Error ? e.message : String(e);
123
+ step.value = "expired";
124
+ }
125
+ }
126
+
127
+ async function approve(): Promise<void> {
128
+ const st = state.value;
129
+ const s = session.value;
130
+ if (!st?.enrollment || !s || busy.value) return;
131
+ // P-8: the tuple must be exactly what the hub verified; P-14: key change
132
+ // needs the explicit replaceKey confirmation.
133
+ busy.value = true;
134
+ error.value = "";
135
+ try {
136
+ const next = await pairingAdmin.approve(issue.value!.codeId, {
137
+ enrollmentId: st.enrollment.enrollmentId,
138
+ deviceUid: st.enrollment.deviceUid,
139
+ keyFingerprint: st.enrollment.keyFingerprint,
140
+ ...(replaceKey.value ? { replaceKey: true } : {}),
141
+ });
142
+ state.value = next;
143
+ if (next.phase === "issued" || next.phase === "connected") step.value = "issued";
144
+ } catch (e) {
145
+ error.value = e instanceof Error ? e.message : String(e);
146
+ } finally {
147
+ busy.value = false;
148
+ }
149
+ }
150
+
151
+ /** Cancel before issuance; 409 already_issued → explicit Revoke path. */
152
+ async function cancelLive(keepalive: boolean): Promise<CancelResult> {
153
+ if (!issue.value) return "cancelled";
154
+ try {
155
+ const next = await pairingAdmin.cancel(issue.value.codeId, undefined, keepalive);
156
+ state.value = next;
157
+ if (session.value) session.value.state = next;
158
+ step.value = next.phase === "expired" ? "expired" : "cancelled";
159
+ session.value?.close();
160
+ return "cancelled";
161
+ } catch (e) {
162
+ if (e instanceof DpiError && e.status === 409 && String(e.code) === "already_issued") {
163
+ const latest = await pairingAdmin.codeState(issue.value.codeId).catch(() => undefined);
164
+ if (latest) {
165
+ state.value = latest;
166
+ if (session.value) session.value.state = latest;
167
+ }
168
+ step.value = "issued";
169
+ error.value =
170
+ "Credential already issued. Closing does not revoke it; choose Revoke explicitly if needed.";
171
+ return "issued";
172
+ }
173
+ error.value = e instanceof Error ? e.message : String(e);
174
+ return "failed";
175
+ }
176
+ }
177
+ const cancelCode = () => cancelLive(false);
178
+
179
+ const settled = () =>
180
+ ["issued", "connected", "cancelled", "expired"].includes(state.value?.phase ?? "");
181
+
182
+ async function revokeIssued(): Promise<void> {
183
+ const id = deviceId.value;
184
+ if (!id) return;
185
+ busy.value = true;
186
+ try {
187
+ await pairingAdmin.revoke(id, "user");
188
+ notify("Issued credential revoked.");
189
+ emit("close");
190
+ } catch (e) {
191
+ error.value = e instanceof Error ? e.message : String(e);
192
+ } finally {
193
+ busy.value = false;
194
+ }
195
+ }
196
+
197
+ async function copyCode(): Promise<void> {
198
+ if (!issue.value) return;
199
+ try {
200
+ await navigator.clipboard.writeText(issue.value.code); // canonical, ungrouped
201
+ copied.value = true;
202
+ setTimeout(() => (copied.value = false), 1500);
203
+ } catch {
204
+ error.value = "Clipboard unavailable; copy the code manually.";
205
+ }
206
+ }
207
+
208
+ async function close(): Promise<void> {
209
+ if (closing.value) return;
210
+ closing.value = true;
211
+ busy.value = true;
212
+ try {
213
+ const outcome = await closeWizard({
214
+ needsCancel: !!issue.value && !settled(),
215
+ cancel: cancelCode,
216
+ timeoutMs: CANCEL_TIMEOUT_MS,
217
+ });
218
+ if (!outcome.closed) return;
219
+ session.value?.close();
220
+ watcher.value?.();
221
+ if (!outcome.confirmed)
222
+ notify(
223
+ "Pairing cancellation was not confirmed. The code expires on its own; check device credentials before retrying.",
224
+ );
225
+ emit("close");
226
+ } finally {
227
+ closing.value = false;
228
+ busy.value = false;
229
+ }
230
+ }
231
+
232
+ defineExpose({ close });
233
+
234
+ onMounted(() => {
235
+ if (props.handoff && "error" in props.handoff) {
236
+ error.value = props.handoff.error;
237
+ step.value = "expired";
238
+ return;
239
+ }
240
+ void createCode(props.handoff?.code);
241
+ });
242
+ onBeforeUnmount(() => {
243
+ const s = session.value;
244
+ if (s && !s.closed() && issue.value && !settled()) {
245
+ void cancelLive(true).then((result) => {
246
+ if (result !== "cancelled")
247
+ notify("Pairing cancellation was not confirmed. Check device credentials before retrying.");
248
+ });
249
+ }
250
+ s?.close();
251
+ watcher.value?.();
252
+ });
253
+ </script>
254
+
255
+ <template>
256
+ <div class="modal-backdrop" @click.self="close">
257
+ <div class="modal pair-wizard" role="dialog" aria-modal="true" aria-labelledby="pair-title">
258
+ <header>
259
+ <h2 id="pair-title">Pair a device</h2>
260
+ <button type="button" class="icon-button" aria-label="Close dialog" @click="close">
261
+ <X :size="18" />
262
+ </button>
263
+ </header>
264
+
265
+ <p v-if="step === 'boot'" class="hint">Minting a pairing code…</p>
266
+
267
+ <template v-else-if="issue">
268
+ <ol class="pair-steps" aria-label="Pairing progress">
269
+ <li :class="{ on: true }">Code</li>
270
+ <li :class="{ on: ['proof', 'approve', 'issued'].includes(step) }">Proof</li>
271
+ <li :class="{ on: ['approve', 'issued'].includes(step) }">Fingerprint</li>
272
+ <li :class="{ on: step === 'issued' }">Online</li>
273
+ </ol>
274
+
275
+ <div class="pair-grid">
276
+ <div class="pair-code-box">
277
+ <div class="pair-code" aria-label="Pairing code">{{ grouped }}</div>
278
+ <button type="button" class="small" @click="copyCode">
279
+ <Copy :size="14" /> {{ copied ? "Copied" : "Copy code" }}
280
+ </button>
281
+ <p class="hint">
282
+ Run the device pairing command and scan, or enter this code on the device. Expires in
283
+ 5 minutes.
284
+ </p>
285
+ <div class="pair-qr" aria-label="Pairing QR code" v-html="qr" />
286
+ </div>
287
+
288
+ <div class="pair-side">
289
+ <template v-if="step === 'code' || step === 'proof'">
290
+ <p class="hint">
291
+ <RefreshCw :size="14" class="spin" /> Waiting for the device to submit its signing
292
+ proof…
293
+ </p>
294
+ </template>
295
+
296
+ <template v-else-if="step === 'approve'">
297
+ <div class="pair-fingerprint">
298
+ <h3>Device key fingerprint</h3>
299
+ <code class="fp">{{ fingerprint }}</code>
300
+ <p class="hint">
301
+ Compare ALL 64 hex characters with the fingerprint shown on the device screen.
302
+ </p>
303
+ </div>
304
+ <label class="pair-confirm">
305
+ <input v-model="confirmed" type="checkbox" />
306
+ The fingerprint matches the device display exactly
307
+ </label>
308
+ <label v-if="state?.enrollment?.keyChanged" class="pair-confirm warn">
309
+ <input v-model="replaceKey" type="checkbox" />
310
+ <ShieldAlert :size="14" />
311
+ Device is already paired with a DIFFERENT key — replace the stored key (replaceKey)
312
+ </label>
313
+ <button
314
+ type="button"
315
+ class="primary"
316
+ :disabled="
317
+ !confirmed || busy || (state?.enrollment?.keyChanged === true && !replaceKey)
318
+ "
319
+ @click="approve"
320
+ >
321
+ Approve device
322
+ </button>
323
+ <button type="button" class="small" :disabled="busy" @click="cancelCode">
324
+ Cancel pairing
325
+ </button>
326
+ </template>
327
+
328
+ <template v-else-if="step === 'issued'">
329
+ <p class="hint">
330
+ <CheckCircle2 :size="14" /> Credential issued. The device is connecting through the
331
+ tunnel — you will get a toast once the monitor probe reports it online.
332
+ </p>
333
+ <button type="button" class="small" @click="close">Close</button>
334
+ <button
335
+ type="button"
336
+ class="small danger"
337
+ :disabled="!deviceId || busy"
338
+ title="Mistaken approval? Revoke the credential now."
339
+ @click="revokeIssued"
340
+ >
341
+ Revoke this credential
342
+ </button>
343
+ </template>
344
+
345
+ <template v-else-if="step === 'cancelled'">
346
+ <p class="hint">Pairing was cancelled (or the signature failed too many times).</p>
347
+ <button type="button" class="small" @click="createCode()">Start over</button>
348
+ </template>
349
+
350
+ <template v-else-if="step === 'expired'">
351
+ <p class="hint">The pairing code expired.</p>
352
+ <button type="button" class="small" @click="createCode()">New code</button>
353
+ </template>
354
+
355
+ <p v-if="error" class="error" role="alert">{{ error }}</p>
356
+ </div>
357
+ </div>
358
+ </template>
359
+ <div v-else>
360
+ <p class="error" role="alert">{{ error || "This pairing link is unavailable." }}</p>
361
+ <button type="button" class="small" @click="createCode()">New code</button>
362
+ </div>
363
+ </div>
364
+ </div>
365
+ </template>
366
+
367
+ <style>
368
+ .pair-wizard .pair-steps {
369
+ display: flex;
370
+ gap: 0.5rem;
371
+ list-style: none;
372
+ padding: 0;
373
+ margin: 0.25rem 0 0.75rem;
374
+ font-size: 0.78rem;
375
+ color: var(--muted, #888);
376
+ }
377
+ .pair-wizard .pair-steps li {
378
+ padding: 0.15rem 0.6rem;
379
+ border: 1px solid transparent;
380
+ border-radius: 999px;
381
+ }
382
+ .pair-wizard .pair-steps li.on {
383
+ color: var(--fg, #eee);
384
+ border-color: currentColor;
385
+ }
386
+ .pair-wizard .pair-grid {
387
+ display: grid;
388
+ grid-template-columns: auto 1fr;
389
+ gap: 1rem;
390
+ align-items: start;
391
+ }
392
+ .pair-wizard .pair-code {
393
+ font-size: 1.7rem;
394
+ letter-spacing: 0.18em;
395
+ font-family: var(--mono, monospace);
396
+ margin-bottom: 0.35rem;
397
+ }
398
+ .pair-wizard .pair-qr svg {
399
+ border: 1px solid var(--border, #333);
400
+ border-radius: 6px;
401
+ }
402
+ .pair-wizard .pair-side {
403
+ display: flex;
404
+ flex-direction: column;
405
+ gap: 0.6rem;
406
+ min-width: 0;
407
+ }
408
+ .pair-wizard .fp {
409
+ display: block;
410
+ word-break: break-all;
411
+ font-size: 0.78rem;
412
+ background: var(--bg2, #222);
413
+ padding: 0.4rem;
414
+ border-radius: 6px;
415
+ }
416
+ .pair-wizard .pair-confirm {
417
+ display: flex;
418
+ align-items: center;
419
+ gap: 0.45rem;
420
+ font-size: 0.85rem;
421
+ }
422
+ .pair-wizard .pair-confirm.warn {
423
+ color: var(--warn, #e0b040);
424
+ }
425
+ .pair-wizard .spin {
426
+ animation: pair-spin 1.2s linear infinite;
427
+ }
428
+ @keyframes pair-spin {
429
+ to {
430
+ transform: rotate(360deg);
431
+ }
432
+ }
433
+ </style>
@@ -0,0 +1,89 @@
1
+ <script setup lang="ts">
2
+ import { nextTick, onMounted, ref } from "vue";
3
+ import { X } from "lucide-vue-next";
4
+ import { message } from "../client/hub";
5
+ import type { Dialog } from "../client/store";
6
+
7
+ // One dialog for every name/note/confirm prompt: rename, fork, reset, archive, remove.
8
+ const props = defineProps<{ dialog: Extract<Dialog, { kind: "prompt" }> }>();
9
+ const emit = defineEmits<{ close: [] }>();
10
+ const value = ref(props.dialog.value ?? "");
11
+ const busy = ref(false);
12
+ const error = ref("");
13
+ const input = ref<HTMLInputElement | HTMLTextAreaElement>();
14
+ const hasInput = !!props.dialog.label;
15
+
16
+ async function submit() {
17
+ if (busy.value) return;
18
+ if (hasInput && !props.dialog.optional && !value.value.trim()) return;
19
+ busy.value = true;
20
+ error.value = "";
21
+ try {
22
+ await props.dialog.run(value.value.trim());
23
+ emit("close");
24
+ } catch (e) {
25
+ error.value = message(e);
26
+ } finally {
27
+ busy.value = false;
28
+ }
29
+ }
30
+ onMounted(() => void nextTick(() => (input.value?.focus(), input.value?.select())));
31
+ </script>
32
+
33
+ <template>
34
+ <div class="modal-backdrop" @click.self="!busy && emit('close')">
35
+ <form
36
+ class="modal"
37
+ role="dialog"
38
+ aria-modal="true"
39
+ aria-labelledby="prompt-title"
40
+ @submit.prevent="submit"
41
+ >
42
+ <header>
43
+ <h2 id="prompt-title">{{ dialog.title }}</h2>
44
+ <button
45
+ type="button"
46
+ class="icon-button"
47
+ aria-label="Close dialog"
48
+ :disabled="busy"
49
+ @click="emit('close')"
50
+ >
51
+ <X :size="18" />
52
+ </button>
53
+ </header>
54
+ <p v-if="dialog.hint" class="hint">{{ dialog.hint }}</p>
55
+ <label v-if="hasInput"
56
+ >{{ dialog.label }}
57
+ <span v-if="dialog.optional" class="muted">(optional)</span>
58
+ <textarea
59
+ v-if="dialog.multiline"
60
+ ref="input"
61
+ v-model="value"
62
+ rows="4"
63
+ :placeholder="dialog.placeholder"
64
+ :disabled="busy" /><input
65
+ v-else
66
+ ref="input"
67
+ v-model="value"
68
+ maxlength="100"
69
+ :required="!dialog.optional"
70
+ :placeholder="dialog.placeholder"
71
+ :aria-label="dialog.label"
72
+ :disabled="busy"
73
+ /></label>
74
+ <p v-if="error" class="message-error" role="alert">{{ error }}</p>
75
+ <footer>
76
+ <button type="button" class="secondary" :disabled="busy" @click="emit('close')">
77
+ Cancel
78
+ </button>
79
+ <button
80
+ class="primary"
81
+ :class="{ danger: dialog.danger }"
82
+ :disabled="busy || (hasInput && !dialog.optional && !value.trim())"
83
+ >
84
+ {{ busy ? "Working…" : dialog.submit }}
85
+ </button>
86
+ </footer>
87
+ </form>
88
+ </div>
89
+ </template>
@@ -0,0 +1,179 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from "vue";
3
+ import { Plus, Minus, Trash2 } from "lucide-vue-next";
4
+ import { statePath, type JsonValue, type StateChange } from "@sdk/app-state";
5
+
6
+ const props = withDefaults(
7
+ defineProps<{ value: JsonValue; path?: string; label?: string; disabled?: boolean }>(),
8
+ { path: "", label: "App State" },
9
+ );
10
+ const emit = defineEmits<{ change: [change: StateChange] }>();
11
+ const kind = computed(() =>
12
+ props.value === null ? "null" : Array.isArray(props.value) ? "array" : typeof props.value,
13
+ );
14
+ const container = computed(() => kind.value === "object" || kind.value === "array");
15
+ const children = computed(() =>
16
+ container.value ? Object.entries(props.value as Record<string, JsonValue>) : [],
17
+ );
18
+ const draft = ref("");
19
+ const editing = ref(false);
20
+ const key = ref("");
21
+ const newType = ref("string");
22
+ const adding = ref(false);
23
+ const types = ["string", "number", "boolean", "object", "array", "null"];
24
+ const defaults: Record<string, () => JsonValue> = {
25
+ string: () => "",
26
+ number: () => 0,
27
+ boolean: () => false,
28
+ object: () => ({}),
29
+ array: () => [],
30
+ null: () => null,
31
+ };
32
+ watch(
33
+ () => props.value,
34
+ (value) => {
35
+ if (!editing.value) draft.value = String(value ?? "");
36
+ },
37
+ { immediate: true },
38
+ );
39
+ function change(operation: StateChange["operation"], value?: JsonValue) {
40
+ emit("change", { operation, path: props.path, value });
41
+ }
42
+ function save() {
43
+ editing.value = false;
44
+ const value = kind.value === "number" ? Number(draft.value) : draft.value;
45
+ if (kind.value === "number" && (!String(draft.value).trim() || !Number.isFinite(value))) {
46
+ draft.value = String(props.value);
47
+ return;
48
+ }
49
+ if (value !== props.value) change("update", value);
50
+ }
51
+ function add() {
52
+ const name = kind.value === "array" ? "-" : key.value.trim();
53
+ if (!name) return;
54
+ emit("change", {
55
+ operation: "create",
56
+ path: statePath(props.path, name),
57
+ value: defaults[newType.value]!(),
58
+ });
59
+ key.value = "";
60
+ adding.value = false;
61
+ }
62
+ </script>
63
+
64
+ <template>
65
+ <div class="state-field" :class="{ 'state-root': path === '' }" :data-state-path="path">
66
+ <div v-if="path !== ''" class="state-field-heading">
67
+ <strong>{{ label }}</strong>
68
+ <select
69
+ :value="kind"
70
+ :aria-label="`Type of ${path}`"
71
+ :disabled="disabled"
72
+ @change="change('update', defaults[($event.target as HTMLSelectElement).value]!())"
73
+ >
74
+ <option v-for="type in types" :key="type" :value="type">{{ type }}</option>
75
+ </select>
76
+ <button
77
+ class="icon-button"
78
+ :aria-label="`Delete ${path}`"
79
+ title="Delete field"
80
+ :disabled="disabled"
81
+ @click="change('delete')"
82
+ >
83
+ <Trash2 :size="18" />
84
+ </button>
85
+ </div>
86
+ <template v-if="container">
87
+ <StateEditor
88
+ v-for="[childKey, child] in children"
89
+ :key="childKey"
90
+ :value="child"
91
+ :path="statePath(path, childKey)"
92
+ :label="childKey"
93
+ :disabled="disabled"
94
+ @change="emit('change', $event)"
95
+ />
96
+ <form v-if="adding && kind === 'array'" class="state-add" @submit.prevent="add">
97
+ <input
98
+ v-if="kind !== 'array'"
99
+ v-model="key"
100
+ placeholder="Field name"
101
+ :aria-label="`New field name in ${path || '/'}`"
102
+ required
103
+ :disabled="disabled"
104
+ />
105
+ <select
106
+ v-model="newType"
107
+ :aria-label="`New field type in ${path || '/'}`"
108
+ :disabled="disabled"
109
+ >
110
+ <option v-for="type in types" :key="type" :value="type">{{ type }}</option>
111
+ </select>
112
+ <button class="secondary small" :disabled="disabled || (kind !== 'array' && !key.trim())">
113
+ Add
114
+ </button>
115
+ <button type="button" class="text-button" @click="adding = false">Cancel</button>
116
+ </form>
117
+ <button
118
+ v-else-if="kind === 'array'"
119
+ class="text-button state-add-button"
120
+ :disabled="disabled"
121
+ :aria-label="`Add ${kind === 'array' ? 'item' : 'field'} to ${path || '/'}`"
122
+ @click="adding = true"
123
+ >
124
+ <Plus :size="18" />{{ kind === "array" ? "Add item" : "Add field" }}
125
+ </button>
126
+ </template>
127
+ <div v-else-if="kind === 'number'" class="state-number">
128
+ <button
129
+ class="secondary"
130
+ :aria-label="`Decrement ${path}`"
131
+ :disabled="disabled"
132
+ @click="change('increment', -1)"
133
+ >
134
+ <Minus :size="18" />
135
+ </button>
136
+ <input
137
+ v-model="draft"
138
+ type="number"
139
+ step="any"
140
+ :aria-label="`Value of ${path}`"
141
+ :disabled="disabled"
142
+ @focus="editing = true"
143
+ @blur="save"
144
+ @keydown.enter="($event.target as HTMLInputElement).blur()"
145
+ />
146
+ <button
147
+ class="secondary"
148
+ :aria-label="`Increment ${path}`"
149
+ :disabled="disabled"
150
+ @click="change('increment', 1)"
151
+ >
152
+ <Plus :size="18" />
153
+ </button>
154
+ </div>
155
+ <div v-else-if="kind === 'boolean'" class="state-boolean">
156
+ <button
157
+ class="switch"
158
+ role="switch"
159
+ :aria-label="`Value of ${path}`"
160
+ :aria-checked="value === true"
161
+ :class="{ on: value === true }"
162
+ :disabled="disabled"
163
+ @click="change('update', !value)"
164
+ >
165
+ <span /></button
166
+ ><span>{{ value }}</span>
167
+ </div>
168
+ <textarea
169
+ v-else-if="kind === 'string'"
170
+ v-model="draft"
171
+ rows="2"
172
+ :aria-label="`Value of ${path}`"
173
+ :disabled="disabled"
174
+ @focus="editing = true"
175
+ @blur="save"
176
+ />
177
+ <span v-else class="muted">null</span>
178
+ </div>
179
+ </template>