talon-agent 3.33.0 → 3.33.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +3 -3
  2. package/src/backend/kilo/factory.ts +39 -119
  3. package/src/backend/kilo/handler/index.ts +1 -11
  4. package/src/backend/kilo/handler/message.ts +20 -317
  5. package/src/backend/kilo/server.ts +55 -272
  6. package/src/backend/kilo/sessions.ts +5 -83
  7. package/src/backend/opencode/factory.ts +41 -118
  8. package/src/backend/opencode/handler/index.ts +1 -11
  9. package/src/backend/opencode/handler/message.ts +20 -321
  10. package/src/backend/opencode/server.ts +57 -217
  11. package/src/backend/opencode/sessions.ts +5 -83
  12. package/src/backend/remote-server/chat-turn.ts +359 -0
  13. package/src/backend/remote-server/factory.ts +150 -0
  14. package/src/backend/remote-server/index.ts +14 -19
  15. package/src/backend/remote-server/server-bindings.ts +232 -0
  16. package/src/backend/{kilo/handler → remote-server}/turn.ts +103 -67
  17. package/src/core/mesh/bridge-links.ts +319 -0
  18. package/src/core/mesh/common.ts +30 -0
  19. package/src/core/mesh/device-files.ts +710 -0
  20. package/src/core/mesh/service.ts +72 -894
  21. package/src/frontend/discord/handlers/access.ts +7 -56
  22. package/src/frontend/discord/handlers/state.ts +15 -8
  23. package/src/frontend/native/server.ts +391 -323
  24. package/src/frontend/shared/access.ts +152 -0
  25. package/src/frontend/telegram/handlers/access.ts +8 -25
  26. package/src/frontend/telegram/handlers/queue.ts +3 -35
  27. package/src/frontend/telegram/handlers/state.ts +15 -8
  28. package/src/backend/kilo/events.ts +0 -60
  29. package/src/backend/kilo/handler/state.ts +0 -9
  30. package/src/backend/opencode/handler/state.ts +0 -9
  31. package/src/backend/opencode/handler/turn.ts +0 -314
@@ -24,34 +24,29 @@
24
24
  * With no transport registered (daemon running without the native bridge),
25
25
  * everything degrades gracefully: locate answers from the last persisted
26
26
  * fix immediately, and commands fail fast with a clear explanation.
27
+ *
28
+ * The service keeps the registry, the transport fan-out, the command
29
+ * channel, device resolution, and the model-facing summaries. Two
30
+ * collaborators own the rest and are reached through thin delegations so
31
+ * the public surface stays one object: {@link DeviceFiles} (chunked and
32
+ * streamed file transfer, the companion/node self-update flows) and
33
+ * {@link BridgeLinks} (node provisioning, companion pairing, reachability).
27
34
  */
28
35
 
29
36
  import { randomUUID } from "node:crypto";
30
- import { createHash } from "node:crypto";
31
- import { createReadStream } from "node:fs";
32
- import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
33
- import { networkInterfaces, tmpdir } from "node:os";
34
- import { basename, dirname, join, resolve } from "node:path";
35
37
  import type { Readable } from "node:stream";
36
38
  import { clampExecOutput } from "../../util/exec-output.js";
37
- import { dirs } from "../../util/paths.js";
38
- import {
39
- NODE_TARGETS,
40
- normalizeGoarch,
41
- normalizeGoos,
42
- platformToGoos,
43
- resolveNodeBinary,
44
- type NodeBinaryResolver,
45
- } from "./node-binaries.js";
39
+ import { BridgeLinks, type MeshBridgeInfo } from "./bridge-links.js";
46
40
  import {
47
- CompanionPairStore,
48
- pairLink,
49
- pairPage,
50
- type CompanionPairPayload,
51
- } from "./companion-pairing.js";
52
- import { installOneLiner, NodeProvisionStore } from "./node-provision.js";
41
+ age,
42
+ formatBytes,
43
+ FS_COMMAND_TIMEOUT_MS,
44
+ requirePath,
45
+ type MeshToolResult,
46
+ } from "./common.js";
47
+ import { DeviceFiles } from "./device-files.js";
48
+ import { resolveNodeBinary, type NodeBinaryResolver } from "./node-binaries.js";
53
49
  import { MeshRegistry } from "./registry.js";
