my-durable 0.0.6 → 0.0.8

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 (45) hide show
  1. package/README.md +6 -0
  2. package/apps/dpi-backend/src/config.ts +10 -3
  3. package/apps/dpi-backend/src/daemon.ts +422 -0
  4. package/apps/dpi-backend/src/main.ts +148 -7
  5. package/apps/dpi-backend/src/pair-page.ts +111 -0
  6. package/apps/dpi-backend/src/pairing-files.ts +39 -9
  7. package/apps/dpi-backend/src/pairing.ts +368 -59
  8. package/apps/dpi-backend/src/routes.ts +14 -1
  9. package/apps/dpi-backend/src/store.ts +1 -0
  10. package/apps/dpi-backend/src/tunnel.ts +294 -55
  11. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Cg3vey95.css +1 -0
  12. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CoYMgU-2.js +77 -0
  13. package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +2 -2
  14. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +17 -4
  15. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +29 -0
  16. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +107 -18
  17. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +1 -1
  18. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceHandoff.vue +132 -0
  19. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +2 -2
  20. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +36 -6
  21. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +107 -5
  22. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +3 -1
  23. package/apps/dpi-playground/apps/dpi-playground-hub/src/admin-auth.ts +19 -17
  24. package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +1 -0
  25. package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +4 -0
  26. package/apps/dpi-playground/apps/dpi-playground-hub/src/hub-identity.ts +98 -0
  27. package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +31 -3
  28. package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +172 -25
  29. package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +24 -0
  30. package/apps/dpi-sdk/src/api.ts +2 -0
  31. package/apps/dpi-sdk/src/node/private-file.ts +140 -0
  32. package/apps/dpi-sdk/src/node/registry-migration.ts +9 -0
  33. package/apps/dpi-sdk/src/node/registry.ts +6 -0
  34. package/apps/dpi-sdk/src/protocol/pairing.ts +137 -16
  35. package/apps/dpi-sdk/src/protocol/tunnel.ts +162 -38
  36. package/apps/dpi-tui/src/app.ts +889 -187
  37. package/apps/dpi-tui/src/hub-feed.ts +71 -0
  38. package/apps/dpi-tui/src/hub-mux.ts +168 -0
  39. package/apps/dpi-tui/src/main.ts +5 -1
  40. package/apps/dpi-tui/src/theme.ts +14 -0
  41. package/apps/dpi-tui/src/views.ts +311 -0
  42. package/bin/dpi.mjs +8 -3
  43. package/package.json +2 -2
  44. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index--aoZ5Tj0.css +0 -1
  45. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Dq0CZYr7.js +0 -77
@@ -7,8 +7,8 @@
7
7
  <meta name="referrer" content="no-referrer" />
8
8
  <link rel="icon" type="image/svg+xml" href="/icons/durable-pi.svg" />
9
9
  <title>Durable Pi | Network</title>
10
- <script type="module" crossorigin src="/assets/index-Dq0CZYr7.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index--aoZ5Tj0.css">
10
+ <script type="module" crossorigin src="/assets/index-CoYMgU-2.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-Cg3vey95.css">
12
12
  </head>
13
13
  <body>
14
14
  <div id="app"></div>
@@ -1,15 +1,28 @@
1
1
  /** Pair links stay in RAM and may only refer to the browser's own hub. */
2
- import { decodePairOffer, normalizePairCode, parsePairOfferUrl } from "../../../../../dpi-sdk/src/protocol/pairing";
2
+ import {
3
+ decodePairOffer,
4
+ normalizePairCode,
5
+ parsePairOfferUrl,
6
+ } from "../../../../../dpi-sdk/src/protocol/pairing";
3
7
 
4
- export type PairHandoff = { code: string; origin: string } | { error: string };
8
+ export type PairHandoff =
9
+ | { code: string; origin: string; offerFragment?: string }
10
+ | { error: string };
5
11
 
