@openscout/protocol 0.2.98 → 0.2.102

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.
@@ -0,0 +1,550 @@
1
+ /**
2
+ * Machines — the physical (or virtual) boxes Scout knows about.
3
+ *
4
+ * A machine is NOT a node. A node is a Scout broker runtime; a machine is the
5
+ * computer it runs on. The distinction matters because most machines Scout can
6
+ * see are not running Scout at all: a tailnet peer, a Mac advertising itself on
7
+ * the LAN, a Linux box that only answers ARP. Modelling them as nodes would
8
+ * either invent fake nodes or make them invisible, and both were wrong.
9
+ *
10
+ * A machine record is assembled from **evidence**, never asserted. Four
11
+ * channels report independently and none of them is authoritative:
12
+ *
13
+ * | channel | tells us |
14
+ * | --------- | ----------------------------------------------- |
15
+ * | `scout` | a broker node lives here (id, mesh, urls) |
16
+ * | `tailnet` | reachable over Tailscale (stable peer id, IPs) |
17
+ * | `lan` | seen on this network (mDNS advert, ARP neighbor)|
18
+ * | `host` | terminal hosts present (herdr, tmux, zellij) |
19
+ *
20
+ * The join between them is `machineIdentityKeys` + `groupMachineEvidence`.
21
+ * Everything else in this module is a pure derivation over a group, so the
22
+ * whole model is testable without a network, a broker, or a database.
23
+ */
24
+ export const MACHINE_ID_PREFIX = "mach-";
25
+ const EMPTY_KEYS = { durable: [], transient: [] };
26
+ /** mDNS service types Scout browses, and what each one proves. */
27
+ export const MACHINE_SERVICE_CAPABILITIES = {
28
+ "_openscout._tcp": "scout-broker",
29
+ "_oscout-pair._tcp": "scout-pairing",
30
+ "_ssh._tcp": "ssh",
31
+ "_sftp-ssh._tcp": "ssh",
32
+ "_rfb._tcp": "vnc",
33
+ "_smb._tcp": "smb",
34
+ "_http._tcp": "http",
35
+ };
36
+ /** Browsed for inventory. `_workstation._tcp` proves nothing but presence. */
37
+ export const MACHINE_LAN_SERVICE_TYPES = [
38
+ "_openscout._tcp",
39
+ "_oscout-pair._tcp",
40
+ "_ssh._tcp",
41
+ "_workstation._tcp",
42
+ "_rfb._tcp",
43
+ "_smb._tcp",
44
+ ];
45
+ /* ── Normalization ── */
46
+ /**
47
+ * Reduce a host label to the machine's short name: `mini.local.` → `mini`,
48
+ * `mini.tail1234.ts.net` → `mini`.
49
+ *
50
+ * Bonjour renames a colliding instance to `mini (2)` or `mini-2.local`, and
51
+ * both must fold back to `mini` or one machine becomes two. The numeric form
52
+ * is only stripped for a `.local` name — a machine genuinely called `node-1`
53
+ * must keep its digit, which is the same rule
54
+ * `stripBonjourCollisionSuffix` follows in the runtime.
55
+ */
56
+ /**
57
+ * Host names that every machine can answer to and therefore identify none of
58
+ * them. iOS devices report `localhost` as their Tailscale host name, so
59
+ * treating this as identity fuses an iPhone and an iPad into one record.
60
+ */
61
+ const NON_IDENTIFYING_HOST_NAMES = new Set(["localhost", "ip6-localhost", "unknown", "android"]);
62
+ export function normalizeMachineHostName(value) {
63
+ const raw = (value ?? "").trim().toLowerCase();
64
+ if (!raw)
65
+ return "";
66
+ const withoutTrailingDot = raw.replace(/\.+$/, "");
67
+ const isMdnsName = /\.local$/.test(withoutTrailingDot);
68
+ const firstLabel = (withoutTrailingDot.split(".")[0] ?? "").replace(/\s*\(\d+\)$/, "").trim();
69
+ // A machine that has re-registered hundreds of times carries a suffix to
70
+ // match: `Arts-Mac-mini-769.local` and `Arts-Mac-mini-612.local` are one Mac,
71
+ // and capping at two digits left 25 of them as 25 machines.
72
+ const name = isMdnsName ? firstLabel.replace(/-\d{1,4}$/, "") : firstLabel;
73
+ return NON_IDENTIFYING_HOST_NAMES.has(name) ? "" : name;
74
+ }
75
+ /**
76
+ * Lowercase colon-form MAC. Returns "" for entries that identify nothing —
77
+ * broadcast, all-zero, or an incomplete ARP row.
78
+ *
79
+ * BSD `arp` drops leading zeros (`3c:22:fb:1:2:3`), so octets are padded
80
+ * rather than the string being scrubbed of separators: scrubbing turns that
81
+ * nine-character form into a rejected MAC instead of a valid one.
82
+ */
83
+ export function normalizeMacAddress(value) {
84
+ const raw = (value ?? "").trim().toLowerCase();
85
+ if (!raw)
86
+ return "";
87
+ const groups = raw.split(/[:-]/);
88
+ const octets = groups.length === 6
89
+ ? groups.map((group) => (/^[0-9a-f]{1,2}$/.test(group) ? group.padStart(2, "0") : ""))
90
+ : (raw.replace(/[^0-9a-f]/g, "").match(/.{2}/g) ?? []);
91
+ if (octets.length !== 6 || octets.some((octet) => !/^[0-9a-f]{2}$/.test(octet)))
92
+ return "";
93
+ const joined = octets.join(":");
94
+ if (joined === "00:00:00:00:00:00")
95
+ return "";
96
+ // The low bit of the first octet is the I/G bit: when it is set the address
97
+ // is a group (multicast or broadcast) destination, never a station address.
98
+ // Real ARP tables are full of these — `01:00:5e:00:00:fb` is mDNS, not a
99
+ // machine — and treating one as identity would mint a phantom neighbour on
100
+ // every network Scout ever sees.
101
+ if ((Number.parseInt(octets[0] ?? "0", 16) & 0x01) === 1)
102
+ return "";
103
+ return joined;
104
+ }
105
+ export function normalizeMachineAddress(value) {
106
+ const raw = (value ?? "").trim().toLowerCase();
107
+ if (!raw)
108
+ return "";
109
+ return raw.replace(/^\[/, "").replace(/\]$/, "").replace(/%.*$/, "");
110
+ }
111
+ export function isLoopbackAddress(value) {
112
+ const address = normalizeMachineAddress(value);
113
+ return address === "127.0.0.1" || address === "::1" || address === "localhost";
114
+ }
115
+ /** Tailscale's CGNAT range (100.64.0.0/10) and its IPv6 ULA prefix. */
116
+ export function isTailnetAddress(value) {
117
+ const address = normalizeMachineAddress(value);
118
+ if (address.startsWith("fd7a:115c:a1e0"))
119
+ return true;
120
+ const octets = address.split(".");
121
+ if (octets.length !== 4)
122
+ return false;
123
+ const first = Number.parseInt(octets[0] ?? "", 10);
124
+ const second = Number.parseInt(octets[1] ?? "", 10);
125
+ return first === 100 && second >= 64 && second <= 127;
126
+ }
127
+ /* ── Identity ── */
128
+ function pushUnique(target, value) {
129
+ if (value === null || value === undefined || value === "")
130
+ return;
131
+ if (!target.includes(value))
132
+ target.push(value);
133
+ }
134
+ const pushKey = (pushUnique);
135
+ /**
136
+ * The keys one piece of evidence claims. Two pieces of evidence describe the
137
+ * same machine when they share any key — see `groupMachineEvidence`.
138
+ */
139
+ export function machineIdentityKeys(evidence) {
140
+ const durable = [];
141
+ const transient = [];
142
+ switch (evidence.kind) {
143
+ case "scout": {
144
+ pushKey(durable, `node:${evidence.nodeId}`);
145
+ const host = normalizeMachineHostName(evidence.hostName)
146
+ || normalizeMachineHostName(evidence.nodeName);
147
+ if (host)
148
+ pushKey(durable, `host:${host}`);
149
+ return { durable, transient };
150
+ }
151
+ case "tailnet": {
152
+ pushKey(durable, `tailnet:${evidence.peerId}`);
153
+ const host = normalizeMachineHostName(evidence.hostName)
154
+ || normalizeMachineHostName(evidence.dnsName);
155
+ if (host)
156
+ pushKey(durable, `host:${host}`);
157
+ // A tailnet address is assigned by the coordination server and stays with
158
+ // the node, so unlike a LAN address it is safe to keep.
159
+ for (const address of evidence.addresses) {
160
+ const normalized = normalizeMachineAddress(address);
161
+ if (!normalized)
162
+ continue;
163
+ if (isTailnetAddress(normalized))
164
+ pushKey(durable, `ip:${normalized}`);
165
+ else
166
+ pushKey(transient, `ip:${normalized}`);
167
+ }
168
+ return { durable, transient };
169
+ }
170
+ case "lan": {
171
+ const mac = normalizeMacAddress(evidence.macAddress);
172
+ if (mac)
173
+ pushKey(durable, `mac:${mac}`);
174
+ const host = normalizeMachineHostName(evidence.hostName)
175
+ || normalizeMachineHostName(evidence.instanceName);
176
+ if (host)
177
+ pushKey(durable, `host:${host}`);
178
+ for (const address of evidence.addresses) {
179
+ const normalized = normalizeMachineAddress(address);
180
+ if (!normalized || isLoopbackAddress(normalized))
181
+ continue;
182
+ if (isTailnetAddress(normalized))
183
+ pushKey(durable, `ip:${normalized}`);
184
+ else
185
+ pushKey(transient, `ip:${normalized}`);
186
+ }
187
+ return { durable, transient };
188
+ }
189
+ case "host": {
190
+ if (evidence.nodeId)
191
+ pushKey(durable, `node:${evidence.nodeId}`);
192
+ const host = normalizeMachineHostName(evidence.hostName);
193
+ if (host)
194
+ pushKey(durable, `host:${host}`);
195
+ return { durable, transient };
196
+ }
197
+ default:
198
+ return EMPTY_KEYS;
199
+ }
200
+ }
201
+ /**
202
+ * Union-find over identity keys. Evidence lands in the same group when it
203
+ * shares any key, directly or through a chain — an ARP row and an mDNS advert
204
+ * share an IP; the advert and a tailnet peer share a hostname; so all three
205
+ * describe one machine even though ARP and Tailscale share nothing at all.
206
+ *
207
+ * `seeds` are the durable key sets of machines already on record. They enter
208
+ * the union as members, which is what lets a machine seen only by ARP today
209
+ * and only by Tailscale tomorrow stay one record: the seed bridges the passes.
210
+ */
211
+ export function groupMachineEvidence(evidence, seeds = []) {
212
+ const parent = [];
213
+ const find = (index) => {
214
+ let root = index;
215
+ while (parent[root] !== root)
216
+ root = parent[root];
217
+ let cursor = index;
218
+ while (parent[cursor] !== root) {
219
+ const next = parent[cursor];
220
+ parent[cursor] = root;
221
+ cursor = next;
222
+ }
223
+ return root;
224
+ };
225
+ const union = (a, b) => {
226
+ const rootA = find(a);
227
+ const rootB = find(b);
228
+ if (rootA !== rootB)
229
+ parent[rootB] = rootA;
230
+ };
231
+ // Members: seeds first (so a seed's slot index is its own), then evidence.
232
+ const memberKeys = [];
233
+ for (const seed of seeds) {
234
+ parent.push(parent.length);
235
+ memberKeys.push({ durable: [...seed], transient: [] });
236
+ }
237
+ const evidenceOffset = memberKeys.length;
238
+ for (const item of evidence) {
239
+ parent.push(parent.length);
240
+ memberKeys.push(machineIdentityKeys(item));
241
+ }
242
+ const owners = new Map();
243
+ memberKeys.forEach((keys, index) => {
244
+ for (const key of [...keys.durable, ...keys.transient]) {
245
+ const existing = owners.get(key);
246
+ if (existing === undefined)
247
+ owners.set(key, index);
248
+ else
249
+ union(existing, index);
250
+ }
251
+ });
252
+ const groups = new Map();
253
+ const groupOrder = [];
254
+ const ensureGroup = (root) => {
255
+ const existing = groups.get(root);
256
+ if (existing)
257
+ return existing;
258
+ const created = { identityKeys: [], evidence: [], seedIndices: [] };
259
+ groups.set(root, created);
260
+ groupOrder.push(root);
261
+ return created;
262
+ };
263
+ memberKeys.forEach((keys, index) => {
264
+ const group = ensureGroup(find(index));
265
+ for (const key of keys.durable)
266
+ pushKey(group.identityKeys, key);
267
+ if (index < evidenceOffset)
268
+ group.seedIndices.push(index);
269
+ else
270
+ group.evidence.push(evidence[index - evidenceOffset]);
271
+ });
272
+ return groupOrder
273
+ .map((root) => groups.get(root))
274
+ // A seed with no fresh evidence is a machine nobody saw this pass. It stays
275
+ // on record (the caller keeps it), but it is not a result of this scan.
276
+ .filter((group) => group.evidence.length > 0)
277
+ .map((group) => ({
278
+ ...group,
279
+ identityKeys: [...group.identityKeys].sort(),
280
+ // Ascending, so the lowest-indexed record is the stable survivor of a merge.
281
+ seedIndices: [...group.seedIndices].sort((a, b) => a - b),
282
+ }));
283
+ }
284
+ /**
285
+ * Deterministic id from a group's durable keys, so two brokers observing the
286
+ * same machine mint the same id and a restart never renumbers the fleet.
287
+ * FNV-1a ×2, matching `stableChannelId`.
288
+ */
289
+ export function stableMachineId(identityKeys) {
290
+ const normalized = [...identityKeys].map((key) => key.trim().toLowerCase()).sort().join("|");
291
+ const hash = (seed) => {
292
+ let value = seed;
293
+ for (let index = 0; index < normalized.length; index += 1) {
294
+ value ^= BigInt(normalized.charCodeAt(index));
295
+ value = BigInt.asUintN(64, value * 0x100000001b3n);
296
+ }
297
+ return value.toString(16).padStart(16, "0");
298
+ };
299
+ return `${MACHINE_ID_PREFIX}${hash(0xcbf29ce484222325n)}${hash(0x84222325cbf29ce4n)}`;
300
+ }
301
+ /* ── Derivations over a group ── */
302
+ function evidenceOf(evidence, kind) {
303
+ return evidence.filter((item) => item.kind === kind);
304
+ }
305
+ /**
306
+ * The name to show. Scout's own name for the node wins because the operator
307
+ * chose it; then the tailnet hostname, then whatever the LAN advertised. A
308
+ * machine known only by MAC gets its MAC, never a fabricated label.
309
+ */
310
+ export function deriveMachineName(evidence) {
311
+ for (const scout of evidenceOf(evidence, "scout")) {
312
+ // A node name is a host name — `Arts-Mac-mini-534.local`, suffix and all —
313
+ // so it goes through the same normalizer rather than straight to the UI.
314
+ const name = normalizeMachineHostName(scout.nodeName) || normalizeMachineHostName(scout.hostName);
315
+ if (name)
316
+ return name;
317
+ }
318
+ const tailnet = evidenceOf(evidence, "tailnet")
319
+ .sort((a, b) => Number(b.online) - Number(a.online))[0];
320
+ const tailnetName = normalizeMachineHostName(tailnet?.hostName)
321
+ || normalizeMachineHostName(tailnet?.dnsName);
322
+ if (tailnetName)
323
+ return tailnetName;
324
+ for (const lan of evidenceOf(evidence, "lan")) {
325
+ const name = normalizeMachineHostName(lan.hostName) || normalizeMachineHostName(lan.instanceName);
326
+ if (name)
327
+ return name;
328
+ }
329
+ // A terminal host names the machine it was probed on. Weaker than a node or
330
+ // a tailnet peer, but far better than falling through to a bare MAC for a
331
+ // box we are literally running sessions on.
332
+ for (const host of evidenceOf(evidence, "host")) {
333
+ const name = normalizeMachineHostName(host.hostName);
334
+ if (name)
335
+ return name;
336
+ }
337
+ const mac = evidenceOf(evidence, "lan")
338
+ .map((lan) => normalizeMacAddress(lan.macAddress))
339
+ .find(Boolean);
340
+ if (mac)
341
+ return mac;
342
+ const address = evidenceOf(evidence, "lan")
343
+ .flatMap((lan) => lan.addresses.map(normalizeMachineAddress))
344
+ .find((value) => value && !isLoopbackAddress(value));
345
+ return address || "unknown";
346
+ }
347
+ const TAILSCALE_OS_PLATFORMS = {
348
+ macos: "macos",
349
+ macOS: "macos",
350
+ darwin: "macos",
351
+ linux: "linux",
352
+ windows: "windows",
353
+ ios: "ios",
354
+ iOS: "ios",
355
+ android: "android",
356
+ };
357
+ export function deriveMachinePlatform(evidence) {
358
+ for (const tailnet of evidenceOf(evidence, "tailnet")) {
359
+ const os = (tailnet.os ?? "").trim();
360
+ const platform = TAILSCALE_OS_PLATFORMS[os] ?? TAILSCALE_OS_PLATFORMS[os.toLowerCase()];
361
+ if (platform)
362
+ return platform;
363
+ }
364
+ // Only Apple platforms publish `_rfb._tcp` alongside `_ssh._tcp` by default
365
+ // on a stock install, but that is a guess, not proof — keep it to the
366
+ // Bonjour `model` TXT key, which Apple devices set and others do not.
367
+ for (const lan of evidenceOf(evidence, "lan")) {
368
+ const model = String(lan.txt?.model ?? "").trim();
369
+ if (/^(mac|imac|macbook|macmini|macpro|macstudio)/i.test(model))
370
+ return "macos";
371
+ }
372
+ return "unknown";
373
+ }
374
+ export function deriveMachineCapabilities(evidence) {
375
+ const capabilities = [];
376
+ for (const scout of evidenceOf(evidence, "scout")) {
377
+ if (scout.brokerUrl)
378
+ pushUnique(capabilities, "scout-broker");
379
+ if (scout.webUrl)
380
+ pushUnique(capabilities, "scout-web");
381
+ }
382
+ for (const lan of evidenceOf(evidence, "lan")) {
383
+ pushUnique(capabilities, lan.serviceType ? MACHINE_SERVICE_CAPABILITIES[lan.serviceType] : null);
384
+ }
385
+ for (const host of evidenceOf(evidence, "host")) {
386
+ pushUnique(capabilities, host.host);
387
+ }
388
+ return capabilities.sort();
389
+ }
390
+ /**
391
+ * Routes, best first: loopback (it's this machine), then LAN (no hop), then
392
+ * tailnet, then whatever the mesh advertised. This is the order a caller
393
+ * should dial, and it mirrors the LAN-first preference iOS already uses.
394
+ */
395
+ /**
396
+ * Every way we know to reach this machine, best first. `isSelf` decides whether
397
+ * loopback belongs at all: a peer advertising `http://127.0.0.1:43120` is
398
+ * describing its own loopback, which is not a route to it from here.
399
+ */
400
+ export function deriveMachineRoutes(evidence, isSelf = false) {
401
+ const routes = [];
402
+ const seen = new Set();
403
+ const add = (route) => {
404
+ const key = `${route.kind}|${route.host}|${route.port ?? ""}`;
405
+ if (seen.has(key))
406
+ return;
407
+ seen.add(key);
408
+ routes.push(route);
409
+ };
410
+ for (const scout of evidenceOf(evidence, "scout")) {
411
+ for (const url of [scout.brokerUrl, scout.webUrl]) {
412
+ if (!url)
413
+ continue;
414
+ try {
415
+ const parsed = new URL(url);
416
+ const host = normalizeMachineAddress(parsed.hostname);
417
+ const port = parsed.port ? Number.parseInt(parsed.port, 10) : undefined;
418
+ const kind = isLoopbackAddress(host)
419
+ ? "loopback"
420
+ : isTailnetAddress(host)
421
+ ? "tailnet"
422
+ : "mesh";
423
+ add({ kind, host, ...(port ? { port } : {}), url, lastSeenAt: scout.observedAt });
424
+ }
425
+ catch {
426
+ // A malformed advertised URL is the peer's problem, not a reason to
427
+ // drop every other route this machine has.
428
+ }
429
+ }
430
+ }
431
+ for (const lan of evidenceOf(evidence, "lan")) {
432
+ for (const address of lan.addresses) {
433
+ const host = normalizeMachineAddress(address);
434
+ if (!host || isLoopbackAddress(host))
435
+ continue;
436
+ add({
437
+ kind: isTailnetAddress(host) ? "tailnet" : "lan",
438
+ host,
439
+ ...(lan.port ? { port: lan.port } : {}),
440
+ lastSeenAt: lan.observedAt,
441
+ });
442
+ }
443
+ }
444
+ for (const tailnet of evidenceOf(evidence, "tailnet")) {
445
+ for (const address of tailnet.addresses) {
446
+ const host = normalizeMachineAddress(address);
447
+ if (!host)
448
+ continue;
449
+ add({ kind: "tailnet", host, lastSeenAt: tailnet.observedAt });
450
+ }
451
+ const dnsName = (tailnet.dnsName ?? "").trim().replace(/\.+$/, "");
452
+ if (dnsName)
453
+ add({ kind: "tailnet", host: dnsName, lastSeenAt: tailnet.observedAt });
454
+ }
455
+ const order = {
456
+ lan: 0,
457
+ tailnet: 1,
458
+ mesh: 2,
459
+ relay: 3,
460
+ // Last, not first: `127.0.0.1` only reaches the machine you are already on.
461
+ // A dozen peers each advertising `http://127.0.0.1:43120` made loopback the
462
+ // headline address for machines it cannot reach at all.
463
+ loopback: 4,
464
+ };
465
+ return routes
466
+ .filter((route) => isSelf || route.kind !== "loopback")
467
+ .sort((a, b) => order[a.kind] - order[b.kind]);
468
+ }
469
+ /** Anything seen inside this window counts as present. */
470
+ export const MACHINE_ONLINE_WINDOW_MS = 2 * 60_000;
471
+ export const MACHINE_RECENT_WINDOW_MS = 60 * 60_000;
472
+ export function machineLabel(machine) {
473
+ return machine.displayName?.trim() || machine.name;
474
+ }
475
+ export function machinePresence(machine, now = Date.now()) {
476
+ const age = now - machine.lastSeenAt;
477
+ if (age <= MACHINE_ONLINE_WINDOW_MS)
478
+ return "online";
479
+ if (age <= MACHINE_RECENT_WINDOW_MS)
480
+ return "recent";
481
+ return "offline";
482
+ }
483
+ /**
484
+ * Build (or rebuild) a record from a group. `previous` carries the parts an
485
+ * operator owns — id, chosen name, notes, pin, first-seen — which no amount of
486
+ * fresh evidence may overwrite.
487
+ */
488
+ export function buildMachineRecord(group, previous) {
489
+ const evidence = [...group.evidence].sort((a, b) => a.observedAt - b.observedAt);
490
+ const observedAt = evidence.reduce((latest, item) => Math.max(latest, item.observedAt), 0);
491
+ const scout = evidenceOf(evidence, "scout")[0];
492
+ const tailnet = evidenceOf(evidence, "tailnet")
493
+ .sort((a, b) => Number(b.online) - Number(a.online))[0];
494
+ const hostNames = [];
495
+ const addresses = [];
496
+ const macAddresses = [];
497
+ for (const key of group.identityKeys) {
498
+ const [prefix, ...rest] = key.split(":");
499
+ const value = rest.join(":");
500
+ if (prefix === "host")
501
+ pushKey(hostNames, value);
502
+ else if (prefix === "ip")
503
+ pushKey(addresses, value);
504
+ else if (prefix === "mac")
505
+ pushKey(macAddresses, value);
506
+ }
507
+ for (const item of evidence) {
508
+ if (item.kind === "lan" || item.kind === "tailnet") {
509
+ for (const address of item.addresses) {
510
+ const normalized = normalizeMachineAddress(address);
511
+ if (normalized && !isLoopbackAddress(normalized))
512
+ pushKey(addresses, normalized);
513
+ }
514
+ }
515
+ }
516
+ const isSelf = evidence.some((item) => ((item.kind === "scout" || item.kind === "tailnet") && item.isSelf === true));
517
+ return {
518
+ id: previous?.id ?? stableMachineId(group.identityKeys),
519
+ displayName: previous?.displayName ?? null,
520
+ name: deriveMachineName(evidence),
521
+ platform: deriveMachinePlatform(evidence),
522
+ identityKeys: group.identityKeys,
523
+ isSelf,
524
+ ...(scout?.nodeId ? { scoutNodeId: scout.nodeId } : {}),
525
+ ...(scout?.meshId ? { meshId: scout.meshId } : {}),
526
+ ...(tailnet?.peerId ? { tailnetId: tailnet.peerId } : {}),
527
+ ...(tailnet?.tailnetName ?? scout?.tailnetName
528
+ ? { tailnetName: tailnet?.tailnetName ?? scout?.tailnetName }
529
+ : {}),
530
+ hostNames,
531
+ addresses,
532
+ macAddresses,
533
+ capabilities: deriveMachineCapabilities(evidence),
534
+ routes: deriveMachineRoutes(evidence, isSelf),
535
+ evidence,
536
+ pinned: previous?.pinned ?? false,
537
+ ...(previous?.notes ? { notes: previous.notes } : {}),
538
+ firstSeenAt: previous?.firstSeenAt ?? observedAt,
539
+ lastSeenAt: Math.max(observedAt, previous?.lastSeenAt ?? 0),
540
+ ...(previous?.metadata ? { metadata: previous.metadata } : {}),
541
+ };
542
+ }
543
+ /** Sort for display: this machine, then present ones, then by name. */
544
+ export function compareMachines(a, b) {
545
+ if (a.isSelf !== b.isSelf)
546
+ return a.isSelf ? -1 : 1;
547
+ if (a.lastSeenAt !== b.lastSeenAt)
548
+ return b.lastSeenAt - a.lastSeenAt;
549
+ return machineLabel(a).localeCompare(machineLabel(b));
550
+ }
@@ -1,6 +1,6 @@
1
1
  export declare const SCOUT_RUNTIME_CATALOG_DATA: {
2
2
  readonly schemaVersion: "openscout.runtime-catalog.v1";
3
- readonly revision: "2026-09-05.1";
3
+ readonly revision: "2026-09-10.1";
4
4
  readonly harnesses: readonly [{
5
5
  readonly id: "claude";
6
6
  readonly label: "Claude Code";
@@ -107,7 +107,7 @@ export declare const SCOUT_RUNTIME_CATALOG_DATA: {
107
107
  readonly enabled: true;
108
108
  readonly listed: false;
109
109
  readonly reasoningEfforts: readonly ["low", "medium", "high", "xhigh"];
110
- readonly defaultReasoningEffort: "medium";
110
+ readonly defaultReasoningEffort: "high";
111
111
  readonly models: readonly [{
112
112
  readonly id: "grok-4.6";
113
113
  readonly label: "Grok 4.6";
@@ -122,18 +122,14 @@ export declare const SCOUT_RUNTIME_CATALOG_DATA: {
122
122
  readonly enabled: true;
123
123
  readonly family: "Grok";
124
124
  readonly version: "4.5";
125
- }, {
126
- readonly id: "grok-4.3";
127
- readonly label: "Grok 4.3";
128
- readonly enabled: true;
129
- readonly family: "Grok";
130
- readonly version: "4.3";
125
+ readonly contextWindowTokens: 500000;
131
126
  }];
132
127
  }, {
133
128
  readonly id: "grok-acp";
134
129
  readonly label: "Grok";
135
130
  readonly enabled: true;
136
- readonly reasoningEfforts: null;
131
+ readonly reasoningEfforts: readonly ["low", "medium", "high", "xhigh"];
132
+ readonly defaultReasoningEffort: "high";
137
133
  readonly models: readonly [{
138
134
  readonly id: "grok-4.6";
139
135
  readonly label: "Grok 4.6";
@@ -148,12 +144,7 @@ export declare const SCOUT_RUNTIME_CATALOG_DATA: {
148
144
  readonly enabled: true;
149
145
  readonly family: "Grok";
150
146
  readonly version: "4.5";
151
- }, {
152
- readonly id: "grok-4.3";
153
- readonly label: "Grok 4.3";
154
- readonly enabled: true;
155
- readonly family: "Grok";
156
- readonly version: "4.3";
147
+ readonly contextWindowTokens: 500000;
157
148
  }];
158
149
  }, {
159
150
  readonly id: "kimi";
@@ -1 +1 @@
1
- {"version":3,"file":"runtime-catalog.generated.d.ts","sourceRoot":"","sources":["../src/runtime-catalog.generated.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgU7B,CAAC"}
1
+ {"version":3,"file":"runtime-catalog.generated.d.ts","sourceRoot":"","sources":["../src/runtime-catalog.generated.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0T7B,CAAC"}
@@ -1,7 +1,7 @@
1
1
  // Generated automatically from runtime-catalog.v1.json. Do not edit.
2
2
  export const SCOUT_RUNTIME_CATALOG_DATA = {
3
3
  "schemaVersion": "openscout.runtime-catalog.v1",
4
- "revision": "2026-09-05.1",
4
+ "revision": "2026-09-10.1",
5
5
  "harnesses": [
6
6
  {
7
7
  "id": "claude",
@@ -160,7 +160,7 @@ export const SCOUT_RUNTIME_CATALOG_DATA = {
160
160
  "high",
161
161
  "xhigh"
162
162
  ],
163
- "defaultReasoningEffort": "medium",
163
+ "defaultReasoningEffort": "high",
164
164
  "models": [
165
165
  {
166
166
  "id": "grok-4.6",
@@ -176,14 +176,8 @@ export const SCOUT_RUNTIME_CATALOG_DATA = {
176
176
  "label": "Grok 4.5",
177
177
  "enabled": true,
178
178
  "family": "Grok",
179
- "version": "4.5"
180
- },
181
- {
182
- "id": "grok-4.3",
183
- "label": "Grok 4.3",
184
- "enabled": true,
185
- "family": "Grok",
186
- "version": "4.3"
179
+ "version": "4.5",
180
+ "contextWindowTokens": 500000
187
181
  }
188
182
  ]
189
183
  },
@@ -191,7 +185,13 @@ export const SCOUT_RUNTIME_CATALOG_DATA = {
191
185
  "id": "grok-acp",
192
186
  "label": "Grok",
193
187
  "enabled": true,
194
- "reasoningEfforts": null,
188
+ "reasoningEfforts": [
189
+ "low",
190
+ "medium",
191
+ "high",
192
+ "xhigh"
193
+ ],
194
+ "defaultReasoningEffort": "high",
195
195
  "models": [
196
196
  {
197
197
  "id": "grok-4.6",
@@ -207,14 +207,8 @@ export const SCOUT_RUNTIME_CATALOG_DATA = {
207
207
  "label": "Grok 4.5",
208
208
  "enabled": true,
209
209
  "family": "Grok",
210
- "version": "4.5"
211
- },
212
- {
213
- "id": "grok-4.3",
214
- "label": "Grok 4.3",
215
- "enabled": true,
216
- "family": "Grok",
217
- "version": "4.3"
210
+ "version": "4.5",
211
+ "contextWindowTokens": 500000
218
212
  }
219
213
  ]
220
214
  },
@@ -45,6 +45,7 @@ export type ScoutOperatorSignal = {
45
45
  replyExpectation: "required";
46
46
  /** What the agent is asking for. Never empty — that is the whole point. */
47
47
  question: string;
48
+ requestKind?: "permission";
48
49
  /** Optional discrete choices, when the answer is a selection. */
49
50
  options?: string[];
50
51
  /** Why the agent cannot continue without it. */