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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "5.19.0",
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.277",
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
- const envEntries = environRaw.split("\0");
314
- if (envEntries.includes(target)) {
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();
@@ -75,6 +75,7 @@ export const HOME_INCLUDES: readonly string[] = [
75
75
  "google",
76
76
  "plugins",
77
77
  "mesh-devices.json",
78
+ "mesh-credentials.json",
78
79
  "mesh-history.json",
79
80
  "mesh-locations.json",
80
81
  "teleport-state.json",
@@ -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) => getMeshService().locateDevice(body.device),
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) => getMeshService().ringDevice(body.device, body.message),
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) => getMeshService().removeDevice(body.device),
24
- get_device_status: (body) => getMeshService().getDeviceStatus(body.device),
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) => getMeshService().statOnDevice(body.device, body.path),
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(body.device, body.path),
42
+ getMeshService().readFileFromDevice(
43
+ body.device ?? body.deviceId,
44
+ body.path,
45
+ ),
38
46
  device_write_file: (body) =>
39
- getMeshService().writeFileToDevice(body.device, body.path, body.content),
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
- // Stale-duplicate eviction. A reinstall (or an old app build) carries its
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(", ")}. Use the device id or full name.`,
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 }