talon-agent 1.35.0 → 1.37.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.
Files changed (46) hide show
  1. package/package.json +3 -3
  2. package/src/backend/claude-sdk/handler.ts +6 -2
  3. package/src/backend/claude-sdk/options.ts +41 -1
  4. package/src/backend/codex/handler/events.ts +1 -1
  5. package/src/backend/codex/handler/message.ts +1 -0
  6. package/src/backend/kilo/handler/message.ts +1 -0
  7. package/src/backend/kilo/handler/turn.ts +1 -0
  8. package/src/backend/openai-agents/handler/events.ts +1 -1
  9. package/src/backend/openai-agents/handler/message.ts +5 -1
  10. package/src/backend/opencode/handler/message.ts +1 -0
  11. package/src/backend/opencode/handler/turn.ts +1 -0
  12. package/src/backend/remote-server/events.ts +3 -1
  13. package/src/backend/shared/metrics.ts +26 -51
  14. package/src/bootstrap.ts +1 -0
  15. package/src/core/engine/gateway-actions/index.ts +2 -0
  16. package/src/core/engine/gateway-actions/mesh.ts +27 -0
  17. package/src/core/engine/gateway-actions/native.ts +607 -0
  18. package/src/core/mcp-hub/index.ts +3 -0
  19. package/src/core/mcp-hub/talon-server.ts +3 -0
  20. package/src/core/mesh/persist.ts +49 -0
  21. package/src/core/mesh/registry.ts +10 -18
  22. package/src/core/mesh/service.ts +754 -42
  23. package/src/core/mesh/teleport.ts +158 -0
  24. package/src/core/mesh/transfers.ts +186 -0
  25. package/src/core/tools/bridge.ts +85 -4
  26. package/src/core/tools/index.ts +23 -2
  27. package/src/core/tools/mesh.ts +83 -1
  28. package/src/core/tools/native.ts +138 -0
  29. package/src/core/tools/types.ts +2 -1
  30. package/src/frontend/discord/commands/admin.ts +9 -2
  31. package/src/frontend/discord/helpers.ts +7 -6
  32. package/src/frontend/native/chats.ts +2 -2
  33. package/src/frontend/native/index.ts +2 -0
  34. package/src/frontend/native/server.ts +40 -1
  35. package/src/frontend/telegram/commands/admin.ts +10 -2
  36. package/src/frontend/telegram/helpers/diagnostics.ts +7 -6
  37. package/src/storage/db.ts +6 -1
  38. package/src/storage/repositories/sessions-repo.ts +20 -1
  39. package/src/storage/sessions.ts +348 -4
  40. package/src/storage/sql/db.sql +5 -0
  41. package/src/storage/sql/schema.sql +2 -1
  42. package/src/storage/sql/sessions.sql +3 -3
  43. package/src/storage/sql/statements.generated.ts +8 -4
  44. package/src/util/config.ts +10 -0
  45. package/src/util/exec-output.ts +64 -0
  46. package/src/util/metrics.ts +148 -60
@@ -27,7 +27,14 @@
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 { clampExecOutput } from "../../util/exec-output.js";
35
+ import { dirs } from "../../util/paths.js";
30
36
  import { MeshRegistry } from "./registry.js";
