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.
- package/package.json +3 -3
- package/src/backend/claude-sdk/handler.ts +6 -2
- package/src/backend/claude-sdk/options.ts +41 -1
- package/src/backend/codex/handler/events.ts +1 -1
- package/src/backend/codex/handler/message.ts +1 -0
- package/src/backend/kilo/handler/message.ts +1 -0
- package/src/backend/kilo/handler/turn.ts +1 -0
- package/src/backend/openai-agents/handler/events.ts +1 -1
- package/src/backend/openai-agents/handler/message.ts +5 -1
- package/src/backend/opencode/handler/message.ts +1 -0
- package/src/backend/opencode/handler/turn.ts +1 -0
- package/src/backend/remote-server/events.ts +3 -1
- package/src/backend/shared/metrics.ts +26 -51
- 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 +607 -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 +754 -42
- package/src/core/mesh/teleport.ts +158 -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 +83 -1
- package/src/core/tools/native.ts +138 -0
- package/src/core/tools/types.ts +2 -1
- package/src/frontend/discord/commands/admin.ts +9 -2
- package/src/frontend/discord/helpers.ts +7 -6
- package/src/frontend/native/chats.ts +2 -2
- package/src/frontend/native/index.ts +2 -0
- package/src/frontend/native/server.ts +40 -1
- package/src/frontend/telegram/commands/admin.ts +10 -2
- package/src/frontend/telegram/helpers/diagnostics.ts +7 -6
- package/src/storage/db.ts +6 -1
- package/src/storage/repositories/sessions-repo.ts +20 -1
- package/src/storage/sessions.ts +348 -4
- package/src/storage/sql/db.sql +5 -0
- package/src/storage/sql/schema.sql +2 -1
- package/src/storage/sql/sessions.sql +3 -3
- package/src/storage/sql/statements.generated.ts +8 -4
- package/src/util/config.ts +10 -0
- package/src/util/exec-output.ts +64 -0
- package/src/util/metrics.ts +148 -60
package/src/core/mesh/service.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
152
|
-
if (!
|
|
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(
|
|
271
|
+
message: `${device.name} did not answer within ${Math.round(timeoutMs / 1000)}s (device ${device.online ? "was online" : "appears offline"}).`,
|
|
194
272
|
});
|
|
195
|
-
},
|
|
273
|
+
}, timeoutMs);
|
|
196
274
|
timer.unref?.();
|
|
197
|
-
this.pendingCommands.set(command.id,
|
|
198
|
-
|
|
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
|
|
234
|
-
if (
|
|
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
|
|
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 =
|
|
240
|
-
|
|
241
|
-
|
|
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}
|
|
248
|
-
:
|
|
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
|
|
274
|
-
if (
|
|
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
|
|
303
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
327
|
-
*
|
|
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
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
464
|
-
|
|
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
|
-
|
|
481
|
-
|
|
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;
|