talon-agent 1.34.1 → 1.36.0

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,1157 @@
1
+ /**
2
+ * MeshService — the daemon-wide device-mesh facade.
3
+ *
4
+ * One instance serves every consumer in the process:
5
+ *
6
+ * - Transports (the native bridge server) feed it registrations, location
7
+ * reports, and command results, and plug in a MeshTransport so locate
8
+ * requests and device commands reach connected companion apps. The
9
+ * service never imports a transport — dependencies point inward.
10
+ * - The model reads and drives it through the shared mesh gateway actions
11
+ * (list_devices, get_device_location, get_device_history, ring_device,
12
+ * get_device_status), so full mesh access works identically from
13
+ * Telegram, Discord, Teams, terminal, and native chats — the mesh is
14
+ * daemon state, not a native-frontend feature.
15
+ *
16
+ * Two request/response flows ride the same SSE-out / HTTP-POST-back loop:
17
+ *
18
+ * locate → `locate` event → device POSTs /location (legacy,
19
+ * kept verbatim so pre-command app builds keep working)
20
+ * command → `device_command` → device POSTs /devices/command-result with
21
+ * the command's correlation id; sendCommand resolves the
22
+ * pending promise or times out.
23
+ *
24
+ * With no transport registered (daemon running without the native bridge),
25
+ * everything degrades gracefully: locate answers from the last persisted
26
+ * fix immediately, and commands fail fast with a clear explanation.
27
+ */
28
+
29
+ import { randomUUID } from "node:crypto";
30
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
31
+ import { tmpdir } from "node:os";
32
+ import { basename, dirname, join, resolve } from "node:path";
33
+ import type { Readable } from "node:stream";
34
+ import { dirs } from "../../util/paths.js";
35
+ import { MeshRegistry } from "./registry.js";
36
+ import { TransferStore } from "./transfers.js";
37
+ import type {
38
+ DeviceCommand,
39
+ DeviceCommandResult,
40
+ DeviceInfo,
41
+ DeviceLocation,
42
+ } from "./types.js";
43
+
44
+ /** How a transport pushes mesh traffic to connected companion devices. */
45
+ export type MeshTransport = {
46
+ /** Ask one device (or all, when undefined) for a fresh location fix. */
47
+ locate(deviceId?: string): void;
48
+ /** Deliver an on-demand command to its target device. */
49
+ command(command: DeviceCommand): void;
50
+ };
51
+
52
+ export type MeshToolResult = { ok: boolean; text: string };
53
+
54
+ export type MeshServiceOptions = {
55
+ /** How long a locate waits for a fresh fix before last-known fallback. */
56
+ freshFixTimeoutMs?: number;
57
+ /** Upper bound between staleness re-checks while waiting. */
58
+ pollIntervalMs?: number;
59
+ /** How long a device command waits for its result before timing out. */
60
+ commandTimeoutMs?: number;
61
+ };
62
+
63
+ const DEFAULT_FRESH_FIX_TIMEOUT_MS = 8_000;
64
+ const DEFAULT_POLL_INTERVAL_MS = 1_000;
65
+ const DEFAULT_COMMAND_TIMEOUT_MS = 12_000;
66
+ const MOBILE_PLATFORMS = new Set(["android", "ios"]);
67
+ const DEFAULT_HISTORY_HOURS = 24;
68
+ const MAX_HISTORY_LINES = 24;
69
+
70
+ // ── Exec / filesystem channel ───────────────────────────────────────────────
71
+ /** Default wall-clock budget for a remote shell command. */
72
+ const DEFAULT_EXEC_TIMEOUT_MS = 60_000;
73
+ /** Hard ceiling on a caller-requested exec timeout. */
74
+ const MAX_EXEC_TIMEOUT_MS = 300_000;
75
+ /** Timeout for a single filesystem command (list/stat/one chunk/etc.). */
76
+ const FS_COMMAND_TIMEOUT_MS = 30_000;
77
+ /**
78
+ * Bytes of file payload per FALLBACK transfer chunk (base64 on the wire).
79
+ * The chunked command channel costs one full mesh round trip per chunk, so
80
+ * it's only used for app builds that don't advertise the streaming commands
81
+ * (`upload_file`/`download_file`) — modern builds move file bodies as a
82
+ * single raw HTTP stream instead (see TransferStore). 1MB keeps even the
83
+ * fallback tolerable without bloating a single SSE frame too far.
84
+ */
85
+ const FILE_CHUNK_BYTES = 1024 * 1024;
86
+ /** Wall-clock budget for one streamed transfer (command dispatch → done). */
87
+ const STREAM_TRANSFER_TIMEOUT_MS = 60 * 60 * 1000;
88
+ /** readFileBytes switches to the streaming path above this size. */
89
+ const STREAM_READ_THRESHOLD_BYTES = 4 * 1024 * 1024;
90
+ /**
91
+ * No policy size cap on transfers — a transfer is attempted whatever the
92
+ * size and fails with a concrete error when a real limit bites (device read
93
+ * error, stream timeout, disk). The streamed paths are disk-to-disk and
94
+ * never hold the file in daemon memory; only the chunked FALLBACK and
95
+ * readFileBytes (whose callers need a Buffer) are memory-bound.
96
+ */
97
+ /**
98
+ * Where pulled device files land on the daemon host when no dest is given.
99
+ * Resolved lazily (not at module load) so a test that mocks `util/paths`
100
+ * doesn't hit its workspace binding before initialization.
101
+ */
102
+ function pullDir(): string {
103
+ return resolve(dirs.workspace, "mesh-pull");
104
+ }
105
+
106
+ export class MeshService {
107
+ private readonly waiters = new Set<() => void>();
108
+ private readonly transports = new Set<MeshTransport>();
109
+ private readonly pendingCommands = new Map<
110
+ string,
111
+ (result: DeviceCommandResult) => void
112
+ >();
113
+ /**
114
+ * Server-side receipt time (this process's clock) of the last fix per
115
+ * device. Freshness is judged against THIS, never the device-supplied
116
+ * `loc.ts` — a companion whose clock runs behind would otherwise have
117
+ * genuinely fresh fixes rejected (every locate burning the full timeout),
118
+ * and one running ahead would have stale fixes accepted as fresh.
119
+ */
120
+ private readonly receivedAt = new Map<string, number>();
121
+ /** One-time tokens arranging streamed (single-HTTP-request) transfers. */
122
+ private readonly transfers = new TransferStore();
123
+ private readonly freshFixTimeoutMs: number;
124
+ private readonly pollIntervalMs: number;
125
+ private readonly commandTimeoutMs: number;
126
+ private loading: Promise<void> | null = null;
127
+
128
+ constructor(
129
+ private readonly registry = new MeshRegistry(),
130
+ options: MeshServiceOptions = {},
131
+ ) {
132
+ this.freshFixTimeoutMs =
133
+ options.freshFixTimeoutMs ?? DEFAULT_FRESH_FIX_TIMEOUT_MS;
134
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
135
+ this.commandTimeoutMs =
136
+ options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
137
+ }
138
+
139
+ /** Hydrate persisted devices/locations. Idempotent — safe to await from
140
+ * every entry point; the first caller does the read, the rest share it. */
141
+ load(): Promise<void> {
142
+ this.loading ??= this.registry.load();
143
+ return this.loading;
144
+ }
145
+
146
+ /**
147
+ * Plug a transport in. Returns an unsubscribe so the transport detaches
148
+ * cleanly on shutdown (no stale broadcasts into a stopped server).
149
+ */
150
+ registerTransport(transport: MeshTransport): () => void {
151
+ this.transports.add(transport);
152
+ return () => this.transports.delete(transport);
153
+ }
154
+
155
+ /** Fan a locate request out to every transport. True when at least one
156
+ * transport is attached (i.e. waiting for a fresh fix can pay off). */
157
+ requestLocate(deviceId?: string): boolean {
158
+ for (const transport of this.transports) {
159
+ try {
160
+ transport.locate(deviceId);
161
+ } catch {
162
+ // One broken transport must not stop the others.
163
+ }
164
+ }
165
+ return this.transports.size > 0;
166
+ }
167
+
168
+ async register(
169
+ body: Record<string, unknown>,
170
+ now?: number,
171
+ ): Promise<DeviceInfo> {
172
+ await this.load();
173
+ return this.registry.register(body, now);
174
+ }
175
+
176
+ /** Store a reported fix and wake anyone waiting on a fresh location. */
177
+ async storeLocation(body: Record<string, unknown>): Promise<DeviceLocation> {
178
+ await this.load();
179
+ const loc = await this.registry.storeLocation(body);
180
+ // Stamp arrival against our own clock so waitForFreshLocation is immune
181
+ // to device clock skew.
182
+ this.receivedAt.set(loc.deviceId, Date.now());
183
+ for (const notify of [...this.waiters]) notify();
184
+ return loc;
185
+ }
186
+
187
+ async list(): Promise<{
188
+ devices: DeviceInfo[];
189
+ locations: DeviceLocation[];
190
+ }> {
191
+ await this.load();
192
+ return this.registry.list();
193
+ }
194
+
195
+ async getLocation(deviceId: string): Promise<DeviceLocation | undefined> {
196
+ await this.load();
197
+ return this.registry.getLocation(deviceId);
198
+ }
199
+
200
+ // ── Command channel ────────────────────────────────────────────────────────
201
+
202
+ /**
203
+ * A device answered a command (bridge route POST /devices/command-result).
204
+ * Resolves the pending sendCommand; false when nothing was waiting (late
205
+ * or unknown correlation id — harmless, just ignored).
206
+ */
207
+ completeCommand(body: Record<string, unknown>): boolean {
208
+ const commandId = typeof body.commandId === "string" ? body.commandId : "";
209
+ const resolve = this.pendingCommands.get(commandId);
210
+ if (!resolve) return false;
211
+ this.pendingCommands.delete(commandId);
212
+ resolve({
213
+ commandId,
214
+ deviceId: typeof body.deviceId === "string" ? body.deviceId : "",
215
+ ok: body.ok === true,
216
+ ...(typeof body.message === "string" && body.message.trim()
217
+ ? { message: body.message.trim().slice(0, 2_000) }
218
+ : {}),
219
+ ...(body.data &&
220
+ typeof body.data === "object" &&
221
+ !Array.isArray(body.data)
222
+ ? { data: body.data as Record<string, unknown> }
223
+ : {}),
224
+ });
225
+ return true;
226
+ }
227
+
228
+ /**
229
+ * Push one command to a device and await its result (or time out). The
230
+ * low-level primitive under every command tool; exposed for tests and
231
+ * future tools.
232
+ */
233
+ sendCommand(
234
+ device: DeviceInfo,
235
+ name: string,
236
+ params: Record<string, unknown> = {},
237
+ timeoutMs = this.commandTimeoutMs,
238
+ ): Promise<DeviceCommandResult> {
239
+ const command: DeviceCommand = {
240
+ id: randomUUID(),
241
+ deviceId: device.id,
242
+ name,
243
+ params,
244
+ };
245
+ return new Promise<DeviceCommandResult>((resolve) => {
246
+ const timer = setTimeout(() => {
247
+ this.pendingCommands.delete(command.id);
248
+ resolve({
249
+ commandId: command.id,
250
+ deviceId: device.id,
251
+ ok: false,
252
+ message: `${device.name} did not answer within ${Math.round(timeoutMs / 1000)}s (device ${device.online ? "was online" : "appears offline"}).`,
253
+ });
254
+ }, timeoutMs);
255
+ timer.unref?.();
256
+ this.pendingCommands.set(command.id, (result) => {
257
+ clearTimeout(timer);
258
+ resolve(result);
259
+ });
260
+ for (const transport of this.transports) {
261
+ try {
262
+ transport.command(command);
263
+ } catch {
264
+ // One broken transport must not stop the others.
265
+ }
266
+ }
267
+ });
268
+ }
269
+
270
+ // ── Model-facing tool surface ──────────────────────────────────────────────
271
+
272
+ /** `list_devices`: every mesh device with presence, battery, last-known
273
+ * position, and capabilities — the model's full view of the mesh. */
274
+ async describeDevices(): Promise<MeshToolResult> {
275
+ const { devices } = await this.list();
276
+ if (devices.length === 0) {
277
+ return { ok: true, text: "No mesh devices have registered yet." };
278
+ }
279
+ return {
280
+ ok: true,
281
+ text: devices.map((d) => this.deviceLine(d)).join("\n"),
282
+ };
283
+ }
284
+
285
+ /**
286
+ * `get_device_location`: resolve the target (id, name fragment, or the
287
+ * most recent mobile device), push a locate to connected transports, wait
288
+ * briefly for a fresh fix, then fall back to the last persisted location.
289
+ */
290
+ async locateDevice(query?: unknown): Promise<MeshToolResult> {
291
+ await this.load();
292
+ const target = this.chooseDevice(query);
293
+ if (!target) return this.noSuchDevice(query);
294
+ const requestedAt = Date.now();
295
+ // Only wait out the fresh-fix window when a transport can actually
296
+ // deliver the locate AND the device is currently present — pinging an
297
+ // offline device just burns the whole timeout for a fix that won't come,
298
+ // so answer from persistence immediately in that case.
299
+ const dispatched = this.requestLocate(target.id);
300
+ const fresh =
301
+ dispatched && target.online
302
+ ? await this.waitForFreshLocation(target.id, requestedAt)
303
+ : undefined;
304
+ const loc = fresh ?? this.registry.getLocation(target.id);
305
+ if (!loc) {
306
+ return {
307
+ ok: false,
308
+ text: !dispatched
309
+ ? `No location is known for ${target.name}, and no companion transport is connected to request one.`
310
+ : target.online
311
+ ? `No location is known for ${target.name}. A locate request was sent, but no fix arrived within ${Math.round(this.freshFixTimeoutMs / 1000)}s.`
312
+ : `No location is known for ${target.name}, and it appears offline (last seen ${age(Date.now() - target.lastSeen)}).`,
313
+ };
314
+ }
315
+ return { ok: true, text: this.locationSummary(target, loc) };
316
+ }
317
+
318
+ /** `ring_device`: make the device sound/vibrate so it can be found. */
319
+ ringDevice(query?: unknown, message?: unknown): Promise<MeshToolResult> {
320
+ return this.commandTool(query, "ring", {
321
+ ...(typeof message === "string" && message.trim()
322
+ ? { message: message.trim().slice(0, 200) }
323
+ : {}),
324
+ });
325
+ }
326
+
327
+ /**
328
+ * `get_device_history`: the device's movement + battery over a window,
329
+ * computed from the fixes the daemon has been receiving all along —
330
+ * timeline, distance traveled, and battery trend.
331
+ */
332
+ async deviceHistory(
333
+ query?: unknown,
334
+ hours?: unknown,
335
+ ): Promise<MeshToolResult> {
336
+ await this.load();
337
+ const target = this.chooseDevice(query);
338
+ if (!target) return this.noSuchDevice(query);
339
+ const windowHours = clampHours(hours);
340
+ const sinceTs = Date.now() - windowHours * 3_600_000;
341
+ const fixes = this.registry.getHistory(target.id, sinceTs);
342
+ if (fixes.length === 0) {
343
+ return {
344
+ ok: true,
345
+ text: `No location reports from ${target.name} in the last ${windowHours}h. Enable periodic reporting in the companion app for a movement history.`,
346
+ };
347
+ }
348
+ return { ok: true, text: this.historySummary(target, fixes, windowHours) };
349
+ }
350
+
351
+ /** `get_device_status`: live telemetry straight from the device. */
352
+ getDeviceStatus(query?: unknown): Promise<MeshToolResult> {
353
+ return this.commandTool(query, "status", {});
354
+ }
355
+
356
+ // ── Exec + filesystem tools (teleport substrate) ───────────────────────────
357
+
358
+ /**
359
+ * Run a shell command on a device and return its stdout/stderr/exit code.
360
+ * The primitive under `device_exec` and the teleported native `bash` tool.
361
+ */
362
+ async execOnDevice(
363
+ query: unknown,
364
+ cmd: unknown,
365
+ cwd?: unknown,
366
+ timeoutSec?: unknown,
367
+ ): Promise<MeshToolResult> {
368
+ const command = typeof cmd === "string" ? cmd : "";
369
+ if (!command.trim()) {
370
+ return { ok: false, text: "No command given to run on the device." };
371
+ }
372
+ const budgetMs = clampExecTimeout(timeoutSec);
373
+ const dispatched = await this.dispatchCommand(
374
+ query,
375
+ "exec",
376
+ {
377
+ cmd: command,
378
+ ...(typeof cwd === "string" && cwd.trim() ? { cwd: cwd.trim() } : {}),
379
+ timeoutMs: budgetMs,
380
+ },
381
+ budgetMs + 5_000, // let the device's own timeout fire first
382
+ );
383
+ if ("error" in dispatched) return { ok: false, text: dispatched.error };
384
+ return {
385
+ ok: dispatched.result.ok,
386
+ text: formatExecResult(dispatched.target, dispatched.result),
387
+ };
388
+ }
389
+
390
+ /** `device_list_dir`: list a directory on the device. */
391
+ async listDirOnDevice(
392
+ query: unknown,
393
+ path: unknown,
394
+ ): Promise<MeshToolResult> {
395
+ const dir = requirePath(path);
396
+ if (!dir) return { ok: false, text: "A directory path is required." };
397
+ const dispatched = await this.dispatchCommand(
398
+ query,
399
+ "list_dir",
400
+ { path: dir },
401
+ FS_COMMAND_TIMEOUT_MS,
402
+ );
403
+ if ("error" in dispatched) return { ok: false, text: dispatched.error };
404
+ const { target, result } = dispatched;
405
+ if (!result.ok) {
406
+ return { ok: false, text: result.message ?? `Could not list ${dir}.` };
407
+ }
408
+ const entries = Array.isArray(result.data?.entries)
409
+ ? (result.data!.entries as Array<Record<string, unknown>>)
410
+ : [];
411
+ if (entries.length === 0) {
412
+ return { ok: true, text: `${dir} on ${target.name} is empty.` };
413
+ }
414
+ const lines = entries.map((e) => {
415
+ const name = String(e.name ?? "?");
416
+ const type = e.type === "dir" ? "/" : "";
417
+ const size =
418
+ typeof e.size === "number" && e.type !== "dir"
419
+ ? ` (${formatBytes(e.size)})`
420
+ : "";
421
+ return `- ${name}${type}${size}`;
422
+ });
423
+ return {
424
+ ok: true,
425
+ text: `${dir} on ${target.name} — ${entries.length} item(s):\n${lines.join("\n")}`,
426
+ };
427
+ }
428
+
429
+ /** `device_stat`: metadata for one path on the device. */
430
+ async statOnDevice(query: unknown, path: unknown): Promise<MeshToolResult> {
431
+ const p = requirePath(path);
432
+ if (!p) return { ok: false, text: "A path is required." };
433
+ const dispatched = await this.dispatchCommand(
434
+ query,
435
+ "stat",
436
+ { path: p },
437
+ FS_COMMAND_TIMEOUT_MS,
438
+ );
439
+ if ("error" in dispatched) return { ok: false, text: dispatched.error };
440
+ const { result } = dispatched;
441
+ if (!result.ok) {
442
+ return { ok: false, text: result.message ?? `Cannot stat ${p}.` };
443
+ }
444
+ const d = result.data ?? {};
445
+ const fields = Object.entries(d)
446
+ .filter(([, v]) => v !== null && v !== undefined && v !== "")
447
+ .map(([k, v]) => `${k}: ${String(v)}`);
448
+ return { ok: true, text: `${p} — ${fields.join(" · ")}` };
449
+ }
450
+
451
+ // ── Streaming transfer bridge surface ─────────────────────────────────────
452
+ // The HTTP routes on the native bridge delegate here; the token is the
453
+ // entire authorization (single-use, device- and path-bound).
454
+
455
+ /** POST /devices/file — a device streams a pull's file body up. */
456
+ acceptFileUpload(
457
+ token: string,
458
+ body: Readable,
459
+ ): Promise<{ ok: true; bytes: number } | { ok: false; error: string }> {
460
+ return this.transfers.acceptUpload(token, body);
461
+ }
462
+
463
+ /** GET /devices/file — a device asks for a push's file body. */
464
+ openFileDownload(
465
+ token: string,
466
+ ): Promise<{ path: string; size: number } | null> {
467
+ return this.transfers.openDownload(token);
468
+ }
469
+
470
+ /** Streaming is per-command capability — old app builds fall back. */
471
+ private canStream(
472
+ target: DeviceInfo,
473
+ command: "upload_file" | "download_file",
474
+ ): boolean {
475
+ return target.capabilities?.includes(command) ?? false;
476
+ }
477
+
478
+ /**
479
+ * Streamed device→daemon transfer: one command round trip to arrange it,
480
+ * then the file body arrives as a single raw HTTP request, written
481
+ * atomically to `dest`. Resolves with the byte count.
482
+ */
483
+ private async pullViaStream(
484
+ target: DeviceInfo,
485
+ remote: string,
486
+ dest: string,
487
+ ): Promise<{ bytes: number } | { error: string }> {
488
+ const { token, done } = this.transfers.createPull(target.id, dest);
489
+ const dispatched = await this.dispatchCommand(
490
+ target.id,
491
+ "upload_file",
492
+ { token, path: remote },
493
+ STREAM_TRANSFER_TIMEOUT_MS,
494
+ );
495
+ if ("error" in dispatched) {
496
+ this.transfers.cancel(token);
497
+ return { error: dispatched.error };
498
+ }
499
+ if (!dispatched.result.ok) {
500
+ this.transfers.cancel(token);
501
+ return {
502
+ error:
503
+ dispatched.result.message ??
504
+ `${target.name} could not upload ${remote}.`,
505
+ };
506
+ }
507
+ // The device answers the command AFTER its upload completes, so `done`
508
+ // is normally already resolved — the grace window only catches a device
509
+ // that claims success without having streamed anything.
510
+ try {
511
+ const bytes = await Promise.race([
512
+ done,
513
+ new Promise<never>((_, rej) =>
514
+ setTimeout(
515
+ () =>
516
+ rej(
517
+ new Error(
518
+ `${target.name} reported success but no upload arrived.`,
519
+ ),
520
+ ),
521
+ 15_000,
522
+ ).unref?.(),
523
+ ),
524
+ ]);
525
+ return { bytes };
526
+ } catch (err) {
527
+ this.transfers.cancel(token);
528
+ return { error: (err as Error).message };
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Raw file bytes off a device — the structured primitive under both the
534
+ * human-readable tool below and the native read/edit path (which must not
535
+ * have to parse a display envelope to recover the content).
536
+ *
537
+ * Small files ride the chunked command channel (one round trip). Files
538
+ * over the streaming threshold are pulled via the streaming path into a
539
+ * temp file first — the chunked channel pays a full mesh round trip per
540
+ * chunk and is far too slow for big payloads.
541
+ */
542
+ async readFileBytes(
543
+ query: unknown,
544
+ path: unknown,
545
+ ): Promise<{ data: Buffer; deviceName: string } | { error: string }> {
546
+ const p = requirePath(path);
547
+ if (!p) return { error: "A file path is required." };
548
+ await this.load();
549
+ const target = this.chooseDevice(query);
550
+ if (target && this.canStream(target, "upload_file")) {
551
+ const size = await this.statSize(target.id, p);
552
+ if (size !== undefined && size > STREAM_READ_THRESHOLD_BYTES) {
553
+ const tmp = join(tmpdir(), `talon-pull-${randomUUID()}-${basename(p)}`);
554
+ const pulled = await this.pullViaStream(target, p, tmp);
555
+ if ("error" in pulled) return { error: pulled.error };
556
+ try {
557
+ const data = await readFile(tmp);
558
+ return { data, deviceName: target.name };
559
+ } catch (err) {
560
+ return {
561
+ error: `Pulled ${p} but could not read the temp copy: ${(err as Error).message}`,
562
+ };
563
+ } finally {
564
+ await rm(tmp, { force: true }).catch(() => {});
565
+ }
566
+ }
567
+ }
568
+ return this.pullBytes(query, p);
569
+ }
570
+
571
+ /** Size of a device path via the `stat` command, if the device can. */
572
+ private async statSize(
573
+ deviceId: string,
574
+ path: string,
575
+ ): Promise<number | undefined> {
576
+ const dispatched = await this.dispatchCommand(
577
+ deviceId,
578
+ "stat",
579
+ { path },
580
+ FS_COMMAND_TIMEOUT_MS,
581
+ );
582
+ if ("error" in dispatched || !dispatched.result.ok) return undefined;
583
+ const size = dispatched.result.data?.size;
584
+ return typeof size === "number" ? size : undefined;
585
+ }
586
+
587
+ /** `device_read_file`: read a (text) file off the device, chunked. */
588
+ async readFileFromDevice(
589
+ query: unknown,
590
+ path: unknown,
591
+ ): Promise<MeshToolResult> {
592
+ const p = requirePath(path);
593
+ if (!p) return { ok: false, text: "A file path is required." };
594
+ const buf = await this.readFileBytes(query, p);
595
+ if ("error" in buf) return { ok: false, text: buf.error };
596
+ return {
597
+ ok: true,
598
+ text: `${p} on ${buf.deviceName} (${formatBytes(buf.data.length)}):\n\n${buf.data.toString("utf8")}`,
599
+ };
600
+ }
601
+
602
+ /** `device_write_file`: write text content to a file on the device. */
603
+ async writeFileToDevice(
604
+ query: unknown,
605
+ path: unknown,
606
+ content: unknown,
607
+ ): Promise<MeshToolResult> {
608
+ const p = requirePath(path);
609
+ if (!p) return { ok: false, text: "A file path is required." };
610
+ const text = typeof content === "string" ? content : "";
611
+ const written = await this.pushBytes(query, p, Buffer.from(text, "utf8"));
612
+ if ("error" in written) return { ok: false, text: written.error };
613
+ return {
614
+ ok: true,
615
+ text: `Wrote ${formatBytes(written.bytes)} to ${p} on ${written.deviceName}.`,
616
+ };
617
+ }
618
+
619
+ /** `device_pull_file`: copy a device file to the daemon host — streamed
620
+ * (single HTTP request, disk-to-disk) when the app supports it, chunked
621
+ * command-channel fallback otherwise. */
622
+ async pullFileFromDevice(
623
+ query: unknown,
624
+ remotePath: unknown,
625
+ localPath?: unknown,
626
+ ): Promise<MeshToolResult> {
627
+ const remote = requirePath(remotePath);
628
+ if (!remote) return { ok: false, text: "A remote file path is required." };
629
+ await this.load();
630
+ const target = this.chooseDevice(query);
631
+ if (!target) return this.noSuchDevice(query);
632
+ const dest =
633
+ typeof localPath === "string" && localPath.trim()
634
+ ? resolve(dirs.workspace, localPath.trim())
635
+ : resolve(
636
+ pullDir(),
637
+ `${target.name.replace(/\W+/g, "_")}-${basename(remote)}`,
638
+ );
639
+ if (this.canStream(target, "upload_file")) {
640
+ const started = Date.now();
641
+ const pulled = await this.pullViaStream(target, remote, dest);
642
+ if ("error" in pulled) return { ok: false, text: pulled.error };
643
+ return {
644
+ ok: true,
645
+ text: `Pulled ${formatBytes(pulled.bytes)} from ${remote} on ${target.name} → ${dest} (streamed, ${transferRate(pulled.bytes, started)})`,
646
+ };
647
+ }
648
+ const buf = await this.pullBytes(target.id, remote);
649
+ if ("error" in buf) return { ok: false, text: buf.error };
650
+ await mkdir(dirname(dest), { recursive: true });
651
+ await writeFile(dest, buf.data);
652
+ return {
653
+ ok: true,
654
+ text: `Pulled ${formatBytes(buf.data.length)} from ${remote} on ${buf.deviceName} → ${dest} (chunked fallback — update the companion app for streamed transfers)`,
655
+ };
656
+ }
657
+
658
+ /** `device_push_file`: copy a daemon-host file to the device — streamed
659
+ * when the app supports it (never buffers the file in daemon memory),
660
+ * chunked command-channel fallback otherwise. */
661
+ async pushFileToDevice(
662
+ query: unknown,
663
+ localPath: unknown,
664
+ remotePath: unknown,
665
+ ): Promise<MeshToolResult> {
666
+ const remote = requirePath(remotePath);
667
+ if (!remote)
668
+ return { ok: false, text: "A remote destination path is required." };
669
+ const local =
670
+ typeof localPath === "string" && localPath.trim()
671
+ ? resolve(dirs.workspace, localPath.trim())
672
+ : "";
673
+ if (!local) return { ok: false, text: "A local source path is required." };
674
+ await this.load();
675
+ const target = this.chooseDevice(query);
676
+ if (!target) return this.noSuchDevice(query);
677
+ if (this.canStream(target, "download_file")) {
678
+ const started = Date.now();
679
+ const { token } = this.transfers.createPush(target.id, local);
680
+ const dispatched = await this.dispatchCommand(
681
+ target.id,
682
+ "download_file",
683
+ { token, path: remote },
684
+ STREAM_TRANSFER_TIMEOUT_MS,
685
+ );
686
+ if ("error" in dispatched) {
687
+ this.transfers.cancel(token);
688
+ return { ok: false, text: dispatched.error };
689
+ }
690
+ if (!dispatched.result.ok) {
691
+ this.transfers.cancel(token);
692
+ return {
693
+ ok: false,
694
+ text:
695
+ dispatched.result.message ??
696
+ `${target.name} could not download ${local}.`,
697
+ };
698
+ }
699
+ const bytes = dispatched.result.data?.bytesWritten;
700
+ const size = typeof bytes === "number" ? bytes : 0;
701
+ return {
702
+ ok: true,
703
+ text: `Pushed ${formatBytes(size)} to ${remote} on ${target.name} (streamed, ${transferRate(size, started)})`,
704
+ };
705
+ }
706
+ let data: Buffer;
707
+ try {
708
+ data = await readFile(local);
709
+ } catch (err) {
710
+ // Includes Node's buffer-size ceiling (ERR_FS_FILE_TOO_LARGE) for
711
+ // files too big to hold in memory — surface the real reason.
712
+ return {
713
+ ok: false,
714
+ text: `Cannot read local file ${local}: ${(err as Error).message}`,
715
+ };
716
+ }
717
+ const written = await this.pushBytes(target.id, remote, data);
718
+ if ("error" in written) return { ok: false, text: written.error };
719
+ return {
720
+ ok: true,
721
+ text: `Pushed ${formatBytes(written.bytes)} to ${remote} on ${written.deviceName} (chunked fallback — update the companion app for streamed transfers).`,
722
+ };
723
+ }
724
+
725
+ /**
726
+ * Chunked read of a remote file into a Buffer. Loops `read_file` with
727
+ * increasing offsets until the device reports EOF.
728
+ */
729
+ private async pullBytes(
730
+ query: unknown,
731
+ path: string,
732
+ ): Promise<{ data: Buffer; deviceName: string } | { error: string }> {
733
+ const chunks: Buffer[] = [];
734
+ let offset = 0;
735
+ let deviceName = "device";
736
+ for (;;) {
737
+ const dispatched = await this.dispatchCommand(
738
+ query,
739
+ "read_file",
740
+ { path, offset, len: FILE_CHUNK_BYTES },
741
+ FS_COMMAND_TIMEOUT_MS,
742
+ );
743
+ if ("error" in dispatched) {
744
+ return {
745
+ error:
746
+ offset > 0
747
+ ? `${dispatched.error} (transfer of ${path} aborted after ${formatBytes(offset)})`
748
+ : dispatched.error,
749
+ };
750
+ }
751
+ deviceName = dispatched.target.name;
752
+ const { result } = dispatched;
753
+ if (!result.ok) {
754
+ const reason = result.message ?? `Could not read ${path}.`;
755
+ return {
756
+ error:
757
+ offset > 0
758
+ ? `${reason} (transfer aborted after ${formatBytes(offset)})`
759
+ : reason,
760
+ };
761
+ }
762
+ const b64 =
763
+ typeof result.data?.base64 === "string" ? result.data.base64 : "";
764
+ const chunk = Buffer.from(b64, "base64");
765
+ chunks.push(chunk);
766
+ offset += chunk.length;
767
+ // EOF when the device says so, or when it returns a short/empty chunk.
768
+ if (result.data?.eof === true || chunk.length < FILE_CHUNK_BYTES) break;
769
+ }
770
+ try {
771
+ return { data: Buffer.concat(chunks), deviceName };
772
+ } catch (err) {
773
+ // Node buffer ceiling / out of memory — the one real size limit left.
774
+ return {
775
+ error: `${path} transferred ${formatBytes(offset)} but is too large to assemble in daemon memory: ${(err as Error).message}`,
776
+ };
777
+ }
778
+ }
779
+
780
+ /**
781
+ * Chunked write of a Buffer to a remote file. The first chunk truncates the
782
+ * target; subsequent chunks append at their offset.
783
+ */
784
+ private async pushBytes(
785
+ query: unknown,
786
+ path: string,
787
+ data: Buffer,
788
+ ): Promise<{ bytes: number; deviceName: string } | { error: string }> {
789
+ let offset = 0;
790
+ let deviceName = "device";
791
+ // On a mid-transfer failure the device is left with a partial file —
792
+ // say so, with how far the transfer got, so the state isn't a mystery.
793
+ const partial = (reason: string): { error: string } => ({
794
+ error:
795
+ offset > 0
796
+ ? `${reason} (upload aborted — ${path} on the device is a ${formatBytes(offset)} partial write of ${formatBytes(data.length)})`
797
+ : reason,
798
+ });
799
+ // A zero-length write still needs one call to create/truncate the file.
800
+ do {
801
+ const chunk = data.subarray(offset, offset + FILE_CHUNK_BYTES);
802
+ const dispatched = await this.dispatchCommand(
803
+ query,
804
+ "write_file",
805
+ {
806
+ path,
807
+ base64: chunk.toString("base64"),
808
+ offset,
809
+ truncate: offset === 0,
810
+ },
811
+ FS_COMMAND_TIMEOUT_MS,
812
+ );
813
+ if ("error" in dispatched) return partial(dispatched.error);
814
+ deviceName = dispatched.target.name;
815
+ if (!dispatched.result.ok) {
816
+ return partial(dispatched.result.message ?? `Could not write ${path}.`);
817
+ }
818
+ offset += chunk.length;
819
+ } while (offset < data.length);
820
+ return { bytes: data.length, deviceName };
821
+ }
822
+
823
+ /**
824
+ * Shared command-tool flow: resolve the target, check its advertised
825
+ * capabilities, require a transport, send, and render the device's answer.
826
+ */
827
+ private async commandTool(
828
+ query: unknown,
829
+ name: string,
830
+ params: Record<string, unknown>,
831
+ ): Promise<MeshToolResult> {
832
+ const dispatched = await this.dispatchCommand(query, name, params);
833
+ if ("error" in dispatched) return { ok: false, text: dispatched.error };
834
+ return {
835
+ ok: dispatched.result.ok,
836
+ text: this.commandSummary(dispatched.target, name, dispatched.result),
837
+ };
838
+ }
839
+
840
+ /**
841
+ * Resolve a target, validate capability + reachability, then send the
842
+ * command and return the raw result — the shared primitive under both the
843
+ * human-summary command tools (ring/status) and the structured exec/fs
844
+ * tools that format the payload themselves. Returns `{ error }` for the
845
+ * pre-flight failures (no device, unsupported, offline, no transport).
846
+ */
847
+ async dispatchCommand(
848
+ query: unknown,
849
+ name: string,
850
+ params: Record<string, unknown>,
851
+ timeoutMs?: number,
852
+ ): Promise<
853
+ { target: DeviceInfo; result: DeviceCommandResult } | { error: string }
854
+ > {
855
+ await this.load();
856
+ const target = this.chooseDevice(query);
857
+ if (!target) return { error: this.noSuchDevice(query).text };
858
+ // Devices advertise what they can do; an explicit list that lacks the
859
+ // command is a clean "can't" — absent list means an older app build, so
860
+ // attempt it and let the timeout speak.
861
+ if (target.capabilities && !target.capabilities.includes(name)) {
862
+ return {
863
+ error: `${target.name} does not support "${name}" (supports: ${target.capabilities.join(", ")}).`,
864
+ };
865
+ }
866
+ if (this.transports.size === 0) {
867
+ return {
868
+ error: `No companion transport is connected, so "${name}" cannot reach ${target.name}.`,
869
+ };
870
+ }
871
+ // Don't wait out a full timeout for a device that's plainly gone.
872
+ if (!target.online) {
873
+ return {
874
+ error: `${target.name} appears offline (last seen ${age(Date.now() - target.lastSeen)}), so "${name}" was not sent.`,
875
+ };
876
+ }
877
+ const result = await this.sendCommand(target, name, params, timeoutMs);
878
+ return { target, result };
879
+ }
880
+
881
+ /** Resolve a device by exact id, name fragment, or default (most recently
882
+ * seen mobile device, falling back to the most recent device overall). */
883
+ chooseDevice(query?: unknown): DeviceInfo | undefined {
884
+ const devices = this.registry.list().devices;
885
+ if (typeof query === "string" && query.trim()) {
886
+ const q = query.trim().toLowerCase();
887
+ return devices.find(
888
+ (d) => d.id.toLowerCase() === q || d.name.toLowerCase().includes(q),
889
+ );
890
+ }
891
+ return devices.find((d) => MOBILE_PLATFORMS.has(d.platform)) ?? devices[0];
892
+ }
893
+
894
+ // ── Formatting ─────────────────────────────────────────────────────────────
895
+
896
+ private noSuchDevice(query: unknown): MeshToolResult {
897
+ const known = this.registry.list().devices;
898
+ return {
899
+ ok: false,
900
+ text:
901
+ typeof query === "string" && query.trim() && known.length > 0
902
+ ? `No mesh device matches "${query.trim()}". Known devices:\n${known.map((d) => this.deviceLine(d)).join("\n")}`
903
+ : "No mesh devices are registered.",
904
+ };
905
+ }
906
+
907
+ private deviceLine(device: DeviceInfo): string {
908
+ const parts = [
909
+ `${device.name} [id: ${device.id}] (${device.platform})`,
910
+ device.online ? "online" : "offline",
911
+ `last seen ${age(Date.now() - device.lastSeen)}`,
912
+ ];
913
+ if (typeof device.battery === "number") {
914
+ parts.push(`${device.battery}%${device.charging ? " charging" : ""}`);
915
+ }
916
+ const loc = this.registry.getLocation(device.id);
917
+ if (loc) {
918
+ parts.push(
919
+ `at ${loc.lat.toFixed(6)},${loc.lon.toFixed(6)} (fix ${age(Date.now() - loc.ts)})`,
920
+ );
921
+ }
922
+ if (device.capabilities?.length) {
923
+ parts.push(`can: ${device.capabilities.join(", ")}`);
924
+ }
925
+ return `- ${parts.join(" · ")}`;
926
+ }
927
+
928
+ private locationSummary(device: DeviceInfo, loc: DeviceLocation): string {
929
+ const ageText = age(Date.now() - loc.ts);
930
+ const accuracy =
931
+ typeof loc.accuracyM === "number"
932
+ ? ` Accuracy ${Math.round(loc.accuracyM)}m.`
933
+ : "";
934
+ return [
935
+ `${device.name} is at ${loc.lat.toFixed(6)}, ${loc.lon.toFixed(6)}.`,
936
+ `${accuracy} Fix age ${ageText}.`,
937
+ `Reverse-geocode pair: ${loc.lat},${loc.lon}`,
938
+ ]
939
+ .join(" ")
940
+ .replace(/\s+/g, " ")
941
+ .trim();
942
+ }
943
+
944
+ private commandSummary(
945
+ device: DeviceInfo,
946
+ name: string,
947
+ result: DeviceCommandResult,
948
+ ): string {
949
+ if (!result.ok) {
950
+ return result.message ?? `${device.name} rejected "${name}".`;
951
+ }
952
+ const detail = result.message ? ` ${result.message}` : "";
953
+ switch (name) {
954
+ case "ring":
955
+ return `${device.name} is ringing.${detail}`;
956
+ case "status": {
957
+ const fields = Object.entries(result.data ?? {})
958
+ .filter(([, v]) => v !== null && v !== undefined && v !== "")
959
+ .map(([k, v]) => `${k}: ${String(v)}`);
960
+ return fields.length
961
+ ? `${device.name} status — ${fields.join(" · ")}`
962
+ : `${device.name} answered but reported no status fields.${detail}`;
963
+ }
964
+ default:
965
+ return (
966
+ result.message ??
967
+ (result.data
968
+ ? `${device.name} answered: ${JSON.stringify(result.data)}`
969
+ : `${device.name} completed "${name}".`)
970
+ );
971
+ }
972
+ }
973
+
974
+ /** Render a window of fixes: headline (count, span, distance, battery
975
+ * trend) plus a bounded, evenly-sampled timeline oldest-first. */
976
+ private historySummary(
977
+ device: DeviceInfo,
978
+ fixes: DeviceLocation[],
979
+ windowHours: number,
980
+ ): string {
981
+ let distanceM = 0;
982
+ for (let i = 1; i < fixes.length; i++) {
983
+ distanceM += haversineM(fixes[i - 1], fixes[i]);
984
+ }
985
+ const batteries = fixes
986
+ .map((f) => f.batteryPct)
987
+ .filter((b): b is number => typeof b === "number");
988
+ const headline = [
989
+ `${device.name}: ${fixes.length} fix${fixes.length === 1 ? "" : "es"} in the last ${windowHours}h`,
990
+ `moved ~${formatDistance(distanceM)}`,
991
+ ...(batteries.length >= 2
992
+ ? [`battery ${batteries[0]}% → ${batteries[batteries.length - 1]}%`]
993
+ : []),
994
+ ].join(" · ");
995
+ const lines = sampleEvenly(fixes, MAX_HISTORY_LINES).map((f) => {
996
+ const parts = [
997
+ `${formatWhen(f.ts)} — ${f.lat.toFixed(5)},${f.lon.toFixed(5)}`,
998
+ ];
999
+ if (typeof f.accuracyM === "number")
1000
+ parts.push(`±${Math.round(f.accuracyM)}m`);
1001
+ if (typeof f.batteryPct === "number") parts.push(`${f.batteryPct}%`);
1002
+ return `- ${parts.join(" · ")}`;
1003
+ });
1004
+ const omitted = fixes.length - Math.min(fixes.length, MAX_HISTORY_LINES);
1005
+ return [
1006
+ headline,
1007
+ ...lines,
1008
+ ...(omitted > 0
1009
+ ? [`(${omitted} more fixes omitted; timeline sampled evenly)`]
1010
+ : []),
1011
+ ].join("\n");
1012
+ }
1013
+
1014
+ private async waitForFreshLocation(
1015
+ deviceId: string,
1016
+ requestedAt: number,
1017
+ ): Promise<DeviceLocation | undefined> {
1018
+ // Judge freshness by server-side arrival time, not the device-reported
1019
+ // `loc.ts` (which is subject to the companion's clock skew).
1020
+ if ((this.receivedAt.get(deviceId) ?? 0) >= requestedAt) {
1021
+ return this.registry.getLocation(deviceId);
1022
+ }
1023
+ const deadline = Date.now() + this.freshFixTimeoutMs;
1024
+ while (Date.now() < deadline) {
1025
+ const remaining = deadline - Date.now();
1026
+ await new Promise<void>((resolve) => {
1027
+ const done = () => {
1028
+ clearTimeout(timer);
1029
+ this.waiters.delete(done);
1030
+ resolve();
1031
+ };
1032
+ const timer = setTimeout(
1033
+ done,
1034
+ Math.min(remaining, this.pollIntervalMs),
1035
+ );
1036
+ this.waiters.add(done);
1037
+ });
1038
+ if ((this.receivedAt.get(deviceId) ?? 0) >= requestedAt) {
1039
+ return this.registry.getLocation(deviceId);
1040
+ }
1041
+ }
1042
+ return undefined;
1043
+ }
1044
+ }
1045
+
1046
+ /** Clamp the requested history window to 1..168 hours (default 24). */
1047
+ function clampHours(value: unknown): number {
1048
+ const n = typeof value === "number" ? value : Number(value);
1049
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_HISTORY_HOURS;
1050
+ return Math.min(168, Math.max(1, Math.round(n)));
1051
+ }
1052
+
1053
+ /** Great-circle distance between two fixes in meters. */
1054
+ function haversineM(a: DeviceLocation, b: DeviceLocation): number {
1055
+ const R = 6_371_000;
1056
+ const rad = (deg: number) => (deg * Math.PI) / 180;
1057
+ const dLat = rad(b.lat - a.lat);
1058
+ const dLon = rad(b.lon - a.lon);
1059
+ const h =
1060
+ Math.sin(dLat / 2) ** 2 +
1061
+ Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2;
1062
+ return 2 * R * Math.asin(Math.sqrt(h));
1063
+ }
1064
+
1065
+ function formatDistance(meters: number): string {
1066
+ if (meters < 1_000) return `${Math.round(meters)}m`;
1067
+ return `${(meters / 1_000).toFixed(1)}km`;
1068
+ }
1069
+
1070
+ /** Local wall-clock stamp for history lines (date + HH:MM). */
1071
+ function formatWhen(ts: number): string {
1072
+ const d = new Date(ts);
1073
+ const pad = (n: number) => String(n).padStart(2, "0");
1074
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
1075
+ }
1076
+
1077
+ /** Up to `max` items spread evenly across the list, endpoints included. */
1078
+ function sampleEvenly<T>(items: T[], max: number): T[] {
1079
+ if (items.length <= max) return items;
1080
+ const out: T[] = [];
1081
+ for (let i = 0; i < max; i++) {
1082
+ out.push(items[Math.round((i * (items.length - 1)) / (max - 1))]);
1083
+ }
1084
+ return out;
1085
+ }
1086
+
1087
+ function age(ms: number): string {
1088
+ const sec = Math.max(0, Math.round(ms / 1000));
1089
+ if (sec < 60) return `${sec}s ago`;
1090
+ const min = Math.round(sec / 60);
1091
+ if (min < 60) return `${min}m ago`;
1092
+ const hrs = Math.round(min / 60);
1093
+ return `${hrs}h ago`;
1094
+ }
1095
+
1096
+ /** Clamp a caller-supplied exec timeout (seconds) to the allowed window. */
1097
+ function clampExecTimeout(value: unknown): number {
1098
+ const sec = typeof value === "number" ? value : Number(value);
1099
+ if (!Number.isFinite(sec) || sec <= 0) return DEFAULT_EXEC_TIMEOUT_MS;
1100
+ return Math.min(
1101
+ MAX_EXEC_TIMEOUT_MS,
1102
+ Math.max(1_000, Math.round(sec * 1_000)),
1103
+ );
1104
+ }
1105
+
1106
+ /** Trim a string path param, returning undefined when blank. */
1107
+ function requirePath(value: unknown): string | undefined {
1108
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
1109
+ }
1110
+
1111
+ /** Render an exec result: exit code headline + stdout/stderr blocks. */
1112
+ function formatExecResult(
1113
+ device: DeviceInfo,
1114
+ result: DeviceCommandResult,
1115
+ ): string {
1116
+ if (!result.ok && !result.data) {
1117
+ return result.message ?? `${device.name} could not run the command.`;
1118
+ }
1119
+ const d = result.data ?? {};
1120
+ const exit = typeof d.exitCode === "number" ? d.exitCode : "?";
1121
+ const stdout = typeof d.stdout === "string" ? d.stdout : "";
1122
+ const stderr = typeof d.stderr === "string" ? d.stderr : "";
1123
+ const parts = [`[${device.name}] exit ${exit}`];
1124
+ if (stdout.trim())
1125
+ parts.push(`--- stdout ---\n${stdout.replace(/\s+$/, "")}`);
1126
+ if (stderr.trim())
1127
+ parts.push(`--- stderr ---\n${stderr.replace(/\s+$/, "")}`);
1128
+ if (!stdout.trim() && !stderr.trim()) parts.push("(no output)");
1129
+ return parts.join("\n");
1130
+ }
1131
+
1132
+ /** "12.4 MB/s in 3.2s" — observability for streamed transfers. */
1133
+ function transferRate(bytes: number, startedAtMs: number): string {
1134
+ const seconds = Math.max((Date.now() - startedAtMs) / 1000, 0.001);
1135
+ return `${formatBytes(bytes / seconds)}/s over ${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}s`;
1136
+ }
1137
+
1138
+ function formatBytes(bytes: number): string {
1139
+ if (bytes < 1024) return `${bytes} B`;
1140
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1141
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1142
+ }
1143
+
1144
+ // ── Process-wide instance ─────────────────────────────────────────────────────
1145
+
1146
+ let instance: MeshService | null = null;
1147
+
1148
+ /** The daemon's shared mesh service (lazily created). */
1149
+ export function getMeshService(): MeshService {
1150
+ instance ??= new MeshService();
1151
+ return instance;
1152
+ }
1153
+
1154
+ /** Swap the shared instance — composition/test seam. Pass null to reset. */
1155
+ export function setMeshService(service: MeshService | null): void {
1156
+ instance = service;
1157
+ }