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 +1 -1
- package/src/backend/codex/handler/events.ts +6 -2
- package/src/backend/shared/handler-to-events.ts +15 -6
- package/src/backend/shared/handler-types.ts +11 -1
- package/src/core/engine/gateway-actions/native.ts +239 -21
- package/src/core/tools/native.ts +9 -2
- package/src/frontend/native/index.ts +5 -3
package/package.json
CHANGED
|
@@ -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?: (
|
|
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: "
|
|
110
|
-
id
|
|
111
|
-
.toString(36)
|
|
112
|
-
.slice(2, 8)}`,
|
|
120
|
+
type: "tool_result",
|
|
121
|
+
id,
|
|
113
122
|
name: toolName,
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
ok:
|
|
176
|
-
text: renderExec(
|
|
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:
|
|
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)
|
|
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(
|
package/src/core/tools/native.ts
CHANGED
|
@@ -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
|
|
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
|
-
// (
|
|
543
|
-
//
|
|
544
|
-
//
|
|
542
|
+
// (a crash can eat a tool_result; callback backends historically
|
|
543
|
+
// never emitted one — handler-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;
|