talon-agent 5.19.0 → 5.20.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 +2 -2
- package/src/backend/claude-sdk/one-shot.ts +32 -2
- package/src/core/agents/registry.ts +16 -0
- package/src/core/backup/plan.ts +1 -0
- package/src/core/engine/gateway-actions/mesh.ts +26 -14
- package/src/core/engine/gateway-actions/native/teleport.ts +1 -1
- package/src/core/mesh/devices/registry.ts +1 -17
- package/src/core/mesh/devices/service.ts +23 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.20.0",
|
|
4
4
|
"description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
|
|
5
5
|
"author": "The Falconry",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
"build:fusefs": "node native/talon-fusefs/build.mjs"
|
|
102
102
|
},
|
|
103
103
|
"dependencies": {
|
|
104
|
-
"@anthropic-ai/claude-agent-sdk": "^0.3.
|
|
104
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.283",
|
|
105
105
|
"@anthropic-ai/sdk": "^0.104.1",
|
|
106
106
|
"@brave/brave-search-mcp-server": "^2.0.75",
|
|
107
107
|
"@clack/prompts": "^1.2.0",
|
|
@@ -279,6 +279,34 @@ async function formatAndAppendMessage(
|
|
|
279
279
|
* spawner (us) and contains the env vars Talon set when launching the SDK
|
|
280
280
|
* subprocess via MCP launcher. SIGTERM with a short grace, then SIGKILL.
|
|
281
281
|
*/
|
|
282
|
+
/**
|
|
283
|
+
* Decide whether a `/proc` entry is a run orphan this sweep may kill.
|
|
284
|
+
*
|
|
285
|
+
* Carrying the chat id is necessary but NOT sufficient. Every chat-scoped
|
|
286
|
+
* child Talon spawns inherits `TALON_CHAT_ID` — including trigger watchers
|
|
287
|
+
* (`core/background/triggers/spawn.ts` sets it alongside `TALON_TRIGGER_ID`)
|
|
288
|
+
* and their descendants, which are long-lived by design and belong to no run.
|
|
289
|
+
* Matching on the chat id alone made every orphan sweep kill that chat's
|
|
290
|
+
* triggers: the warden respawned them, the next sweep killed them again, and
|
|
291
|
+
* the only visible symptom was a trigger stuck in "errored" with no output.
|
|
292
|
+
*
|
|
293
|
+
* Two independent guards, because this function issues SIGKILL:
|
|
294
|
+
* - refuse anything tagged `TALON_TRIGGER_ID` (a trigger, or its child);
|
|
295
|
+
* - require the argv to actually be the `claude` SDK binary, which is the
|
|
296
|
+
* only thing this sweep was ever meant to reap.
|
|
297
|
+
*/
|
|
298
|
+
export function isEvictableOrphan(
|
|
299
|
+
envEntries: string[],
|
|
300
|
+
argv: string[],
|
|
301
|
+
target: string,
|
|
302
|
+
): boolean {
|
|
303
|
+
if (!envEntries.includes(target)) return false;
|
|
304
|
+
if (envEntries.some((entry) => entry.startsWith("TALON_TRIGGER_ID="))) {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
return argv.some((arg) => arg === "claude" || arg.endsWith("/claude"));
|
|
308
|
+
}
|
|
309
|
+
|
|
282
310
|
export async function evictOrphanSubprocesses(contextLabel: string): Promise<{
|
|
283
311
|
found: number;
|
|
284
312
|
termed: number;
|
|
@@ -306,12 +334,14 @@ export async function evictOrphanSubprocesses(contextLabel: string): Promise<{
|
|
|
306
334
|
if (!Number.isInteger(pid) || pid === myPid) continue;
|
|
307
335
|
try {
|
|
308
336
|
const environRaw = await readFile(`/proc/${pid}/environ`, "utf-8");
|
|
337
|
+
const argvRaw = await readFile(`/proc/${pid}/cmdline`, "utf-8");
|
|
309
338
|
// /proc/<pid>/environ is NUL-delimited. Split on \0 and match exact
|
|
310
339
|
// entries — a raw .includes() can false-positive on other vars whose
|
|
311
340
|
// value happens to contain the substring. Since this code can SIGKILL,
|
|
312
341
|
// err on the side of strict matching. (Copilot review on #144.)
|
|
313
|
-
|
|
314
|
-
|
|
342
|
+
if (
|
|
343
|
+
isEvictableOrphan(environRaw.split("\0"), argvRaw.split("\0"), target)
|
|
344
|
+
) {
|
|
315
345
|
matched.push(pid);
|
|
316
346
|
}
|
|
317
347
|
} catch {
|
|
@@ -216,6 +216,22 @@ export class AgentRegistry {
|
|
|
216
216
|
const entry = this.live.get(id);
|
|
217
217
|
if (!entry || entry.record.state !== "queued") return;
|
|
218
218
|
entry.abort = binding.abort;
|
|
219
|
+
// A kill can arrive while the agent is still `queued` — before any abort
|
|
220
|
+
// handle exists, so requestKill's `entry.abort?.abort()` was a no-op that
|
|
221
|
+
// only set the flag. When the handle finally binds here, honour that
|
|
222
|
+
// pending kill; otherwise the fresh, un-aborted controller lets the run
|
|
223
|
+
// proceed and the kill is silently lost. The runner checks the signal
|
|
224
|
+
// right after start() and settles the run as "killed".
|
|
225
|
+
if (entry.killRequested) {
|
|
226
|
+
try {
|
|
227
|
+
binding.abort.abort();
|
|
228
|
+
} catch (err) {
|
|
229
|
+
logWarn(
|
|
230
|
+
"agents",
|
|
231
|
+
`Abort hook threw on start agent=${id}: ${err instanceof Error ? err.message : String(err)}`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
219
235
|
entry.record.model = binding.model;
|
|
220
236
|
entry.record.state = "running";
|
|
221
237
|
entry.record.startedAt = Date.now();
|
package/src/core/backup/plan.ts
CHANGED
|
@@ -14,38 +14,50 @@ import type { SharedActionHandlers } from "./types.js";
|
|
|
14
14
|
|
|
15
15
|
export const meshHandlers: SharedActionHandlers = {
|
|
16
16
|
list_devices: () => getMeshService().describeDevices(),
|
|
17
|
-
get_device_location: (body) =>
|
|
17
|
+
get_device_location: (body) =>
|
|
18
|
+
getMeshService().locateDevice(body.device ?? body.deviceId),
|
|
18
19
|
get_device_history: (body) =>
|
|
19
|
-
getMeshService().deviceHistory(body.device, body.hours),
|
|
20
|
-
ring_device: (body) =>
|
|
20
|
+
getMeshService().deviceHistory(body.device ?? body.deviceId, body.hours),
|
|
21
|
+
ring_device: (body) =>
|
|
22
|
+
getMeshService().ringDevice(body.device ?? body.deviceId, body.message),
|
|
21
23
|
// Registry hygiene: drop a stale/superseded device entry. Requires an
|
|
22
24
|
// explicit target — a destructive action must never default-pick a device.
|
|
23
|
-
remove_device: (body) =>
|
|
24
|
-
|
|
25
|
+
remove_device: (body) =>
|
|
26
|
+
getMeshService().removeDevice(body.device ?? body.deviceId),
|
|
27
|
+
get_device_status: (body) =>
|
|
28
|
+
getMeshService().getDeviceStatus(body.device ?? body.deviceId),
|
|
25
29
|
// Exec + filesystem surface — the substrate teleport routes through.
|
|
26
30
|
device_exec: (body) =>
|
|
27
31
|
getMeshService().execOnDevice(
|
|
28
|
-
body.device,
|
|
32
|
+
body.device ?? body.deviceId,
|
|
29
33
|
body.cmd,
|
|
30
34
|
body.cwd,
|
|
31
35
|
body.timeout_sec,
|
|
32
36
|
),
|
|
33
37
|
device_list_dir: (body) =>
|
|
34
|
-
getMeshService().listDirOnDevice(body.device, body.path),
|
|
35
|
-
device_stat: (body) =>
|
|
38
|
+
getMeshService().listDirOnDevice(body.device ?? body.deviceId, body.path),
|
|
39
|
+
device_stat: (body) =>
|
|
40
|
+
getMeshService().statOnDevice(body.device ?? body.deviceId, body.path),
|
|
36
41
|
device_read_file: (body) =>
|
|
37
|
-
getMeshService().readFileFromDevice(
|
|
42
|
+
getMeshService().readFileFromDevice(
|
|
43
|
+
body.device ?? body.deviceId,
|
|
44
|
+
body.path,
|
|
45
|
+
),
|
|
38
46
|
device_write_file: (body) =>
|
|
39
|
-
getMeshService().writeFileToDevice(
|
|
47
|
+
getMeshService().writeFileToDevice(
|
|
48
|
+
body.device ?? body.deviceId,
|
|
49
|
+
body.path,
|
|
50
|
+
body.content,
|
|
51
|
+
),
|
|
40
52
|
device_pull_file: (body) =>
|
|
41
53
|
getMeshService().pullFileFromDevice(
|
|
42
|
-
body.device,
|
|
54
|
+
body.device ?? body.deviceId,
|
|
43
55
|
body.remote_path,
|
|
44
56
|
body.local_path,
|
|
45
57
|
),
|
|
46
58
|
device_push_file: (body) =>
|
|
47
59
|
getMeshService().pushFileToDevice(
|
|
48
|
-
body.device,
|
|
60
|
+
body.device ?? body.deviceId,
|
|
49
61
|
body.local_path,
|
|
50
62
|
body.remote_path,
|
|
51
63
|
),
|
|
@@ -53,7 +65,7 @@ export const meshHandlers: SharedActionHandlers = {
|
|
|
53
65
|
// the mesh connection across the app restart.
|
|
54
66
|
update_device: (body) =>
|
|
55
67
|
getMeshService().updateDeviceApp(
|
|
56
|
-
body.device,
|
|
68
|
+
body.device ?? body.deviceId,
|
|
57
69
|
body.apk_path,
|
|
58
70
|
body.remote_path,
|
|
59
71
|
),
|
|
@@ -63,7 +75,7 @@ export const meshHandlers: SharedActionHandlers = {
|
|
|
63
75
|
// platform/arch itself (source build or verified release download).
|
|
64
76
|
update_node: (body) =>
|
|
65
77
|
getMeshService().updateNodeBinary(
|
|
66
|
-
body.device,
|
|
78
|
+
body.device ?? body.deviceId,
|
|
67
79
|
body.binary_path,
|
|
68
80
|
body.remote_path,
|
|
69
81
|
),
|
|
@@ -11,7 +11,7 @@ import type { Result } from "./results.js";
|
|
|
11
11
|
import type { SharedActionHandlers } from "../types.js";
|
|
12
12
|
|
|
13
13
|
export const teleportHandlers: SharedActionHandlers = {
|
|
14
|
-
teleport: (body, chatId) => teleport(chatId, body.device),
|
|
14
|
+
teleport: (body, chatId) => teleport(chatId, body.device ?? body.deviceId),
|
|
15
15
|
teleport_back: (_body, chatId) => teleportBack(chatId),
|
|
16
16
|
};
|
|
17
17
|
|
|
@@ -125,23 +125,7 @@ export class MeshRegistry {
|
|
|
125
125
|
now,
|
|
126
126
|
);
|
|
127
127
|
this.devices.set(device.id, device);
|
|
128
|
-
|
|
129
|
-
// own persisted device id, so the same physical machine re-registers
|
|
130
|
-
// under a fresh identity and the old entry lingers forever as an offline
|
|
131
|
-
// ghost. Same name + platform under a different id, currently OFFLINE →
|
|
132
|
-
// superseded install, drop it. An online doppelganger is kept: two live
|
|
133
|
-
// devices can legitimately share a name.
|
|
134
|
-
let evicted = false;
|
|
135
|
-
for (const [id, d] of this.devices) {
|
|
136
|
-
if (id === device.id) continue;
|
|
137
|
-
if (d.name !== device.name || d.platform !== device.platform) continue;
|
|
138
|
-
if (toDeviceInfo(d, now, PRESENCE_TIMEOUT_MS).online) continue;
|
|
139
|
-
this.devices.delete(id);
|
|
140
|
-
this.locations.delete(id);
|
|
141
|
-
this.history.delete(id);
|
|
142
|
-
evicted = true;
|
|
143
|
-
}
|
|
144
|
-
if (this.enforceDeviceCap()) evicted = true;
|
|
128
|
+
const evicted = this.enforceDeviceCap();
|
|
145
129
|
await this.persistDevices();
|
|
146
130
|
if (evicted) {
|
|
147
131
|
await this.persistLocations();
|
|
@@ -877,6 +877,7 @@ export class MeshService {
|
|
|
877
877
|
const q = raw.toLowerCase();
|
|
878
878
|
const byId = devices.find((d) => d.id.toLowerCase() === q);
|
|
879
879
|
if (byId) return { target: byId };
|
|
880
|
+
|
|
880
881
|
const fold = (s: string): string =>
|
|
881
882
|
s.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
882
883
|
const folded = fold(raw);
|
|
@@ -885,6 +886,7 @@ export class MeshService {
|
|
|
885
886
|
): { target: DeviceInfo } | { error: string } | undefined => {
|
|
886
887
|
if (candidates.length === 1) return { target: candidates[0]! };
|
|
887
888
|
if (candidates.length > 1) {
|
|
889
|
+
const exactNames = new Set(candidates.map((d) => fold(d.name)));
|
|
888
890
|
const online = candidates.filter((d) => d.online);
|
|
889
891
|
if (online.length === 1) return { target: online[0]! };
|
|
890
892
|
return {
|
|
@@ -893,13 +895,32 @@ export class MeshService {
|
|
|
893
895
|
(d) =>
|
|
894
896
|
`${d.name} [id: ${d.id}, ${d.online ? "online" : "offline"}]`,
|
|
895
897
|
)
|
|
896
|
-
.join(
|
|
898
|
+
.join(
|
|
899
|
+
", ",
|
|
900
|
+
)}. Use the device id${exactNames.size > 1 ? " or full name" : ""}.`,
|
|
897
901
|
};
|
|
898
902
|
}
|
|
899
903
|
return undefined;
|
|
900
904
|
};
|
|
905
|
+
|
|
906
|
+
const exactMatch = pick(devices.filter((d) => fold(d.name) === folded));
|
|
907
|
+
if (exactMatch) return exactMatch;
|
|
908
|
+
|
|
909
|
+
if (q.length >= 6) {
|
|
910
|
+
const byPrefix = devices.filter((d) =>
|
|
911
|
+
d.id.toLowerCase().startsWith(q),
|
|
912
|
+
);
|
|
913
|
+
if (byPrefix.length === 1) return { target: byPrefix[0]! };
|
|
914
|
+
if (byPrefix.length > 1) {
|
|
915
|
+
return {
|
|
916
|
+
error: `Device id prefix "${raw}" matches ${byPrefix.length} devices: ${byPrefix
|
|
917
|
+
.map((d) => `${d.name} [id: ${d.id}]`)
|
|
918
|
+
.join(", ")}. Provide more characters of the id.`,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
901
923
|
return (
|
|
902
|
-
pick(devices.filter((d) => fold(d.name) === folded)) ??
|
|
903
924
|
(folded
|
|
904
925
|
? pick(devices.filter((d) => fold(d.name).includes(folded)))
|
|
905
926
|
: undefined) ?? { error: this.noSuchDevice(query).text }
|