talon-agent 1.35.0 → 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.
@@ -27,7 +27,13 @@
27
27
  */
28
28
 
29
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";
30
35
  import { MeshRegistry } from "./registry.js";
36
+ import { TransferStore } from "./transfers.js";
31
37
  import type {
32
38
  DeviceCommand,
33
39
  DeviceCommandResult,
@@ -61,6 +67,42 @@ const MOBILE_PLATFORMS = new Set(["android", "ios"]);
61
67
  const DEFAULT_HISTORY_HOURS = 24;
62
68
  const MAX_HISTORY_LINES = 24;
63
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
+
64
106
  export class MeshService {
65
107
  private readonly waiters = new Set<() => void>();
66
108
  private readonly transports = new Set<MeshTransport>();
@@ -68,6 +110,16 @@ export class MeshService {
68
110
  string,
69
111
  (result: DeviceCommandResult) => void
70
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();
71
123
  private readonly freshFixTimeoutMs: number;
72
124
  private readonly pollIntervalMs: number;
73
125
  private readonly commandTimeoutMs: number;
@@ -113,15 +165,21 @@ export class MeshService {
113
165
  return this.transports.size > 0;
114
166
  }
115
167
 
116
- async register(body: Record<string, unknown>): Promise<DeviceInfo> {
168
+ async register(
169
+ body: Record<string, unknown>,
170
+ now?: number,
171
+ ): Promise<DeviceInfo> {
117
172
  await this.load();
118
- return this.registry.register(body);
173
+ return this.registry.register(body, now);
119
174
  }
120
175
 
121
176
  /** Store a reported fix and wake anyone waiting on a fresh location. */
122
177
  async storeLocation(body: Record<string, unknown>): Promise<DeviceLocation> {
123
178
  await this.load();
124
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());
125
183
  for (const notify of [...this.waiters]) notify();
126
184
  return loc;
127
185
  }
@@ -176,6 +234,7 @@ export class MeshService {
176
234
  device: DeviceInfo,
177
235
  name: string,
178
236
  params: Record<string, unknown> = {},
237
+ timeoutMs = this.commandTimeoutMs,
179
238
  ): Promise<DeviceCommandResult> {
180
239
  const command: DeviceCommand = {
181
240
  id: randomUUID(),
@@ -190,9 +249,9 @@ export class MeshService {
190
249
  commandId: command.id,
191
250
  deviceId: device.id,
192
251
  ok: false,
193
- message: `${device.name} did not answer within ${Math.round(this.commandTimeoutMs / 1000)}s (device ${device.online ? "was online" : "appears offline"}).`,
252
+ message: `${device.name} did not answer within ${Math.round(timeoutMs / 1000)}s (device ${device.online ? "was online" : "appears offline"}).`,
194
253
  });
195
- }, this.commandTimeoutMs);
254
+ }, timeoutMs);
196
255
  timer.unref?.();
197
256
  this.pendingCommands.set(command.id, (result) => {
198
257
  clearTimeout(timer);
@@ -234,18 +293,23 @@ export class MeshService {
234
293
  if (!target) return this.noSuchDevice(query);
235
294
  const requestedAt = Date.now();
236
295
  // Only wait out the fresh-fix window when a transport can actually
237
- // deliver the locate otherwise answer from persistence immediately.
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.
238
299
  const dispatched = this.requestLocate(target.id);
239
- const fresh = dispatched
240
- ? await this.waitForFreshLocation(target.id, requestedAt)
241
- : undefined;
300
+ const fresh =
301
+ dispatched && target.online
302
+ ? await this.waitForFreshLocation(target.id, requestedAt)
303
+ : undefined;
242
304
  const loc = fresh ?? this.registry.getLocation(target.id);
243
305
  if (!loc) {
244
306
  return {
245
307
  ok: false,
246
- text: dispatched
247
- ? `No location is known for ${target.name}. A locate request was sent, but no fix arrived within ${Math.round(this.freshFixTimeoutMs / 1000)}s.`
248
- : `No location is known for ${target.name}, and no companion transport is connected to request one.`,
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)}).`,
249
313
  };
250
314
  }
251
315
  return { ok: true, text: this.locationSummary(target, loc) };
@@ -289,6 +353,473 @@ export class MeshService {
289
353
  return this.commandTool(query, "status", {});
290
354
  }
291
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
+
292
823
  /**
293
824
  * Shared command-tool flow: resolve the target, check its advertised
294
825
  * capabilities, require a transport, send, and render the device's answer.
@@ -298,29 +829,53 @@ export class MeshService {
298
829
  name: string,
299
830
  params: Record<string, unknown>,
300
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
+ > {
301
855
  await this.load();
302
856
  const target = this.chooseDevice(query);
303
- if (!target) return this.noSuchDevice(query);
857
+ if (!target) return { error: this.noSuchDevice(query).text };
304
858
  // Devices advertise what they can do; an explicit list that lacks the
305
859
  // command is a clean "can't" — absent list means an older app build, so
306
860
  // attempt it and let the timeout speak.
307
861
  if (target.capabilities && !target.capabilities.includes(name)) {
308
862
  return {
309
- ok: false,
310
- text: `${target.name} does not support "${name}" (supports: ${target.capabilities.join(", ")}).`,
863
+ error: `${target.name} does not support "${name}" (supports: ${target.capabilities.join(", ")}).`,
311
864
  };
312
865
  }
313
866
  if (this.transports.size === 0) {
314
867
  return {
315
- ok: false,
316
- text: `No companion transport is connected, so "${name}" cannot reach ${target.name}.`,
868
+ error: `No companion transport is connected, so "${name}" cannot reach ${target.name}.`,
317
869
  };
318
870
  }
319
- const result = await this.sendCommand(target, name, params);
320
- return {
321
- ok: result.ok,
322
- text: this.commandSummary(target, name, result),
323
- };
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 };
324
879
  }
325
880
 
326
881
  /** Resolve a device by exact id, name fragment, or default (most recently
@@ -460,8 +1015,11 @@ export class MeshService {
460
1015
  deviceId: string,
461
1016
  requestedAt: number,
462
1017
  ): Promise<DeviceLocation | undefined> {
463
- const existing = this.registry.getLocation(deviceId);
464
- if (existing && existing.ts >= requestedAt) return existing;
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
+ }
465
1023
  const deadline = Date.now() + this.freshFixTimeoutMs;
466
1024
  while (Date.now() < deadline) {
467
1025
  const remaining = deadline - Date.now();
@@ -477,8 +1035,9 @@ export class MeshService {
477
1035
  );
478
1036
  this.waiters.add(done);
479
1037
  });
480
- const loc = this.registry.getLocation(deviceId);
481
- if (loc && loc.ts >= requestedAt) return loc;
1038
+ if ((this.receivedAt.get(deviceId) ?? 0) >= requestedAt) {
1039
+ return this.registry.getLocation(deviceId);
1040
+ }
482
1041
  }
483
1042
  return undefined;
484
1043
  }
@@ -534,6 +1093,54 @@ function age(ms: number): string {
534
1093
  return `${hrs}h ago`;
535
1094
  }
536
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
+
537
1144
  // ── Process-wide instance ─────────────────────────────────────────────────────
538
1145
 
539
1146
  let instance: MeshService | null = null;