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
@@ -0,0 +1,710 @@
1
+ /**
2
+ * Device file transfer and self-update — the MeshService collaborator that
3
+ * moves bytes between the daemon host and a device.
4
+ *
5
+ * Two transports for a file body: the chunked command channel (one mesh
6
+ * round trip per chunk, base64 on the wire — the fallback for app builds
7
+ * that predate streaming) and the streamed path, where one command
8
+ * arranges a single-use token and the body then travels as a single raw
9
+ * HTTP request against the native bridge (see transfers.ts). The self-
10
+ * update flows (companion APK, headless node binary) are a push followed
11
+ * by a digest-verified install command.
12
+ *
13
+ * Everything device-resolution and command-dispatch related is the
14
+ * service's; this class reaches it through {@link DeviceFilesHost}.
15
+ */
16
+
17
+ import { randomUUID } from "node:crypto";
18
+ import { createHash } from "node:crypto";
19
+ import { createReadStream } from "node:fs";
20
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
21
+ import { tmpdir } from "node:os";
22
+ import { basename, dirname, join, resolve } from "node:path";
23
+ import type { Readable } from "node:stream";
24
+ import { dirs } from "../../util/paths.js";
25
+ import {
26
+ formatBytes,
27
+ FS_COMMAND_TIMEOUT_MS,
28
+ requirePath,
29
+ type MeshToolResult,
30
+ } from "./common.js";
31
+ import {
32
+ normalizeGoarch,
33
+ platformToGoos,
34
+ type NodeBinaryResolver,
35
+ } from "./node-binaries.js";
36
+ import { TransferStore } from "./transfers.js";
37
+ import type { DeviceCommandResult, DeviceInfo } from "./types.js";
38
+
39
+ /** The slice of MeshService a transfer needs. */
40
+ export interface DeviceFilesHost {
41
+ load(): Promise<void>;
42
+ resolveDevice(query?: unknown): { target: DeviceInfo } | { error: string };
43
+ dispatchCommand(
44
+ query: unknown,
45
+ name: string,
46
+ params: Record<string, unknown>,
47
+ timeoutMs?: number,
48
+ ): Promise<
49
+ { target: DeviceInfo; result: DeviceCommandResult } | { error: string }
50
+ >;
51
+ readonly commandTimeoutMs: number;
52
+ readonly resolveNode: NodeBinaryResolver;
53
+ }
54
+
55
+ /**
56
+ * Bytes of file payload per FALLBACK transfer chunk (base64 on the wire).
57
+ * The chunked command channel costs one full mesh round trip per chunk, so
58
+ * it's only used for app builds that don't advertise the streaming commands
59
+ * (`upload_file`/`download_file`) — modern builds move file bodies as a
60
+ * single raw HTTP stream instead (see TransferStore). 1MB keeps even the
61
+ * fallback tolerable without bloating a single SSE frame too far.
62
+ */
63
+ const FILE_CHUNK_BYTES = 1024 * 1024;
64
+ /** Wall-clock budget for one streamed transfer (command dispatch → done). */
65
+ const STREAM_TRANSFER_TIMEOUT_MS = 60 * 60 * 1000;
66
+ /** readFileBytes switches to the streaming path above this size. */
67
+ const STREAM_READ_THRESHOLD_BYTES = 4 * 1024 * 1024;
68
+ /**
69
+ * Hard ceiling on a chunked (command-channel) transfer. The chunked path
70
+ * assembles the whole file in daemon memory one mesh round trip at a time —
71
+ * past this size it's both a memory hazard and unusably slow, so fail with
72
+ * a pointer to the streaming path instead of grinding on.
73
+ */
74
+ const MAX_CHUNKED_TRANSFER_BYTES = 64 * 1024 * 1024;
75
+ /**
76
+ * No policy size cap on transfers — a transfer is attempted whatever the
77
+ * size and fails with a concrete error when a real limit bites (device read
78
+ * error, stream timeout, disk). The streamed paths are disk-to-disk and
79
+ * never hold the file in daemon memory; only the chunked FALLBACK and
80
+ * readFileBytes (whose callers need a Buffer) are memory-bound.
81
+ */
82
+ /**
83
+ * Where pulled device files land on the daemon host when no dest is given.
84
+ * Resolved lazily (not at module load) so a test that mocks `util/paths`
85
+ * doesn't hit its workspace binding before initialization.
86
+ */
87
+ function pullDir(): string {
88
+ return resolve(dirs.workspace, "mesh-pull");
89
+ }
90
+
91
+ export class DeviceFiles {
92
+ /** One-time tokens arranging streamed (single-HTTP-request) transfers. */
93
+ private readonly transfers = new TransferStore();
94
+
95
+ constructor(private readonly host: DeviceFilesHost) {}
96
+
97
+ // ── Streaming transfer bridge surface ─────────────────────────────────────
98
+ // The HTTP routes on the native bridge delegate here; the token is the
99
+ // entire authorization (single-use, device- and path-bound). `fromDeviceId`
100
+ // is the caller's self-declared identity — checked against the device the
101
+ // token was minted for, so a leaked token can't be redeemed by a peer.
102
+
103
+ /** POST /devices/file — a device streams a pull's file body up. */
104
+ acceptFileUpload(
105
+ token: string,
106
+ body: Readable,
107
+ fromDeviceId?: string,
108
+ ): Promise<{ ok: true; bytes: number } | { ok: false; error: string }> {
109
+ return this.transfers.acceptUpload(token, body, fromDeviceId);
110
+ }
111
+
112
+ /** GET /devices/file — a device asks for a push's file body. */
113
+ openFileDownload(
114
+ token: string,
115
+ fromDeviceId?: string,
116
+ ): Promise<{ path: string; size: number } | null> {
117
+ return this.transfers.openDownload(token, fromDeviceId);
118
+ }
119
+
120
+ /** Streaming is per-command capability — old app builds fall back. */
121
+ private canStream(
122
+ target: DeviceInfo,
123
+ command: "upload_file" | "download_file",
124
+ ): boolean {
125
+ return target.capabilities?.includes(command) ?? false;
126
+ }
127
+
128
+ /**
129
+ * Streamed device→daemon transfer: one command round trip to arrange it,
130
+ * then the file body arrives as a single raw HTTP request, written
131
+ * atomically to `dest`. Resolves with the byte count.
132
+ */
133
+ private async pullViaStream(
134
+ target: DeviceInfo,
135
+ remote: string,
136
+ dest: string,
137
+ ): Promise<{ bytes: number } | { error: string }> {
138
+ const { token, done } = this.transfers.createPull(target.id, dest);
139
+ const dispatched = await this.host.dispatchCommand(
140
+ target.id,
141
+ "upload_file",
142
+ { token, path: remote },
143
+ STREAM_TRANSFER_TIMEOUT_MS,
144
+ );
145
+ if ("error" in dispatched) {
146
+ this.transfers.cancel(token);
147
+ return { error: dispatched.error };
148
+ }
149
+ if (!dispatched.result.ok) {
150
+ this.transfers.cancel(token);
151
+ return {
152
+ error:
153
+ dispatched.result.message ??
154
+ `${target.name} could not upload ${remote}.`,
155
+ };
156
+ }
157
+ // The device answers the command AFTER its upload completes, so `done`
158
+ // is normally already resolved — the grace window only catches a device
159
+ // that claims success without having streamed anything.
160
+ try {
161
+ const bytes = await Promise.race([
162
+ done,
163
+ new Promise<never>((_, rej) =>
164
+ setTimeout(
165
+ () =>
166
+ rej(
167
+ new Error(
168
+ `${target.name} reported success but no upload arrived.`,
169
+ ),
170
+ ),
171
+ 15_000,
172
+ ).unref?.(),
173
+ ),
174
+ ]);
175
+ return { bytes };
176
+ } catch (err) {
177
+ this.transfers.cancel(token);
178
+ return { error: (err as Error).message };
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Raw file bytes off a device — the structured primitive under both the
184
+ * human-readable tool below and the native read/edit path (which must not
185
+ * have to parse a display envelope to recover the content).
186
+ *
187
+ * Small files ride the chunked command channel (one round trip). Files
188
+ * over the streaming threshold are pulled via the streaming path into a
189
+ * temp file first — the chunked channel pays a full mesh round trip per
190
+ * chunk and is far too slow for big payloads.
191
+ */
192
+ async readFileBytes(
193
+ query: unknown,
194
+ path: unknown,
195
+ ): Promise<{ data: Buffer; deviceName: string } | { error: string }> {
196
+ const p = requirePath(path);
197
+ if (!p) return { error: "A file path is required." };
198
+ await this.host.load();
199
+ const resolved = this.host.resolveDevice(query);
200
+ if ("error" in resolved) return { error: resolved.error };
201
+ const target = resolved.target;
202
+ if (this.canStream(target, "upload_file")) {
203
+ const size = await this.statSize(target.id, p);
204
+ if (size !== undefined && size > STREAM_READ_THRESHOLD_BYTES) {
205
+ const tmp = join(tmpdir(), `talon-pull-${randomUUID()}-${basename(p)}`);
206
+ const pulled = await this.pullViaStream(target, p, tmp);
207
+ if ("error" in pulled) return { error: pulled.error };
208
+ try {
209
+ const data = await readFile(tmp);
210
+ return { data, deviceName: target.name };
211
+ } catch (err) {
212
+ return {
213
+ error: `Pulled ${p} but could not read the temp copy: ${(err as Error).message}`,
214
+ };
215
+ } finally {
216
+ await rm(tmp, { force: true }).catch(() => {});
217
+ }
218
+ }
219
+ }
220
+ return this.pullBytes(target.id, p);
221
+ }
222
+
223
+ /** Size of a device path via the `stat` command, if the device can. */
224
+ private async statSize(
225
+ deviceId: string,
226
+ path: string,
227
+ ): Promise<number | undefined> {
228
+ const dispatched = await this.host.dispatchCommand(
229
+ deviceId,
230
+ "stat",
231
+ { path },
232
+ FS_COMMAND_TIMEOUT_MS,
233
+ );
234
+ if ("error" in dispatched || !dispatched.result.ok) return undefined;
235
+ const size = dispatched.result.data?.size;
236
+ return typeof size === "number" ? size : undefined;
237
+ }
238
+
239
+ /** `device_read_file`: read a (text) file off the device, chunked. */
240
+ async readFileFromDevice(
241
+ query: unknown,
242
+ path: unknown,
243
+ ): Promise<MeshToolResult> {
244
+ const p = requirePath(path);
245
+ if (!p) return { ok: false, text: "A file path is required." };
246
+ const buf = await this.readFileBytes(query, p);
247
+ if ("error" in buf) return { ok: false, text: buf.error };
248
+ return {
249
+ ok: true,
250
+ text: `${p} on ${buf.deviceName} (${formatBytes(buf.data.length)}):\n\n${buf.data.toString("utf8")}`,
251
+ };
252
+ }
253
+
254
+ /** `device_write_file`: write text content to a file on the device. */
255
+ async writeFileToDevice(
256
+ query: unknown,
257
+ path: unknown,
258
+ content: unknown,
259
+ ): Promise<MeshToolResult> {
260
+ const p = requirePath(path);
261
+ if (!p) return { ok: false, text: "A file path is required." };
262
+ // A non-string body must fail, not silently truncate the target to an
263
+ // empty file (an empty string is a legitimate truncate-to-zero).
264
+ if (typeof content !== "string") {
265
+ return { ok: false, text: "File content must be a string." };
266
+ }
267
+ const written = await this.pushBytes(
268
+ query,
269
+ p,
270
+ Buffer.from(content, "utf8"),
271
+ );
272
+ if ("error" in written) return { ok: false, text: written.error };
273
+ return {
274
+ ok: true,
275
+ text: `Wrote ${formatBytes(written.bytes)} to ${p} on ${written.deviceName}.`,
276
+ };
277
+ }
278
+
279
+ /** `device_pull_file`: copy a device file to the daemon host — streamed
280
+ * (single HTTP request, disk-to-disk) when the app supports it, chunked
281
+ * command-channel fallback otherwise. */
282
+ async pullFileFromDevice(
283
+ query: unknown,
284
+ remotePath: unknown,
285
+ localPath?: unknown,
286
+ ): Promise<MeshToolResult> {
287
+ const remote = requirePath(remotePath);
288
+ if (!remote) return { ok: false, text: "A remote file path is required." };
289
+ await this.host.load();
290
+ const resolved = this.host.resolveDevice(query);
291
+ if ("error" in resolved) return { ok: false, text: resolved.error };
292
+ const target = resolved.target;
293
+ const dest =
294
+ typeof localPath === "string" && localPath.trim()
295
+ ? resolve(dirs.workspace, localPath.trim())
296
+ : resolve(
297
+ pullDir(),
298
+ `${target.name.replace(/\W+/g, "_")}-${basename(remote)}`,
299
+ );
300
+ if (this.canStream(target, "upload_file")) {
301
+ const started = Date.now();
302
+ const pulled = await this.pullViaStream(target, remote, dest);
303
+ if ("error" in pulled) return { ok: false, text: pulled.error };
304
+ return {
305
+ ok: true,
306
+ text: `Pulled ${formatBytes(pulled.bytes)} from ${remote} on ${target.name} → ${dest} (streamed, ${transferRate(pulled.bytes, started)})`,
307
+ };
308
+ }
309
+ const buf = await this.pullBytes(target.id, remote);
310
+ if ("error" in buf) return { ok: false, text: buf.error };
311
+ await mkdir(dirname(dest), { recursive: true });
312
+ await writeFile(dest, buf.data);
313
+ return {
314
+ ok: true,
315
+ text: `Pulled ${formatBytes(buf.data.length)} from ${remote} on ${buf.deviceName} → ${dest} (chunked fallback — update the companion app for streamed transfers)`,
316
+ };
317
+ }
318
+
319
+ /** `device_push_file`: copy a daemon-host file to the device — streamed
320
+ * when the app supports it (never buffers the file in daemon memory),
321
+ * chunked command-channel fallback otherwise. */
322
+ async pushFileToDevice(
323
+ query: unknown,
324
+ localPath: unknown,
325
+ remotePath: unknown,
326
+ ): Promise<MeshToolResult> {
327
+ const remote = requirePath(remotePath);
328
+ if (!remote)
329
+ return { ok: false, text: "A remote destination path is required." };
330
+ const local =
331
+ typeof localPath === "string" && localPath.trim()
332
+ ? resolve(dirs.workspace, localPath.trim())
333
+ : "";
334
+ if (!local) return { ok: false, text: "A local source path is required." };
335
+ await this.host.load();
336
+ const resolved = this.host.resolveDevice(query);
337
+ if ("error" in resolved) return { ok: false, text: resolved.error };
338
+ const target = resolved.target;
339
+ if (this.canStream(target, "download_file")) {
340
+ const started = Date.now();
341
+ const { token } = this.transfers.createPush(target.id, local);
342
+ const dispatched = await this.host.dispatchCommand(
343
+ target.id,
344
+ "download_file",
345
+ { token, path: remote },
346
+ STREAM_TRANSFER_TIMEOUT_MS,
347
+ );
348
+ if ("error" in dispatched) {
349
+ this.transfers.cancel(token);
350
+ return { ok: false, text: dispatched.error };
351
+ }
352
+ if (!dispatched.result.ok) {
353
+ this.transfers.cancel(token);
354
+ return {
355
+ ok: false,
356
+ text:
357
+ dispatched.result.message ??
358
+ `${target.name} could not download ${local}.`,
359
+ };
360
+ }
361
+ const bytes = dispatched.result.data?.bytesWritten;
362
+ const size = typeof bytes === "number" ? bytes : 0;
363
+ return {
364
+ ok: true,
365
+ text: `Pushed ${formatBytes(size)} to ${remote} on ${target.name} (streamed, ${transferRate(size, started)})`,
366
+ };
367
+ }
368
+ let data: Buffer;
369
+ try {
370
+ data = await readFile(local);
371
+ } catch (err) {
372
+ // Includes Node's buffer-size ceiling (ERR_FS_FILE_TOO_LARGE) for
373
+ // files too big to hold in memory — surface the real reason.
374
+ return {
375
+ ok: false,
376
+ text: `Cannot read local file ${local}: ${(err as Error).message}`,
377
+ };
378
+ }
379
+ const written = await this.pushBytes(target.id, remote, data);
380
+ if ("error" in written) return { ok: false, text: written.error };
381
+ return {
382
+ ok: true,
383
+ text: `Pushed ${formatBytes(written.bytes)} to ${remote} on ${written.deviceName} (chunked fallback — update the companion app for streamed transfers).`,
384
+ };
385
+ }
386
+
387
+ /**
388
+ * `update_device`: remote self-update for the companion. Streams a new APK
389
+ * to the device, then tells it to silently install (root or Shizuku) and
390
+ * restart. The mesh foreground service's autoRunOnMyPackageReplaced brings
391
+ * the connection back on its own — the link drops only for the seconds the
392
+ * process is swapped, no manual reopen.
393
+ *
394
+ * The APK is hashed here and the digest travels with the install command;
395
+ * the device re-hashes the pushed file and refuses to install on a
396
+ * mismatch, so a truncated transfer can never be installed.
397
+ */
398
+ async updateDeviceApp(
399
+ query: unknown,
400
+ localApkPath: unknown,
401
+ remotePath?: unknown,
402
+ ): Promise<MeshToolResult> {
403
+ const local =
404
+ typeof localApkPath === "string" && localApkPath.trim()
405
+ ? resolve(dirs.workspace, localApkPath.trim())
406
+ : "";
407
+ if (!local) return { ok: false, text: "A local APK path is required." };
408
+ await this.host.load();
409
+ const resolved = this.host.resolveDevice(query);
410
+ if ("error" in resolved) return { ok: false, text: resolved.error };
411
+ const target = resolved.target;
412
+ if (target.capabilities && !target.capabilities.includes("install_apk")) {
413
+ return {
414
+ ok: false,
415
+ 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).`,
416
+ };
417
+ }
418
+
419
+ let sha256: string;
420
+ let size: number;
421
+ try {
422
+ ({ sha256, size } = await hashFile(local));
423
+ } catch (err) {
424
+ return {
425
+ ok: false,
426
+ text: `Cannot read APK ${local}: ${(err as Error).message}`,
427
+ };
428
+ }
429
+ if (size === 0) {
430
+ return { ok: false, text: `APK ${local} is empty.` };
431
+ }
432
+
433
+ const remote =
434
+ typeof remotePath === "string" && remotePath.trim()
435
+ ? remotePath.trim()
436
+ : "/sdcard/Download/talon-companion-update.apk";
437
+
438
+ // 1. Stream the APK to the device.
439
+ const push = await this.pushFileToDevice(target.id, local, remote);
440
+ if (!push.ok) {
441
+ return { ok: false, text: `Update aborted — push failed: ${push.text}` };
442
+ }
443
+
444
+ // 2. Trigger the silent install (device verifies the digest first).
445
+ const dispatched = await this.host.dispatchCommand(
446
+ target.id,
447
+ "install_apk",
448
+ { path: remote, sha256 },
449
+ this.host.commandTimeoutMs,
450
+ );
451
+ if ("error" in dispatched) return { ok: false, text: dispatched.error };
452
+ if (!dispatched.result.ok) {
453
+ return {
454
+ ok: false,
455
+ text:
456
+ dispatched.result.message ?? `${target.name} refused the install.`,
457
+ };
458
+ }
459
+ return {
460
+ ok: true,
461
+ text:
462
+ `Pushed ${formatBytes(size)} and staged the update on ${target.name}. ` +
463
+ `${dispatched.result.message ?? "Installing now."} ` +
464
+ `Confirm with get_device_status once it reconnects (appVersion should change).`,
465
+ };
466
+ }
467
+
468
+ /**
469
+ * `update_node`: remote self-update for a headless talon-node. Streams a
470
+ * replacement binary to the node, then sends `update_node` so the node
471
+ * verifies the digest, atomically swaps its own binary, and restarts into
472
+ * it (an in-place execve under systemd/launchd, so the mesh connection
473
+ * returns on its own within seconds — the same UX as the Android path).
474
+ *
475
+ * The binary is hashed here and the digest travels with the command; the
476
+ * node re-hashes the pushed file and refuses to swap on a mismatch, so a
477
+ * truncated transfer can never be installed.
478
+ *
479
+ * With no binary_path, the replacement is auto-resolved for the node's
480
+ * registered platform/arch (source build in a dev checkout, else the
481
+ * version-matched release download — see node-binaries.ts).
482
+ */
483
+ async updateNodeBinary(
484
+ query: unknown,
485
+ localBinaryPath?: unknown,
486
+ remotePath?: unknown,
487
+ ): Promise<MeshToolResult> {
488
+ await this.host.load();
489
+ const resolved = this.host.resolveDevice(query);
490
+ if ("error" in resolved) return { ok: false, text: resolved.error };
491
+ const target = resolved.target;
492
+ if (target.capabilities && !target.capabilities.includes("update_node")) {
493
+ return {
494
+ ok: false,
495
+ text: `${target.name} can't self-update — it needs the update_node capability (a talon-node headless device).`,
496
+ };
497
+ }
498
+
499
+ let local: string;
500
+ let provenance = "";
501
+ if (typeof localBinaryPath === "string" && localBinaryPath.trim()) {
502
+ local = resolve(dirs.workspace, localBinaryPath.trim());
503
+ } else {
504
+ const goos = platformToGoos(target.platform);
505
+ if (!goos) {
506
+ return {
507
+ ok: false,
508
+ text: `${target.name} is a ${target.platform} device — update_node targets headless nodes only.`,
509
+ };
510
+ }
511
+ const goarch = normalizeGoarch(target.arch);
512
+ if (!goarch) {
513
+ return {
514
+ ok: false,
515
+ 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.`,
516
+ };
517
+ }
518
+ try {
519
+ const bin = await this.host.resolveNode(goos, goarch);
520
+ local = bin.path;
521
+ provenance = ` (auto-resolved ${bin.version} for ${goos}/${goarch} via ${bin.source})`;
522
+ } catch (err) {
523
+ return { ok: false, text: (err as Error).message };
524
+ }
525
+ }
526
+
527
+ let sha256: string;
528
+ let size: number;
529
+ try {
530
+ ({ sha256, size } = await hashFile(local));
531
+ } catch (err) {
532
+ return {
533
+ ok: false,
534
+ text: `Cannot read binary ${local}: ${(err as Error).message}`,
535
+ };
536
+ }
537
+ if (size === 0) return { ok: false, text: `Binary ${local} is empty.` };
538
+
539
+ // Default staging path is /tmp on unix nodes (the node re-stages next to
540
+ // its own executable before the atomic swap, so this is only transient).
541
+ const remote =
542
+ typeof remotePath === "string" && remotePath.trim()
543
+ ? remotePath.trim()
544
+ : "/tmp/talon-node.update";
545
+
546
+ // 1. Stream the new binary to the node.
547
+ const push = await this.pushFileToDevice(target.id, local, remote);
548
+ if (!push.ok) {
549
+ return { ok: false, text: `Update aborted — push failed: ${push.text}` };
550
+ }
551
+
552
+ // 2. Trigger the swap + restart (node verifies the digest first).
553
+ const dispatched = await this.host.dispatchCommand(
554
+ target.id,
555
+ "update_node",
556
+ { path: remote, sha256 },
557
+ this.host.commandTimeoutMs,
558
+ );
559
+ if ("error" in dispatched) return { ok: false, text: dispatched.error };
560
+ if (!dispatched.result.ok) {
561
+ return {
562
+ ok: false,
563
+ text: dispatched.result.message ?? `${target.name} refused the update.`,
564
+ };
565
+ }
566
+ return {
567
+ ok: true,
568
+ text:
569
+ `Pushed ${formatBytes(size)}${provenance} and staged the update on ${target.name}. ` +
570
+ `${dispatched.result.message ?? "Restarting now."} ` +
571
+ `Confirm with get_device_status once it reconnects (appVersion should change).`,
572
+ };
573
+ }
574
+
575
+ /**
576
+ * Chunked read of a remote file into a Buffer. Loops `read_file` with
577
+ * increasing offsets until the device reports EOF.
578
+ *
579
+ * End-of-file is the DEVICE's call (`eof: true`), never inferred from a
580
+ * short chunk: devices cap their chunk size (the companion serves at most
581
+ * 256KB per read regardless of the requested length), so a chunk shorter
582
+ * than the request is normal mid-file and treating it as EOF silently
583
+ * truncated every chunked read past the device's cap. A zero-length chunk
584
+ * without `eof` is a stuck transfer and fails loudly instead of looping.
585
+ */
586
+ private async pullBytes(
587
+ query: unknown,
588
+ path: string,
589
+ ): Promise<{ data: Buffer; deviceName: string } | { error: string }> {
590
+ const chunks: Buffer[] = [];
591
+ let offset = 0;
592
+ let deviceName = "device";
593
+ for (;;) {
594
+ const dispatched = await this.host.dispatchCommand(
595
+ query,
596
+ "read_file",
597
+ { path, offset, len: FILE_CHUNK_BYTES },
598
+ FS_COMMAND_TIMEOUT_MS,
599
+ );
600
+ if ("error" in dispatched) {
601
+ return {
602
+ error:
603
+ offset > 0
604
+ ? `${dispatched.error} (transfer of ${path} aborted after ${formatBytes(offset)})`
605
+ : dispatched.error,
606
+ };
607
+ }
608
+ deviceName = dispatched.target.name;
609
+ const { result } = dispatched;
610
+ if (!result.ok) {
611
+ const reason = result.message ?? `Could not read ${path}.`;
612
+ return {
613
+ error:
614
+ offset > 0
615
+ ? `${reason} (transfer aborted after ${formatBytes(offset)})`
616
+ : reason,
617
+ };
618
+ }
619
+ const b64 =
620
+ typeof result.data?.base64 === "string" ? result.data.base64 : "";
621
+ const chunk = Buffer.from(b64, "base64");
622
+ chunks.push(chunk);
623
+ offset += chunk.length;
624
+ if (result.data?.eof === true) break;
625
+ if (chunk.length === 0) {
626
+ return {
627
+ error: `${path} transfer stalled: the device returned an empty chunk without reporting end-of-file (after ${formatBytes(offset)}).`,
628
+ };
629
+ }
630
+ if (offset > MAX_CHUNKED_TRANSFER_BYTES) {
631
+ return {
632
+ 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.`,
633
+ };
634
+ }
635
+ }
636
+ try {
637
+ return { data: Buffer.concat(chunks), deviceName };
638
+ } catch (err) {
639
+ // Node buffer ceiling / out of memory — the one real size limit left.
640
+ return {
641
+ error: `${path} transferred ${formatBytes(offset)} but is too large to assemble in daemon memory: ${(err as Error).message}`,
642
+ };
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Chunked write of a Buffer to a remote file. The first chunk truncates the
648
+ * target; subsequent chunks append at their offset.
649
+ */
650
+ private async pushBytes(
651
+ query: unknown,
652
+ path: string,
653
+ data: Buffer,
654
+ ): Promise<{ bytes: number; deviceName: string } | { error: string }> {
655
+ let offset = 0;
656
+ let deviceName = "device";
657
+ // On a mid-transfer failure the device is left with a partial file —
658
+ // say so, with how far the transfer got, so the state isn't a mystery.
659
+ const partial = (reason: string): { error: string } => ({
660
+ error:
661
+ offset > 0
662
+ ? `${reason} (upload aborted — ${path} on the device is a ${formatBytes(offset)} partial write of ${formatBytes(data.length)})`
663
+ : reason,
664
+ });
665
+ // A zero-length write still needs one call to create/truncate the file.
666
+ do {
667
+ const chunk = data.subarray(offset, offset + FILE_CHUNK_BYTES);
668
+ const dispatched = await this.host.dispatchCommand(
669
+ query,
670
+ "write_file",
671
+ {
672
+ path,
673
+ base64: chunk.toString("base64"),
674
+ offset,
675
+ truncate: offset === 0,
676
+ },
677
+ FS_COMMAND_TIMEOUT_MS,
678
+ );
679
+ if ("error" in dispatched) return partial(dispatched.error);
680
+ deviceName = dispatched.target.name;
681
+ if (!dispatched.result.ok) {
682
+ return partial(dispatched.result.message ?? `Could not write ${path}.`);
683
+ }
684
+ offset += chunk.length;
685
+ } while (offset < data.length);
686
+ return { bytes: data.length, deviceName };
687
+ }
688
+ }
689
+
690
+ /** "12.4 MB/s in 3.2s" — observability for streamed transfers. */
691
+ function transferRate(bytes: number, startedAtMs: number): string {
692
+ const seconds = Math.max((Date.now() - startedAtMs) / 1000, 0.001);
693
+ return `${formatBytes(bytes / seconds)}/s over ${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}s`;
694
+ }
695
+
696
+ /** Stream a file through SHA-256 without loading it into memory (APKs are big
697
+ * and Buffer has a hard ceiling). Returns the hex digest and byte size. */
698
+ async function hashFile(
699
+ path: string,
700
+ ): Promise<{ sha256: string; size: number }> {
701
+ const { size } = await stat(path);
702
+ const hash = createHash("sha256");
703
+ await new Promise<void>((resolve, reject) => {
704
+ createReadStream(path)
705
+ .on("data", (chunk) => hash.update(chunk))
706
+ .on("end", () => resolve())
707
+ .on("error", reject);
708
+ });
709
+ return { sha256: hash.digest("hex"), size };
710
+ }