talon-agent 1.40.2 → 1.42.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": "1.40.2",
3
+ "version": "1.42.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",
@@ -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. */
@@ -18,6 +18,9 @@ export const meshHandlers: SharedActionHandlers = {
18
18
  get_device_history: (body) =>
19
19
  getMeshService().deviceHistory(body.device, body.hours),
20
20
  ring_device: (body) => getMeshService().ringDevice(body.device, body.message),
21
+ // Registry hygiene: drop a stale/superseded device entry. Requires an
22
+ // explicit target — a destructive action must never default-pick a device.
23
+ remove_device: (body) => getMeshService().removeDevice(body.device),
21
24
  get_device_status: (body) => getMeshService().getDeviceStatus(body.device),
22
25
  // Exec + filesystem surface — the substrate teleport routes through.
23
26
  device_exec: (body) =>
@@ -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(
@@ -91,10 +91,48 @@ export class MeshRegistry {
91
91
  now,
92
92
  );
93
93
  this.devices.set(device.id, device);
94
+ // Stale-duplicate eviction. A reinstall (or an old app build) carries its
95
+ // own persisted device id, so the same physical machine re-registers
96
+ // under a fresh identity and the old entry lingers forever as an offline
97
+ // ghost. Same name + platform under a different id, currently OFFLINE →
98
+ // superseded install, drop it. An online doppelganger is kept: two live
99
+ // devices can legitimately share a name.
100
+ let evicted = false;
101
+ for (const [id, d] of this.devices) {
102
+ if (id === device.id) continue;
103
+ if (d.name !== device.name || d.platform !== device.platform) continue;
104
+ if (toDeviceInfo(d, now, PRESENCE_TIMEOUT_MS).online) continue;
105
+ this.devices.delete(id);
106
+ this.locations.delete(id);
107
+ this.history.delete(id);
108
+ evicted = true;
109
+ }
94
110
  await this.persistDevices();
111
+ if (evicted) {
112
+ await this.persistLocations();
113
+ await this.persistHistory();
114
+ }
95
115
  return device;
96
116
  }
97
117
 
118
+ /**
119
+ * Drop a device (and its location + history) from the registry. Returns
120
+ * the removed device, or undefined when the id is unknown. Note a still-
121
+ * connected companion re-registers on its next heartbeat — removal is for
122
+ * stale entries.
123
+ */
124
+ async removeDevice(deviceId: string): Promise<DeviceInfo | undefined> {
125
+ const device = this.devices.get(deviceId);
126
+ if (!device) return undefined;
127
+ this.devices.delete(deviceId);
128
+ const hadLocation = this.locations.delete(deviceId);
129
+ const hadHistory = this.history.delete(deviceId);
130
+ await this.persistDevices();
131
+ if (hadLocation) await this.persistLocations();
132
+ if (hadHistory) await this.persistHistory();
133
+ return toDeviceInfo(device, device.lastSeen);
134
+ }
135
+
98
136
  async storeLocation(
99
137
  body: Record<string, unknown>,
100
138
  now = Date.now(),
@@ -340,6 +340,36 @@ export class MeshService {
340
340
  return { ok: true, text: this.locationSummary(target, loc) };
341
341
  }
342
342
 
343
+ /**
344
+ * `remove_device`: drop a stale device from the mesh registry (with its
345
+ * location + history). Destructive, so it requires an explicit target —
346
+ * no "default to the most recent device" like the read tools.
347
+ */
348
+ async removeDevice(query?: unknown): Promise<MeshToolResult> {
349
+ await this.load();
350
+ if (typeof query !== "string" || !query.trim()) {
351
+ return {
352
+ ok: false,
353
+ text: "remove_device needs an explicit device id or name — there is no default target for a destructive operation. See list_devices.",
354
+ };
355
+ }
356
+ const resolved = this.resolveDevice(query);
357
+ if ("error" in resolved) return { ok: false, text: resolved.error };
358
+ const target = resolved.target;
359
+ const removed = await this.registry.removeDevice(target.id);
360
+ if (!removed) {
361
+ return { ok: false, text: this.noSuchDevice(query).text };
362
+ }
363
+ return {
364
+ ok: true,
365
+ text:
366
+ `Removed ${removed.name} [id: ${removed.id}] (${removed.platform}, last seen ${age(Date.now() - removed.lastSeen)}) from the mesh registry.` +
367
+ (target.online
368
+ ? " Note: it was still online — a connected companion re-registers within ~60s, so quit the app first if it keeps coming back."
369
+ : ""),
370
+ };
371
+ }
372
+
343
373
  /** `ring_device`: make the device sound/vibrate so it can be found. */
344
374
  ringDevice(query?: unknown, message?: unknown): Promise<MeshToolResult> {
345
375
  return this.commandTool(query, "ring", {
@@ -60,6 +60,20 @@ export const meshTools: ToolDefinition[] = [
60
60
  execute: (params, bridge) => bridge("ring_device", params),
61
61
  tag: "mesh",
62
62
  },
63
+ {
64
+ name: "remove_device",
65
+ description:
66
+ "Remove a stale device from the mesh registry (with its stored location and history). Use for superseded installs and old duplicates — e.g. a reinstalled companion that re-registered under a new id, leaving the old entry as an offline ghost. Requires an explicit device id or name (no default target). Removing a still-connected device is pointless: it re-registers on its next heartbeat within ~60s.",
67
+ schema: {
68
+ device: z
69
+ .string()
70
+ .describe(
71
+ "Device id, exact name, or unique name fragment of the entry to remove (see list_devices). Prefer the id when duplicates share a name.",
72
+ ),
73
+ },
74
+ execute: (params, bridge) => bridge("remove_device", params),
75
+ tag: "mesh",
76
+ },
63
77
  {
64
78
  name: "get_device_status",
65
79
  description:
@@ -71,7 +85,7 @@ export const meshTools: ToolDefinition[] = [
71
85
  {
72
86
  name: "device_exec",
73
87
  description:
74
- "Run a shell command ON a Talon companion device (e.g. the phone) and return its stdout/stderr/exit code. The device executes it in its own sandbox (Android: app UID, or elevated via Shizuku if available). Use for on-device tasks like tidying a folder. Prefer `teleport` for a sustained session.",
88
+ "Run a shell command ON a Talon companion device (e.g. the phone) and return its stdout/stderr/exit code. The device executes it in its own sandbox (Android: app UID, or elevated via Shizuku if available). Use for on-device tasks like tidying a folder. Prefer `teleport` for a sustained session. For persistent streams (log tailing, watchers), background with output redirected to a file — `cmd > /tmp/out.log 2>&1 &` returns immediately; poll the file with device_read_file.",
75
89
  schema: {
76
90
  device: deviceParam,
77
91
  cmd: z.string().describe("The shell command to run on the device."),
@@ -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;