6
12
  export function readPairHandoff(url: URL): PairHandoff | undefined {
7
13
  if (!/^\/pair\/?$/.test(url.pathname)) return undefined;
8
- // v2 FIRST: the whole handoff rides the #offer= fragment, decoded client-side —
14
+ // v2/v3 FIRST: the whole handoff rides the #offer= fragment, decoded client-side —
9
15
  // the code never travels in a query, so it never reaches any server or CDN log.
10
16
  if (url.hash.startsWith("#offer=")) {
11
17
  const parsed = parsePairOfferUrl(url.href);
12
- if (parsed) return { code: parsed.offer.code, origin: url.origin };
18
+ if (parsed)
19
+ return {
20
+ code: parsed.offer.code,
21
+ origin: url.origin,
22
+ // The RAW fragment, RAM-only: /pair's device-handoff card relays it to
23
+ // the device machine's own pairing page (127.0.0.1) without any fetch.
24
+ offerFragment: url.hash.slice(1),
25
+ };
13
26
  const offer = decodePairOffer(url.hash.slice("#offer=".length));
14
27
  if (offer && offer.hub !== url.origin)
15
28
  return {
@@ -55,9 +55,38 @@ async function call<T>(
55
55
  /** The only auth failure the browser treats as "authenticate again". */
56
56
  export const needsAuth = (e: unknown): boolean => e instanceof DpiError && e.status === 401;
57
57
 
58
+ /**
59
+ * The wizard QR payload (gap #3): the v2 offer link when the hub minted one —
60
+ * the whole handoff rides the #offer= fragment, which no server, CDN or proxy
61
+ * ever logs — and only a legacy hub's v1 pairUrl as the fallback. This is the
62
+ * exact string "Copy link" writes to the clipboard, so the QR and the copied
63
+ * link are always the same handoff.
64
+ */
65
+ export function qrPayload(issue: PairCodeIssue): string {
66
+ return issue.offerUrl ?? issue.pairUrl;
67
+ }
68
+
58
69
  export type RevokeReason = "user" | "compromise";
59
70
 
71
+ /** One device-initiated request as the hub lists it (identity only — no secrets). */
72
+ export interface PendingPairing {
73
+ codeId: string;
74
+ expiresAt: number;
75
+ phase: "awaiting_device" | "awaiting_proof" | "approved_ready" | "cancelled";
76
+ enrollmentId?: string;
77
+ deviceUid?: string;
78
+ deviceName?: string;
79
+ hostname?: string;
80
+ platform?: string;
81
+ keyFingerprint?: string;
82
+ proofVerified?: boolean;
83
+ startedAt?: number;
84
+ }
85
+
60
86
  export const pairingAdmin = {
87
+ /** Machines that asked for a code themselves — the identity the operator approves. */
88
+ pending: (signal?: AbortSignal) =>
89
+ call<{ codes: PendingPairing[] }>("GET", "/pair/codes", undefined, signal),
61
90
  pairings: (signal?: AbortSignal) => call<PublicPairing[]>("GET", "/pairings", undefined, signal),
62
91
  /** Operator opens the wizard: mint a code (admin-gated upstream by Caddy). */
63
92
  createCode: (signal?: AbortSignal) => call<PairCodeIssue>("POST", "/pair/code", {}, signal),
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * Minimal local QR encoder for the PairDeviceWizard (P2-24: "QR render local;
3
- * no remote QR service"). Byte mode, EC level L, versions 1–5 (pair URLs are
4
- * ≤ ~106 bytes). Pure function: text → boolean[size][size] module matrix.
5
- * Cross-validated against the reference `python-qrcode` implementation.
3
+ * no remote QR service"). Byte mode, EC level L, versions 1–12 — v1–5 covers
4
+ * legacy pair URLs (≤ ~106 bytes); v6–12 covers the v2 offer links whose whole
5
+ * handoff rides the #offer= fragment (~229 bytes today → v9; ~310 once the hub
6
+ * identity fingerprint rides the offer → v11; headroom to v12-L = 367 bytes).
7
+ * Pure function: text → boolean[size][size] module matrix.
8
+ * Cross-validated against the reference `python-qrcode` implementation
9
+ * (every module, every mask, every supported version).
6
10
  */
7
11
 
8
12
  // -- GF(256), poly 0x11D ------------------------------------------------------
@@ -48,20 +52,63 @@ function rsEncode(data: number[], ecLen: number): number[] {
48
52
  return rem;
49
53
  }
50
54
 
51
- // -- version tables (EC level L, single data block for v1–5) -------------------
55
+ // -- version tables (EC level L, v1–12; transcribed from python-qrcode's
56
+ // RS_BLOCK_TABLE / PATTERN_POSITION_TABLE so encoder and cross-validation
57
+ // reference can never drift apart) ----------------------------------------
52
58
 
53
- const SIZES = [21, 25, 29, 33, 37];
54
- const DATA_CODEWORDS = [19, 34, 55, 80, 108];
55
- const EC_PER_BLOCK = [7, 10, 15, 20, 26];
56
- const ALIGN = [[], [6, 18], [6, 22], [6, 26], [6, 30]]; // alignment centers
57
- const REMAINDER_BITS = [0, 7, 7, 7, 7];
59
+ const SIZES = [21, 25, 29, 33, 37, 41, 45, 49, 53, 57, 61, 65];
60
+ /** Total DATA codewords per version (all blocks summed). */
61
+ const DATA_CODEWORDS = [19, 34, 55, 80, 108, 136, 156, 194, 232, 274, 324, 370];
62
+ /** Total EC codewords per version (all blocks summed). */
63
+ const EC_CODEWORDS = [7, 10, 15, 20, 26, 36, 40, 48, 60, 72, 80, 96];
64
+ /** EC codewords per single block (equal within a version). */
65
+ const EC_PER_BLOCK = [7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24];
66
+ /** Block groups per version: [count, dataCodewordsPerBlock], shorter first. */
67
+ const BLOCKS: ReadonlyArray<ReadonlyArray<readonly [number, number]>> = [
68
+ [[1, 19]],
69
+ [[1, 34]],
70
+ [[1, 55]],
71
+ [[1, 80]],
72
+ [[1, 108]],
73
+ [[2, 68]],
74
+ [[2, 78]],
75
+ [[2, 97]],
76
+ [[2, 116]],
77
+ [
78
+ [2, 68],
79
+ [2, 69],
80
+ ],
81
+ [[4, 81]],
82
+ [
83
+ [2, 92],
84
+ [2, 93],
85
+ ],
86
+ ];
87
+ const ALIGN = [
88
+ [],
89
+ [6, 18],
90
+ [6, 22],
91
+ [6, 26],
92
+ [6, 30],
93
+ [6, 34],
94
+ [6, 22, 38],
95
+ [6, 24, 42],
96
+ [6, 26, 46],
97
+ [6, 28, 50],
98
+ [6, 30, 54],
99
+ [6, 32, 58],
100
+ ]; // alignment centers
101
+
102
+ /** Byte-mode character-count indicator width: 8 bits for v1–9, 16 for v10+. */
103
+ const countBits = (version: number): number => (version <= 9 ? 8 : 16);
58
104
 
59
105
  function selectVersion(byteLen: number): number {
60
- for (let v = 1; v <= 5; v++) {
61
- // byte mode: 4-bit indicator + 8-bit count (v1–9) + payload
62
- if (byteLen + 2 <= DATA_CODEWORDS[v - 1]) return v;
106
+ for (let v = 1; v <= SIZES.length; v++) {
107
+ // byte mode: 4-bit indicator + count (8/16 bits) + payload; the terminator
108
+ // may be truncated to zero bits, so it never forces a larger version.
109
+ if (4 + countBits(v) + byteLen * 8 <= DATA_CODEWORDS[v - 1] * 8) return v;
63
110
  }
64
- throw new Error(`qr: payload too long (${byteLen} bytes; v5L max ${DATA_CODEWORDS[4] - 2})`);
111
+ throw new Error(`qr: payload too long (${byteLen} bytes; v12L max ${DATA_CODEWORDS[11] - 3})`);
65
112
  }
66
113
 
67
114
  // -- bit stream ---------------------------------------------------------------
@@ -73,7 +120,7 @@ function buildCodewords(text: string, version: number): number[] {
73
120
  for (let i = len - 1; i >= 0; i--) bits.push((value >> i) & 1);
74
121
  };
75
122
  push(0b0100, 4); // byte mode
76
- push(bytes.length, 8);
123
+ push(bytes.length, countBits(version));
77
124
  for (const b of bytes) push(b, 8);
78
125
  const dataCodewords = DATA_CODEWORDS[version - 1];
79
126
  const capacityBits = dataCodewords * 8;
@@ -91,6 +138,26 @@ function buildCodewords(text: string, version: number): number[] {
91
138
  return out;
92
139
  }
93
140
 
141
+ /**
142
+ * Split the data codewords into the version's blocks, add per-block RS error
143
+ * correction, and interleave both block-wise (the spec's placement order for
144
+ * multi-block versions; for v1–5 this degenerates to the old single stream).
145
+ */
146
+ function interleave(version: number, data: number[]): number[] {
147
+ const ecLen = EC_PER_BLOCK[version - 1];
148
+ const blocks: number[][] = [];
149
+ let offset = 0;
150
+ for (const [count, dataLen] of BLOCKS[version - 1])
151
+ for (let b = 0; b < count; b++) blocks.push(data.slice(offset, (offset += dataLen)));
152
+ const out: number[] = [];
153
+ const maxData = Math.max(...blocks.map((b) => b.length));
154
+ for (let i = 0; i < maxData; i++)
155
+ for (const block of blocks) if (i < block.length) out.push(block[i]);
156
+ const ecs = blocks.map((block) => rsEncode(block, ecLen));
157
+ for (let i = 0; i < ecLen; i++) for (const ec of ecs) out.push(ec[i]);
158
+ return out;
159
+ }
160
+
94
161
  // -- matrix scaffolding ---------------------------------------------------------
95
162
 
96
163
  function makeMatrix(size: number): boolean[][] {
@@ -130,6 +197,21 @@ function placeFunctionPatterns(m: boolean[][], version: number): void {
130
197
  }
131
198
  // dark module
132
199
  m[size - 8][8] = true;
200
+ // version information (v≥7 only): 6-bit version + 12 BCH bits (G18 = 0x1f25), two copies
201
+ if (version >= 7) {
202
+ // long division of version·x¹² by G18, MSB first (same algorithm as the
203
+ // reference implementation's BCH_type_number)
204
+ let d = version << 12;
205
+ for (let bit = 17; bit >= 12; bit--) if ((d >> bit) & 1) d ^= 0x1f25 << (bit - 12);
206
+ const bits = (version << 12) | (d & 0xfff);
207
+ for (let i = 0; i < 18; i++) {
208
+ const bit = ((bits >> i) & 1) === 1;
209
+ const a = size - 11 + (i % 3);
210
+ const b = (i / 3) | 0;
211
+ m[a][b] = bit; // bottom-left strip: rows size-11..size-9, cols 0..5
212
+ m[b][a] = bit; // top-right strip: rows 0..5, cols size-11..size-9
213
+ }
214
+ }
133
215
  }
134
216
 
135
217
  // format info: EC level L (01) + mask, BCH(15,5), XOR 0x5412
@@ -177,7 +259,7 @@ function placeData(m: boolean[][], codewords: number[], version: number, mask: n
177
259
  const row = upward ? size - 1 - step : step;
178
260
  for (const c of [col, col - 1]) {
179
261
  if (isFunctionModule(row, c, version)) continue;
180
- if (bitIndex >= (DATA_CODEWORDS[version - 1] + EC_PER_BLOCK[version - 1]) * 8) {
262
+ if (bitIndex >= (DATA_CODEWORDS[version - 1] + EC_CODEWORDS[version - 1]) * 8) {
181
263
  // past data+ec bits → remainder modules (decoders ignore them)
182
264
  bitIndex++;
183
265
  continue;
@@ -220,6 +302,14 @@ function isFunctionModule(row: number, col: number, version: number): boolean {
220
302
  if (row >= size - 8 && col <= 8) return true;
221
303
  // timing
222
304
  if (row === 6 || col === 6) return true;
305
+ // version information (v≥7): the two 3×6 strips beside the top-right and
306
+ // bottom-left finders
307
+ if (
308
+ version >= 7 &&
309
+ ((row >= size - 11 && row <= size - 9 && col <= 5) ||
310
+ (col >= size - 11 && col <= size - 9 && row <= 5))
311
+ )
312
+ return true;
223
313
  // alignment
224
314
  const centers = ALIGN[version - 1];
225
315
  for (const r of centers)
@@ -286,8 +376,7 @@ function matrixWithMask(text: string, mask: number): boolean[][] {
286
376
  const version = selectVersion(new TextEncoder().encode(text).length);
287
377
  const size = SIZES[version - 1];
288
378
  const data = buildCodewords(text, version);
289
- const ec = rsEncode(data, EC_PER_BLOCK[version - 1]);
290
- const all = [...data, ...ec];
379
+ const all = interleave(version, data);
291
380
  const m = makeMatrix(size);
292
381
  placeFunctionPatterns(m, version);
293
382
  placeData(m, all, version, mask);
@@ -295,7 +384,7 @@ function matrixWithMask(text: string, mask: number): boolean[][] {
295
384
  return m;
296
385
  }
297
386
 
298
- /** Encode text into a QR matrix (true = dark module), EC level L, versions 1–5. */
387
+ /** Encode text into a QR matrix (true = dark module), EC level L, versions 1–12. */
299
388
  export function qrMatrix(text: string, maskOverride?: number): boolean[][] {
300
389
  if (maskOverride !== undefined) return matrixWithMask(text, maskOverride);
301
390
  let best: { m: boolean[][]; score: number } | undefined;
@@ -162,7 +162,7 @@ watch(
162
162
  <span class="crumb-kind">device</span>
163
163
  <span class="crumb-main"
164
164
  ><DeviceIcon :device="device" :size="18" /><span class="device-chip">{{
165
- device?.id
165
+ device?.name ?? device?.id
166
166
  }}</span></span
167
167
  >
168
168
  </button>
@@ -0,0 +1,132 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The no-admin "Pair THIS device" card (gap #4): when /pair carries an
4
+ * #offer= fragment, the machine whose BROWSER opened it can relay the offer —
5
+ * unchanged, fragment-only — to its own dpi backend's pairing page. No login,
6
+ * no hub API: the hub wizard stays the operator side (mint + approve).
7
+ */
8
+ import { computed, ref } from "vue";
9
+ import { MonitorSmartphone, Copy, ExternalLink } from "lucide-vue-next";
10
+
11
+ const props = defineProps<{ offerFragment: string }>();
12
+ const port = ref("4780");
13
+ const copied = ref(false);
14
+
15
+ const deviceUrl = computed(() => {
16
+ const p = /^\d{1,5}$/.test(port.value) ? Number(port.value) : 0;
17
+ if (!p || p < 1 || p > 65535) return undefined;
18
+ return `http://127.0.0.1:${p}/pair#${props.offerFragment}`;
19
+ });
20
+
21
+ function open() {
22
+ const url = deviceUrl.value;
23
+ if (url) window.open(url, "_blank", "noopener");
24
+ }
25
+
26
+ async function copy() {
27
+ const url = deviceUrl.value;
28
+ if (!url) return;
29
+ try {
30
+ await navigator.clipboard.writeText(url);
31
+ copied.value = true;
32
+ setTimeout(() => (copied.value = false), 1600);
33
+ } catch {
34
+ /* clipboard denied: the open button still works */
35
+ }
36
+ }
37
+ </script>
38
+
39
+ <template>
40
+ <section class="device-handoff" aria-label="Pair this machine">
41
+ <header>
42
+ <MonitorSmartphone :size="18" />
43
+ <h3>Pair THIS machine</h3>
44
+ </header>
45
+ <p class="what">
46
+ If this browser runs on the device itself, open its local pairing page — the offer is relayed
47
+ inside the link, nothing is sent to the hub. Start <code>dpi device</code> there first.
48
+ </p>
49
+ <div class="row">
50
+ <label>
51
+ local port
52
+ <input
53
+ v-model="port"
54
+ inputmode="numeric"
55
+ spellcheck="false"
56
+ aria-label="Local dpi backend port"
57
+ />
58
+ </label>
59
+ <button class="primary" type="button" :disabled="!deviceUrl" @click="open">
60
+ <ExternalLink :size="15" />Open the device page
61
+ </button>
62
+ <button class="secondary" type="button" :disabled="!deviceUrl" @click="copy">
63
+ <Copy :size="15" />{{ copied ? "Copied" : "Copy link" }}
64
+ </button>
65
+ </div>
66
+ <p class="target" v-if="deviceUrl" data-testid="device-url">{{ deviceUrl }}</p>
67
+ <p class="err" v-if="!deviceUrl">port must be 1–65535</p>
68
+ </section>
69
+ </template>
70
+
71
+ <style scoped>
72
+ .device-handoff {
73
+ border: 1px solid var(--border);
74
+ border-radius: 12px;
75
+ padding: 14px 16px;
76
+ margin-bottom: 14px;
77
+ background: var(--panel);
78
+ }
79
+ header {
80
+ display: flex;
81
+ align-items: center;
82
+ gap: 8px;
83
+ font-weight: 650;
84
+ margin-bottom: 6px;
85
+ }
86
+ h3 {
87
+ margin: 0;
88
+ font-size: 14.5px;
89
+ }
90
+ .what {
91
+ margin: 0 0 10px;
92
+ font-size: 13px;
93
+ color: var(--muted);
94
+ }
95
+ code {
96
+ font-family: ui-monospace, monospace;
97
+ font-size: 12px;
98
+ }
99
+ .row {
100
+ display: flex;
101
+ flex-wrap: wrap;
102
+ gap: 8px;
103
+ align-items: end;
104
+ }
105
+ label {
106
+ display: grid;
107
+ gap: 4px;
108
+ font-size: 12px;
109
+ color: var(--muted);
110
+ }
111
+ input {
112
+ width: 90px;
113
+ padding: 7px 9px;
114
+ border-radius: 8px;
115
+ border: 1px solid var(--border);
116
+ background: var(--panel);
117
+ color: inherit;
118
+ font: inherit;
119
+ }
120
+ .err {
121
+ color: var(--danger, #f28b82);
122
+ font-size: 12.5px;
123
+ margin: 8px 0 0;
124
+ }
125
+ .target {
126
+ margin: 10px 0 0;
127
+ font-family: ui-monospace, monospace;
128
+ font-size: 12px;
129
+ color: var(--muted);
130
+ word-break: break-all;
131
+ }
132
+ </style>
@@ -239,7 +239,7 @@ const offeredTools = computed(() => detail.value?.tools.filter((t) => t.offered)
239
239
  <span class="status-pill" :class="{ working: busy }"
240
240
  ><i />{{ busy ? "Working" : "Idle" }}</span
241
241
  ><span v-if="background" class="background-pill">{{ background }} background</span
242
- ><span class="saved"><Server :size="18" />{{ device?.id }}</span>
242
+ ><span class="saved" :title="device?.id"><Server :size="18" />{{ device?.name ?? device?.id }}</span>
243
243
  <TunnelStatusBar v-if="device" :device="device" />
244
244
  </div>
245
245
  </section>
@@ -318,7 +318,7 @@ const offeredTools = computed(() => detail.value?.tools.filter((t) => t.offered)
318
318
  <Braces :size="18" />
319
319
  </div>
320
320
  <p class="hint app-state-description">
321
- One shared JSON object for every agent, session and fork on {{ device?.id }}. You and
321
+ One shared JSON object for every agent, session and fork on {{ device?.name ?? device?.id }}. You and
322
322
  your agents can read, create, update or delete values and fields. Changes appear live
323
323
  and persist across closed browsers, interrupted agents and backend restarts.
324
324
  </p>
@@ -13,7 +13,31 @@ const deviceId = ref(
13
13
  devices.value.find((d) => d.id === props.deviceId)?.id ?? devices.value[0]?.id ?? "",
14
14
  );
15
15
  const device = computed(() => devices.value.find((d) => d.id === deviceId.value));
16
- const models = computed(() => device.value?.info?.models ?? []);
16
+ const models = computed(() => {
17
+ const list = device.value?.info?.models ?? [];
18
+ if (list.length) return list;
19
+ // No usable catalog (device has no provider key): still offer the device's
20
+ // default so a session can be created — chatting needs the key, creating
21
+ // must not. Caught live: the dropdown went empty and create always failed.
22
+ const info = device.value?.info;
23
+ if (info?.defaultModel && info.provider)
24
+ return [
25
+ {
26
+ id: info.defaultModel,
27
+ provider: info.provider,
28
+ name: `${info.defaultModel} (device default)`,
29
+ tier: "—",
30
+ icon: "lab.png",
31
+ contextWindow: 0,
32
+ reasoning: false,
33
+ cost: { input: 0, output: 0 },
34
+ },
35
+ ];
36
+ return [];
37
+ });
38
+ const catalogEmpty = computed(
39
+ () => !models.value.length || (device.value?.info?.models?.length ?? 0) === 0,
40
+ );
17
41
  const key = (m: { provider: string; id: string }) => `${m.provider}/${m.id}`;
18
42
  const draft = ref({ name: "", cwd: "", model: "" });
19
43
  const pending = ref(false);
@@ -22,8 +46,9 @@ const error = ref("");
22
46
  watch(
23
47
  device,
24
48
  (d) => {
49
+ const list = models.value;
25
50
  const fallback =
26
- d?.info?.models.find((m) => m.id === d.info?.defaultModel) ?? d?.info?.models[0];
51
+ list.find((m) => m.id === d?.info?.defaultModel) ?? list[0];
27
52
  draft.value.model = fallback ? key(fallback) : "";
28
53
  draft.value.cwd = "";
29
54
  },
@@ -35,7 +60,7 @@ async function create() {
35
60
  pending.value = true;
36
61
  error.value = "";
37
62
  try {
38
- const model = models.value.find((m) => key(m) === draft.value.model);
63
+ const model = models.value.find((m) => key(m) === draft.value.model) ?? models.value[0];
39
64
  if (!model) throw Error("Choose a model");
40
65
  const created = await deviceClient(device.value.id).createSession({
41
66
  name: draft.value.name.trim() || "New session",
@@ -79,7 +104,7 @@ async function create() {
79
104
  <label
80
105
  >Device<select v-model="deviceId" aria-label="Device">
81
106
  <option v-for="d in devices" :key="d.id" :value="d.id">
82
- {{ d.id }} · {{ d.info?.hostname ?? d.url }}
107
+ {{ d.name }}{{ d.info?.hostname && d.info.hostname !== d.name ? ` · ${d.info.hostname}` : "" }}
83
108
  </option>
84
109
  </select></label
85
110
  >
@@ -91,11 +116,16 @@ async function create() {
91
116
  maxlength="100"
92
117
  /></label>
93
118
  <div class="field">
94
- <span>Working directory on {{ deviceId }}</span>
119
+ <span>Working directory on {{ devices.find((d) => d.id === deviceId)?.name ?? deviceId }}</span>
95
120
  <DirBrowser v-model="draft.cwd" :device-id="deviceId" />
96
121
  </div>
97
122
  <label
98
- >Model<select v-model="draft.model" aria-label="New session model">
123
+ >Model<span
124
+ v-if="catalogEmpty"
125
+ class="hint"
126
+ title="The device reports no usable models — create still works with its default; add the provider key on the device (e.g. OPENROUTER_API_KEY) to see the catalog"
127
+ >no key on device — using default</span
128
+ ><select v-model="draft.model" aria-label="New session model">
99
129
  <option v-for="m in models" :key="key(m)" :value="key(m)">
100
130
  {{ m.tier }} / {{ m.name }}
101
131
  </option>