37
+ import { TransferStore } from "./transfers.js";
31
38
  import type {
32
39
  DeviceCommand,
33
40
  DeviceCommandResult,
@@ -61,13 +68,69 @@ const MOBILE_PLATFORMS = new Set(["android", "ios"]);
61
68
  const DEFAULT_HISTORY_HOURS = 24;
62
69
  const MAX_HISTORY_LINES = 24;
63
70
 
71
+ // ── Exec / filesystem channel ───────────────────────────────────────────────
72
+ /** Default wall-clock budget for a remote shell command. */
73
+ const DEFAULT_EXEC_TIMEOUT_MS = 60_000;
74
+ /** Hard ceiling on a caller-requested exec timeout. */
75
+ const MAX_EXEC_TIMEOUT_MS = 300_000;
76
+ /** Timeout for a single filesystem command (list/stat/one chunk/etc.). */
77
+ const FS_COMMAND_TIMEOUT_MS = 30_000;
78
+ /**
79
+ * Bytes of file payload per FALLBACK transfer chunk (base64 on the wire).
80
+ * The chunked command channel costs one full mesh round trip per chunk, so
81
+ * it's only used for app builds that don't advertise the streaming commands
82
+ * (`upload_file`/`download_file`) — modern builds move file bodies as a
83
+ * single raw HTTP stream instead (see TransferStore). 1MB keeps even the
84
+ * fallback tolerable without bloating a single SSE frame too far.
85
+ */
86
+ const FILE_CHUNK_BYTES = 1024 * 1024;
87
+ /** Wall-clock budget for one streamed transfer (command dispatch → done). */
88
+ const STREAM_TRANSFER_TIMEOUT_MS = 60 * 60 * 1000;
89
+ /** readFileBytes switches to the streaming path above this size. */
90
+ const STREAM_READ_THRESHOLD_BYTES = 4 * 1024 * 1024;
91
+ /**
92
+ * Hard ceiling on a chunked (command-channel) transfer. The chunked path
93
+ * assembles the whole file in daemon memory one mesh round trip at a time —
94
+ * past this size it's both a memory hazard and unusably slow, so fail with
95
+ * a pointer to the streaming path instead of grinding on.
96
+ */
97
+ const MAX_CHUNKED_TRANSFER_BYTES = 64 * 1024 * 1024;
98
+ /**
99
+ * No policy size cap on transfers — a transfer is attempted whatever the
100
+ * size and fails with a concrete error when a real limit bites (device read
101
+ * error, stream timeout, disk). The streamed paths are disk-to-disk and
102
+ * never hold the file in daemon memory; only the chunked FALLBACK and
103
+ * readFileBytes (whose callers need a Buffer) are memory-bound.
104
+ */
105
+ /**
106
+ * Where pulled device files land on the daemon host when no dest is given.
107
+ * Resolved lazily (not at module load) so a test that mocks `util/paths`
108
+ * doesn't hit its workspace binding before initialization.
109
+ */
110
+ function pullDir(): string {
111
+ return resolve(dirs.workspace, "mesh-pull");
112
+ }
113
+
64
114
  export class MeshService {
65
115
  private readonly waiters = new Set<() => void>();
66
116
  private readonly transports = new Set<MeshTransport>();
67
117
  private readonly pendingCommands = new Map<
68
118
  string,
69
- (result: DeviceCommandResult) => void
119
+ {
120
+ deviceId: string;
121
+ resolve: (result: DeviceCommandResult) => void;
122
+ }
70
123
  >();
124
+ /**
125
+ * Server-side receipt time (this process's clock) of the last fix per
126
+ * device. Freshness is judged against THIS, never the device-supplied
127
+ * `loc.ts` — a companion whose clock runs behind would otherwise have
128
+ * genuinely fresh fixes rejected (every locate burning the full timeout),
129
+ * and one running ahead would have stale fixes accepted as fresh.
130
+ */
131
+ private readonly receivedAt = new Map<string, number>();
132
+ /** One-time tokens arranging streamed (single-HTTP-request) transfers. */
133
+ private readonly transfers = new TransferStore();
71
134
  private readonly freshFixTimeoutMs: number;
72
135
  private readonly pollIntervalMs: number;
73
136
  private readonly commandTimeoutMs: number;
@@ -113,15 +176,21 @@ export class MeshService {
113
176
  return this.transports.size > 0;
114
177
  }
115
178
 
116
- async register(body: Record<string, unknown>): Promise<DeviceInfo> {
179
+ async register(
180
+ body: Record<string, unknown>,
181
+ now?: number,
182
+ ): Promise<DeviceInfo> {
117
183
  await this.load();
118
- return this.registry.register(body);
184
+ return this.registry.register(body, now);
119
185
  }
120
186
 
121
187
  /** Store a reported fix and wake anyone waiting on a fresh location. */
122
188
  async storeLocation(body: Record<string, unknown>): Promise<DeviceLocation> {
123
189
  await this.load();
124
190
  const loc = await this.registry.storeLocation(body);
191
+ // Stamp arrival against our own clock so waitForFreshLocation is immune
192
+ // to device clock skew.
193
+ this.receivedAt.set(loc.deviceId, Date.now());
125
194
  for (const notify of [...this.waiters]) notify();
126
195
  return loc;
127
196
  }
@@ -145,11 +214,19 @@ export class MeshService {
145
214
  * A device answered a command (bridge route POST /devices/command-result).
146
215
  * Resolves the pending sendCommand; false when nothing was waiting (late
147
216
  * or unknown correlation id — harmless, just ignored).
217
+ *
218
+ * The result must come from the device the command was sent to: a reply
219
+ * whose deviceId names a DIFFERENT device is dropped (a confused or
220
+ * misbehaving companion must not be able to answer for its peers). A
221
+ * missing deviceId is tolerated for older app builds.
148
222
  */
149
223
  completeCommand(body: Record<string, unknown>): boolean {
150
224
  const commandId = typeof body.commandId === "string" ? body.commandId : "";
151
- const resolve = this.pendingCommands.get(commandId);
152
- if (!resolve) return false;
225
+ const pending = this.pendingCommands.get(commandId);
226
+ if (!pending) return false;
227
+ const from = typeof body.deviceId === "string" ? body.deviceId : "";
228
+ if (from && from !== pending.deviceId) return false;
229
+ const { resolve } = pending;
153
230
  this.pendingCommands.delete(commandId);
154
231
  resolve({
155
232
  commandId,
@@ -176,6 +253,7 @@ export class MeshService {
176
253
  device: DeviceInfo,
177
254
  name: string,
178
255
  params: Record<string, unknown> = {},
256
+ timeoutMs = this.commandTimeoutMs,
179
257
  ): Promise<DeviceCommandResult> {
180
258
  const command: DeviceCommand = {
181
259
  id: randomUUID(),
@@ -190,13 +268,16 @@ export class MeshService {
190
268
  commandId: command.id,
191
269
  deviceId: device.id,
192
270
  ok: false,
193
- message: `${device.name} did not answer within ${Math.round(this.commandTimeoutMs / 1000)}s (device ${device.online ? "was online" : "appears offline"}).`,
271
+ message: `${device.name} did not answer within ${Math.round(timeoutMs / 1000)}s (device ${device.online ? "was online" : "appears offline"}).`,
194
272
  });
195
- }, this.commandTimeoutMs);
273
+ }, timeoutMs);
196
274
  timer.unref?.();
197
- this.pendingCommands.set(command.id, (result) => {
198
- clearTimeout(timer);
199
- resolve(result);
275
+ this.pendingCommands.set(command.id, {
276
+ deviceId: device.id,
277
+ resolve: (result) => {
278
+ clearTimeout(timer);
279
+ resolve(result);
280
+ },
200
281
  });
201
282
  for (const transport of this.transports) {
202
283
  try {
@@ -230,22 +311,28 @@ export class MeshService {
230
311
  */
231
312
  async locateDevice(query?: unknown): Promise<MeshToolResult> {
232
313
  await this.load();
233
- const target = this.chooseDevice(query);
234
- if (!target) return this.noSuchDevice(query);
314
+ const resolved = this.resolveDevice(query);
315
+ if ("error" in resolved) return { ok: false, text: resolved.error };
316
+ const target = resolved.target;
235
317
  const requestedAt = Date.now();
236
318
  // Only wait out the fresh-fix window when a transport can actually
237
- // deliver the locate otherwise answer from persistence immediately.
319
+ // deliver the locate AND the device is currently present — pinging an
320
+ // offline device just burns the whole timeout for a fix that won't come,
321
+ // so answer from persistence immediately in that case.
238
322
  const dispatched = this.requestLocate(target.id);
239
- const fresh = dispatched
240
- ? await this.waitForFreshLocation(target.id, requestedAt)
241
- : undefined;
323
+ const fresh =
324
+ dispatched && target.online
325
+ ? await this.waitForFreshLocation(target.id, requestedAt)
326
+ : undefined;
242
327
  const loc = fresh ?? this.registry.getLocation(target.id);
243
328
  if (!loc) {
244
329
  return {
245
330
  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.`,
331
+ text: !dispatched
332
+ ? `No location is known for ${target.name}, and no companion transport is connected to request one.`
333
+ : target.online
334
+ ? `No location is known for ${target.name}. A locate request was sent, but no fix arrived within ${Math.round(this.freshFixTimeoutMs / 1000)}s.`
335
+ : `No location is known for ${target.name}, and it appears offline (last seen ${age(Date.now() - target.lastSeen)}).`,
249
336
  };
250
337
  }
251
338
  return { ok: true, text: this.locationSummary(target, loc) };
@@ -270,8 +357,9 @@ export class MeshService {
270
357
  hours?: unknown,
271
358
  ): Promise<MeshToolResult> {
272
359
  await this.load();
273
- const target = this.chooseDevice(query);
274
- if (!target) return this.noSuchDevice(query);
360
+ const resolved = this.resolveDevice(query);
361
+ if ("error" in resolved) return { ok: false, text: resolved.error };
362
+ const target = resolved.target;
275
363
  const windowHours = clampHours(hours);
276
364
  const sinceTs = Date.now() - windowHours * 3_600_000;
277
365
  const fixes = this.registry.getHistory(target.id, sinceTs);
@@ -289,6 +377,501 @@ export class MeshService {
289
377
  return this.commandTool(query, "status", {});
290
378
  }
291
379
 
380
+ // ── Exec + filesystem tools (teleport substrate) ───────────────────────────
381
+
382
+ /**
383
+ * Run a shell command on a device and return its stdout/stderr/exit code.
384
+ * The primitive under `device_exec` and the teleported native `bash` tool.
385
+ */
386
+ async execOnDevice(
387
+ query: unknown,
388
+ cmd: unknown,
389
+ cwd?: unknown,
390
+ timeoutSec?: unknown,
391
+ ): Promise<MeshToolResult> {
392
+ const command = typeof cmd === "string" ? cmd : "";
393
+ if (!command.trim()) {
394
+ return { ok: false, text: "No command given to run on the device." };
395
+ }
396
+ const budgetMs = clampExecTimeout(timeoutSec);
397
+ const dispatched = await this.dispatchCommand(
398
+ query,
399
+ "exec",
400
+ {
401
+ cmd: command,
402
+ ...(typeof cwd === "string" && cwd.trim() ? { cwd: cwd.trim() } : {}),
403
+ timeoutMs: budgetMs,
404
+ },
405
+ budgetMs + 5_000, // let the device's own timeout fire first
406
+ );
407
+ if ("error" in dispatched) return { ok: false, text: dispatched.error };
408
+ return {
409
+ ok: dispatched.result.ok,
410
+ text: formatExecResult(dispatched.target, dispatched.result),
411
+ };
412
+ }
413
+
414
+ /** `device_list_dir`: list a directory on the device. */
415
+ async listDirOnDevice(
416
+ query: unknown,
417
+ path: unknown,
418
+ ): Promise<MeshToolResult> {
419
+ const dir = requirePath(path);
420
+ if (!dir) return { ok: false, text: "A directory path is required." };
421
+ const dispatched = await this.dispatchCommand(
422
+ query,
423
+ "list_dir",
424
+ { path: dir },
425
+ FS_COMMAND_TIMEOUT_MS,
426
+ );
427
+ if ("error" in dispatched) return { ok: false, text: dispatched.error };
428
+ const { target, result } = dispatched;
429
+ if (!result.ok) {
430
+ return { ok: false, text: result.message ?? `Could not list ${dir}.` };
431
+ }
432
+ const entries = Array.isArray(result.data?.entries)
433
+ ? (result.data!.entries as Array<Record<string, unknown>>)
434
+ : [];
435
+ if (entries.length === 0) {
436
+ return { ok: true, text: `${dir} on ${target.name} is empty.` };
437
+ }
438
+ const lines = entries.map((e) => {
439
+ const name = String(e.name ?? "?");
440
+ const type = e.type === "dir" ? "/" : "";
441
+ const size =
442
+ typeof e.size === "number" && e.type !== "dir"
443
+ ? ` (${formatBytes(e.size)})`
444
+ : "";
445
+ return `- ${name}${type}${size}`;
446
+ });
447
+ return {
448
+ ok: true,
449
+ text: `${dir} on ${target.name} — ${entries.length} item(s):\n${lines.join("\n")}`,
450
+ };
451
+ }
452
+
453
+ /** `device_stat`: metadata for one path on the device. */
454
+ async statOnDevice(query: unknown, path: unknown): Promise<MeshToolResult> {
455
+ const p = requirePath(path);
456
+ if (!p) return { ok: false, text: "A path is required." };
457
+ const dispatched = await this.dispatchCommand(
458
+ query,
459
+ "stat",
460
+ { path: p },
461
+ FS_COMMAND_TIMEOUT_MS,
462
+ );
463
+ if ("error" in dispatched) return { ok: false, text: dispatched.error };
464
+ const { result } = dispatched;
465
+ if (!result.ok) {
466
+ return { ok: false, text: result.message ?? `Cannot stat ${p}.` };
467
+ }
468
+ const d = result.data ?? {};
469
+ const fields = Object.entries(d)
470
+ .filter(([, v]) => v !== null && v !== undefined && v !== "")
471
+ .map(([k, v]) => `${k}: ${String(v)}`);
472
+ return { ok: true, text: `${p} — ${fields.join(" · ")}` };
473
+ }
474
+
475
+ // ── Streaming transfer bridge surface ─────────────────────────────────────
476
+ // The HTTP routes on the native bridge delegate here; the token is the
477
+ // entire authorization (single-use, device- and path-bound).
478
+
479
+ /** POST /devices/file — a device streams a pull's file body up. */
480
+ acceptFileUpload(
481
+ token: string,
482
+ body: Readable,
483
+ ): Promise<{ ok: true; bytes: number } | { ok: false; error: string }> {
484
+ return this.transfers.acceptUpload(token, body);
485
+ }
486
+
487
+ /** GET /devices/file — a device asks for a push's file body. */
488
+ openFileDownload(
489
+ token: string,
490
+ ): Promise<{ path: string; size: number } | null> {
491
+ return this.transfers.openDownload(token);
492
+ }
493
+
494
+ /** Streaming is per-command capability — old app builds fall back. */
495
+ private canStream(
496
+ target: DeviceInfo,
497
+ command: "upload_file" | "download_file",
498
+ ): boolean {
499
+ return target.capabilities?.includes(command) ?? false;
500
+ }
501
+
502
+ /**
503
+ * Streamed device→daemon transfer: one command round trip to arrange it,
504
+ * then the file body arrives as a single raw HTTP request, written
505
+ * atomically to `dest`. Resolves with the byte count.
506
+ */
507
+ private async pullViaStream(
508
+ target: DeviceInfo,
509
+ remote: string,
510
+ dest: string,
511
+ ): Promise<{ bytes: number } | { error: string }> {
512
+ const { token, done } = this.transfers.createPull(target.id, dest);
513
+ const dispatched = await this.dispatchCommand(
514
+ target.id,
515
+ "upload_file",
516
+ { token, path: remote },
517
+ STREAM_TRANSFER_TIMEOUT_MS,
518
+ );
519
+ if ("error" in dispatched) {
520
+ this.transfers.cancel(token);
521
+ return { error: dispatched.error };
522
+ }
523
+ if (!dispatched.result.ok) {
524
+ this.transfers.cancel(token);
525
+ return {
526
+ error:
527
+ dispatched.result.message ??
528
+ `${target.name} could not upload ${remote}.`,
529
+ };
530
+ }
531
+ // The device answers the command AFTER its upload completes, so `done`
532
+ // is normally already resolved — the grace window only catches a device
533
+ // that claims success without having streamed anything.
534
+ try {
535
+ const bytes = await Promise.race([
536
+ done,
537
+ new Promise<never>((_, rej) =>
538
+ setTimeout(
539
+ () =>
540
+ rej(
541
+ new Error(
542
+ `${target.name} reported success but no upload arrived.`,
543
+ ),
544
+ ),
545
+ 15_000,
546
+ ).unref?.(),
547
+ ),
548
+ ]);
549
+ return { bytes };
550
+ } catch (err) {
551
+ this.transfers.cancel(token);
552
+ return { error: (err as Error).message };
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Raw file bytes off a device — the structured primitive under both the
558
+ * human-readable tool below and the native read/edit path (which must not
559
+ * have to parse a display envelope to recover the content).
560
+ *
561
+ * Small files ride the chunked command channel (one round trip). Files
562
+ * over the streaming threshold are pulled via the streaming path into a
563
+ * temp file first — the chunked channel pays a full mesh round trip per
564
+ * chunk and is far too slow for big payloads.
565
+ */
566
+ async readFileBytes(
567
+ query: unknown,
568
+ path: unknown,
569
+ ): Promise<{ data: Buffer; deviceName: string } | { error: string }> {
570
+ const p = requirePath(path);
571
+ if (!p) return { error: "A file path is required." };
572
+ await this.load();
573
+ const resolved = this.resolveDevice(query);
574
+ if ("error" in resolved) return { error: resolved.error };
575
+ const target = resolved.target;
576
+ if (this.canStream(target, "upload_file")) {
577
+ const size = await this.statSize(target.id, p);
578
+ if (size !== undefined && size > STREAM_READ_THRESHOLD_BYTES) {
579
+ const tmp = join(tmpdir(), `talon-pull-${randomUUID()}-${basename(p)}`);
580
+ const pulled = await this.pullViaStream(target, p, tmp);
581
+ if ("error" in pulled) return { error: pulled.error };
582
+ try {
583
+ const data = await readFile(tmp);
584
+ return { data, deviceName: target.name };
585
+ } catch (err) {
586
+ return {
587
+ error: `Pulled ${p} but could not read the temp copy: ${(err as Error).message}`,
588
+ };
589
+ } finally {
590
+ await rm(tmp, { force: true }).catch(() => {});
591
+ }
592
+ }
593
+ }
594
+ return this.pullBytes(target.id, p);
595
+ }
596
+
597
+ /** Size of a device path via the `stat` command, if the device can. */
598
+ private async statSize(
599
+ deviceId: string,
600
+ path: string,
601
+ ): Promise<number | undefined> {
602
+ const dispatched = await this.dispatchCommand(
603
+ deviceId,
604
+ "stat",
605
+ { path },
606
+ FS_COMMAND_TIMEOUT_MS,
607
+ );
608
+ if ("error" in dispatched || !dispatched.result.ok) return undefined;
609
+ const size = dispatched.result.data?.size;
610
+ return typeof size === "number" ? size : undefined;
611
+ }
612
+
613
+ /** `device_read_file`: read a (text) file off the device, chunked. */
614
+ async readFileFromDevice(
615
+ query: unknown,
616
+ path: unknown,
617
+ ): Promise<MeshToolResult> {
618
+ const p = requirePath(path);
619
+ if (!p) return { ok: false, text: "A file path is required." };
620
+ const buf = await this.readFileBytes(query, p);
621
+ if ("error" in buf) return { ok: false, text: buf.error };
622
+ return {
623
+ ok: true,
624
+ text: `${p} on ${buf.deviceName} (${formatBytes(buf.data.length)}):\n\n${buf.data.toString("utf8")}`,
625
+ };
626
+ }
627
+
628
+ /** `device_write_file`: write text content to a file on the device. */
629
+ async writeFileToDevice(
630
+ query: unknown,
631
+ path: unknown,
632
+ content: unknown,
633
+ ): Promise<MeshToolResult> {
634
+ const p = requirePath(path);
635
+ if (!p) return { ok: false, text: "A file path is required." };
636
+ // A non-string body must fail, not silently truncate the target to an
637
+ // empty file (an empty string is a legitimate truncate-to-zero).
638
+ if (typeof content !== "string") {
639
+ return { ok: false, text: "File content must be a string." };
640
+ }
641
+ const written = await this.pushBytes(
642
+ query,
643
+ p,
644
+ Buffer.from(content, "utf8"),
645
+ );
646
+ if ("error" in written) return { ok: false, text: written.error };
647
+ return {
648
+ ok: true,
649
+ text: `Wrote ${formatBytes(written.bytes)} to ${p} on ${written.deviceName}.`,
650
+ };
651
+ }
652
+
653
+ /** `device_pull_file`: copy a device file to the daemon host — streamed
654
+ * (single HTTP request, disk-to-disk) when the app supports it, chunked
655
+ * command-channel fallback otherwise. */
656
+ async pullFileFromDevice(
657
+ query: unknown,
658
+ remotePath: unknown,
659
+ localPath?: unknown,
660
+ ): Promise<MeshToolResult> {
661
+ const remote = requirePath(remotePath);
662
+ if (!remote) return { ok: false, text: "A remote file path is required." };
663
+ await this.load();
664
+ const resolved = this.resolveDevice(query);
665
+ if ("error" in resolved) return { ok: false, text: resolved.error };
666
+ const target = resolved.target;
667
+ const dest =
668
+ typeof localPath === "string" && localPath.trim()
669
+ ? resolve(dirs.workspace, localPath.trim())
670
+ : resolve(
671
+ pullDir(),
672
+ `${target.name.replace(/\W+/g, "_")}-${basename(remote)}`,
673
+ );
674
+ if (this.canStream(target, "upload_file")) {
675
+ const started = Date.now();
676
+ const pulled = await this.pullViaStream(target, remote, dest);
677
+ if ("error" in pulled) return { ok: false, text: pulled.error };
678
+ return {
679
+ ok: true,
680
+ text: `Pulled ${formatBytes(pulled.bytes)} from ${remote} on ${target.name} → ${dest} (streamed, ${transferRate(pulled.bytes, started)})`,
681
+ };
682
+ }
683
+ const buf = await this.pullBytes(target.id, remote);
684
+ if ("error" in buf) return { ok: false, text: buf.error };
685
+ await mkdir(dirname(dest), { recursive: true });
686
+ await writeFile(dest, buf.data);
687
+ return {
688
+ ok: true,
689
+ text: `Pulled ${formatBytes(buf.data.length)} from ${remote} on ${buf.deviceName} → ${dest} (chunked fallback — update the companion app for streamed transfers)`,
690
+ };
691
+ }
692
+
693
+ /** `device_push_file`: copy a daemon-host file to the device — streamed
694
+ * when the app supports it (never buffers the file in daemon memory),
695
+ * chunked command-channel fallback otherwise. */
696
+ async pushFileToDevice(
697
+ query: unknown,
698
+ localPath: unknown,
699
+ remotePath: unknown,
700
+ ): Promise<MeshToolResult> {
701
+ const remote = requirePath(remotePath);
702
+ if (!remote)
703
+ return { ok: false, text: "A remote destination path is required." };
704
+ const local =
705
+ typeof localPath === "string" && localPath.trim()
706
+ ? resolve(dirs.workspace, localPath.trim())
707
+ : "";
708
+ if (!local) return { ok: false, text: "A local source path is required." };
709
+ await this.load();
710
+ const resolved = this.resolveDevice(query);
711
+ if ("error" in resolved) return { ok: false, text: resolved.error };
712
+ const target = resolved.target;
713
+ if (this.canStream(target, "download_file")) {
714
+ const started = Date.now();
715
+ const { token } = this.transfers.createPush(target.id, local);
716
+ const dispatched = await this.dispatchCommand(
717
+ target.id,
718
+ "download_file",
719
+ { token, path: remote },
720
+ STREAM_TRANSFER_TIMEOUT_MS,
721
+ );
722
+ if ("error" in dispatched) {
723
+ this.transfers.cancel(token);
724
+ return { ok: false, text: dispatched.error };
725
+ }
726
+ if (!dispatched.result.ok) {
727
+ this.transfers.cancel(token);
728
+ return {
729
+ ok: false,
730
+ text:
731
+ dispatched.result.message ??
732
+ `${target.name} could not download ${local}.`,
733
+ };
734
+ }
735
+ const bytes = dispatched.result.data?.bytesWritten;
736
+ const size = typeof bytes === "number" ? bytes : 0;
737
+ return {
738
+ ok: true,
739
+ text: `Pushed ${formatBytes(size)} to ${remote} on ${target.name} (streamed, ${transferRate(size, started)})`,
740
+ };
741
+ }
742
+ let data: Buffer;
743
+ try {
744
+ data = await readFile(local);
745
+ } catch (err) {
746
+ // Includes Node's buffer-size ceiling (ERR_FS_FILE_TOO_LARGE) for
747
+ // files too big to hold in memory — surface the real reason.
748
+ return {
749
+ ok: false,
750
+ text: `Cannot read local file ${local}: ${(err as Error).message}`,
751
+ };
752
+ }
753
+ const written = await this.pushBytes(target.id, remote, data);
754
+ if ("error" in written) return { ok: false, text: written.error };
755
+ return {
756
+ ok: true,
757
+ text: `Pushed ${formatBytes(written.bytes)} to ${remote} on ${written.deviceName} (chunked fallback — update the companion app for streamed transfers).`,
758
+ };
759
+ }
760
+
761
+ /**
762
+ * Chunked read of a remote file into a Buffer. Loops `read_file` with
763
+ * increasing offsets until the device reports EOF.
764
+ *
765
+ * End-of-file is the DEVICE's call (`eof: true`), never inferred from a
766
+ * short chunk: devices cap their chunk size (the companion serves at most
767
+ * 256KB per read regardless of the requested length), so a chunk shorter
768
+ * than the request is normal mid-file and treating it as EOF silently
769
+ * truncated every chunked read past the device's cap. A zero-length chunk
770
+ * without `eof` is a stuck transfer and fails loudly instead of looping.
771
+ */
772
+ private async pullBytes(
773
+ query: unknown,
774
+ path: string,
775
+ ): Promise<{ data: Buffer; deviceName: string } | { error: string }> {
776
+ const chunks: Buffer[] = [];
777
+ let offset = 0;
778
+ let deviceName = "device";
779
+ for (;;) {
780
+ const dispatched = await this.dispatchCommand(
781
+ query,
782
+ "read_file",
783
+ { path, offset, len: FILE_CHUNK_BYTES },
784
+ FS_COMMAND_TIMEOUT_MS,
785
+ );
786
+ if ("error" in dispatched) {
787
+ return {
788
+ error:
789
+ offset > 0
790
+ ? `${dispatched.error} (transfer of ${path} aborted after ${formatBytes(offset)})`
791
+ : dispatched.error,
792
+ };
793
+ }
794
+ deviceName = dispatched.target.name;
795
+ const { result } = dispatched;
796
+ if (!result.ok) {
797
+ const reason = result.message ?? `Could not read ${path}.`;
798
+ return {
799
+ error:
800
+ offset > 0
801
+ ? `${reason} (transfer aborted after ${formatBytes(offset)})`
802
+ : reason,
803
+ };
804
+ }
805
+ const b64 =
806
+ typeof result.data?.base64 === "string" ? result.data.base64 : "";
807
+ const chunk = Buffer.from(b64, "base64");
808
+ chunks.push(chunk);
809
+ offset += chunk.length;
810
+ if (result.data?.eof === true) break;
811
+ if (chunk.length === 0) {
812
+ return {
813
+ error: `${path} transfer stalled: the device returned an empty chunk without reporting end-of-file (after ${formatBytes(offset)}).`,
814
+ };
815
+ }
816
+ if (offset > MAX_CHUNKED_TRANSFER_BYTES) {
817
+ return {
818
+ 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.`,
819
+ };
820
+ }
821
+ }
822
+ try {
823
+ return { data: Buffer.concat(chunks), deviceName };
824
+ } catch (err) {
825
+ // Node buffer ceiling / out of memory — the one real size limit left.
826
+ return {
827
+ error: `${path} transferred ${formatBytes(offset)} but is too large to assemble in daemon memory: ${(err as Error).message}`,
828
+ };
829
+ }
830
+ }
831
+
832
+ /**
833
+ * Chunked write of a Buffer to a remote file. The first chunk truncates the
834
+ * target; subsequent chunks append at their offset.
835
+ */
836
+ private async pushBytes(
837
+ query: unknown,
838
+ path: string,
839
+ data: Buffer,
840
+ ): Promise<{ bytes: number; deviceName: string } | { error: string }> {
841
+ let offset = 0;
842
+ let deviceName = "device";
843
+ // On a mid-transfer failure the device is left with a partial file —
844
+ // say so, with how far the transfer got, so the state isn't a mystery.
845
+ const partial = (reason: string): { error: string } => ({
846
+ error:
847
+ offset > 0
848
+ ? `${reason} (upload aborted — ${path} on the device is a ${formatBytes(offset)} partial write of ${formatBytes(data.length)})`
849
+ : reason,
850
+ });
851
+ // A zero-length write still needs one call to create/truncate the file.
852
+ do {
853
+ const chunk = data.subarray(offset, offset + FILE_CHUNK_BYTES);
854
+ const dispatched = await this.dispatchCommand(
855
+ query,
856
+ "write_file",
857
+ {
858
+ path,
859
+ base64: chunk.toString("base64"),
860
+ offset,
861
+ truncate: offset === 0,
862
+ },
863
+ FS_COMMAND_TIMEOUT_MS,
864
+ );
865
+ if ("error" in dispatched) return partial(dispatched.error);
866
+ deviceName = dispatched.target.name;
867
+ if (!dispatched.result.ok) {
868
+ return partial(dispatched.result.message ?? `Could not write ${path}.`);
869
+ }
870
+ offset += chunk.length;
871
+ } while (offset < data.length);
872
+ return { bytes: data.length, deviceName };
873
+ }
874
+
292
875
  /**
293
876
  * Shared command-tool flow: resolve the target, check its advertised
294
877
  * capabilities, require a transport, send, and render the device's answer.
@@ -298,42 +881,114 @@ export class MeshService {
298
881
  name: string,
299
882
  params: Record<string, unknown>,
300
883
  ): Promise<MeshToolResult> {
884
+ const dispatched = await this.dispatchCommand(query, name, params);
885
+ if ("error" in dispatched) return { ok: false, text: dispatched.error };
886
+ return {
887
+ ok: dispatched.result.ok,
888
+ text: this.commandSummary(dispatched.target, name, dispatched.result),
889
+ };
890
+ }
891
+
892
+ /**
893
+ * Resolve a target, validate capability + reachability, then send the
894
+ * command and return the raw result — the shared primitive under both the
895
+ * human-summary command tools (ring/status) and the structured exec/fs
896
+ * tools that format the payload themselves. Returns `{ error }` for the
897
+ * pre-flight failures (no device, unsupported, offline, no transport).
898
+ */
899
+ async dispatchCommand(
900
+ query: unknown,
901
+ name: string,
902
+ params: Record<string, unknown>,
903
+ timeoutMs?: number,
904
+ ): Promise<
905
+ { target: DeviceInfo; result: DeviceCommandResult } | { error: string }
906
+ > {
301
907
  await this.load();
302
- const target = this.chooseDevice(query);
303
- if (!target) return this.noSuchDevice(query);
908
+ const resolved = this.resolveDevice(query);
909
+ if ("error" in resolved) return { error: resolved.error };
910
+ const target = resolved.target;
304
911
  // Devices advertise what they can do; an explicit list that lacks the
305
912
  // command is a clean "can't" — absent list means an older app build, so
306
913
  // attempt it and let the timeout speak.
307
914
  if (target.capabilities && !target.capabilities.includes(name)) {
308
915
  return {
309
- ok: false,
310
- text: `${target.name} does not support "${name}" (supports: ${target.capabilities.join(", ")}).`,
916
+ error: `${target.name} does not support "${name}" (supports: ${target.capabilities.join(", ")}).`,
311
917
  };
312
918
  }
313
919
  if (this.transports.size === 0) {
314
920
  return {
315
- ok: false,
316
- text: `No companion transport is connected, so "${name}" cannot reach ${target.name}.`,
921
+ error: `No companion transport is connected, so "${name}" cannot reach ${target.name}.`,
317
922
  };
318
923
  }
319
- const result = await this.sendCommand(target, name, params);
320
- return {
321
- ok: result.ok,
322
- text: this.commandSummary(target, name, result),
323
- };
924
+ // Don't wait out a full timeout for a device that's plainly gone.
925
+ if (!target.online) {
926
+ return {
927
+ error: `${target.name} appears offline (last seen ${age(Date.now() - target.lastSeen)}), so "${name}" was not sent.`,
928
+ };
929
+ }
930
+ const result = await this.sendCommand(target, name, params, timeoutMs);
931
+ return { target, result };
324
932
  }
325
933
 
326
- /** Resolve a device by exact id, name fragment, or default (most recently
327
- * seen mobile device, falling back to the most recent device overall). */
934
+ /** Resolve a device by exact id, exact name, or unique name fragment;
935
+ * undefined when nothing (or more than one device) matches. */
328
936
  chooseDevice(query?: unknown): DeviceInfo | undefined {
937
+ const resolved = this.resolveDevice(query);
938
+ return "target" in resolved ? resolved.target : undefined;
939
+ }
940
+
941
+ /**
942
+ * Device resolution with a caller-facing error. Matching order: exact id,
943
+ * exact name (case-insensitive), then name fragment — with names and
944
+ * queries compared separator-insensitively ("pixel 10" finds
945
+ * "Google-Pixel10"). When several devices match, a sole ONLINE match wins
946
+ * (a stale duplicate registration must not shadow the live device);
947
+ * otherwise it's an explicit error, never a silent first pick — exec and
948
+ * file commands aimed at "pixel" must not land on whichever Pixel
949
+ * happened to register first. No query defaults to the most recently
950
+ * seen mobile device, then the most recent device overall.
951
+ */
952
+ resolveDevice(query?: unknown): { target: DeviceInfo } | { error: string } {
329
953
  const devices = this.registry.list().devices;
330
954
  if (typeof query === "string" && query.trim()) {
331
- const q = query.trim().toLowerCase();
332
- return devices.find(
333
- (d) => d.id.toLowerCase() === q || d.name.toLowerCase().includes(q),
955
+ const raw = query.trim();
956
+ const q = raw.toLowerCase();
957
+ const byId = devices.find((d) => d.id.toLowerCase() === q);
958
+ if (byId) return { target: byId };
959
+ const fold = (s: string): string =>
960
+ s.toLowerCase().replace(/[^a-z0-9]+/g, "");
961
+ const folded = fold(raw);
962
+ const pick = (
963
+ candidates: DeviceInfo[],
964
+ ): { target: DeviceInfo } | { error: string } | undefined => {
965
+ if (candidates.length === 1) return { target: candidates[0]! };
966
+ if (candidates.length > 1) {
967
+ const online = candidates.filter((d) => d.online);
968
+ if (online.length === 1) return { target: online[0]! };
969
+ return {
970
+ error: `"${raw}" matches ${candidates.length} devices: ${candidates
971
+ .map(
972
+ (d) =>
973
+ `${d.name} [id: ${d.id}, ${d.online ? "online" : "offline"}]`,
974
+ )
975
+ .join(", ")}. Use the device id or full name.`,
976
+ };
977
+ }
978
+ return undefined;
979
+ };
980
+ return (
981
+ pick(devices.filter((d) => fold(d.name) === folded)) ??
982
+ (folded
983
+ ? pick(devices.filter((d) => fold(d.name).includes(folded)))
984
+ : undefined) ?? { error: this.noSuchDevice(query).text }
334
985
  );
335
986
  }
336
- return devices.find((d) => MOBILE_PLATFORMS.has(d.platform)) ?? devices[0];
987
+ const fallback =
988
+ devices.find((d) => MOBILE_PLATFORMS.has(d.platform)) ?? devices[0];
989
+ return fallback
990
+ ? { target: fallback }
991
+ : { error: this.noSuchDevice(query).text };
337
992
  }
338
993
 
339
994
  // ── Formatting ─────────────────────────────────────────────────────────────
@@ -460,8 +1115,11 @@ export class MeshService {
460
1115
  deviceId: string,
461
1116
  requestedAt: number,
462
1117
  ): Promise<DeviceLocation | undefined> {
463
- const existing = this.registry.getLocation(deviceId);
464
- if (existing && existing.ts >= requestedAt) return existing;
1118
+ // Judge freshness by server-side arrival time, not the device-reported
1119
+ // `loc.ts` (which is subject to the companion's clock skew).
1120
+ if ((this.receivedAt.get(deviceId) ?? 0) >= requestedAt) {
1121
+ return this.registry.getLocation(deviceId);
1122
+ }
465
1123
  const deadline = Date.now() + this.freshFixTimeoutMs;
466
1124
  while (Date.now() < deadline) {
467
1125
  const remaining = deadline - Date.now();
@@ -477,8 +1135,9 @@ export class MeshService {
477
1135
  );
478
1136
  this.waiters.add(done);
479
1137
  });
480
- const loc = this.registry.getLocation(deviceId);
481
- if (loc && loc.ts >= requestedAt) return loc;
1138
+ if ((this.receivedAt.get(deviceId) ?? 0) >= requestedAt) {
1139
+ return this.registry.getLocation(deviceId);
1140
+ }
482
1141
  }
483
1142
  return undefined;
484
1143
  }
@@ -534,6 +1193,59 @@ function age(ms: number): string {
534
1193
  return `${hrs}h ago`;
535
1194
  }
536
1195
 
1196
+ /** Clamp a caller-supplied exec timeout (seconds) to the allowed window. */
1197
+ function clampExecTimeout(value: unknown): number {
1198
+ const sec = typeof value === "number" ? value : Number(value);
1199
+ if (!Number.isFinite(sec) || sec <= 0) return DEFAULT_EXEC_TIMEOUT_MS;
1200
+ return Math.min(
1201
+ MAX_EXEC_TIMEOUT_MS,
1202
+ Math.max(1_000, Math.round(sec * 1_000)),
1203
+ );
1204
+ }
1205
+
1206
+ /** Trim a string path param, returning undefined when blank. */
1207
+ function requirePath(value: unknown): string | undefined {
1208
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
1209
+ }
1210
+
1211
+ /** Render an exec result: exit code headline + stdout/stderr blocks. */
1212
+ function formatExecResult(
1213
+ device: DeviceInfo,
1214
+ result: DeviceCommandResult,
1215
+ ): string {
1216
+ if (!result.ok && !result.data) {
1217
+ return result.message ?? `${device.name} could not run the command.`;
1218
+ }
1219
+ const d = result.data ?? {};
1220
+ const exit = typeof d.exitCode === "number" ? d.exitCode : "?";
1221
+ const stdout = typeof d.stdout === "string" ? d.stdout : "";
1222
+ const stderr = typeof d.stderr === "string" ? d.stderr : "";
1223
+ const via = typeof d.via === "string" && d.via ? ` via ${d.via}` : "";
1224
+ const parts = [`[${device.name}${via}] exit ${exit}`];
1225
+ if (stdout.trim())
1226
+ parts.push(
1227
+ `--- stdout ---\n${clampExecOutput(stdout.replace(/\s+$/, ""))}`,
1228
+ );
1229
+ if (stderr.trim())
1230
+ parts.push(
1231
+ `--- stderr ---\n${clampExecOutput(stderr.replace(/\s+$/, ""))}`,
1232
+ );
1233
+ if (!stdout.trim() && !stderr.trim()) parts.push("(no output)");
1234
+ return parts.join("\n");
1235
+ }
1236
+
1237
+ /** "12.4 MB/s in 3.2s" — observability for streamed transfers. */
1238
+ function transferRate(bytes: number, startedAtMs: number): string {
1239
+ const seconds = Math.max((Date.now() - startedAtMs) / 1000, 0.001);
1240
+ return `${formatBytes(bytes / seconds)}/s over ${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}s`;
1241
+ }
1242
+
1243
+ function formatBytes(bytes: number): string {
1244
+ if (bytes < 1024) return `${bytes} B`;
1245
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1246
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1247
+ }
1248
+
537
1249
  // ── Process-wide instance ─────────────────────────────────────────────────────
538
1250
 
539
1251
  let instance: MeshService | null = null;