talon-agent 1.35.0 → 1.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/backend/claude-sdk/options.ts +41 -1
- package/src/bootstrap.ts +1 -0
- package/src/core/engine/gateway-actions/index.ts +2 -0
- package/src/core/engine/gateway-actions/mesh.ts +27 -0
- package/src/core/engine/gateway-actions/native.ts +574 -0
- package/src/core/mcp-hub/index.ts +3 -0
- package/src/core/mcp-hub/talon-server.ts +3 -0
- package/src/core/mesh/persist.ts +49 -0
- package/src/core/mesh/registry.ts +10 -18
- package/src/core/mesh/service.ts +632 -25
- package/src/core/mesh/teleport.ts +104 -0
- package/src/core/mesh/transfers.ts +186 -0
- package/src/core/tools/bridge.ts +85 -4
- package/src/core/tools/index.ts +23 -2
- package/src/core/tools/mesh.ts +82 -0
- package/src/core/tools/native.ts +138 -0
- package/src/core/tools/types.ts +2 -1
- package/src/frontend/native/index.ts +2 -0
- package/src/frontend/native/server.ts +40 -1
- package/src/util/config.ts +10 -0
package/src/core/mesh/service.ts
CHANGED
|
@@ -27,7 +27,13 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { randomUUID } from "node:crypto";
|
|
30
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
31
|
+
import { tmpdir } from "node:os";
|
|
32
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
33
|
+
import type { Readable } from "node:stream";
|
|
34
|
+
import { dirs } from "../../util/paths.js";
|
|
30
35
|
import { MeshRegistry } from "./registry.js";
|
|
36
|
+
import { TransferStore } from "./transfers.js";
|
|
31
37
|
import type {
|
|
32
38
|
DeviceCommand,
|
|
33
39
|
DeviceCommandResult,
|
|
@@ -61,6 +67,42 @@ const MOBILE_PLATFORMS = new Set(["android", "ios"]);
|
|
|
61
67
|
const DEFAULT_HISTORY_HOURS = 24;
|
|
62
68
|
const MAX_HISTORY_LINES = 24;
|
|
63
69
|
|
|
70
|
+
// ── Exec / filesystem channel ───────────────────────────────────────────────
|
|
71
|
+
/** Default wall-clock budget for a remote shell command. */
|
|
72
|
+
const DEFAULT_EXEC_TIMEOUT_MS = 60_000;
|
|
73
|
+
/** Hard ceiling on a caller-requested exec timeout. */
|
|
74
|
+
const MAX_EXEC_TIMEOUT_MS = 300_000;
|
|
75
|
+
/** Timeout for a single filesystem command (list/stat/one chunk/etc.). */
|
|
76
|
+
const FS_COMMAND_TIMEOUT_MS = 30_000;
|
|
77
|
+
/**
|
|
78
|
+
* Bytes of file payload per FALLBACK transfer chunk (base64 on the wire).
|
|
79
|
+
* The chunked command channel costs one full mesh round trip per chunk, so
|
|
80
|
+
* it's only used for app builds that don't advertise the streaming commands
|
|
81
|
+
* (`upload_file`/`download_file`) — modern builds move file bodies as a
|
|
82
|
+
* single raw HTTP stream instead (see TransferStore). 1MB keeps even the
|
|
83
|
+
* fallback tolerable without bloating a single SSE frame too far.
|
|
84
|
+
*/
|
|
85
|
+
const FILE_CHUNK_BYTES = 1024 * 1024;
|
|
86
|
+
/** Wall-clock budget for one streamed transfer (command dispatch → done). */
|
|
87
|
+
const STREAM_TRANSFER_TIMEOUT_MS = 60 * 60 * 1000;
|
|
88
|
+
/** readFileBytes switches to the streaming path above this size. */
|
|
89
|
+
const STREAM_READ_THRESHOLD_BYTES = 4 * 1024 * 1024;
|
|
90
|
+
/**
|
|
91
|
+
* No policy size cap on transfers — a transfer is attempted whatever the
|
|
92
|
+
* size and fails with a concrete error when a real limit bites (device read
|
|
93
|
+
* error, stream timeout, disk). The streamed paths are disk-to-disk and
|
|
94
|
+
* never hold the file in daemon memory; only the chunked FALLBACK and
|
|
95
|
+
* readFileBytes (whose callers need a Buffer) are memory-bound.
|
|
96
|
+
*/
|
|
97
|
+
/**
|
|
98
|
+
* Where pulled device files land on the daemon host when no dest is given.
|
|
99
|
+
* Resolved lazily (not at module load) so a test that mocks `util/paths`
|
|
100
|
+
* doesn't hit its workspace binding before initialization.
|
|
101
|
+
*/
|
|
102
|
+
function pullDir(): string {
|
|
103
|
+
return resolve(dirs.workspace, "mesh-pull");
|
|
104
|
+
}
|
|
105
|
+
|
|
64
106
|
export class MeshService {
|
|
65
107
|
private readonly waiters = new Set<() => void>();
|
|
66
108
|
private readonly transports = new Set<MeshTransport>();
|
|
@@ -68,6 +110,16 @@ export class MeshService {
|
|
|
68
110
|
string,
|
|
69
111
|
(result: DeviceCommandResult) => void
|
|
70
112
|
>();
|
|
113
|
+
/**
|
|
114
|
+
* Server-side receipt time (this process's clock) of the last fix per
|
|
115
|
+
* device. Freshness is judged against THIS, never the device-supplied
|
|
116
|
+
* `loc.ts` — a companion whose clock runs behind would otherwise have
|
|
117
|
+
* genuinely fresh fixes rejected (every locate burning the full timeout),
|
|
118
|
+
* and one running ahead would have stale fixes accepted as fresh.
|
|
119
|
+
*/
|
|
120
|
+
private readonly receivedAt = new Map<string, number>();
|
|
121
|
+
/** One-time tokens arranging streamed (single-HTTP-request) transfers. */
|
|
122
|
+
private readonly transfers = new TransferStore();
|
|
71
123
|
private readonly freshFixTimeoutMs: number;
|
|
72
124
|
private readonly pollIntervalMs: number;
|
|
73
125
|
private readonly commandTimeoutMs: number;
|
|
@@ -113,15 +165,21 @@ export class MeshService {
|
|
|
113
165
|
return this.transports.size > 0;
|
|
114
166
|
}
|
|
115
167
|
|
|
116
|
-
async register(
|
|
168
|
+
async register(
|
|
169
|
+
body: Record<string, unknown>,
|
|
170
|
+
now?: number,
|
|
171
|
+
): Promise<DeviceInfo> {
|
|
117
172
|
await this.load();
|
|
118
|
-
return this.registry.register(body);
|
|
173
|
+
return this.registry.register(body, now);
|
|
119
174
|
}
|
|
120
175
|
|
|
121
176
|
/** Store a reported fix and wake anyone waiting on a fresh location. */
|
|
122
177
|
async storeLocation(body: Record<string, unknown>): Promise<DeviceLocation> {
|
|
123
178
|
await this.load();
|
|
124
179
|
const loc = await this.registry.storeLocation(body);
|
|
180
|
+
// Stamp arrival against our own clock so waitForFreshLocation is immune
|
|
181
|
+
// to device clock skew.
|
|
182
|
+
this.receivedAt.set(loc.deviceId, Date.now());
|
|
125
183
|
for (const notify of [...this.waiters]) notify();
|
|
126
184
|
return loc;
|
|
127
185
|
}
|
|
@@ -176,6 +234,7 @@ export class MeshService {
|
|
|
176
234
|
device: DeviceInfo,
|
|
177
235
|
name: string,
|
|
178
236
|
params: Record<string, unknown> = {},
|
|
237
|
+
timeoutMs = this.commandTimeoutMs,
|
|
179
238
|
): Promise<DeviceCommandResult> {
|
|
180
239
|
const command: DeviceCommand = {
|
|
181
240
|
id: randomUUID(),
|
|
@@ -190,9 +249,9 @@ export class MeshService {
|
|
|
190
249
|
commandId: command.id,
|
|
191
250
|
deviceId: device.id,
|
|
192
251
|
ok: false,
|
|
193
|
-
message: `${device.name} did not answer within ${Math.round(
|
|
252
|
+
message: `${device.name} did not answer within ${Math.round(timeoutMs / 1000)}s (device ${device.online ? "was online" : "appears offline"}).`,
|
|
194
253
|
});
|
|
195
|
-
},
|
|
254
|
+
}, timeoutMs);
|
|
196
255
|
timer.unref?.();
|
|
197
256
|
this.pendingCommands.set(command.id, (result) => {
|
|
198
257
|
clearTimeout(timer);
|
|
@@ -234,18 +293,23 @@ export class MeshService {
|
|
|
234
293
|
if (!target) return this.noSuchDevice(query);
|
|
235
294
|
const requestedAt = Date.now();
|
|
236
295
|
// Only wait out the fresh-fix window when a transport can actually
|
|
237
|
-
// deliver the locate
|
|
296
|
+
// deliver the locate AND the device is currently present — pinging an
|
|
297
|
+
// offline device just burns the whole timeout for a fix that won't come,
|
|
298
|
+
// so answer from persistence immediately in that case.
|
|
238
299
|
const dispatched = this.requestLocate(target.id);
|
|
239
|
-
const fresh =
|
|
240
|
-
|
|
241
|
-
|
|
300
|
+
const fresh =
|
|
301
|
+
dispatched && target.online
|
|
302
|
+
? await this.waitForFreshLocation(target.id, requestedAt)
|
|
303
|
+
: undefined;
|
|
242
304
|
const loc = fresh ?? this.registry.getLocation(target.id);
|
|
243
305
|
if (!loc) {
|
|
244
306
|
return {
|
|
245
307
|
ok: false,
|
|
246
|
-
text: dispatched
|
|
247
|
-
? `No location is known for ${target.name}
|
|
248
|
-
:
|
|
308
|
+
text: !dispatched
|
|
309
|
+
? `No location is known for ${target.name}, and no companion transport is connected to request one.`
|
|
310
|
+
: target.online
|
|
311
|
+
? `No location is known for ${target.name}. A locate request was sent, but no fix arrived within ${Math.round(this.freshFixTimeoutMs / 1000)}s.`
|
|
312
|
+
: `No location is known for ${target.name}, and it appears offline (last seen ${age(Date.now() - target.lastSeen)}).`,
|
|
249
313
|
};
|
|
250
314
|
}
|
|
251
315
|
return { ok: true, text: this.locationSummary(target, loc) };
|
|
@@ -289,6 +353,473 @@ export class MeshService {
|
|
|
289
353
|
return this.commandTool(query, "status", {});
|
|
290
354
|
}
|
|
291
355
|
|
|
356
|
+
// ── Exec + filesystem tools (teleport substrate) ───────────────────────────
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Run a shell command on a device and return its stdout/stderr/exit code.
|
|
360
|
+
* The primitive under `device_exec` and the teleported native `bash` tool.
|
|
361
|
+
*/
|
|
362
|
+
async execOnDevice(
|
|
363
|
+
query: unknown,
|
|
364
|
+
cmd: unknown,
|
|
365
|
+
cwd?: unknown,
|
|
366
|
+
timeoutSec?: unknown,
|
|
367
|
+
): Promise<MeshToolResult> {
|
|
368
|
+
const command = typeof cmd === "string" ? cmd : "";
|
|
369
|
+
if (!command.trim()) {
|
|
370
|
+
return { ok: false, text: "No command given to run on the device." };
|
|
371
|
+
}
|
|
372
|
+
const budgetMs = clampExecTimeout(timeoutSec);
|
|
373
|
+
const dispatched = await this.dispatchCommand(
|
|
374
|
+
query,
|
|
375
|
+
"exec",
|
|
376
|
+
{
|
|
377
|
+
cmd: command,
|
|
378
|
+
...(typeof cwd === "string" && cwd.trim() ? { cwd: cwd.trim() } : {}),
|
|
379
|
+
timeoutMs: budgetMs,
|
|
380
|
+
},
|
|
381
|
+
budgetMs + 5_000, // let the device's own timeout fire first
|
|
382
|
+
);
|
|
383
|
+
if ("error" in dispatched) return { ok: false, text: dispatched.error };
|
|
384
|
+
return {
|
|
385
|
+
ok: dispatched.result.ok,
|
|
386
|
+
text: formatExecResult(dispatched.target, dispatched.result),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** `device_list_dir`: list a directory on the device. */
|
|
391
|
+
async listDirOnDevice(
|
|
392
|
+
query: unknown,
|
|
393
|
+
path: unknown,
|
|
394
|
+
): Promise<MeshToolResult> {
|
|
395
|
+
const dir = requirePath(path);
|
|
396
|
+
if (!dir) return { ok: false, text: "A directory path is required." };
|
|
397
|
+
const dispatched = await this.dispatchCommand(
|
|
398
|
+
query,
|
|
399
|
+
"list_dir",
|
|
400
|
+
{ path: dir },
|
|
401
|
+
FS_COMMAND_TIMEOUT_MS,
|
|
402
|
+
);
|
|
403
|
+
if ("error" in dispatched) return { ok: false, text: dispatched.error };
|
|
404
|
+
const { target, result } = dispatched;
|
|
405
|
+
if (!result.ok) {
|
|
406
|
+
return { ok: false, text: result.message ?? `Could not list ${dir}.` };
|
|
407
|
+
}
|
|
408
|
+
const entries = Array.isArray(result.data?.entries)
|
|
409
|
+
? (result.data!.entries as Array<Record<string, unknown>>)
|
|
410
|
+
: [];
|
|
411
|
+
if (entries.length === 0) {
|
|
412
|
+
return { ok: true, text: `${dir} on ${target.name} is empty.` };
|
|
413
|
+
}
|
|
414
|
+
const lines = entries.map((e) => {
|
|
415
|
+
const name = String(e.name ?? "?");
|
|
416
|
+
const type = e.type === "dir" ? "/" : "";
|
|
417
|
+
const size =
|
|
418
|
+
typeof e.size === "number" && e.type !== "dir"
|
|
419
|
+
? ` (${formatBytes(e.size)})`
|
|
420
|
+
: "";
|
|
421
|
+
return `- ${name}${type}${size}`;
|
|
422
|
+
});
|
|
423
|
+
return {
|
|
424
|
+
ok: true,
|
|
425
|
+
text: `${dir} on ${target.name} — ${entries.length} item(s):\n${lines.join("\n")}`,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** `device_stat`: metadata for one path on the device. */
|
|
430
|
+
async statOnDevice(query: unknown, path: unknown): Promise<MeshToolResult> {
|
|
431
|
+
const p = requirePath(path);
|
|
432
|
+
if (!p) return { ok: false, text: "A path is required." };
|
|
433
|
+
const dispatched = await this.dispatchCommand(
|
|
434
|
+
query,
|
|
435
|
+
"stat",
|
|
436
|
+
{ path: p },
|
|
437
|
+
FS_COMMAND_TIMEOUT_MS,
|
|
438
|
+
);
|
|
439
|
+
if ("error" in dispatched) return { ok: false, text: dispatched.error };
|
|
440
|
+
const { result } = dispatched;
|
|
441
|
+
if (!result.ok) {
|
|
442
|
+
return { ok: false, text: result.message ?? `Cannot stat ${p}.` };
|
|
443
|
+
}
|
|
444
|
+
const d = result.data ?? {};
|
|
445
|
+
const fields = Object.entries(d)
|
|
446
|
+
.filter(([, v]) => v !== null && v !== undefined && v !== "")
|
|
447
|
+
.map(([k, v]) => `${k}: ${String(v)}`);
|
|
448
|
+
return { ok: true, text: `${p} — ${fields.join(" · ")}` };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ── Streaming transfer bridge surface ─────────────────────────────────────
|
|
452
|
+
// The HTTP routes on the native bridge delegate here; the token is the
|
|
453
|
+
// entire authorization (single-use, device- and path-bound).
|
|
454
|
+
|
|
455
|
+
/** POST /devices/file — a device streams a pull's file body up. */
|
|
456
|
+
acceptFileUpload(
|
|
457
|
+
token: string,
|
|
458
|
+
body: Readable,
|
|
459
|
+
): Promise<{ ok: true; bytes: number } | { ok: false; error: string }> {
|
|
460
|
+
return this.transfers.acceptUpload(token, body);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** GET /devices/file — a device asks for a push's file body. */
|
|
464
|
+
openFileDownload(
|
|
465
|
+
token: string,
|
|
466
|
+
): Promise<{ path: string; size: number } | null> {
|
|
467
|
+
return this.transfers.openDownload(token);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Streaming is per-command capability — old app builds fall back. */
|
|
471
|
+
private canStream(
|
|
472
|
+
target: DeviceInfo,
|
|
473
|
+
command: "upload_file" | "download_file",
|
|
474
|
+
): boolean {
|
|
475
|
+
return target.capabilities?.includes(command) ?? false;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Streamed device→daemon transfer: one command round trip to arrange it,
|
|
480
|
+
* then the file body arrives as a single raw HTTP request, written
|
|
481
|
+
* atomically to `dest`. Resolves with the byte count.
|
|
482
|
+
*/
|
|
483
|
+
private async pullViaStream(
|
|
484
|
+
target: DeviceInfo,
|
|
485
|
+
remote: string,
|
|
486
|
+
dest: string,
|
|
487
|
+
): Promise<{ bytes: number } | { error: string }> {
|
|
488
|
+
const { token, done } = this.transfers.createPull(target.id, dest);
|
|
489
|
+
const dispatched = await this.dispatchCommand(
|
|
490
|
+
target.id,
|
|
491
|
+
"upload_file",
|
|
492
|
+
{ token, path: remote },
|
|
493
|
+
STREAM_TRANSFER_TIMEOUT_MS,
|
|
494
|
+
);
|
|
495
|
+
if ("error" in dispatched) {
|
|
496
|
+
this.transfers.cancel(token);
|
|
497
|
+
return { error: dispatched.error };
|
|
498
|
+
}
|
|
499
|
+
if (!dispatched.result.ok) {
|
|
500
|
+
this.transfers.cancel(token);
|
|
501
|
+
return {
|
|
502
|
+
error:
|
|
503
|
+
dispatched.result.message ??
|
|
504
|
+
`${target.name} could not upload ${remote}.`,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
// The device answers the command AFTER its upload completes, so `done`
|
|
508
|
+
// is normally already resolved — the grace window only catches a device
|
|
509
|
+
// that claims success without having streamed anything.
|
|
510
|
+
try {
|
|
511
|
+
const bytes = await Promise.race([
|
|
512
|
+
done,
|
|
513
|
+
new Promise<never>((_, rej) =>
|
|
514
|
+
setTimeout(
|
|
515
|
+
() =>
|
|
516
|
+
rej(
|
|
517
|
+
new Error(
|
|
518
|
+
`${target.name} reported success but no upload arrived.`,
|
|
519
|
+
),
|
|
520
|
+
),
|
|
521
|
+
15_000,
|
|
522
|
+
).unref?.(),
|
|
523
|
+
),
|
|
524
|
+
]);
|
|
525
|
+
return { bytes };
|
|
526
|
+
} catch (err) {
|
|
527
|
+
this.transfers.cancel(token);
|
|
528
|
+
return { error: (err as Error).message };
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Raw file bytes off a device — the structured primitive under both the
|
|
534
|
+
* human-readable tool below and the native read/edit path (which must not
|
|
535
|
+
* have to parse a display envelope to recover the content).
|
|
536
|
+
*
|
|
537
|
+
* Small files ride the chunked command channel (one round trip). Files
|
|
538
|
+
* over the streaming threshold are pulled via the streaming path into a
|
|
539
|
+
* temp file first — the chunked channel pays a full mesh round trip per
|
|
540
|
+
* chunk and is far too slow for big payloads.
|
|
541
|
+
*/
|
|
542
|
+
async readFileBytes(
|
|
543
|
+
query: unknown,
|
|
544
|
+
path: unknown,
|
|
545
|
+
): Promise<{ data: Buffer; deviceName: string } | { error: string }> {
|
|
546
|
+
const p = requirePath(path);
|
|
547
|
+
if (!p) return { error: "A file path is required." };
|
|
548
|
+
await this.load();
|
|
549
|
+
const target = this.chooseDevice(query);
|
|
550
|
+
if (target && this.canStream(target, "upload_file")) {
|
|
551
|
+
const size = await this.statSize(target.id, p);
|
|
552
|
+
if (size !== undefined && size > STREAM_READ_THRESHOLD_BYTES) {
|
|
553
|
+
const tmp = join(tmpdir(), `talon-pull-${randomUUID()}-${basename(p)}`);
|
|
554
|
+
const pulled = await this.pullViaStream(target, p, tmp);
|
|
555
|
+
if ("error" in pulled) return { error: pulled.error };
|
|
556
|
+
try {
|
|
557
|
+
const data = await readFile(tmp);
|
|
558
|
+
return { data, deviceName: target.name };
|
|
559
|
+
} catch (err) {
|
|
560
|
+
return {
|
|
561
|
+
error: `Pulled ${p} but could not read the temp copy: ${(err as Error).message}`,
|
|
562
|
+
};
|
|
563
|
+
} finally {
|
|
564
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return this.pullBytes(query, p);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Size of a device path via the `stat` command, if the device can. */
|
|
572
|
+
private async statSize(
|
|
573
|
+
deviceId: string,
|
|
574
|
+
path: string,
|
|
575
|
+
): Promise<number | undefined> {
|
|
576
|
+
const dispatched = await this.dispatchCommand(
|
|
577
|
+
deviceId,
|
|
578
|
+
"stat",
|
|
579
|
+
{ path },
|
|
580
|
+
FS_COMMAND_TIMEOUT_MS,
|
|
581
|
+
);
|
|
582
|
+
if ("error" in dispatched || !dispatched.result.ok) return undefined;
|
|
583
|
+
const size = dispatched.result.data?.size;
|
|
584
|
+
return typeof size === "number" ? size : undefined;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** `device_read_file`: read a (text) file off the device, chunked. */
|
|
588
|
+
async readFileFromDevice(
|
|
589
|
+
query: unknown,
|
|
590
|
+
path: unknown,
|
|
591
|
+
): Promise<MeshToolResult> {
|
|
592
|
+
const p = requirePath(path);
|
|
593
|
+
if (!p) return { ok: false, text: "A file path is required." };
|
|
594
|
+
const buf = await this.readFileBytes(query, p);
|
|
595
|
+
if ("error" in buf) return { ok: false, text: buf.error };
|
|
596
|
+
return {
|
|
597
|
+
ok: true,
|
|
598
|
+
text: `${p} on ${buf.deviceName} (${formatBytes(buf.data.length)}):\n\n${buf.data.toString("utf8")}`,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** `device_write_file`: write text content to a file on the device. */
|
|
603
|
+
async writeFileToDevice(
|
|
604
|
+
query: unknown,
|
|
605
|
+
path: unknown,
|
|
606
|
+
content: unknown,
|
|
607
|
+
): Promise<MeshToolResult> {
|
|
608
|
+
const p = requirePath(path);
|
|
609
|
+
if (!p) return { ok: false, text: "A file path is required." };
|
|
610
|
+
const text = typeof content === "string" ? content : "";
|
|
611
|
+
const written = await this.pushBytes(query, p, Buffer.from(text, "utf8"));
|
|
612
|
+
if ("error" in written) return { ok: false, text: written.error };
|
|
613
|
+
return {
|
|
614
|
+
ok: true,
|
|
615
|
+
text: `Wrote ${formatBytes(written.bytes)} to ${p} on ${written.deviceName}.`,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** `device_pull_file`: copy a device file to the daemon host — streamed
|
|
620
|
+
* (single HTTP request, disk-to-disk) when the app supports it, chunked
|
|
621
|
+
* command-channel fallback otherwise. */
|
|
622
|
+
async pullFileFromDevice(
|
|
623
|
+
query: unknown,
|
|
624
|
+
remotePath: unknown,
|
|
625
|
+
localPath?: unknown,
|
|
626
|
+
): Promise<MeshToolResult> {
|
|
627
|
+
const remote = requirePath(remotePath);
|
|
628
|
+
if (!remote) return { ok: false, text: "A remote file path is required." };
|
|
629
|
+
await this.load();
|
|
630
|
+
const target = this.chooseDevice(query);
|
|
631
|
+
if (!target) return this.noSuchDevice(query);
|
|
632
|
+
const dest =
|
|
633
|
+
typeof localPath === "string" && localPath.trim()
|
|
634
|
+
? resolve(dirs.workspace, localPath.trim())
|
|
635
|
+
: resolve(
|
|
636
|
+
pullDir(),
|
|
637
|
+
`${target.name.replace(/\W+/g, "_")}-${basename(remote)}`,
|
|
638
|
+
);
|
|
639
|
+
if (this.canStream(target, "upload_file")) {
|
|
640
|
+
const started = Date.now();
|
|
641
|
+
const pulled = await this.pullViaStream(target, remote, dest);
|
|
642
|
+
if ("error" in pulled) return { ok: false, text: pulled.error };
|
|
643
|
+
return {
|
|
644
|
+
ok: true,
|
|
645
|
+
text: `Pulled ${formatBytes(pulled.bytes)} from ${remote} on ${target.name} → ${dest} (streamed, ${transferRate(pulled.bytes, started)})`,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
const buf = await this.pullBytes(target.id, remote);
|
|
649
|
+
if ("error" in buf) return { ok: false, text: buf.error };
|
|
650
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
651
|
+
await writeFile(dest, buf.data);
|
|
652
|
+
return {
|
|
653
|
+
ok: true,
|
|
654
|
+
text: `Pulled ${formatBytes(buf.data.length)} from ${remote} on ${buf.deviceName} → ${dest} (chunked fallback — update the companion app for streamed transfers)`,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** `device_push_file`: copy a daemon-host file to the device — streamed
|
|
659
|
+
* when the app supports it (never buffers the file in daemon memory),
|
|
660
|
+
* chunked command-channel fallback otherwise. */
|
|
661
|
+
async pushFileToDevice(
|
|
662
|
+
query: unknown,
|
|
663
|
+
localPath: unknown,
|
|
664
|
+
remotePath: unknown,
|
|
665
|
+
): Promise<MeshToolResult> {
|
|
666
|
+
const remote = requirePath(remotePath);
|
|
667
|
+
if (!remote)
|
|
668
|
+
return { ok: false, text: "A remote destination path is required." };
|
|
669
|
+
const local =
|
|
670
|
+
typeof localPath === "string" && localPath.trim()
|
|
671
|
+
? resolve(dirs.workspace, localPath.trim())
|
|
672
|
+
: "";
|
|
673
|
+
if (!local) return { ok: false, text: "A local source path is required." };
|
|
674
|
+
await this.load();
|
|
675
|
+
const target = this.chooseDevice(query);
|
|
676
|
+
if (!target) return this.noSuchDevice(query);
|
|
677
|
+
if (this.canStream(target, "download_file")) {
|
|
678
|
+
const started = Date.now();
|
|
679
|
+
const { token } = this.transfers.createPush(target.id, local);
|
|
680
|
+
const dispatched = await this.dispatchCommand(
|
|
681
|
+
target.id,
|
|
682
|
+
"download_file",
|
|
683
|
+
{ token, path: remote },
|
|
684
|
+
STREAM_TRANSFER_TIMEOUT_MS,
|
|
685
|
+
);
|
|
686
|
+
if ("error" in dispatched) {
|
|
687
|
+
this.transfers.cancel(token);
|
|
688
|
+
return { ok: false, text: dispatched.error };
|
|
689
|
+
}
|
|
690
|
+
if (!dispatched.result.ok) {
|
|
691
|
+
this.transfers.cancel(token);
|
|
692
|
+
return {
|
|
693
|
+
ok: false,
|
|
694
|
+
text:
|
|
695
|
+
dispatched.result.message ??
|
|
696
|
+
`${target.name} could not download ${local}.`,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
const bytes = dispatched.result.data?.bytesWritten;
|
|
700
|
+
const size = typeof bytes === "number" ? bytes : 0;
|
|
701
|
+
return {
|
|
702
|
+
ok: true,
|
|
703
|
+
text: `Pushed ${formatBytes(size)} to ${remote} on ${target.name} (streamed, ${transferRate(size, started)})`,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
let data: Buffer;
|
|
707
|
+
try {
|
|
708
|
+
data = await readFile(local);
|
|
709
|
+
} catch (err) {
|
|
710
|
+
// Includes Node's buffer-size ceiling (ERR_FS_FILE_TOO_LARGE) for
|
|
711
|
+
// files too big to hold in memory — surface the real reason.
|
|
712
|
+
return {
|
|
713
|
+
ok: false,
|
|
714
|
+
text: `Cannot read local file ${local}: ${(err as Error).message}`,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
const written = await this.pushBytes(target.id, remote, data);
|
|
718
|
+
if ("error" in written) return { ok: false, text: written.error };
|
|
719
|
+
return {
|
|
720
|
+
ok: true,
|
|
721
|
+
text: `Pushed ${formatBytes(written.bytes)} to ${remote} on ${written.deviceName} (chunked fallback — update the companion app for streamed transfers).`,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Chunked read of a remote file into a Buffer. Loops `read_file` with
|
|
727
|
+
* increasing offsets until the device reports EOF.
|
|
728
|
+
*/
|
|
729
|
+
private async pullBytes(
|
|
730
|
+
query: unknown,
|
|
731
|
+
path: string,
|
|
732
|
+
): Promise<{ data: Buffer; deviceName: string } | { error: string }> {
|
|
733
|
+
const chunks: Buffer[] = [];
|
|
734
|
+
let offset = 0;
|
|
735
|
+
let deviceName = "device";
|
|
736
|
+
for (;;) {
|
|
737
|
+
const dispatched = await this.dispatchCommand(
|
|
738
|
+
query,
|
|
739
|
+
"read_file",
|
|
740
|
+
{ path, offset, len: FILE_CHUNK_BYTES },
|
|
741
|
+
FS_COMMAND_TIMEOUT_MS,
|
|
742
|
+
);
|
|
743
|
+
if ("error" in dispatched) {
|
|
744
|
+
return {
|
|
745
|
+
error:
|
|
746
|
+
offset > 0
|
|
747
|
+
? `${dispatched.error} (transfer of ${path} aborted after ${formatBytes(offset)})`
|
|
748
|
+
: dispatched.error,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
deviceName = dispatched.target.name;
|
|
752
|
+
const { result } = dispatched;
|
|
753
|
+
if (!result.ok) {
|
|
754
|
+
const reason = result.message ?? `Could not read ${path}.`;
|
|
755
|
+
return {
|
|
756
|
+
error:
|
|
757
|
+
offset > 0
|
|
758
|
+
? `${reason} (transfer aborted after ${formatBytes(offset)})`
|
|
759
|
+
: reason,
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
const b64 =
|
|
763
|
+
typeof result.data?.base64 === "string" ? result.data.base64 : "";
|
|
764
|
+
const chunk = Buffer.from(b64, "base64");
|
|
765
|
+
chunks.push(chunk);
|
|
766
|
+
offset += chunk.length;
|
|
767
|
+
// EOF when the device says so, or when it returns a short/empty chunk.
|
|
768
|
+
if (result.data?.eof === true || chunk.length < FILE_CHUNK_BYTES) break;
|
|
769
|
+
}
|
|
770
|
+
try {
|
|
771
|
+
return { data: Buffer.concat(chunks), deviceName };
|
|
772
|
+
} catch (err) {
|
|
773
|
+
// Node buffer ceiling / out of memory — the one real size limit left.
|
|
774
|
+
return {
|
|
775
|
+
error: `${path} transferred ${formatBytes(offset)} but is too large to assemble in daemon memory: ${(err as Error).message}`,
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Chunked write of a Buffer to a remote file. The first chunk truncates the
|
|
782
|
+
* target; subsequent chunks append at their offset.
|
|
783
|
+
*/
|
|
784
|
+
private async pushBytes(
|
|
785
|
+
query: unknown,
|
|
786
|
+
path: string,
|
|
787
|
+
data: Buffer,
|
|
788
|
+
): Promise<{ bytes: number; deviceName: string } | { error: string }> {
|
|
789
|
+
let offset = 0;
|
|
790
|
+
let deviceName = "device";
|
|
791
|
+
// On a mid-transfer failure the device is left with a partial file —
|
|
792
|
+
// say so, with how far the transfer got, so the state isn't a mystery.
|
|
793
|
+
const partial = (reason: string): { error: string } => ({
|
|
794
|
+
error:
|
|
795
|
+
offset > 0
|
|
796
|
+
? `${reason} (upload aborted — ${path} on the device is a ${formatBytes(offset)} partial write of ${formatBytes(data.length)})`
|
|
797
|
+
: reason,
|
|
798
|
+
});
|
|
799
|
+
// A zero-length write still needs one call to create/truncate the file.
|
|
800
|
+
do {
|
|
801
|
+
const chunk = data.subarray(offset, offset + FILE_CHUNK_BYTES);
|
|
802
|
+
const dispatched = await this.dispatchCommand(
|
|
803
|
+
query,
|
|
804
|
+
"write_file",
|
|
805
|
+
{
|
|
806
|
+
path,
|
|
807
|
+
base64: chunk.toString("base64"),
|
|
808
|
+
offset,
|
|
809
|
+
truncate: offset === 0,
|
|
810
|
+
},
|
|
811
|
+
FS_COMMAND_TIMEOUT_MS,
|
|
812
|
+
);
|
|
813
|
+
if ("error" in dispatched) return partial(dispatched.error);
|
|
814
|
+
deviceName = dispatched.target.name;
|
|
815
|
+
if (!dispatched.result.ok) {
|
|
816
|
+
return partial(dispatched.result.message ?? `Could not write ${path}.`);
|
|
817
|
+
}
|
|
818
|
+
offset += chunk.length;
|
|
819
|
+
} while (offset < data.length);
|
|
820
|
+
return { bytes: data.length, deviceName };
|
|
821
|
+
}
|
|
822
|
+
|
|
292
823
|
/**
|
|
293
824
|
* Shared command-tool flow: resolve the target, check its advertised
|
|
294
825
|
* capabilities, require a transport, send, and render the device's answer.
|
|
@@ -298,29 +829,53 @@ export class MeshService {
|
|
|
298
829
|
name: string,
|
|
299
830
|
params: Record<string, unknown>,
|
|
300
831
|
): Promise<MeshToolResult> {
|
|
832
|
+
const dispatched = await this.dispatchCommand(query, name, params);
|
|
833
|
+
if ("error" in dispatched) return { ok: false, text: dispatched.error };
|
|
834
|
+
return {
|
|
835
|
+
ok: dispatched.result.ok,
|
|
836
|
+
text: this.commandSummary(dispatched.target, name, dispatched.result),
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Resolve a target, validate capability + reachability, then send the
|
|
842
|
+
* command and return the raw result — the shared primitive under both the
|
|
843
|
+
* human-summary command tools (ring/status) and the structured exec/fs
|
|
844
|
+
* tools that format the payload themselves. Returns `{ error }` for the
|
|
845
|
+
* pre-flight failures (no device, unsupported, offline, no transport).
|
|
846
|
+
*/
|
|
847
|
+
async dispatchCommand(
|
|
848
|
+
query: unknown,
|
|
849
|
+
name: string,
|
|
850
|
+
params: Record<string, unknown>,
|
|
851
|
+
timeoutMs?: number,
|
|
852
|
+
): Promise<
|
|
853
|
+
{ target: DeviceInfo; result: DeviceCommandResult } | { error: string }
|
|
854
|
+
> {
|
|
301
855
|
await this.load();
|
|
302
856
|
const target = this.chooseDevice(query);
|
|
303
|
-
if (!target) return this.noSuchDevice(query);
|
|
857
|
+
if (!target) return { error: this.noSuchDevice(query).text };
|
|
304
858
|
// Devices advertise what they can do; an explicit list that lacks the
|
|
305
859
|
// command is a clean "can't" — absent list means an older app build, so
|
|
306
860
|
// attempt it and let the timeout speak.
|
|
307
861
|
if (target.capabilities && !target.capabilities.includes(name)) {
|
|
308
862
|
return {
|
|
309
|
-
|
|
310
|
-
text: `${target.name} does not support "${name}" (supports: ${target.capabilities.join(", ")}).`,
|
|
863
|
+
error: `${target.name} does not support "${name}" (supports: ${target.capabilities.join(", ")}).`,
|
|
311
864
|
};
|
|
312
865
|
}
|
|
313
866
|
if (this.transports.size === 0) {
|
|
314
867
|
return {
|
|
315
|
-
|
|
316
|
-
text: `No companion transport is connected, so "${name}" cannot reach ${target.name}.`,
|
|
868
|
+
error: `No companion transport is connected, so "${name}" cannot reach ${target.name}.`,
|
|
317
869
|
};
|
|
318
870
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
871
|
+
// Don't wait out a full timeout for a device that's plainly gone.
|
|
872
|
+
if (!target.online) {
|
|
873
|
+
return {
|
|
874
|
+
error: `${target.name} appears offline (last seen ${age(Date.now() - target.lastSeen)}), so "${name}" was not sent.`,
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
const result = await this.sendCommand(target, name, params, timeoutMs);
|
|
878
|
+
return { target, result };
|
|
324
879
|
}
|
|
325
880
|
|
|
326
881
|
/** Resolve a device by exact id, name fragment, or default (most recently
|
|
@@ -460,8 +1015,11 @@ export class MeshService {
|
|
|
460
1015
|
deviceId: string,
|
|
461
1016
|
requestedAt: number,
|
|
462
1017
|
): Promise<DeviceLocation | undefined> {
|
|
463
|
-
|
|
464
|
-
|
|
1018
|
+
// Judge freshness by server-side arrival time, not the device-reported
|
|
1019
|
+
// `loc.ts` (which is subject to the companion's clock skew).
|
|
1020
|
+
if ((this.receivedAt.get(deviceId) ?? 0) >= requestedAt) {
|
|
1021
|
+
return this.registry.getLocation(deviceId);
|
|
1022
|
+
}
|
|
465
1023
|
const deadline = Date.now() + this.freshFixTimeoutMs;
|
|
466
1024
|
while (Date.now() < deadline) {
|
|
467
1025
|
const remaining = deadline - Date.now();
|
|
@@ -477,8 +1035,9 @@ export class MeshService {
|
|
|
477
1035
|
);
|
|
478
1036
|
this.waiters.add(done);
|
|
479
1037
|
});
|
|
480
|
-
|
|
481
|
-
|
|
1038
|
+
if ((this.receivedAt.get(deviceId) ?? 0) >= requestedAt) {
|
|
1039
|
+
return this.registry.getLocation(deviceId);
|
|
1040
|
+
}
|
|
482
1041
|
}
|
|
483
1042
|
return undefined;
|
|
484
1043
|
}
|
|
@@ -534,6 +1093,54 @@ function age(ms: number): string {
|
|
|
534
1093
|
return `${hrs}h ago`;
|
|
535
1094
|
}
|
|
536
1095
|
|
|
1096
|
+
/** Clamp a caller-supplied exec timeout (seconds) to the allowed window. */
|
|
1097
|
+
function clampExecTimeout(value: unknown): number {
|
|
1098
|
+
const sec = typeof value === "number" ? value : Number(value);
|
|
1099
|
+
if (!Number.isFinite(sec) || sec <= 0) return DEFAULT_EXEC_TIMEOUT_MS;
|
|
1100
|
+
return Math.min(
|
|
1101
|
+
MAX_EXEC_TIMEOUT_MS,
|
|
1102
|
+
Math.max(1_000, Math.round(sec * 1_000)),
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** Trim a string path param, returning undefined when blank. */
|
|
1107
|
+
function requirePath(value: unknown): string | undefined {
|
|
1108
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/** Render an exec result: exit code headline + stdout/stderr blocks. */
|
|
1112
|
+
function formatExecResult(
|
|
1113
|
+
device: DeviceInfo,
|
|
1114
|
+
result: DeviceCommandResult,
|
|
1115
|
+
): string {
|
|
1116
|
+
if (!result.ok && !result.data) {
|
|
1117
|
+
return result.message ?? `${device.name} could not run the command.`;
|
|
1118
|
+
}
|
|
1119
|
+
const d = result.data ?? {};
|
|
1120
|
+
const exit = typeof d.exitCode === "number" ? d.exitCode : "?";
|
|
1121
|
+
const stdout = typeof d.stdout === "string" ? d.stdout : "";
|
|
1122
|
+
const stderr = typeof d.stderr === "string" ? d.stderr : "";
|
|
1123
|
+
const parts = [`[${device.name}] exit ${exit}`];
|
|
1124
|
+
if (stdout.trim())
|
|
1125
|
+
parts.push(`--- stdout ---\n${stdout.replace(/\s+$/, "")}`);
|
|
1126
|
+
if (stderr.trim())
|
|
1127
|
+
parts.push(`--- stderr ---\n${stderr.replace(/\s+$/, "")}`);
|
|
1128
|
+
if (!stdout.trim() && !stderr.trim()) parts.push("(no output)");
|
|
1129
|
+
return parts.join("\n");
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/** "12.4 MB/s in 3.2s" — observability for streamed transfers. */
|
|
1133
|
+
function transferRate(bytes: number, startedAtMs: number): string {
|
|
1134
|
+
const seconds = Math.max((Date.now() - startedAtMs) / 1000, 0.001);
|
|
1135
|
+
return `${formatBytes(bytes / seconds)}/s over ${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}s`;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function formatBytes(bytes: number): string {
|
|
1139
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
1140
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1141
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
537
1144
|
// ── Process-wide instance ─────────────────────────────────────────────────────
|
|
538
1145
|
|
|
539
1146
|
let instance: MeshService | null = null;
|