talon-agent 1.41.0 → 1.42.1

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": "1.41.0",
3
+ "version": "1.42.1",
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",
@@ -23,7 +23,11 @@ export interface HandleEventContext {
23
23
  seenToolCallIds: Set<string>;
24
24
  codexToolMetrics: { count: number };
25
25
  onTextBlock?: (text: string) => Promise<void>;
26
- onToolUse?: (toolName: string, input: Record<string, unknown>) => void;
26
+ onToolUse?: (
27
+ toolName: string,
28
+ input: Record<string, unknown>,
29
+ meta?: { failed?: boolean },
30
+ ) => void;
27
31
  chatId: string;
28
32
  }
29
33
 
@@ -148,7 +152,7 @@ function handleMcpToolCall(
148
152
  // flip) that assume a successful call.
149
153
  if (ctx.onToolUse) {
150
154
  try {
151
- ctx.onToolUse(toolName, input);
155
+ ctx.onToolUse(toolName, input, { failed: true });
152
156
  } catch {
153
157
  /* non-fatal */
154
158
  }
@@ -104,14 +104,23 @@ export async function* handlerToEvents(
104
104
  // deltas restart from empty.
105
105
  lastAccumulated = "";
106
106
  },
107
- onToolUse: (toolName, input) => {
107
+ onToolUse: (toolName, input, meta) => {
108
+ const id = `${toolName}-${Date.now()}-${Math.random()
109
+ .toString(36)
110
+ .slice(2, 8)}`;
111
+ emit({ type: "tool_call", id, name: toolName, input });
112
+ // Callback backends (Codex, OpenCode, Kilo, OpenAI Agents) surface a
113
+ // tool call only at terminal status — by the time onToolUse fires,
114
+ // the tool has already finished. Emit the matching `tool_result`
115
+ // immediately so consumers see the same call→result contract the
116
+ // Claude SDK backend emits. Without it, tool spinners opened on
117
+ // `tool_call` hang until the end-of-turn flush — on a long Codex
118
+ // grind that reads as "running forever" in the companion app.
108
119
  emit({
109
- type: "tool_call",
110
- id: `${toolName}-${Date.now()}-${Math.random()
111
- .toString(36)
112
- .slice(2, 8)}`,
120
+ type: "tool_result",
121
+ id,
113
122
  name: toolName,
114
- input,
123
+ ...(meta?.failed ? { error: "tool call failed" } : {}),
115
124
  });
116
125
  },
117
126
  };
@@ -43,7 +43,17 @@ export type QueryParams = {
43
43
  retrievedMemory?: RetrievedMemory;
44
44
  onStreamDelta?: (accumulated: string, phase?: "thinking" | "text") => void;
45
45
  onTextBlock?: (text: string) => Promise<void>;
46
- onToolUse?: (toolName: string, input: Record<string, unknown>) => void;
46
+ /**
47
+ * Callback backends report a tool call as one already-resolved unit —
48
+ * their SDKs surface it at (or after) terminal status, with no separate
49
+ * start/finish pair. `meta.failed` marks a call whose terminal status was
50
+ * an error, so consumers can render it as failed rather than successful.
51
+ */
52
+ onToolUse?: (
53
+ toolName: string,
54
+ input: Record<string, unknown>,
55
+ meta?: { failed?: boolean },
56
+ ) => void;
47
57
  };
48
58
 
49
59
  /** Result of a backend AI query. */
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { spawn } from "node:child_process";
19
+ import { closeSync, openSync } from "node:fs";
19
20
  import {
20
21
  glob as fsGlob,
21
22
  mkdir,
@@ -23,6 +24,7 @@ import {
23
24
  stat as fsStat,
24
25
  writeFile,
25
26
  } from "node:fs/promises";
27
+ import { tmpdir } from "node:os";
26
28
  import { dirname, join } from "node:path";
27
29
  import { getMeshService } from "../../mesh/index.js";
28
30
  import {
@@ -50,6 +52,23 @@ function rgBin(): string {
50
52
  }
51
53
  const DEFAULT_EXEC_TIMEOUT_MS = 60_000;
52
54
  const MAX_EXEC_TIMEOUT_MS = 300_000;
55
+ /** Grace between SIGTERM and SIGKILL on timeout, so pipelines can flush. */
56
+ const KILL_GRACE_MS = 2_000;
57
+ /** How long a background launch waits to catch fast failures. */
58
+ const BACKGROUND_SETTLE_MS = 1_200;
59
+ /** Where background job output lands (one log file per job). */
60
+ const BACKGROUND_LOG_DIR = join(tmpdir(), "talon-bash");
61
+ /** Appended to a timed-out bash result so the model self-corrects. */
62
+ const TIMEOUT_HINT =
63
+ "(Streaming/never-ending commands — adb logcat, tail -f, dev servers, watchers — " +
64
+ "will always hit this wall. Re-run with background:true to launch it detached with " +
65
+ "output captured to a log file, or bound the command itself: `adb logcat -d`, " +
66
+ "`timeout 30 …`, `head -n 200`.)";
67
+ /** Same self-correction hint, phrased for a teleported (on-device) run. */
68
+ const TELEPORT_TIMEOUT_HINT =
69
+ "(Streaming/never-ending commands can't ride a teleported foreground call — " +
70
+ "background it in-shell instead: `cmd > /tmp/out.log 2>&1 &`, then poll the log " +
71
+ "with read, or bound the command itself: `logcat -d`, `timeout 30 …`, `head -n 200`.)";
53
72
  const MAX_READ_LINES = 2_000;
54
73
  /** Caps for the pure-JS glob/search fallbacks (rg unavailable). */
55
74
  const MAX_JS_RESULTS = 2_000;
@@ -63,7 +82,7 @@ export const nativeHandlers: SharedActionHandlers = {
63
82
  teleport: (body, chatId) => teleport(chatId, body.device),
64
83
  teleport_back: (_body, chatId) => teleportBack(chatId),
65
84
  native_bash: (body, chatId) =>
66
- bash(chatId, body.command, body.cwd, body.timeout_sec),
85
+ bash(chatId, body.command, body.cwd, body.timeout_sec, body.background),
67
86
  native_read: (body, chatId) =>
68
87
  read(chatId, body.path, body.offset, body.limit),
69
88
  native_write: (body, chatId) => write(chatId, body.path, body.content),
@@ -122,15 +141,138 @@ async function bash(
122
141
  command: unknown,
123
142
  cwd: unknown,
124
143
  timeoutSec: unknown,
144
+ background?: unknown,
125
145
  ): Promise<Result> {
126
146
  const cmd = typeof command === "string" ? command : "";
127
147
  if (!cmd.trim()) return { ok: false, text: "No command given." };
128
148
  const timeoutMs = clampTimeout(timeoutSec);
129
149
  const active = await getTeleport(chatId);
150
+ if (background === true) {
151
+ if (active) {
152
+ return {
153
+ ok: false,
154
+ text:
155
+ "background:true runs on the daemon host only. On a teleported device, " +
156
+ "background it in-shell instead: `cmd > /tmp/out.log 2>&1 &`, then poll " +
157
+ "the log with read.",
158
+ };
159
+ }
160
+ return bashBackground(cmd, typeof cwd === "string" ? cwd : undefined);
161
+ }
130
162
  if (active) return bashTeleported(chatId, active.deviceId, cmd, timeoutMs);
131
163
  return bashLocal(cmd, typeof cwd === "string" ? cwd : undefined, timeoutMs);
132
164
  }
133
165
 
166
+ /**
167
+ * Launch a command detached from the request cycle: its own process group,
168
+ * stdout+stderr appended to a per-job log file, tool returns immediately.
169
+ * This is the sanctioned path for streaming/long-running commands (adb
170
+ * logcat, dev servers, watchers) that would otherwise burn the whole
171
+ * foreground timeout and come back "killed".
172
+ *
173
+ * A short settle window catches fast failures (typo'd binary, instant
174
+ * non-zero exit) so those still surface as a normal error instead of a
175
+ * "started" message pointing at a log with one line in it.
176
+ */
177
+ async function bashBackground(
178
+ cmd: string,
179
+ cwd: string | undefined,
180
+ ): Promise<Result> {
181
+ // The background contract is POSIX-shaped end to end: detached process
182
+ // group, `kill -- -pid` to stop, survives daemon restarts. Windows has
183
+ // none of those (and the CI legs showed the detached writer's output not
184
+ // reaching the log) — refuse loudly with the native alternative instead
185
+ // of pretending.
186
+ if (process.platform === "win32") {
187
+ return {
188
+ ok: false,
189
+ text:
190
+ "background:true needs POSIX process groups and isn't supported on a Windows " +
191
+ "daemon host. Run it foreground with a bound command (`timeout 30 …`, `head -n 200`) " +
192
+ "or start it yourself: `powershell Start-Process -WindowStyle Hidden` with output redirected to a file.",
193
+ };
194
+ }
195
+ try {
196
+ await mkdir(BACKGROUND_LOG_DIR, { recursive: true });
197
+ } catch (err) {
198
+ return {
199
+ ok: false,
200
+ text: `Cannot create log dir ${BACKGROUND_LOG_DIR}: ${(err as Error).message}`,
201
+ };
202
+ }
203
+ const slug =
204
+ cmd
205
+ .replace(/[^a-zA-Z0-9]+/g, "-")
206
+ .replace(/^-+|-+$/g, "")
207
+ .slice(0, 40) || "job";
208
+ const logPath = join(BACKGROUND_LOG_DIR, `${Date.now()}-${slug}.log`);
209
+ let fd: number;
210
+ try {
211
+ fd = openSync(logPath, "a");
212
+ } catch (err) {
213
+ return {
214
+ ok: false,
215
+ text: `Cannot open log file ${logPath}: ${(err as Error).message}`,
216
+ };
217
+ }
218
+ // Always detached: the win32 guard above returned already, so this only
219
+ // runs on POSIX where the job gets its own process group.
220
+ const child = spawn("bash", ["-c", cmd], {
221
+ ...(cwd ? { cwd } : {}),
222
+ env: process.env,
223
+ detached: true,
224
+ stdio: ["ignore", fd, fd],
225
+ });
226
+ return new Promise((resolvePromise) => {
227
+ let settled = false;
228
+ const done = (r: Result) => {
229
+ if (settled) return;
230
+ settled = true;
231
+ try {
232
+ closeSync(fd);
233
+ } catch {
234
+ // parent's dup only; the child keeps its own copy either way
235
+ }
236
+ resolvePromise(r);
237
+ };
238
+ child.on("error", (err) =>
239
+ done({ ok: false, text: `Failed to start: ${err.message}` }),
240
+ );
241
+ // Fast failure inside the settle window → report it like a normal run.
242
+ child.on("close", (code) => {
243
+ void (async () => {
244
+ let logged = "";
245
+ try {
246
+ logged = await readFile(logPath, "utf8");
247
+ } catch {
248
+ // log unreadable — report the exit alone
249
+ }
250
+ done({
251
+ ok: (code ?? 0) === 0,
252
+ text:
253
+ `Background command exited almost immediately (exit ${code ?? 0}).\n` +
254
+ renderExec("local", `exit ${code ?? 0}`, logged, "") +
255
+ `\nFull log: ${logPath}`,
256
+ });
257
+ })();
258
+ });
259
+ setTimeout(() => {
260
+ if (settled) return;
261
+ child.unref();
262
+ done({
263
+ ok: true,
264
+ text: [
265
+ `🚀 Started in background [local] — pid ${child.pid}.`,
266
+ `Output (stdout+stderr) → ${logPath}`,
267
+ `Follow it with read/bash (e.g. \`tail -n 50 ${logPath}\`).`,
268
+ `Stop it with \`kill -- -${child.pid}\` (whole process group).`,
269
+ `Unsupervised: it keeps running until it exits or is killed — it even survives a Talon restart.`,
270
+ ].join("\n"),
271
+ });
272
+ }, BACKGROUND_SETTLE_MS);
273
+ });
274
+ }
275
+
134
276
  function bashLocal(
135
277
  cmd: string,
136
278
  cwd: string | undefined,
@@ -148,32 +290,76 @@ function bashLocal(
148
290
  const stdout = createOutputCapture();
149
291
  const stderr = createOutputCapture();
150
292
  let killed = false;
151
- const timer = setTimeout(() => {
152
- killed = true;
293
+ // Timeout escalation: SIGTERM the whole process group first (lets
294
+ // pipelines flush + children clean up), SIGKILL any survivors after a
295
+ // short grace. Straight-to-SIGKILL used to eat buffered output.
296
+ const killTree = (signal: NodeJS.Signals) => {
153
297
  if (detached && child.pid) {
154
298
  try {
155
- process.kill(-child.pid, "SIGKILL");
299
+ process.kill(-child.pid, signal);
156
300
  return;
157
301
  } catch {
158
302
  // group already gone — fall through to the direct kill
159
303
  }
160
304
  }
161
- child.kill("SIGKILL");
305
+ try {
306
+ child.kill(signal);
307
+ } catch {
308
+ // already dead
309
+ }
310
+ };
311
+ let settled = false;
312
+ let killTimer: NodeJS.Timeout | undefined;
313
+ let forceTimer: NodeJS.Timeout | undefined;
314
+ const finish = (r: Result) => {
315
+ if (settled) return;
316
+ settled = true;
317
+ clearTimeout(timer);
318
+ if (killTimer) clearTimeout(killTimer);
319
+ if (forceTimer) clearTimeout(forceTimer);
320
+ resolvePromise(r);
321
+ };
322
+ const timedOutResult = () => {
323
+ const body = renderExec(
324
+ "local",
325
+ `⏱️ timed out after ${timeoutMs / 1000}s — killed; partial output kept below`,
326
+ stdout.value(),
327
+ stderr.value(),
328
+ );
329
+ return { ok: false, text: `${body}\n${TIMEOUT_HINT}` };
330
+ };
331
+ const timer = setTimeout(() => {
332
+ killed = true;
333
+ killTree("SIGTERM");
334
+ killTimer = setTimeout(() => killTree("SIGKILL"), KILL_GRACE_MS);
335
+ // `close` waits for the stdio pipes to drain — a surviving grandchild
336
+ // that inherited stdout (Windows has no process groups; a detached
337
+ // POSIX grandchild can escape the group kill) would otherwise hold
338
+ // this promise open long past the timeout. Force-resolve with the
339
+ // partial output once the escalation window has passed.
340
+ forceTimer = setTimeout(
341
+ () => finish(timedOutResult()),
342
+ KILL_GRACE_MS + 1_000,
343
+ );
162
344
  }, timeoutMs);
163
345
  child.stdout.on("data", stdout.push);
164
346
  child.stderr.on("data", stderr.push);
165
347
  child.on("error", (err) => {
166
- clearTimeout(timer);
167
- resolvePromise({ ok: false, text: `Failed to run: ${err.message}` });
348
+ finish({ ok: false, text: `Failed to run: ${err.message}` });
168
349
  });
169
350
  child.on("close", (code) => {
170
- clearTimeout(timer);
171
- const exit = killed
172
- ? `killed (timeout ${timeoutMs / 1000}s)`
173
- : `exit ${code ?? 0}`;
174
- resolvePromise({
175
- ok: !killed && (code ?? 0) === 0,
176
- text: renderExec("local", exit, stdout.value(), stderr.value()),
351
+ if (killed) {
352
+ finish(timedOutResult());
353
+ return;
354
+ }
355
+ finish({
356
+ ok: (code ?? 0) === 0,
357
+ text: renderExec(
358
+ "local",
359
+ `exit ${code ?? 0}`,
360
+ stdout.value(),
361
+ stderr.value(),
362
+ ),
177
363
  });
178
364
  });
179
365
  });
@@ -225,14 +411,19 @@ async function bashTeleported(
225
411
  text: result.message ?? `${target.name} could not run the command.`,
226
412
  };
227
413
  }
414
+ // The device marks a command it had to kill at its own exec budget with
415
+ // this stderr marker (see the companion's device_exec) — surface the same
416
+ // self-correction hint the local timeout path gets.
417
+ const timedOutOnDevice = stderr.includes("[killed: timeout]");
418
+ const body = renderExec(
419
+ `${target.name}${via}`,
420
+ `exit ${exitCode ?? "?"}`,
421
+ stdout,
422
+ stderr,
423
+ );
228
424
  return {
229
425
  ok: exitCode === 0,
230
- text: renderExec(
231
- `${target.name}${via}`,
232
- `exit ${exitCode ?? "?"}`,
233
- stdout,
234
- stderr,
235
- ),
426
+ text: timedOutOnDevice ? `${body}\n${TELEPORT_TIMEOUT_HINT}` : body,
236
427
  };
237
428
  }
238
429
 
@@ -330,7 +521,12 @@ async function edit(
330
521
  }
331
522
 
332
523
  const count = from ? content.split(from).length - 1 : 0;
333
- if (count === 0) return { ok: false, text: `old_string not found in ${p}.` };
524
+ if (count === 0) {
525
+ return {
526
+ ok: false,
527
+ text: `old_string not found in ${p}.${nearMissHint(content, from)}`,
528
+ };
529
+ }
334
530
  if (count > 1 && replaceAll !== true) {
335
531
  return {
336
532
  ok: false,
@@ -536,6 +732,28 @@ async function searchJs(
536
732
  return lines;
537
733
  }
538
734
 
735
+ /**
736
+ * When an edit's old_string doesn't match verbatim, the cause is almost
737
+ * always invisible: indentation, tabs vs spaces, or a trailing space. Point
738
+ * at the closest-looking line so the model re-reads that region instead of
739
+ * blindly retrying the same string.
740
+ */
741
+ function nearMissHint(content: string, from: string): string {
742
+ const probe =
743
+ from
744
+ .split("\n")
745
+ .map((l) => l.trim())
746
+ .find((l) => l.length >= 8) ?? from.trim();
747
+ if (!probe) return "";
748
+ const lines = content.split("\n");
749
+ const idx = lines.findIndex((l) => l.trim().includes(probe));
750
+ if (idx === -1) return "";
751
+ return (
752
+ `\nLine ${idx + 1} looks close — whitespace/indentation must match the file byte-for-byte. ` +
753
+ `Re-read that region before retrying:\n${String(idx + 1).padStart(6)}\t${lines[idx]}`
754
+ );
755
+ }
756
+
539
757
  // ── helpers ─────────────────────────────────────────────────────────────────
540
758
 
541
759
  function runLocal(
@@ -38,7 +38,8 @@ export const nativeTools: ToolDefinition[] = [
38
38
  {
39
39
  name: "bash",
40
40
  description:
41
- "Run a shell command. Runs on the daemon host, or ON the active teleport device if one is engaged. On a teleported device, `cd` persists across calls (a real working-directory session).",
41
+ "Run a shell command. Runs on the daemon host, or ON the active teleport device if one is engaged. On a teleported device, `cd` persists across calls (a real working-directory session). " +
42
+ "Foreground runs are killed at timeout_sec — for streaming/never-ending commands (adb logcat, tail -f, dev servers, watchers) set background:true instead: the command is launched detached in its own process group with stdout+stderr captured to a log file, and the tool returns immediately with the pid + log path so you can poll the log and kill it when done.",
42
43
  schema: {
43
44
  command: z.string().describe("The shell command to run."),
44
45
  cwd: z
@@ -51,7 +52,13 @@ export const nativeTools: ToolDefinition[] = [
51
52
  .number()
52
53
  .optional()
53
54
  .describe(
54
- "Max seconds before the command is killed (default 60, max 300).",
55
+ "Max seconds before a foreground command is killed (default 60, max 300). Ignored with background:true.",
56
+ ),
57
+ background: z
58
+ .boolean()
59
+ .optional()
60
+ .describe(
61
+ "Launch detached and return immediately with pid + log file path (local runs only). Use for streaming or long-running commands that would otherwise time out.",
55
62
  ),
56
63
  },
57
64
  execute: (params, bridge) => bridge("native_bash", params),
@@ -539,9 +539,11 @@ export function createNativeFrontend(
539
539
  const turnTools = new Map<string, LiveToolEntry>();
540
540
  liveTurns.set(entry.id, turnTools);
541
541
  // Safety net: any tool the backend announced but never resolved
542
- // (text-mode backends don't emit tool_result; crashes can eat one)
543
- // gets a synthetic result at turn end a spinner the app opened
544
- // on phase:"call" must always see a phase:"result".
542
+ // (a crash can eat a tool_result; callback backends historically
543
+ // never emitted onehandler-to-events now pairs each tool_call
544
+ // with an immediate synthetic result) gets a synthetic result at
545
+ // turn end — a spinner the app opened on phase:"call" must always
546
+ // see a phase:"result".
545
547
  const flushOpenTools = () => {
546
548
  for (const [id, live] of turnTools) {
547
549
  if (live.done) continue;