talon-agent 3.1.1 → 3.3.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
|
|
5
5
|
"author": "Dylan Neve",
|
|
6
6
|
"license": "MIT",
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
"@kilocode/sdk": "^7.2.22",
|
|
104
104
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
105
105
|
"@openai/agents": "^0.13.0",
|
|
106
|
-
"@openai/codex-sdk": "^0.
|
|
106
|
+
"@openai/codex-sdk": "^0.145.0",
|
|
107
107
|
"@opencode-ai/sdk": "^1.17.4",
|
|
108
108
|
"@playwright/mcp": "^0.0.78",
|
|
109
109
|
"@types/cross-spawn": "^6.0.6",
|
|
@@ -57,4 +57,12 @@ export const meshHandlers: SharedActionHandlers = {
|
|
|
57
57
|
body.apk_path,
|
|
58
58
|
body.remote_path,
|
|
59
59
|
),
|
|
60
|
+
// Remote self-update for a headless talon-node: push a new binary and have
|
|
61
|
+
// the node verify, swap, and restart into it.
|
|
62
|
+
update_node: (body) =>
|
|
63
|
+
getMeshService().updateNodeBinary(
|
|
64
|
+
body.device,
|
|
65
|
+
body.binary_path,
|
|
66
|
+
body.remote_path,
|
|
67
|
+
),
|
|
60
68
|
};
|
package/src/core/mesh/service.ts
CHANGED
|
@@ -54,6 +54,17 @@ export type MeshTransport = {
|
|
|
54
54
|
|
|
55
55
|
export type MeshToolResult = { ok: boolean; text: string };
|
|
56
56
|
|
|
57
|
+
/** Outcome of pinging one device (see {@link MeshService.pingAll}). */
|
|
58
|
+
export type MeshPingResult = {
|
|
59
|
+
device: DeviceInfo;
|
|
60
|
+
/** True when the device answered the probe (offline devices are false). */
|
|
61
|
+
reachable: boolean;
|
|
62
|
+
/** Round-trip time of the probe, present only when reachable. */
|
|
63
|
+
latencyMs?: number;
|
|
64
|
+
/** Why the probe didn't land (offline, no transport, timeout). */
|
|
65
|
+
error?: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
57
68
|
export type MeshServiceOptions = {
|
|
58
69
|
/** How long a locate waits for a fresh fix before last-known fallback. */
|
|
59
70
|
freshFixTimeoutMs?: number;
|
|
@@ -308,6 +319,36 @@ export class MeshService {
|
|
|
308
319
|
};
|
|
309
320
|
}
|
|
310
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Ping every registered device: read the registry, then actively probe
|
|
324
|
+
* each ONLINE device with a lightweight `status` command (concurrently)
|
|
325
|
+
* and measure round-trip latency. Offline devices are reported from
|
|
326
|
+
* presence without a probe (a probe would just burn the timeout). The
|
|
327
|
+
* structured result powers frontend surfaces like Telegram's /mesh — the
|
|
328
|
+
* mesh tools stay text-only for the model, this is for humans.
|
|
329
|
+
*/
|
|
330
|
+
async pingAll(timeoutMs = 5_000): Promise<MeshPingResult[]> {
|
|
331
|
+
const { devices } = await this.list();
|
|
332
|
+
const hasTransport = this.transports.size > 0;
|
|
333
|
+
return Promise.all(
|
|
334
|
+
devices.map(async (device): Promise<MeshPingResult> => {
|
|
335
|
+
if (!device.online) return { device, reachable: false };
|
|
336
|
+
if (!hasTransport) {
|
|
337
|
+
return { device, reachable: false, error: "no transport connected" };
|
|
338
|
+
}
|
|
339
|
+
const start = Date.now();
|
|
340
|
+
const result = await this.sendCommand(device, "status", {}, timeoutMs);
|
|
341
|
+
return result.ok
|
|
342
|
+
? { device, reachable: true, latencyMs: Date.now() - start }
|
|
343
|
+
: {
|
|
344
|
+
device,
|
|
345
|
+
reachable: false,
|
|
346
|
+
error: result.message ?? "no response",
|
|
347
|
+
};
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
311
352
|
/**
|
|
312
353
|
* `get_device_location`: resolve the target (id, name fragment, or the
|
|
313
354
|
* most recent mobile device), push a locate to connected transports, wait
|
|
@@ -873,6 +914,87 @@ export class MeshService {
|
|
|
873
914
|
};
|
|
874
915
|
}
|
|
875
916
|
|
|
917
|
+
/**
|
|
918
|
+
* `update_node`: remote self-update for a headless talon-node. Streams a
|
|
919
|
+
* replacement binary to the node, then sends `update_node` so the node
|
|
920
|
+
* verifies the digest, atomically swaps its own binary, and restarts into
|
|
921
|
+
* it (an in-place execve under systemd/launchd, so the mesh connection
|
|
922
|
+
* returns on its own within seconds — the same UX as the Android path).
|
|
923
|
+
*
|
|
924
|
+
* The binary is hashed here and the digest travels with the command; the
|
|
925
|
+
* node re-hashes the pushed file and refuses to swap on a mismatch, so a
|
|
926
|
+
* truncated transfer can never be installed. Build the replacement for the
|
|
927
|
+
* node's platform/arch (talon-node-<os>-<arch>) before calling.
|
|
928
|
+
*/
|
|
929
|
+
async updateNodeBinary(
|
|
930
|
+
query: unknown,
|
|
931
|
+
localBinaryPath: unknown,
|
|
932
|
+
remotePath?: unknown,
|
|
933
|
+
): Promise<MeshToolResult> {
|
|
934
|
+
const local =
|
|
935
|
+
typeof localBinaryPath === "string" && localBinaryPath.trim()
|
|
936
|
+
? resolve(dirs.workspace, localBinaryPath.trim())
|
|
937
|
+
: "";
|
|
938
|
+
if (!local) return { ok: false, text: "A local binary path is required." };
|
|
939
|
+
await this.load();
|
|
940
|
+
const resolved = this.resolveDevice(query);
|
|
941
|
+
if ("error" in resolved) return { ok: false, text: resolved.error };
|
|
942
|
+
const target = resolved.target;
|
|
943
|
+
if (target.capabilities && !target.capabilities.includes("update_node")) {
|
|
944
|
+
return {
|
|
945
|
+
ok: false,
|
|
946
|
+
text: `${target.name} can't self-update — it needs the update_node capability (a talon-node headless device).`,
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
let sha256: string;
|
|
951
|
+
let size: number;
|
|
952
|
+
try {
|
|
953
|
+
({ sha256, size } = await hashFile(local));
|
|
954
|
+
} catch (err) {
|
|
955
|
+
return {
|
|
956
|
+
ok: false,
|
|
957
|
+
text: `Cannot read binary ${local}: ${(err as Error).message}`,
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
if (size === 0) return { ok: false, text: `Binary ${local} is empty.` };
|
|
961
|
+
|
|
962
|
+
// Default staging path is /tmp on unix nodes (the node re-stages next to
|
|
963
|
+
// its own executable before the atomic swap, so this is only transient).
|
|
964
|
+
const remote =
|
|
965
|
+
typeof remotePath === "string" && remotePath.trim()
|
|
966
|
+
? remotePath.trim()
|
|
967
|
+
: "/tmp/talon-node.update";
|
|
968
|
+
|
|
969
|
+
// 1. Stream the new binary to the node.
|
|
970
|
+
const push = await this.pushFileToDevice(target.id, local, remote);
|
|
971
|
+
if (!push.ok) {
|
|
972
|
+
return { ok: false, text: `Update aborted — push failed: ${push.text}` };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// 2. Trigger the swap + restart (node verifies the digest first).
|
|
976
|
+
const dispatched = await this.dispatchCommand(
|
|
977
|
+
target.id,
|
|
978
|
+
"update_node",
|
|
979
|
+
{ path: remote, sha256 },
|
|
980
|
+
this.commandTimeoutMs,
|
|
981
|
+
);
|
|
982
|
+
if ("error" in dispatched) return { ok: false, text: dispatched.error };
|
|
983
|
+
if (!dispatched.result.ok) {
|
|
984
|
+
return {
|
|
985
|
+
ok: false,
|
|
986
|
+
text: dispatched.result.message ?? `${target.name} refused the update.`,
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
return {
|
|
990
|
+
ok: true,
|
|
991
|
+
text:
|
|
992
|
+
`Pushed ${formatBytes(size)} and staged the update on ${target.name}. ` +
|
|
993
|
+
`${dispatched.result.message ?? "Restarting now."} ` +
|
|
994
|
+
`Confirm with get_device_status once it reconnects (appVersion should change).`,
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
876
998
|
/**
|
|
877
999
|
* Chunked read of a remote file into a Buffer. Loops `read_file` with
|
|
878
1000
|
* increasing offsets until the device reports EOF.
|
package/src/core/tools/mesh.ts
CHANGED
|
@@ -185,4 +185,25 @@ export const meshTools: ToolDefinition[] = [
|
|
|
185
185
|
execute: (params, bridge) => bridge("update_device", params),
|
|
186
186
|
tag: "mesh",
|
|
187
187
|
},
|
|
188
|
+
{
|
|
189
|
+
name: "update_node",
|
|
190
|
+
description:
|
|
191
|
+
"Remotely update a headless talon-node (Linux/macOS/Windows server on the mesh): stream a new node binary from the daemon and have the node verify its hash, atomically swap its own binary, and restart into it. On Linux/macOS this is an in-place execve so the mesh connection returns within seconds; the node re-hashes the pushed file and refuses a truncated or mismatched binary. Build the replacement for the node's platform/arch first (talon-node-<os>-<arch>). Confirm success with get_device_status afterwards (appVersion should change). This is the node counterpart of update_device — use update_device for Android companions.",
|
|
192
|
+
schema: {
|
|
193
|
+
device: deviceParam,
|
|
194
|
+
binary_path: z
|
|
195
|
+
.string()
|
|
196
|
+
.describe(
|
|
197
|
+
"Path to the new talon-node binary, relative to the workspace (or absolute), built for the node's OS/arch.",
|
|
198
|
+
),
|
|
199
|
+
remote_path: z
|
|
200
|
+
.string()
|
|
201
|
+
.optional()
|
|
202
|
+
.describe(
|
|
203
|
+
"Where to stage the binary on the node (default /tmp/talon-node.update; the node re-stages next to its own executable before the atomic swap).",
|
|
204
|
+
),
|
|
205
|
+
},
|
|
206
|
+
execute: (params, bridge) => bridge("update_node", params),
|
|
207
|
+
tag: "mesh",
|
|
208
|
+
},
|
|
188
209
|
];
|
|
@@ -22,6 +22,7 @@ export const TELEGRAM_COMMANDS: ReadonlyArray<{
|
|
|
22
22
|
},
|
|
23
23
|
{ command: "status", description: "Session info, usage, and stats" },
|
|
24
24
|
{ command: "ping", description: "Health check with latency" },
|
|
25
|
+
{ command: "mesh", description: "Ping and list mesh devices" },
|
|
25
26
|
{ command: "model", description: "Show or change model" },
|
|
26
27
|
{ command: "effort", description: "Set thinking effort level" },
|
|
27
28
|
{ command: "pulse", description: "Conversation engagement settings" },
|
|
@@ -7,6 +7,8 @@ import { isUserClientReady } from "../userbot.js";
|
|
|
7
7
|
import { escapeHtml } from "../formatting.js";
|
|
8
8
|
import { formatDuration } from "../helpers/index.js";
|
|
9
9
|
import { getLoadedPlugins } from "../../../core/plugin/index.js";
|
|
10
|
+
import { getMeshService } from "../../../core/mesh/index.js";
|
|
11
|
+
import type { MeshPingResult } from "../../../core/mesh/service.js";
|
|
10
12
|
|
|
11
13
|
export function registerInfoCommands(bot: Bot): void {
|
|
12
14
|
bot.command("start", (ctx) =>
|
|
@@ -42,6 +44,7 @@ export function registerInfoCommands(bot: Bot): void {
|
|
|
42
44
|
" /doctor -- environment and native-module health (admin)",
|
|
43
45
|
" /dream -- force memory consolidation now",
|
|
44
46
|
" /ping -- health check with latency",
|
|
47
|
+
" /mesh -- ping and list companion mesh devices",
|
|
45
48
|
" /reset -- clear session and start fresh",
|
|
46
49
|
" /restart -- restart the bot process",
|
|
47
50
|
" /plugins -- list loaded plugins",
|
|
@@ -100,6 +103,50 @@ export function registerInfoCommands(bot: Bot): void {
|
|
|
100
103
|
}
|
|
101
104
|
});
|
|
102
105
|
|
|
106
|
+
bot.command("mesh", async (ctx) => {
|
|
107
|
+
const sent = await ctx.reply("🛰️ Pinging mesh devices…");
|
|
108
|
+
let results: MeshPingResult[];
|
|
109
|
+
try {
|
|
110
|
+
results = await getMeshService().pingAll();
|
|
111
|
+
} catch {
|
|
112
|
+
await editOrReply(
|
|
113
|
+
bot,
|
|
114
|
+
ctx.chat.id,
|
|
115
|
+
sent.message_id,
|
|
116
|
+
"Could not reach the mesh service.",
|
|
117
|
+
);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (results.length === 0) {
|
|
121
|
+
await editOrReply(
|
|
122
|
+
bot,
|
|
123
|
+
ctx.chat.id,
|
|
124
|
+
sent.message_id,
|
|
125
|
+
"<b>🛰️ Mesh</b>\n\nNo devices have registered yet.",
|
|
126
|
+
);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// Online (reachable first, by latency) before offline; a stable, useful
|
|
130
|
+
// order for a glance at the fleet.
|
|
131
|
+
const sorted = [...results].sort((a, b) => {
|
|
132
|
+
if (a.device.online !== b.device.online) return a.device.online ? -1 : 1;
|
|
133
|
+
return (a.latencyMs ?? Infinity) - (b.latencyMs ?? Infinity);
|
|
134
|
+
});
|
|
135
|
+
const online = results.filter((r) => r.device.online).length;
|
|
136
|
+
const reachable = results.filter((r) => r.reachable).length;
|
|
137
|
+
const lines = sorted.map((r) => meshLine(r));
|
|
138
|
+
await editOrReply(
|
|
139
|
+
bot,
|
|
140
|
+
ctx.chat.id,
|
|
141
|
+
sent.message_id,
|
|
142
|
+
[
|
|
143
|
+
`<b>🛰️ Mesh</b> — ${results.length} device(s), ${online} online, ${reachable} responding`,
|
|
144
|
+
"",
|
|
145
|
+
...lines,
|
|
146
|
+
].join("\n"),
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
103
150
|
bot.command("plugins", async (ctx) => {
|
|
104
151
|
const plugins = getLoadedPlugins();
|
|
105
152
|
if (plugins.length === 0) {
|
|
@@ -123,3 +170,38 @@ export function registerInfoCommands(bot: Bot): void {
|
|
|
123
170
|
);
|
|
124
171
|
});
|
|
125
172
|
}
|
|
173
|
+
|
|
174
|
+
/** One `/mesh` line: presence + reachability + latency + platform. */
|
|
175
|
+
function meshLine(r: MeshPingResult): string {
|
|
176
|
+
const d = r.device;
|
|
177
|
+
const dot = r.reachable ? "🟢" : d.online ? "🟡" : "⚪";
|
|
178
|
+
const name = `<b>${escapeHtml(d.name)}</b>`;
|
|
179
|
+
const bits: string[] = [`${d.platform}`];
|
|
180
|
+
if (r.reachable && typeof r.latencyMs === "number") {
|
|
181
|
+
bits.push(`${r.latencyMs}ms`);
|
|
182
|
+
} else if (d.online && r.error) {
|
|
183
|
+
bits.push(escapeHtml(r.error));
|
|
184
|
+
} else if (!d.online) {
|
|
185
|
+
bits.push(`offline, last seen ${formatDuration(Date.now() - d.lastSeen)}`);
|
|
186
|
+
}
|
|
187
|
+
if (typeof d.battery === "number") {
|
|
188
|
+
bits.push(`${d.battery}%${d.charging ? "⚡" : ""}`);
|
|
189
|
+
}
|
|
190
|
+
return `${dot} ${name} — ${bits.join(" · ")}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Edit the placeholder in place, falling back to a fresh reply. */
|
|
194
|
+
async function editOrReply(
|
|
195
|
+
bot: Bot,
|
|
196
|
+
chatId: number,
|
|
197
|
+
messageId: number,
|
|
198
|
+
text: string,
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
try {
|
|
201
|
+
await bot.api.editMessageText(chatId, messageId, text, {
|
|
202
|
+
parse_mode: "HTML",
|
|
203
|
+
});
|
|
204
|
+
} catch {
|
|
205
|
+
await bot.api.sendMessage(chatId, text, { parse_mode: "HTML" });
|
|
206
|
+
}
|
|
207
|
+
}
|