54
- import { TransferStore } from "./transfers.js";
55
50
  import type {
56
51
  DeviceCommand,
57
52
  DeviceCommandResult,
@@ -67,7 +62,7 @@ export type MeshTransport = {
67
62
  command(command: DeviceCommand): void;
68
63
  };
69
64
 
70
- export type MeshToolResult = { ok: boolean; text: string };
65
+ export type { MeshToolResult } from "./common.js";
71
66
 
72
67
  /** Outcome of pinging one device (see {@link MeshService.pingAll}). */
73
68
  export type MeshPingResult = {
@@ -91,22 +86,7 @@ export type MeshServiceOptions = {
91
86
  nodeBinaryResolver?: NodeBinaryResolver;
92
87
  };
93
88
 
94
- /**
95
- * What the native bridge tells the mesh about itself once it's listening —
96
- * everything a generated node installer needs to point a fresh host here.
97
- * Registered by the native frontend after server start; null when the
98
- * bridge isn't running (provisioning tools then fail with a clear reason).
99
- */
100
- export type MeshBridgeInfo = {
101
- scheme: "http" | "https";
102
- /** The bind host from config — may be a wildcard (0.0.0.0/::). */
103
- host: string;
104
- port: number;
105
- /** Bearer token clients authenticate with (absent on open loopback). */
106
- token?: string;
107
- /** TLS certificate SHA-256 (absent over plain HTTP). */
108
- fingerprint?: string;
109
- };
89
+ export type { MeshBridgeInfo } from "./bridge-links.js";
110
90
 
111
91
  const DEFAULT_FRESH_FIX_TIMEOUT_MS = 8_000;
112
92
  const DEFAULT_POLL_INTERVAL_MS = 1_000;
@@ -120,44 +100,6 @@ const MAX_HISTORY_LINES = 24;
120
100
  const DEFAULT_EXEC_TIMEOUT_MS = 60_000;
121
101
  /** Hard ceiling on a caller-requested exec timeout. */
122
102
  const MAX_EXEC_TIMEOUT_MS = 300_000;
123
- /** Timeout for a single filesystem command (list/stat/one chunk/etc.). */
124
- const FS_COMMAND_TIMEOUT_MS = 30_000;
125
- /**
126
- * Bytes of file payload per FALLBACK transfer chunk (base64 on the wire).
127
- * The chunked command channel costs one full mesh round trip per chunk, so
128
- * it's only used for app builds that don't advertise the streaming commands
129
- * (`upload_file`/`download_file`) — modern builds move file bodies as a
130
- * single raw HTTP stream instead (see TransferStore). 1MB keeps even the
131
- * fallback tolerable without bloating a single SSE frame too far.
132
- */
133
- const FILE_CHUNK_BYTES = 1024 * 1024;
134
- /** Wall-clock budget for one streamed transfer (command dispatch → done). */
135
- const STREAM_TRANSFER_TIMEOUT_MS = 60 * 60 * 1000;
136
- /** readFileBytes switches to the streaming path above this size. */
137
- const STREAM_READ_THRESHOLD_BYTES = 4 * 1024 * 1024;
138
- /**
139
- * Hard ceiling on a chunked (command-channel) transfer. The chunked path
140
- * assembles the whole file in daemon memory one mesh round trip at a time —
141
- * past this size it's both a memory hazard and unusably slow, so fail with
142
- * a pointer to the streaming path instead of grinding on.
143
- */
144
- const MAX_CHUNKED_TRANSFER_BYTES = 64 * 1024 * 1024;
145
- /**
146
- * No policy size cap on transfers — a transfer is attempted whatever the
147
- * size and fails with a concrete error when a real limit bites (device read
148
- * error, stream timeout, disk). The streamed paths are disk-to-disk and
149
- * never hold the file in daemon memory; only the chunked FALLBACK and
150
- * readFileBytes (whose callers need a Buffer) are memory-bound.
151
- */
152
- /**
153
- * Where pulled device files land on the daemon host when no dest is given.
154
- * Resolved lazily (not at module load) so a test that mocks `util/paths`
155
- * doesn't hit its workspace binding before initialization.
156
- */
157
- function pullDir(): string {
158
- return resolve(dirs.workspace, "mesh-pull");
159
- }
160
-
161
103
  export class MeshService {
162
104
  private readonly waiters = new Set<() => void>();
163
105
  private readonly transports = new Set<MeshTransport>();
@@ -176,14 +118,11 @@ export class MeshService {
176
118
  * and one running ahead would have stale fixes accepted as fresh.
177
119
  */
178
120
  private readonly receivedAt = new Map<string, number>();
179
- /** One-time tokens arranging streamed (single-HTTP-request) transfers. */
180
- private readonly transfers = new TransferStore();
181
- /** One-time grants for bridge-served node installers. */
182
- private readonly provision = new NodeProvisionStore();
183
- /** One-time grants that hand a phone this bridge's connection details. */
184
- private readonly companionPairs = new CompanionPairStore();
185
121
  private readonly resolveNode: NodeBinaryResolver;
186
- private bridgeInfo: MeshBridgeInfo | null = null;
122
+ /** Chunked + streamed file transfer and the self-update flows. */
123
+ private readonly files: DeviceFiles;
124
+ /** Node provisioning, companion pairing, and bridge reachability. */
125
+ private readonly links: BridgeLinks;
187
126
  private readonly freshFixTimeoutMs: number;
188
127
  private readonly pollIntervalMs: number;
189
128
  private readonly commandTimeoutMs: number;
@@ -199,11 +138,20 @@ export class MeshService {
199
138
  this.commandTimeoutMs =
200
139
  options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
201
140
  this.resolveNode = options.nodeBinaryResolver ?? resolveNodeBinary;
141
+ this.files = new DeviceFiles({
142
+ load: () => this.load(),
143
+ resolveDevice: (query) => this.resolveDevice(query),
144
+ dispatchCommand: (query, name, params, timeoutMs) =>
145
+ this.dispatchCommand(query, name, params, timeoutMs),
146
+ commandTimeoutMs: this.commandTimeoutMs,
147
+ resolveNode: this.resolveNode,
148
+ });
149
+ this.links = new BridgeLinks(this.resolveNode);
202
150
  }
203
151
 
204
152
  /** The native bridge reports its reachable identity here (null on stop). */
205
153
  setBridgeInfo(info: MeshBridgeInfo | null): void {
206
- this.bridgeInfo = info;
154
+ this.links.setBridgeInfo(info);
207
155
  }
208
156
 
209
157
  /** Hydrate persisted devices/locations. Idempotent — safe to await from
@@ -604,11 +552,7 @@ export class MeshService {
604
552
  return { ok: true, text: `${p} — ${fields.join(" · ")}` };
605
553
  }
606
554
 
607
- // ── Streaming transfer bridge surface ─────────────────────────────────────
608
- // The HTTP routes on the native bridge delegate here; the token is the
609
- // entire authorization (single-use, device- and path-bound). `fromDeviceId`
610
- // is the caller's self-declared identity — checked against the device the
611
- // token was minted for, so a leaked token can't be redeemed by a peer.
555
+ // ── File transfer + self-update (see device-files.ts) ─────────────────────
612
556
 
613
557
  /** POST /devices/file — a device streams a pull's file body up. */
614
558
  acceptFileUpload(
@@ -616,7 +560,7 @@ export class MeshService {
616
560
  body: Readable,
617
561
  fromDeviceId?: string,
618
562
  ): Promise<{ ok: true; bytes: number } | { ok: false; error: string }> {
619
- return this.transfers.acceptUpload(token, body, fromDeviceId);
563
+ return this.files.acceptFileUpload(token, body, fromDeviceId);
620
564
  }
621
565
 
622
566
  /** GET /devices/file — a device asks for a push's file body. */
@@ -624,815 +568,113 @@ export class MeshService {
624
568
  token: string,
625
569
  fromDeviceId?: string,
626
570
  ): Promise<{ path: string; size: number } | null> {
627
- return this.transfers.openDownload(token, fromDeviceId);
628
- }
629
-
630
- /** Streaming is per-command capability — old app builds fall back. */
631
- private canStream(
632
- target: DeviceInfo,
633
- command: "upload_file" | "download_file",
634
- ): boolean {
635
- return target.capabilities?.includes(command) ?? false;
571
+ return this.files.openFileDownload(token, fromDeviceId);
636
572
  }
637
573
 
638
- /**
639
- * Streamed device→daemon transfer: one command round trip to arrange it,
640
- * then the file body arrives as a single raw HTTP request, written
641
- * atomically to `dest`. Resolves with the byte count.
642
- */
643
- private async pullViaStream(
644
- target: DeviceInfo,
645
- remote: string,
646
- dest: string,
647
- ): Promise<{ bytes: number } | { error: string }> {
648
- const { token, done } = this.transfers.createPull(target.id, dest);
649
- const dispatched = await this.dispatchCommand(
650
- target.id,
651
- "upload_file",
652
- { token, path: remote },
653
- STREAM_TRANSFER_TIMEOUT_MS,
654
- );
655
- if ("error" in dispatched) {
656
- this.transfers.cancel(token);
657
- return { error: dispatched.error };
658
- }
659
- if (!dispatched.result.ok) {
660
- this.transfers.cancel(token);
661
- return {
662
- error:
663
- dispatched.result.message ??
664
- `${target.name} could not upload ${remote}.`,
665
- };
666
- }
667
- // The device answers the command AFTER its upload completes, so `done`
668
- // is normally already resolved — the grace window only catches a device
669
- // that claims success without having streamed anything.
670
- try {
671
- const bytes = await Promise.race([
672
- done,
673
- new Promise<never>((_, rej) =>
674
- setTimeout(
675
- () =>
676
- rej(
677
- new Error(
678
- `${target.name} reported success but no upload arrived.`,
679
- ),
680
- ),
681
- 15_000,
682
- ).unref?.(),
683
- ),
684
- ]);
685
- return { bytes };
686
- } catch (err) {
687
- this.transfers.cancel(token);
688
- return { error: (err as Error).message };
689
- }
690
- }
691
-
692
- /**
693
- * Raw file bytes off a device — the structured primitive under both the
694
- * human-readable tool below and the native read/edit path (which must not
695
- * have to parse a display envelope to recover the content).
696
- *
697
- * Small files ride the chunked command channel (one round trip). Files
698
- * over the streaming threshold are pulled via the streaming path into a
699
- * temp file first — the chunked channel pays a full mesh round trip per
700
- * chunk and is far too slow for big payloads.
701
- */
702
- async readFileBytes(
574
+ /** Raw file bytes off a device — the primitive under the read tools. */
575
+ readFileBytes(
703
576
  query: unknown,
704
577
  path: unknown,
705
578
  ): Promise<{ data: Buffer; deviceName: string } | { error: string }> {
706
- const p = requirePath(path);
707
- if (!p) return { error: "A file path is required." };
708
- await this.load();
709
- const resolved = this.resolveDevice(query);
710
- if ("error" in resolved) return { error: resolved.error };
711
- const target = resolved.target;
712
- if (this.canStream(target, "upload_file")) {
713
- const size = await this.statSize(target.id, p);
714
- if (size !== undefined && size > STREAM_READ_THRESHOLD_BYTES) {
715
- const tmp = join(tmpdir(), `talon-pull-${randomUUID()}-${basename(p)}`);
716
- const pulled = await this.pullViaStream(target, p, tmp);
717
- if ("error" in pulled) return { error: pulled.error };
718
- try {
719
- const data = await readFile(tmp);
720
- return { data, deviceName: target.name };
721
- } catch (err) {
722
- return {
723
- error: `Pulled ${p} but could not read the temp copy: ${(err as Error).message}`,
724
- };
725
- } finally {
726
- await rm(tmp, { force: true }).catch(() => {});
727
- }
728
- }
729
- }
730
- return this.pullBytes(target.id, p);
579
+ return this.files.readFileBytes(query, path);
731
580
  }
732
581
 
733
- /** Size of a device path via the `stat` command, if the device can. */
734
- private async statSize(
735
- deviceId: string,
736
- path: string,
737
- ): Promise<number | undefined> {
738
- const dispatched = await this.dispatchCommand(
739
- deviceId,
740
- "stat",
741
- { path },
742
- FS_COMMAND_TIMEOUT_MS,
743
- );
744
- if ("error" in dispatched || !dispatched.result.ok) return undefined;
745
- const size = dispatched.result.data?.size;
746
- return typeof size === "number" ? size : undefined;
747
- }
748
-
749
- /** `device_read_file`: read a (text) file off the device, chunked. */
750
- async readFileFromDevice(
751
- query: unknown,
752
- path: unknown,
753
- ): Promise<MeshToolResult> {
754
- const p = requirePath(path);
755
- if (!p) return { ok: false, text: "A file path is required." };
756
- const buf = await this.readFileBytes(query, p);
757
- if ("error" in buf) return { ok: false, text: buf.error };
758
- return {
759
- ok: true,
760
- text: `${p} on ${buf.deviceName} (${formatBytes(buf.data.length)}):\n\n${buf.data.toString("utf8")}`,
761
- };
582
+ /** `device_read_file`: read a (text) file off the device. */
583
+ readFileFromDevice(query: unknown, path: unknown): Promise<MeshToolResult> {
584
+ return this.files.readFileFromDevice(query, path);
762
585
  }
763
586
 
764
587
  /** `device_write_file`: write text content to a file on the device. */
765
- async writeFileToDevice(
588
+ writeFileToDevice(
766
589
  query: unknown,
767
590
  path: unknown,
768
591
  content: unknown,
769
592
  ): Promise<MeshToolResult> {
770
- const p = requirePath(path);
771
- if (!p) return { ok: false, text: "A file path is required." };
772
- // A non-string body must fail, not silently truncate the target to an
773
- // empty file (an empty string is a legitimate truncate-to-zero).
774
- if (typeof content !== "string") {
775
- return { ok: false, text: "File content must be a string." };
776
- }
777
- const written = await this.pushBytes(
778
- query,
779
- p,
780
- Buffer.from(content, "utf8"),
781
- );
782
- if ("error" in written) return { ok: false, text: written.error };
783
- return {
784
- ok: true,
785
- text: `Wrote ${formatBytes(written.bytes)} to ${p} on ${written.deviceName}.`,
786
- };
593
+ return this.files.writeFileToDevice(query, path, content);
787
594
  }
788
595
 
789
- /** `device_pull_file`: copy a device file to the daemon host — streamed
790
- * (single HTTP request, disk-to-disk) when the app supports it, chunked
791
- * command-channel fallback otherwise. */
792
- async pullFileFromDevice(
596
+ /** `device_pull_file`: copy a device file to the daemon host. */
597
+ pullFileFromDevice(
793
598
  query: unknown,
794
599
  remotePath: unknown,
795
600
  localPath?: unknown,
796
601
  ): Promise<MeshToolResult> {
797
- const remote = requirePath(remotePath);
798
- if (!remote) return { ok: false, text: "A remote file path is required." };
799
- await this.load();
800
- const resolved = this.resolveDevice(query);
801
- if ("error" in resolved) return { ok: false, text: resolved.error };
802
- const target = resolved.target;
803
- const dest =
804
- typeof localPath === "string" && localPath.trim()
805
- ? resolve(dirs.workspace, localPath.trim())
806
- : resolve(
807
- pullDir(),
808
- `${target.name.replace(/\W+/g, "_")}-${basename(remote)}`,
809
- );
810
- if (this.canStream(target, "upload_file")) {
811
- const started = Date.now();
812
- const pulled = await this.pullViaStream(target, remote, dest);
813
- if ("error" in pulled) return { ok: false, text: pulled.error };
814
- return {
815
- ok: true,
816
- text: `Pulled ${formatBytes(pulled.bytes)} from ${remote} on ${target.name} → ${dest} (streamed, ${transferRate(pulled.bytes, started)})`,
817
- };
818
- }
819
- const buf = await this.pullBytes(target.id, remote);
820
- if ("error" in buf) return { ok: false, text: buf.error };
821
- await mkdir(dirname(dest), { recursive: true });
822
- await writeFile(dest, buf.data);
823
- return {
824
- ok: true,
825
- text: `Pulled ${formatBytes(buf.data.length)} from ${remote} on ${buf.deviceName} → ${dest} (chunked fallback — update the companion app for streamed transfers)`,
826
- };
602
+ return this.files.pullFileFromDevice(query, remotePath, localPath);
827
603
  }
828
604
 
829
- /** `device_push_file`: copy a daemon-host file to the device — streamed
830
- * when the app supports it (never buffers the file in daemon memory),
831
- * chunked command-channel fallback otherwise. */
832
- async pushFileToDevice(
605
+ /** `device_push_file`: copy a daemon-host file to the device. */
606
+ pushFileToDevice(
833
607
  query: unknown,
834
608
  localPath: unknown,
835
609
  remotePath: unknown,
836
610
  ): Promise<MeshToolResult> {
837
- const remote = requirePath(remotePath);
838
- if (!remote)
839
- return { ok: false, text: "A remote destination path is required." };
840
- const local =
841
- typeof localPath === "string" && localPath.trim()
842
- ? resolve(dirs.workspace, localPath.trim())
843
- : "";
844
- if (!local) return { ok: false, text: "A local source path is required." };
845
- await this.load();
846
- const resolved = this.resolveDevice(query);
847
- if ("error" in resolved) return { ok: false, text: resolved.error };
848
- const target = resolved.target;
849
- if (this.canStream(target, "download_file")) {
850
- const started = Date.now();
851
- const { token } = this.transfers.createPush(target.id, local);
852
- const dispatched = await this.dispatchCommand(
853
- target.id,
854
- "download_file",
855
- { token, path: remote },
856
- STREAM_TRANSFER_TIMEOUT_MS,
857
- );
858
- if ("error" in dispatched) {
859
- this.transfers.cancel(token);
860
- return { ok: false, text: dispatched.error };
861
- }
862
- if (!dispatched.result.ok) {
863
- this.transfers.cancel(token);
864
- return {
865
- ok: false,
866
- text:
867
- dispatched.result.message ??
868
- `${target.name} could not download ${local}.`,
869
- };
870
- }
871
- const bytes = dispatched.result.data?.bytesWritten;
872
- const size = typeof bytes === "number" ? bytes : 0;
873
- return {
874
- ok: true,
875
- text: `Pushed ${formatBytes(size)} to ${remote} on ${target.name} (streamed, ${transferRate(size, started)})`,
876
- };
877
- }
878
- let data: Buffer;
879
- try {
880
- data = await readFile(local);
881
- } catch (err) {
882
- // Includes Node's buffer-size ceiling (ERR_FS_FILE_TOO_LARGE) for
883
- // files too big to hold in memory — surface the real reason.
884
- return {
885
- ok: false,
886
- text: `Cannot read local file ${local}: ${(err as Error).message}`,
887
- };
888
- }
889
- const written = await this.pushBytes(target.id, remote, data);
890
- if ("error" in written) return { ok: false, text: written.error };
891
- return {
892
- ok: true,
893
- text: `Pushed ${formatBytes(written.bytes)} to ${remote} on ${written.deviceName} (chunked fallback — update the companion app for streamed transfers).`,
894
- };
611
+ return this.files.pushFileToDevice(query, localPath, remotePath);
895
612
  }
896
613
 
897
- /**
898
- * `update_device`: remote self-update for the companion. Streams a new APK
899
- * to the device, then tells it to silently install (root or Shizuku) and
900
- * restart. The mesh foreground service's autoRunOnMyPackageReplaced brings
901
- * the connection back on its own — the link drops only for the seconds the
902
- * process is swapped, no manual reopen.
903
- *
904
- * The APK is hashed here and the digest travels with the install command;
905
- * the device re-hashes the pushed file and refuses to install on a
906
- * mismatch, so a truncated transfer can never be installed.
907
- */
908
- async updateDeviceApp(
614
+ /** `update_device`: remote self-update for the companion app. */
615
+ updateDeviceApp(
909
616
  query: unknown,
910
617
  localApkPath: unknown,
911
618
  remotePath?: unknown,
912
619
  ): Promise<MeshToolResult> {
913
- const local =
914
- typeof localApkPath === "string" && localApkPath.trim()
915
- ? resolve(dirs.workspace, localApkPath.trim())
916
- : "";
917
- if (!local) return { ok: false, text: "A local APK path is required." };
918
- await this.load();
919
- const resolved = this.resolveDevice(query);
920
- if ("error" in resolved) return { ok: false, text: resolved.error };
921
- const target = resolved.target;
922
- if (target.capabilities && !target.capabilities.includes("install_apk")) {
923
- return {
924
- ok: false,
925
- text: `${target.name} can't self-update — it needs a companion build with the install_apk capability and root or Shizuku enabled (device control on).`,
926
- };
927
- }
928
-
929
- let sha256: string;
930
- let size: number;
931
- try {
932
- ({ sha256, size } = await hashFile(local));
933
- } catch (err) {
934
- return {
935
- ok: false,
936
- text: `Cannot read APK ${local}: ${(err as Error).message}`,
937
- };
938
- }
939
- if (size === 0) {
940
- return { ok: false, text: `APK ${local} is empty.` };
941
- }
942
-
943
- const remote =
944
- typeof remotePath === "string" && remotePath.trim()
945
- ? remotePath.trim()
946
- : "/sdcard/Download/talon-companion-update.apk";
947
-
948
- // 1. Stream the APK to the device.
949
- const push = await this.pushFileToDevice(target.id, local, remote);
950
- if (!push.ok) {
951
- return { ok: false, text: `Update aborted — push failed: ${push.text}` };
952
- }
953
-
954
- // 2. Trigger the silent install (device verifies the digest first).
955
- const dispatched = await this.dispatchCommand(
956
- target.id,
957
- "install_apk",
958
- { path: remote, sha256 },
959
- this.commandTimeoutMs,
960
- );
961
- if ("error" in dispatched) return { ok: false, text: dispatched.error };
962
- if (!dispatched.result.ok) {
963
- return {
964
- ok: false,
965
- text:
966
- dispatched.result.message ?? `${target.name} refused the install.`,
967
- };
968
- }
969
- return {
970
- ok: true,
971
- text:
972
- `Pushed ${formatBytes(size)} and staged the update on ${target.name}. ` +
973
- `${dispatched.result.message ?? "Installing now."} ` +
974
- `Confirm with get_device_status once it reconnects (appVersion should change).`,
975
- };
620
+ return this.files.updateDeviceApp(query, localApkPath, remotePath);
976
621
  }
977
622
 
978
- /**
979
- * `update_node`: remote self-update for a headless talon-node. Streams a
980
- * replacement binary to the node, then sends `update_node` so the node
981
- * verifies the digest, atomically swaps its own binary, and restarts into
982
- * it (an in-place execve under systemd/launchd, so the mesh connection
983
- * returns on its own within seconds — the same UX as the Android path).
984
- *
985
- * The binary is hashed here and the digest travels with the command; the
986
- * node re-hashes the pushed file and refuses to swap on a mismatch, so a
987
- * truncated transfer can never be installed.
988
- *
989
- * With no binary_path, the replacement is auto-resolved for the node's
990
- * registered platform/arch (source build in a dev checkout, else the
991
- * version-matched release download — see node-binaries.ts).
992
- */
993
- async updateNodeBinary(
623
+ /** `update_node`: remote self-update for a headless talon-node. */
624
+ updateNodeBinary(
994
625
  query: unknown,
995
626
  localBinaryPath?: unknown,
996
627
  remotePath?: unknown,
997
628
  ): Promise<MeshToolResult> {
998
- await this.load();
999
- const resolved = this.resolveDevice(query);
1000
- if ("error" in resolved) return { ok: false, text: resolved.error };
1001
- const target = resolved.target;
1002
- if (target.capabilities && !target.capabilities.includes("update_node")) {
1003
- return {
1004
- ok: false,
1005
- text: `${target.name} can't self-update — it needs the update_node capability (a talon-node headless device).`,
1006
- };
1007
- }
1008
-
1009
- let local: string;
1010
- let provenance = "";
1011
- if (typeof localBinaryPath === "string" && localBinaryPath.trim()) {
1012
- local = resolve(dirs.workspace, localBinaryPath.trim());
1013
- } else {
1014
- const goos = platformToGoos(target.platform);
1015
- if (!goos) {
1016
- return {
1017
- ok: false,
1018
- text: `${target.name} is a ${target.platform} device — update_node targets headless nodes only.`,
1019
- };
1020
- }
1021
- const goarch = normalizeGoarch(target.arch);
1022
- if (!goarch) {
1023
- return {
1024
- ok: false,
1025
- text: `${target.name} has not advertised its CPU architecture (a node build from before arch reporting). Pass binary_path explicitly for this update — after it, the node advertises arch and future updates auto-resolve.`,
1026
- };
1027
- }
1028
- try {
1029
- const bin = await this.resolveNode(goos, goarch);
1030
- local = bin.path;
1031
- provenance = ` (auto-resolved ${bin.version} for ${goos}/${goarch} via ${bin.source})`;
1032
- } catch (err) {
1033
- return { ok: false, text: (err as Error).message };
1034
- }
1035
- }
1036
-
1037
- let sha256: string;
1038
- let size: number;
1039
- try {
1040
- ({ sha256, size } = await hashFile(local));
1041
- } catch (err) {
1042
- return {
1043
- ok: false,
1044
- text: `Cannot read binary ${local}: ${(err as Error).message}`,
1045
- };
1046
- }
1047
- if (size === 0) return { ok: false, text: `Binary ${local} is empty.` };
1048
-
1049
- // Default staging path is /tmp on unix nodes (the node re-stages next to
1050
- // its own executable before the atomic swap, so this is only transient).
1051
- const remote =
1052
- typeof remotePath === "string" && remotePath.trim()
1053
- ? remotePath.trim()
1054
- : "/tmp/talon-node.update";
1055
-
1056
- // 1. Stream the new binary to the node.
1057
- const push = await this.pushFileToDevice(target.id, local, remote);
1058
- if (!push.ok) {
1059
- return { ok: false, text: `Update aborted — push failed: ${push.text}` };
1060
- }
1061
-
1062
- // 2. Trigger the swap + restart (node verifies the digest first).
1063
- const dispatched = await this.dispatchCommand(
1064
- target.id,
1065
- "update_node",
1066
- { path: remote, sha256 },
1067
- this.commandTimeoutMs,
1068
- );
1069
- if ("error" in dispatched) return { ok: false, text: dispatched.error };
1070
- if (!dispatched.result.ok) {
1071
- return {
1072
- ok: false,
1073
- text: dispatched.result.message ?? `${target.name} refused the update.`,
1074
- };
1075
- }
1076
- return {
1077
- ok: true,
1078
- text:
1079
- `Pushed ${formatBytes(size)}${provenance} and staged the update on ${target.name}. ` +
1080
- `${dispatched.result.message ?? "Restarting now."} ` +
1081
- `Confirm with get_device_status once it reconnects (appVersion should change).`,
1082
- };
629
+ return this.files.updateNodeBinary(query, localBinaryPath, remotePath);
1083
630
  }
1084
631
 
1085
- // ── Node provisioning ──────────────────────────────────────────────────────
632
+ // ── Provisioning + pairing (see bridge-links.ts) ──────────────────────────
1086
633
 
1087
- /**
1088
- * `get_node_binary`: materialize a talon-node binary for any supported
1089
- * platform/arch on the daemon host — source build in a dev checkout, else
1090
- * the digest-verified release download (cached under ~/.talon/node-bin).
1091
- */
1092
- async getNodeBinary(os: unknown, arch: unknown): Promise<MeshToolResult> {
1093
- const goos = normalizeGoos(os);
1094
- const goarch = normalizeGoarch(arch);
1095
- if (!goos || !goarch) {
1096
- return { ok: false, text: unknownTargetText(os, arch) };
1097
- }
1098
- try {
1099
- const bin = await this.resolveNode(goos, goarch);
1100
- return {
1101
- ok: true,
1102
- text: `talon-node ${bin.version} for ${goos}/${goarch}: ${bin.path} (${formatBytes(bin.size)}, sha256 ${bin.sha256}, via ${bin.source})`,
1103
- };
1104
- } catch (err) {
1105
- return { ok: false, text: (err as Error).message };
1106
- }
634
+ /** `get_node_binary`: materialize a talon-node binary on the daemon host. */
635
+ getNodeBinary(os: unknown, arch: unknown): Promise<MeshToolResult> {
636
+ return this.links.getNodeBinary(os, arch);
1107
637
  }
1108
638
 
1109
- /**
1110
- * `make_node_install_link`: mint a single-use provisioning URL on the
1111
- * bridge and return the one command that turns a fresh host into a mesh
1112
- * node — it fetches the installer script, which downloads the (digest-
1113
- * verified) binary from the same bridge, installs it, pre-pins the bridge
1114
- * certificate, and registers the boot service.
1115
- */
1116
- async makeNodeInstallLink(
639
+ /** `make_node_install_link`: mint a single-use node provisioning URL. */
640
+ makeNodeInstallLink(
1117
641
  os: unknown,
1118
642
  arch: unknown,
1119
643
  name?: unknown,
1120
644
  bridgeUrl?: unknown,
1121
645
  ): Promise<MeshToolResult> {
1122
- const goos = normalizeGoos(os);
1123
- const goarch = normalizeGoarch(arch);
1124
- if (!goos || !goarch) {
1125
- return { ok: false, text: unknownTargetText(os, arch) };
1126
- }
1127
- const info = this.bridgeInfo;
1128
- if (!info) {
1129
- return {
1130
- ok: false,
1131
- text: "The native bridge isn't running, so there is nothing for a new node to connect to. Enable the native frontend first.",
1132
- };
1133
- }
1134
- if (!info.token) {
1135
- return {
1136
- ok: false,
1137
- text: "The bridge has no bearer token (loopback-only bind), and nodes authenticate with one. Set native.host to a reachable address (a token is auto-minted) and restart.",
1138
- };
1139
- }
1140
- const base = this.bridgeBaseUrl(info, bridgeUrl);
1141
- if (typeof base !== "string") return { ok: false, text: base.error };
1142
- let bin;
1143
- try {
1144
- bin = await this.resolveNode(goos, goarch);
1145
- } catch (err) {
1146
- return { ok: false, text: (err as Error).message };
1147
- }
1148
- const grant = this.provision.create({
1149
- goos,
1150
- goarch,
1151
- ...(typeof name === "string" && name.trim() ? { name: name.trim() } : {}),
1152
- binaryPath: bin.path,
1153
- sha256: bin.sha256,
1154
- size: bin.size,
1155
- version: bin.version,
1156
- bridgeUrl: base,
1157
- bearerToken: info.token,
1158
- ...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
1159
- });
1160
- return {
1161
- ok: true,
1162
- text: [
1163
- `Run this on the new ${goos}/${goarch} host:`,
1164
- "",
1165
- ` ${installOneLiner(grant)}`,
1166
- "",
1167
- `It installs talon-node ${bin.version} (sha256-verified against ${grant.sha256.slice(0, 12)}…), pins the bridge certificate, and registers a boot service — the host appears on the mesh within a minute.`,
1168
- `Single-use link, expires in 30 minutes. The host must be able to reach ${base}.`,
1169
- ].join("\n"),
1170
- };
646
+ return this.links.makeNodeInstallLink(os, arch, name, bridgeUrl);
1171
647
  }
1172
648
 
1173
- /**
1174
- * Mint a single-use link that connects a phone to this bridge without
1175
- * anyone typing an address or a token — the companion half of
1176
- * {@link makeNodeInstallLink}.
1177
- *
1178
- * Returns the link plus the values it carries, so a caller (a `/mesh`
1179
- * reply, say) can print both: the link is the one-tap path, the values are
1180
- * what someone falls back to when the app isn't installed yet.
1181
- */
649
+ /** Mint a single-use link that connects a phone to this bridge. */
1182
650
  makeCompanionPairLink(
1183
651
  label?: unknown,
1184
652
  bridgeUrl?: unknown,
1185
- ):
1186
- | {
1187
- ok: true;
1188
- link: string;
1189
- url: string;
1190
- token: string;
1191
- fingerprint?: string;
1192
- }
1193
- | { ok: false; text: string } {
1194
- const info = this.bridgeInfo;
1195
- if (!info) {
1196
- return {
1197
- ok: false,
1198
- text: "The native bridge isn't running, so there is nothing for a phone to connect to. Enable the native frontend first.",
1199
- };
1200
- }
1201
- if (!info.token) {
1202
- return {
1203
- ok: false,
1204
- text: "The bridge has no bearer token (loopback-only bind), and companions authenticate with one. Set native.host to a reachable address (a token is auto-minted) and restart.",
1205
- };
1206
- }
1207
- const base = this.bridgeBaseUrl(info, bridgeUrl);
1208
- if (typeof base !== "string") return { ok: false, text: base.error };
1209
- const grant = this.companionPairs.create({
1210
- bridgeUrl: base,
1211
- bearerToken: info.token,
1212
- ...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
1213
- ...(typeof label === "string" && label.trim()
1214
- ? { label: label.trim() }
1215
- : {}),
1216
- });
1217
- return {
1218
- ok: true,
1219
- link: pairLink(grant),
1220
- url: base,
1221
- token: info.token,
1222
- ...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
1223
- };
653
+ ): ReturnType<BridgeLinks["makeCompanionPairLink"]> {
654
+ return this.links.makeCompanionPairLink(label, bridgeUrl);
1224
655
  }
1225
656
 
1226
- /**
1227
- * GET /pair — serve a pairing grant (single-use), as the landing page or
1228
- * as the JSON the companion reads.
1229
- */
657
+ /** GET /pair — serve a pairing grant (single-use). */
1230
658
  openCompanionPair(
1231
659
  token: string,
1232
660
  format: "html" | "json",
1233
661
  ): { contentType: string; body: string } | null {
1234
- const payload = this.companionPairs.claim(token);
1235
- if (!payload) return null;
1236
- return format === "json"
1237
- ? {
1238
- contentType: "application/json; charset=utf-8",
1239
- body: JSON.stringify(payload satisfies CompanionPairPayload),
1240
- }
1241
- : {
1242
- contentType: "text/html; charset=utf-8",
1243
- body: pairPage(payload),
1244
- };
662
+ return this.links.openCompanionPair(token, format);
1245
663
  }
1246
664
 
1247
- /**
1248
- * How this bridge is reachable, for an operator asking "what do I point a
1249
- * device at?".
1250
- *
1251
- * The bearer token comes back with it. Deciding who may see a credential is
1252
- * the caller's job, not this seam's — the Telegram `/mesh` shows it to the
1253
- * configured admin and withholds it from everyone else — and an operator who
1254
- * asked their own daemon for its own connection details should get an
1255
- * answer, not a lecture.
1256
- */
1257
- bridgeReachability():
1258
- | {
1259
- ok: true;
1260
- url: string;
1261
- authRequired: boolean;
1262
- token?: string;
1263
- fingerprint?: string;
1264
- }
1265
- | { ok: false; text: string } {
1266
- const info = this.bridgeInfo;
1267
- if (!info) return { ok: false, text: "The native bridge isn't running." };
1268
- const base = this.bridgeBaseUrl(info);
1269
- if (typeof base !== "string") return { ok: false, text: base.error };
1270
- return {
1271
- ok: true,
1272
- url: base,
1273
- authRequired: Boolean(info.token),
1274
- ...(info.token ? { token: info.token } : {}),
1275
- ...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
1276
- };
665
+ /** How this bridge is reachable, for an operator asking where to point a device. */
666
+ bridgeReachability(): ReturnType<BridgeLinks["bridgeReachability"]> {
667
+ return this.links.bridgeReachability();
1277
668
  }
1278
669
 
1279
670
  /** GET /node/install — serve a grant's installer script (single-use). */
1280
671
  openNodeInstall(token: string): { script: string; filename: string } | null {
1281
- return this.provision.openScript(token);
672
+ return this.links.openNodeInstall(token);
1282
673
  }
1283
674
 
1284
675
  /** GET /node/binary — serve a grant's binary (single-use). */
1285
676
  openNodeBinary(token: string): { path: string; size: number } | null {
1286
- return this.provision.openBinary(token);
1287
- }
1288
-
1289
- /**
1290
- * The bridge base URL a NEW host should dial: an explicit override wins;
1291
- * otherwise derive from the bridge's bind. A wildcard bind maps to this
1292
- * host's first external IPv4; a loopback bind is unreachable from other
1293
- * machines, so it's an error rather than a link that can't work.
1294
- */
1295
- private bridgeBaseUrl(
1296
- info: MeshBridgeInfo,
1297
- explicit?: unknown,
1298
- ): string | { error: string } {
1299
- if (typeof explicit === "string" && explicit.trim()) {
1300
- const url = explicit.trim().replace(/\/+$/, "");
1301
- if (!/^https?:\/\/\S+$/.test(url)) {
1302
- return { error: `bridge_url must be an http(s) URL, got "${url}".` };
1303
- }
1304
- return url;
1305
- }
1306
- let host = info.host;
1307
- if (host === "0.0.0.0" || host === "::") {
1308
- const external = firstExternalIPv4();
1309
- if (!external) {
1310
- return {
1311
- error:
1312
- "Could not determine this host's external address — pass bridge_url explicitly (the URL the new node should dial).",
1313
- };
1314
- }
1315
- host = external;
1316
- } else if (isLoopbackAddress(host)) {
1317
- return {
1318
- error: `The bridge is bound to loopback (${host}), which other machines can't reach. Set native.host to a reachable address, or pass bridge_url if a tunnel exposes it.`,
1319
- };
1320
- }
1321
- return `${info.scheme}://${host}:${info.port}`;
1322
- }
1323
-
1324
- /**
1325
- * Chunked read of a remote file into a Buffer. Loops `read_file` with
1326
- * increasing offsets until the device reports EOF.
1327
- *
1328
- * End-of-file is the DEVICE's call (`eof: true`), never inferred from a
1329
- * short chunk: devices cap their chunk size (the companion serves at most
1330
- * 256KB per read regardless of the requested length), so a chunk shorter
1331
- * than the request is normal mid-file and treating it as EOF silently
1332
- * truncated every chunked read past the device's cap. A zero-length chunk
1333
- * without `eof` is a stuck transfer and fails loudly instead of looping.
1334
- */
1335
- private async pullBytes(
1336
- query: unknown,
1337
- path: string,
1338
- ): Promise<{ data: Buffer; deviceName: string } | { error: string }> {
1339
- const chunks: Buffer[] = [];
1340
- let offset = 0;
1341
- let deviceName = "device";
1342
- for (;;) {
1343
- const dispatched = await this.dispatchCommand(
1344
- query,
1345
- "read_file",
1346
- { path, offset, len: FILE_CHUNK_BYTES },
1347
- FS_COMMAND_TIMEOUT_MS,
1348
- );
1349
- if ("error" in dispatched) {
1350
- return {
1351
- error:
1352
- offset > 0
1353
- ? `${dispatched.error} (transfer of ${path} aborted after ${formatBytes(offset)})`
1354
- : dispatched.error,
1355
- };
1356
- }
1357
- deviceName = dispatched.target.name;
1358
- const { result } = dispatched;
1359
- if (!result.ok) {
1360
- const reason = result.message ?? `Could not read ${path}.`;
1361
- return {
1362
- error:
1363
- offset > 0
1364
- ? `${reason} (transfer aborted after ${formatBytes(offset)})`
1365
- : reason,
1366
- };
1367
- }
1368
- const b64 =
1369
- typeof result.data?.base64 === "string" ? result.data.base64 : "";
1370
- const chunk = Buffer.from(b64, "base64");
1371
- chunks.push(chunk);
1372
- offset += chunk.length;
1373
- if (result.data?.eof === true) break;
1374
- if (chunk.length === 0) {
1375
- return {
1376
- error: `${path} transfer stalled: the device returned an empty chunk without reporting end-of-file (after ${formatBytes(offset)}).`,
1377
- };
1378
- }
1379
- if (offset > MAX_CHUNKED_TRANSFER_BYTES) {
1380
- return {
1381
- error: `${path} exceeds the ${formatBytes(MAX_CHUNKED_TRANSFER_BYTES)} chunked-transfer limit (device never reported end-of-file after ${formatBytes(offset)}). Use device_pull_file with a streaming-capable companion build for large files.`,
1382
- };
1383
- }
1384
- }
1385
- try {
1386
- return { data: Buffer.concat(chunks), deviceName };
1387
- } catch (err) {
1388
- // Node buffer ceiling / out of memory — the one real size limit left.
1389
- return {
1390
- error: `${path} transferred ${formatBytes(offset)} but is too large to assemble in daemon memory: ${(err as Error).message}`,
1391
- };
1392
- }
1393
- }
1394
-
1395
- /**
1396
- * Chunked write of a Buffer to a remote file. The first chunk truncates the
1397
- * target; subsequent chunks append at their offset.
1398
- */
1399
- private async pushBytes(
1400
- query: unknown,
1401
- path: string,
1402
- data: Buffer,
1403
- ): Promise<{ bytes: number; deviceName: string } | { error: string }> {
1404
- let offset = 0;
1405
- let deviceName = "device";
1406
- // On a mid-transfer failure the device is left with a partial file —
1407
- // say so, with how far the transfer got, so the state isn't a mystery.
1408
- const partial = (reason: string): { error: string } => ({
1409
- error:
1410
- offset > 0
1411
- ? `${reason} (upload aborted — ${path} on the device is a ${formatBytes(offset)} partial write of ${formatBytes(data.length)})`
1412
- : reason,
1413
- });
1414
- // A zero-length write still needs one call to create/truncate the file.
1415
- do {
1416
- const chunk = data.subarray(offset, offset + FILE_CHUNK_BYTES);
1417
- const dispatched = await this.dispatchCommand(
1418
- query,
1419
- "write_file",
1420
- {
1421
- path,
1422
- base64: chunk.toString("base64"),
1423
- offset,
1424
- truncate: offset === 0,
1425
- },
1426
- FS_COMMAND_TIMEOUT_MS,
1427
- );
1428
- if ("error" in dispatched) return partial(dispatched.error);
1429
- deviceName = dispatched.target.name;
1430
- if (!dispatched.result.ok) {
1431
- return partial(dispatched.result.message ?? `Could not write ${path}.`);
1432
- }
1433
- offset += chunk.length;
1434
- } while (offset < data.length);
1435
- return { bytes: data.length, deviceName };
677
+ return this.links.openNodeBinary(token);
1436
678
  }
1437
679
 
1438
680
  /**
@@ -1747,15 +989,6 @@ function sampleEvenly<T>(items: T[], max: number): T[] {
1747
989
  return out;
1748
990
  }
1749
991
 
1750
- function age(ms: number): string {
1751
- const sec = Math.max(0, Math.round(ms / 1000));
1752
- if (sec < 60) return `${sec}s ago`;
1753
- const min = Math.round(sec / 60);
1754
- if (min < 60) return `${min}m ago`;
1755
- const hrs = Math.round(min / 60);
1756
- return `${hrs}h ago`;
1757
- }
1758
-
1759
992
  /** Clamp a caller-supplied exec timeout (seconds) to the allowed window. */
1760
993
  function clampExecTimeout(value: unknown): number {
1761
994
  const sec = typeof value === "number" ? value : Number(value);
@@ -1766,33 +999,6 @@ function clampExecTimeout(value: unknown): number {
1766
999
  );
1767
1000
  }
1768
1001
 
1769
- /** Trim a string path param, returning undefined when blank. */
1770
- function requirePath(value: unknown): string | undefined {
1771
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
1772
- }
1773
-
1774
- /** Error text for an os/arch pair outside the talon-node build matrix. */
1775
- function unknownTargetText(os: unknown, arch: unknown): string {
1776
- return `No talon-node target for os="${String(os)}", arch="${String(arch)}". Supported: ${NODE_TARGETS.map(
1777
- (t) => `${t.goos}/${t.goarch}`,
1778
- ).join(", ")} (macos ≡ darwin, x86_64 ≡ amd64, aarch64 ≡ arm64).`;
1779
- }
1780
-
1781
- /** First non-internal IPv4 on this host — the wildcard-bind fallback. */
1782
- function firstExternalIPv4(): string | undefined {
1783
- for (const list of Object.values(networkInterfaces())) {
1784
- for (const iface of list ?? []) {
1785
- if (!iface.internal && iface.family === "IPv4") return iface.address;
1786
- }
1787
- }
1788
- return undefined;
1789
- }
1790
-
1791
- function isLoopbackAddress(host: string): boolean {
1792
- const h = host.toLowerCase();
1793
- return h === "localhost" || h === "::1" || h.startsWith("127.");
1794
- }
1795
-
1796
1002
  /** Render an exec result: exit code headline + stdout/stderr blocks. */
1797
1003
  function formatExecResult(
1798
1004
  device: DeviceInfo,
@@ -1819,34 +1025,6 @@ function formatExecResult(
1819
1025
  return parts.join("\n");
1820
1026
  }
1821
1027
 
1822
- /** "12.4 MB/s in 3.2s" — observability for streamed transfers. */
1823
- function transferRate(bytes: number, startedAtMs: number): string {
1824
- const seconds = Math.max((Date.now() - startedAtMs) / 1000, 0.001);
1825
- return `${formatBytes(bytes / seconds)}/s over ${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}s`;
1826
- }
1827
-
1828
- function formatBytes(bytes: number): string {
1829
- if (bytes < 1024) return `${bytes} B`;
1830
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1831
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1832
- }
1833
-
1834
- /** Stream a file through SHA-256 without loading it into memory (APKs are big
1835
- * and Buffer has a hard ceiling). Returns the hex digest and byte size. */
1836
- async function hashFile(
1837
- path: string,
1838
- ): Promise<{ sha256: string; size: number }> {
1839
- const { size } = await stat(path);
1840
- const hash = createHash("sha256");
1841
- await new Promise<void>((resolve, reject) => {
1842
- createReadStream(path)
1843
- .on("data", (chunk) => hash.update(chunk))
1844
- .on("end", () => resolve())
1845
- .on("error", reject);
1846
- });
1847
- return { sha256: hash.digest("hex"), size };
1848
- }
1849
-
1850
1028
  // ── Process-wide instance ─────────────────────────────────────────────────────
1851
1029
 
1852
1030
  let instance: MeshService | null = null;