talon-agent 5.0.0 → 5.0.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.
Files changed (127) hide show
  1. package/package.json +1 -1
  2. package/src/app.ts +3 -3
  3. package/src/backend/claude-sdk/factory.ts +21 -41
  4. package/src/backend/claude-sdk/host/in-process.ts +147 -0
  5. package/src/backend/codex/mcp-config.ts +1 -1
  6. package/src/backend/openai-agents/mcp-pool.ts +1 -1
  7. package/src/backend/runtime/index.ts +2 -2
  8. package/src/backend/runtime/turn/delivery.ts +1 -1
  9. package/src/backend/runtime/turn/turn-phases.ts +1 -1
  10. package/src/bootstrap.ts +2 -2
  11. package/src/cli.ts +5 -2
  12. package/src/core/agent-runtime/README.md +15 -0
  13. package/src/core/agent-runtime/agent-host.ts +387 -0
  14. package/src/core/background/cron/scheduler.ts +1 -1
  15. package/src/core/background/triggers/command.ts +1 -1
  16. package/src/{util → core/config}/harden.ts +1 -1
  17. package/src/core/config/index.ts +1 -1
  18. package/src/core/daemon/pidfile.ts +1 -1
  19. package/src/core/daemon/resource-sampler.ts +2 -2
  20. package/src/{util → core/daemon}/respawn.ts +1 -1
  21. package/src/core/doctor/index.ts +1 -1
  22. package/src/core/engine/gateway-actions/fetch-url.ts +1 -1
  23. package/src/core/engine/gateway-actions/index.ts +1 -1
  24. package/src/core/engine/gateway-actions/memory.ts +2 -2
  25. package/src/core/engine/gateway-actions/native/exec-background.ts +129 -0
  26. package/src/core/engine/gateway-actions/native/exec-remote.ts +85 -0
  27. package/src/core/engine/gateway-actions/native/exec.ts +182 -0
  28. package/src/core/engine/gateway-actions/native/index.ts +46 -0
  29. package/src/core/engine/gateway-actions/native/params.ts +46 -0
  30. package/src/core/engine/gateway-actions/native/read.ts +182 -0
  31. package/src/core/engine/gateway-actions/native/results.ts +34 -0
  32. package/src/core/engine/gateway-actions/native/search.ts +212 -0
  33. package/src/core/engine/gateway-actions/native/shell.ts +39 -0
  34. package/src/core/engine/gateway-actions/native/teleport.ts +55 -0
  35. package/src/core/engine/gateway-actions/native/write.ts +170 -0
  36. package/src/core/frontend-runtime/builtins.ts +2 -2
  37. package/src/core/mcp-hub/index.ts +1 -1
  38. package/src/core/mcp-hub/talon-server.ts +1 -1
  39. package/src/core/memory/taps.ts +1 -1
  40. package/src/core/plugin/mcp.ts +1 -1
  41. package/src/core/scripts/lua.ts +1 -1
  42. package/src/core/tools/{chat.ts → chat/chat.ts} +1 -1
  43. package/src/core/tools/{cross-send.ts → chat/cross-send.ts} +1 -1
  44. package/src/core/tools/{history.ts → chat/history.ts} +2 -2
  45. package/src/core/tools/{media.ts → chat/media.ts} +1 -1
  46. package/src/core/tools/{members.ts → chat/members.ts} +2 -2
  47. package/src/core/tools/{messaging.ts → chat/messaging.ts} +2 -2
  48. package/src/core/tools/{moderation.ts → chat/moderation.ts} +2 -2
  49. package/src/core/tools/{stickers.ts → chat/stickers.ts} +2 -2
  50. package/src/core/tools/{whatsapp.ts → chat/whatsapp.ts} +1 -1
  51. package/src/core/tools/{memory.ts → content/memory.ts} +2 -2
  52. package/src/core/tools/{web.ts → content/web.ts} +1 -1
  53. package/src/core/tools/index.ts +20 -20
  54. package/src/core/tools/{admin.ts → ops/admin.ts} +1 -1
  55. package/src/core/tools/{bridge.ts → ops/bridge.ts} +2 -2
  56. package/src/core/tools/{goals.ts → ops/goals.ts} +2 -2
  57. package/src/core/tools/{mesh.ts → ops/mesh.ts} +1 -1
  58. package/src/core/tools/{models.ts → ops/models.ts} +1 -1
  59. package/src/core/tools/{native.ts → ops/native.ts} +1 -1
  60. package/src/core/tools/{scheduling.ts → ops/scheduling.ts} +1 -1
  61. package/src/core/tools/{scripts.ts → ops/scripts.ts} +1 -1
  62. package/src/core/tools/{skills.ts → ops/skills.ts} +1 -1
  63. package/src/core/tools/{triggers.ts → ops/triggers.ts} +1 -1
  64. package/src/{util → core/vfs}/workspace.ts +2 -2
  65. package/src/frontend/discord/callbacks/components/effort.ts +1 -1
  66. package/src/frontend/discord/callbacks/components/index.ts +1 -1
  67. package/src/frontend/discord/callbacks/components/settings.ts +1 -1
  68. package/src/frontend/discord/commands/admin.ts +1 -1
  69. package/src/frontend/discord/commands/info.ts +1 -1
  70. package/src/frontend/discord/commands/interaction.ts +1 -1
  71. package/src/frontend/discord/commands/session.ts +3 -3
  72. package/src/frontend/discord/commands/settings.ts +1 -1
  73. package/src/frontend/discord/handlers/messages.ts +1 -1
  74. package/src/frontend/discord/handlers/state.ts +1 -1
  75. package/src/frontend/discord/middleware.ts +1 -1
  76. package/src/frontend/discord/ready.ts +1 -1
  77. package/src/frontend/discord/render.ts +41 -259
  78. package/src/frontend/native/chats/chats.ts +1 -1
  79. package/src/frontend/native/surface/models.ts +1 -1
  80. package/src/frontend/native/turn/context.ts +1 -1
  81. package/src/frontend/native/turn/emit.ts +1 -1
  82. package/src/frontend/{shared → presentation}/format.ts +4 -3
  83. package/src/frontend/{shared → presentation}/reasoning-levels.ts +12 -0
  84. package/src/frontend/presentation/reports.ts +493 -0
  85. package/src/frontend/{shared → presentation}/session-status.ts +1 -1
  86. package/src/frontend/teams/commands.ts +2 -2
  87. package/src/frontend/teams/turn.ts +1 -1
  88. package/src/frontend/telegram/admin/health.ts +1 -1
  89. package/src/frontend/telegram/admin/sessions.ts +1 -1
  90. package/src/frontend/telegram/callbacks/effort.ts +2 -2
  91. package/src/frontend/telegram/callbacks/metrics.ts +1 -1
  92. package/src/frontend/telegram/callbacks/model/views.ts +1 -1
  93. package/src/frontend/telegram/callbacks/query.ts +1 -1
  94. package/src/frontend/telegram/callbacks/settings.ts +2 -2
  95. package/src/frontend/telegram/commands/admin.ts +4 -4
  96. package/src/frontend/telegram/commands/info.ts +2 -2
  97. package/src/frontend/telegram/commands/session.ts +4 -4
  98. package/src/frontend/telegram/commands/settings.ts +4 -2
  99. package/src/frontend/telegram/handlers/state.ts +1 -1
  100. package/src/frontend/telegram/model-menu.ts +1 -1
  101. package/src/frontend/telegram/render/html.ts +30 -0
  102. package/src/frontend/telegram/{helpers → render}/menu.ts +46 -19
  103. package/src/frontend/telegram/render/reports.ts +182 -0
  104. package/src/frontend/terminal/builtins/context.ts +2 -2
  105. package/src/frontend/terminal/builtins/session.ts +1 -1
  106. package/src/frontend/terminal/builtins/status.ts +2 -2
  107. package/src/frontend/terminal/index.ts +2 -2
  108. package/src/frontend/terminal/renderer.ts +1 -1
  109. package/src/frontend/whatsapp/commands.ts +5 -5
  110. package/src/frontend/whatsapp/registry.ts +1 -1
  111. package/src/index.ts +5 -2
  112. package/src/util/runtime.ts +1 -1
  113. package/src/core/engine/gateway-actions/native.ts +0 -1035
  114. package/src/frontend/telegram/helpers/diagnostics.ts +0 -428
  115. package/src/frontend/telegram/helpers/format.ts +0 -42
  116. package/src/frontend/telegram/helpers/index.ts +0 -13
  117. package/src/util/cleanup-registry.ts +0 -36
  118. /package/src/{util → core/daemon}/boot-timer.ts +0 -0
  119. /package/src/{util → core/frontend-runtime}/chat-id.ts +0 -0
  120. /package/src/{util/mcp-launcher.ts → core/mcp-hub/launcher.ts} +0 -0
  121. /package/src/{util → core/tools/content}/web-content.ts +0 -0
  122. /package/src/core/tools/{mcp-env.ts → ops/mcp-env.ts} +0 -0
  123. /package/src/{util → core/weaver}/session-name.ts +0 -0
  124. /package/src/frontend/{shared → presentation}/access.ts +0 -0
  125. /package/src/frontend/{shared → presentation}/model-commands.ts +0 -0
  126. /package/src/frontend/{shared → presentation}/plan-usage-report.ts +0 -0
  127. /package/src/frontend/{shared → presentation}/status-context.ts +0 -0
@@ -1,1035 +0,0 @@
1
- /**
2
- * Native tools — Talon's own shell/filesystem tools, replacing the SDK's
3
- * built-in Bash/Read/Write/Edit/Glob/Grep when `config.nativeTools` is on.
4
- *
5
- * Their defining feature: every one checks the current chat's active
6
- * `teleport` target. With no teleport for that chat, they run on the daemon
7
- * host (local spawn / local fs / local ripgrep). With a teleport engaged, they
8
- * run ON the companion device via the mesh exec/fs channel — so
9
- * `bash`/`read`/`write`/… transparently operate on the phone for that chat.
10
- *
11
- * teleport(device) → native tools target that device
12
- * teleport_back() → native tools run locally again
13
- *
14
- * The teleported path reuses the exec/fs command surface on MeshService; the
15
- * local path is a thin, well-scoped reimplementation of the built-ins.
16
- */
17
-
18
- import { spawn } from "node:child_process";
19
- import { closeSync, openSync } from "node:fs";
20
- import {
21
- glob as fsGlob,
22
- mkdir,
23
- readFile,
24
- stat as fsStat,
25
- writeFile,
26
- } from "node:fs/promises";
27
- import { homedir, tmpdir } from "node:os";
28
- import { dirname, extname, join } from "node:path";
29
- import { getMeshService } from "../../mesh/index.js";
30
- import {
31
- clearTeleport,
32
- getTeleport,
33
- setTeleport,
34
- setTeleportCwd,
35
- } from "../../mesh/teleport.js";
36
- import {
37
- clampExecOutput,
38
- createOutputCapture,
39
- } from "../../../util/exec-output.js";
40
- import type { SharedActionHandlers } from "./types.js";
41
-
42
- type Result = {
43
- ok: boolean;
44
- text: string;
45
- /** Set for image files so the tool result carries a viewable image block. */
46
- image?: { data: string; mimeType: string };
47
- };
48
-
49
- /**
50
- * ripgrep binary, resolved from PATH (env-overridable for tests). NOT a
51
- * hardcoded absolute path: /usr/bin/rg only exists on some Linux installs,
52
- * and a missing binary must fall back loudly (or to the pure-JS walker),
53
- * never masquerade as "no matches".
54
- */
55
- function rgBin(): string {
56
- return process.env.TALON_NATIVE_RG ?? "rg";
57
- }
58
- const DEFAULT_EXEC_TIMEOUT_MS = 60_000;
59
- const MAX_EXEC_TIMEOUT_MS = 300_000;
60
- /** Grace between SIGTERM and SIGKILL on timeout, so pipelines can flush. */
61
- const KILL_GRACE_MS = 2_000;
62
- /** How long a background launch waits to catch fast failures. */
63
- const BACKGROUND_SETTLE_MS = 1_200;
64
- /** Where background job output lands (one log file per job). */
65
- const BACKGROUND_LOG_DIR = join(tmpdir(), "talon-bash");
66
- /** Appended to a timed-out bash result so the model self-corrects. */
67
- const TIMEOUT_HINT =
68
- "(Streaming/never-ending commands — adb logcat, tail -f, dev servers, watchers — " +
69
- "will always hit this wall. Re-run with background:true to launch it detached with " +
70
- "output captured to a log file, or bound the command itself: `adb logcat -d`, " +
71
- "`timeout 30 …`, `head -n 200`.)";
72
- /** Same self-correction hint, phrased for a teleported (on-device) run. */
73
- const TELEPORT_TIMEOUT_HINT =
74
- "(Streaming/never-ending commands can't ride a teleported foreground call — " +
75
- "background it in-shell instead: `cmd > /tmp/out.log 2>&1 &`, then poll the log " +
76
- "with read, or bound the command itself: `logcat -d`, `timeout 30 …`, `head -n 200`.)";
77
- const MAX_READ_LINES = 2_000;
78
- /**
79
- * Refuse to slurp huge files into memory: `read` loads the whole file to
80
- * slice lines, so an unbounded readFile of a multi-GB log balloons RSS.
81
- * Past this, bash is the right tool (`sed -n`, `tail`, `head`).
82
- */
83
- const MAX_READ_FILE_BYTES = 32 * 1024 * 1024;
84
- /**
85
- * Image extensions the model can actually view, mapped to their MIME type.
86
- * Reading one returns an image content block instead of the file's raw bytes
87
- * decoded as (garbage) UTF-8 — so `read`ing a photo/screenshot/design shows
88
- * the picture, not mojibake.
89
- */
90
- const IMAGE_MIME: Record<string, string> = {
91
- ".png": "image/png",
92
- ".jpg": "image/jpeg",
93
- ".jpeg": "image/jpeg",
94
- ".gif": "image/gif",
95
- ".webp": "image/webp",
96
- };
97
- /** Anthropic caps a single image around 5MB; refuse larger with a downscale hint. */
98
- const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
99
- /** Caps for the pure-JS glob/search fallbacks (rg unavailable). */
100
- const MAX_JS_RESULTS = 2_000;
101
- const MAX_JS_FILE_BYTES = 2 * 1024 * 1024;
102
- const SKIP_DIRS_RE = /(^|[\\/])(node_modules|\.git)([\\/]|$)/;
103
- /** Markers used to recover the post-command working dir from a teleport shell. */
104
- const CWD_OPEN = "__TALON_CWD__";
105
- const CWD_CLOSE = "__TALON_CWD_END__";
106
-
107
- export const nativeHandlers: SharedActionHandlers = {
108
- teleport: (body, chatId) => teleport(chatId, body.device),
109
- teleport_back: (_body, chatId) => teleportBack(chatId),
110
- native_bash: (body, chatId) =>
111
- bash(chatId, body.command, body.cwd, body.timeout_sec, body.background),
112
- native_read: (body, chatId) =>
113
- read(chatId, body.path, body.offset, body.limit),
114
- native_write: (body, chatId) => write(chatId, body.path, body.content),
115
- native_edit: (body, chatId) =>
116
- edit(chatId, body.path, body.old_string, body.new_string, body.replace_all),
117
- native_glob: (body, chatId) => glob(chatId, body.pattern, body.path),
118
- native_search: (body, chatId) =>
119
- search(chatId, body.pattern, body.path, body.glob, body.case_insensitive),
120
- };
121
-
122
- // ── Teleport control ────────────────────────────────────────────────────────
123
-
124
- async function teleport(chatId: number, query: unknown): Promise<Result> {
125
- const svc = getMeshService();
126
- await svc.list();
127
- const resolved = svc.resolveDevice(query);
128
- if ("error" in resolved) return { ok: false, text: resolved.error };
129
- const target = resolved.target;
130
- if (!target.online) {
131
- return {
132
- ok: false,
133
- text: `${target.name} appears offline — cannot teleport onto a device that isn't connected.`,
134
- };
135
- }
136
- if (target.capabilities && !target.capabilities.includes("exec")) {
137
- return {
138
- ok: false,
139
- text: `${target.name} does not advertise the "exec" capability, so teleport can't run commands on it.`,
140
- };
141
- }
142
- await setTeleport(chatId, target.id, target.name);
143
- return {
144
- ok: true,
145
- text: [
146
- `🛰️ Teleported onto ${target.name}. Native bash/read/write/edit/glob/search now run ON that device.`,
147
- `Working dir starts at the device default; \`cd\` in bash persists across calls.`,
148
- `Call teleport_back to return to the daemon host.`,
149
- ].join(" "),
150
- };
151
- }
152
-
153
- async function teleportBack(chatId: number): Promise<Result> {
154
- const prior = await clearTeleport(chatId);
155
- return {
156
- ok: true,
157
- text: prior
158
- ? `↩️ Teleported back from ${prior.deviceName}. Native tools run on the daemon host again.`
159
- : "Not teleported — native tools already run on the daemon host.",
160
- };
161
- }
162
-
163
- // ── bash ────────────────────────────────────────────────────────────────────
164
-
165
- async function bash(
166
- chatId: number,
167
- command: unknown,
168
- cwd: unknown,
169
- timeoutSec: unknown,
170
- background?: unknown,
171
- ): Promise<Result> {
172
- const cmd = typeof command === "string" ? command : "";
173
- if (!cmd.trim()) return { ok: false, text: "No command given." };
174
- const timeoutMs = clampTimeout(timeoutSec);
175
- const active = await getTeleport(chatId);
176
-
177
- let dir = typeof cwd === "string" && cwd.trim() ? cwd.trim() : undefined;
178
- if (dir !== undefined && !active) {
179
- dir = resolvePathParam(dir, undefined);
180
- // Validate here: a bad cwd makes spawn fail with "spawn bash ENOENT",
181
- // which reads as "bash is missing" and sends the model down the wrong
182
- // path. Name the actual problem instead.
183
- try {
184
- const st = await fsStat(dir);
185
- if (!st.isDirectory())
186
- return { ok: false, text: `cwd is not a directory: ${dir}` };
187
- } catch {
188
- return { ok: false, text: `Working directory does not exist: ${dir}` };
189
- }
190
- }
191
-
192
- const result = await (async () => {
193
- if (background === true) {
194
- if (active) {
195
- return {
196
- ok: false,
197
- text:
198
- "background:true runs on the daemon host only. On a teleported device, " +
199
- "background it in-shell instead: `cmd > /tmp/out.log 2>&1 &`, then poll " +
200
- "the log with read.",
201
- };
202
- }
203
- return bashBackground(cmd, dir);
204
- }
205
- if (active) return bashTeleported(chatId, active.deviceId, cmd, timeoutMs);
206
- return bashLocal(cmd, dir, timeoutMs);
207
- })();
208
- return result;
209
- }
210
-
211
- /**
212
- * Launch a command detached from the request cycle: its own process group,
213
- * stdout+stderr appended to a per-job log file, tool returns immediately.
214
- * This is the sanctioned path for streaming/long-running commands (adb
215
- * logcat, dev servers, watchers) that would otherwise burn the whole
216
- * foreground timeout and come back "killed".
217
- *
218
- * A short settle window catches fast failures (typo'd binary, instant
219
- * non-zero exit) so those still surface as a normal error instead of a
220
- * "started" message pointing at a log with one line in it.
221
- */
222
- async function bashBackground(
223
- cmd: string,
224
- cwd: string | undefined,
225
- ): Promise<Result> {
226
- // The background contract is POSIX-shaped end to end: detached process
227
- // group, `kill -- -pid` to stop, survives daemon restarts. Windows has
228
- // none of those (and the CI legs showed the detached writer's output not
229
- // reaching the log) — refuse loudly with the native alternative instead
230
- // of pretending.
231
- if (process.platform === "win32") {
232
- return {
233
- ok: false,
234
- text:
235
- "background:true needs POSIX process groups and isn't supported on a Windows " +
236
- "daemon host. Run it foreground with a bound command (`timeout 30 …`, `head -n 200`) " +
237
- "or start it yourself: `powershell Start-Process -WindowStyle Hidden` with output redirected to a file.",
238
- };
239
- }
240
- try {
241
- await mkdir(BACKGROUND_LOG_DIR, { recursive: true });
242
- } catch (err) {
243
- return {
244
- ok: false,
245
- text: `Cannot create log dir ${BACKGROUND_LOG_DIR}: ${(err as Error).message}`,
246
- };
247
- }
248
- const slug =
249
- cmd
250
- .replace(/[^a-zA-Z0-9]+/g, "-")
251
- .replace(/^-+|-+$/g, "")
252
- .slice(0, 40) || "job";
253
- const logPath = join(BACKGROUND_LOG_DIR, `${Date.now()}-${slug}.log`);
254
- let fd: number;
255
- try {
256
- fd = openSync(logPath, "a");
257
- } catch (err) {
258
- return {
259
- ok: false,
260
- text: `Cannot open log file ${logPath}: ${(err as Error).message}`,
261
- };
262
- }
263
- // Always detached: the win32 guard above returned already, so this only
264
- // runs on POSIX where the job gets its own process group.
265
- const child = spawn("bash", ["-c", cmd], {
266
- ...(cwd ? { cwd } : {}),
267
- env: process.env,
268
- detached: true,
269
- stdio: ["ignore", fd, fd],
270
- });
271
- return new Promise((resolvePromise) => {
272
- let settled = false;
273
- const done = (r: Result) => {
274
- if (settled) return;
275
- settled = true;
276
- try {
277
- closeSync(fd);
278
- } catch {
279
- // parent's dup only; the child keeps its own copy either way
280
- }
281
- resolvePromise(r);
282
- };
283
- child.on("error", (err) =>
284
- done({ ok: false, text: `Failed to start: ${err.message}` }),
285
- );
286
- // Fast failure inside the settle window → report it like a normal run.
287
- child.on("close", (code) => {
288
- void (async () => {
289
- let logged = "";
290
- try {
291
- logged = await readFile(logPath, "utf8");
292
- } catch {
293
- // log unreadable — report the exit alone
294
- }
295
- done({
296
- ok: (code ?? 0) === 0,
297
- text:
298
- `Background command exited almost immediately (exit ${code ?? 0}).\n` +
299
- renderExec("local", `exit ${code ?? 0}`, logged, "") +
300
- `\nFull log: ${logPath}`,
301
- });
302
- })();
303
- });
304
- setTimeout(() => {
305
- if (settled) return;
306
- child.unref();
307
- done({
308
- ok: true,
309
- text: [
310
- `🚀 Started in background [local] — pid ${child.pid}.`,
311
- `Output (stdout+stderr) → ${logPath}`,
312
- `Follow it with read/bash (e.g. \`tail -n 50 ${logPath}\`).`,
313
- `Stop it with \`kill -- -${child.pid}\` (whole process group).`,
314
- `Unsupervised: it keeps running until it exits or is killed — it even survives a Talon restart.`,
315
- ].join("\n"),
316
- });
317
- }, BACKGROUND_SETTLE_MS);
318
- });
319
- }
320
-
321
- function bashLocal(
322
- cmd: string,
323
- cwd: string | undefined,
324
- timeoutMs: number,
325
- ): Promise<Result> {
326
- return new Promise((resolvePromise) => {
327
- // detached → own process group on POSIX, so a timeout can kill the whole
328
- // tree (bash's children included), not just the shell itself.
329
- const detached = process.platform !== "win32";
330
- const child = spawn("bash", ["-c", cmd], {
331
- ...(cwd ? { cwd } : {}),
332
- env: process.env,
333
- detached,
334
- });
335
- const stdout = createOutputCapture();
336
- const stderr = createOutputCapture();
337
- let killed = false;
338
- // Timeout escalation: SIGTERM the whole process group first (lets
339
- // pipelines flush + children clean up), SIGKILL any survivors after a
340
- // short grace. Straight-to-SIGKILL used to eat buffered output.
341
- const killTree = (signal: NodeJS.Signals) => {
342
- if (detached && child.pid) {
343
- try {
344
- process.kill(-child.pid, signal);
345
- return;
346
- } catch {
347
- // group already gone — fall through to the direct kill
348
- }
349
- }
350
- try {
351
- child.kill(signal);
352
- } catch {
353
- // already dead
354
- }
355
- };
356
- let settled = false;
357
- let killTimer: NodeJS.Timeout | undefined;
358
- let forceTimer: NodeJS.Timeout | undefined;
359
- const finish = (r: Result) => {
360
- if (settled) return;
361
- settled = true;
362
- clearTimeout(timer);
363
- if (killTimer) clearTimeout(killTimer);
364
- if (forceTimer) clearTimeout(forceTimer);
365
- resolvePromise(r);
366
- };
367
- const timedOutResult = () => {
368
- const body = renderExec(
369
- "local",
370
- `⏱️ timed out after ${timeoutMs / 1000}s — killed; partial output kept below`,
371
- stdout.value(),
372
- stderr.value(),
373
- );
374
- return { ok: false, text: `${body}\n${TIMEOUT_HINT}` };
375
- };
376
- const timer = setTimeout(() => {
377
- killed = true;
378
- killTree("SIGTERM");
379
- killTimer = setTimeout(() => killTree("SIGKILL"), KILL_GRACE_MS);
380
- // `close` waits for the stdio pipes to drain — a surviving grandchild
381
- // that inherited stdout (Windows has no process groups; a detached
382
- // POSIX grandchild can escape the group kill) would otherwise hold
383
- // this promise open long past the timeout. Force-resolve with the
384
- // partial output once the escalation window has passed.
385
- forceTimer = setTimeout(
386
- () => finish(timedOutResult()),
387
- KILL_GRACE_MS + 1_000,
388
- );
389
- }, timeoutMs);
390
- child.stdout.on("data", stdout.push);
391
- child.stderr.on("data", stderr.push);
392
- child.on("error", (err) => {
393
- finish({ ok: false, text: `Failed to run: ${err.message}` });
394
- });
395
- child.on("close", (code) => {
396
- if (killed) {
397
- finish(timedOutResult());
398
- return;
399
- }
400
- finish({
401
- ok: (code ?? 0) === 0,
402
- text: renderExec(
403
- "local",
404
- `exit ${code ?? 0}`,
405
- stdout.value(),
406
- stderr.value(),
407
- ),
408
- });
409
- });
410
- });
411
- }
412
-
413
- async function bashTeleported(
414
- chatId: number,
415
- deviceId: string,
416
- cmd: string,
417
- timeoutMs: number,
418
- ): Promise<Result> {
419
- const active = await getTeleport(chatId);
420
- const cwd = active?.cwd;
421
- // Wrap so the resulting working dir is reported back and persists across
422
- // calls (a `cd` in `cmd` carries forward), while the real exit code is
423
- // preserved. printf can't fail in a way that masks the command's status.
424
- const wrapped =
425
- `${cwd ? `cd ${shellQuote(cwd)} 2>/dev/null; ` : ""}` +
426
- `{ ${cmd}\n}; __talon_rc=$?; ` +
427
- `printf '${CWD_OPEN}%s${CWD_CLOSE}' "$(pwd 2>/dev/null)"; exit $__talon_rc`;
428
- const dispatched = await getMeshService().dispatchCommand(
429
- deviceId,
430
- "exec",
431
- { cmd: wrapped, timeoutMs },
432
- timeoutMs + 5_000,
433
- );
434
- if ("error" in dispatched) return { ok: false, text: dispatched.error };
435
- const { target, result } = dispatched;
436
- const data = result.data ?? {};
437
- let stdout = typeof data.stdout === "string" ? data.stdout : "";
438
- const stderr = typeof data.stderr === "string" ? data.stderr : "";
439
- const via =
440
- typeof data.via === "string" && data.via ? ` via ${data.via}` : "";
441
- const exitCode =
442
- typeof data.exitCode === "number" ? data.exitCode : undefined;
443
- // Recover + strip the trailing cwd marker.
444
- const open = stdout.lastIndexOf(CWD_OPEN);
445
- if (open !== -1) {
446
- const close = stdout.indexOf(CWD_CLOSE, open);
447
- if (close !== -1) {
448
- const newCwd = stdout.slice(open + CWD_OPEN.length, close).trim();
449
- stdout = stdout.slice(0, open);
450
- if (newCwd) await setTeleportCwd(chatId, newCwd);
451
- }
452
- }
453
- if (!result.ok && exitCode === undefined) {
454
- return {
455
- ok: false,
456
- text: result.message ?? `${target.name} could not run the command.`,
457
- };
458
- }
459
- // The device marks a command it had to kill at its own exec budget with
460
- // this stderr marker (see the companion's device_exec) — surface the same
461
- // self-correction hint the local timeout path gets.
462
- const timedOutOnDevice = stderr.includes("[killed: timeout]");
463
- const body = renderExec(
464
- `${target.name}${via}`,
465
- `exit ${exitCode ?? "?"}`,
466
- stdout,
467
- stderr,
468
- );
469
- return {
470
- ok: exitCode === 0,
471
- text: timedOutOnDevice ? `${body}\n${TELEPORT_TIMEOUT_HINT}` : body,
472
- };
473
- }
474
-
475
- // ── path parameter resolution ───────────────────────────────────────────────
476
-
477
- /** Expand a leading `~` — local runs only; a device's home is not ours. */
478
- function expandHome(path: string): string {
479
- if (path === "~") return homedir();
480
- if (path.startsWith("~/")) return join(homedir(), path.slice(2));
481
- return path;
482
- }
483
-
484
- /**
485
- * One rule for every path parameter: `~` expands (local runs only),
486
- * everything else passes through untouched.
487
- *
488
- * The namespace has no tool-facing address scheme — its nodes are reached
489
- * by their real paths (`~/.talon/ns/…`, kept real by the symlink farm in
490
- * nsdir.ts and the FUSE layer in fusefs.ts). Real paths need no
491
- * translation and behave identically here, in a bare shell, in another
492
- * backend's built-in shell (Codex), and in any spawned child process.
493
- * Teleported, paths belong to the device and pass through verbatim.
494
- */
495
- function resolvePathParam(
496
- path: string,
497
- teleportedTo: string | undefined,
498
- ): string {
499
- return teleportedTo !== undefined ? path : expandHome(path);
500
- }
501
-
502
- // ── read / write / edit ─────────────────────────────────────────────────────
503
-
504
- async function read(
505
- chatId: number,
506
- path: unknown,
507
- offset: unknown,
508
- limit: unknown,
509
- ): Promise<Result> {
510
- const address = str(path);
511
- if (!address) return { ok: false, text: "A file path is required." };
512
- const active = await getTeleport(chatId);
513
- const where = active ? active.deviceName : "local";
514
-
515
- const p = resolvePathParam(address, active?.deviceName);
516
- const shown = p === address ? p : `${address} → ${p}`;
517
-
518
- // Image files: return a viewable image block, not the raw bytes decoded as
519
- // UTF-8. Without this, `read`ing a photo/screenshot/design hands the model
520
- // mojibake and it can't see the picture at all.
521
- const mime = IMAGE_MIME[extname(p).toLowerCase()];
522
- if (mime) {
523
- let bytes: Buffer;
524
- if (active) {
525
- const res = await getMeshService().readFileBytes(active.deviceId, p);
526
- if ("error" in res) return { ok: false, text: res.error };
527
- bytes = res.data;
528
- } else {
529
- try {
530
- // stat first — no point slurping a 40MB photo just to refuse it.
531
- const st = await fsStat(p);
532
- if (st.size > MAX_IMAGE_BYTES)
533
- return oversizeImageResult(shown, where, st.size, p);
534
- bytes = await readFile(p);
535
- } catch (err) {
536
- return {
537
- ok: false,
538
- text: `Cannot read ${shown}: ${(err as Error).message}`,
539
- };
540
- }
541
- }
542
- if (bytes.length > MAX_IMAGE_BYTES) {
543
- return oversizeImageResult(shown, where, bytes.length, p);
544
- }
545
- return {
546
- ok: true,
547
- text: `${shown} [${where}] — image (${mime}, ${bytes.length} bytes)`,
548
- image: { data: bytes.toString("base64"), mimeType: mime },
549
- };
550
- }
551
-
552
- // Clamp: a negative offset would silently flip slice() into
553
- // count-from-the-end (with line numbers that lie), and a zero/negative
554
- // limit would return an empty read that looks like an empty file.
555
- const start = Math.max(0, Math.trunc(num(offset) ?? 0));
556
- const max = Math.min(
557
- Math.max(1, Math.trunc(num(limit) ?? MAX_READ_LINES)),
558
- MAX_READ_LINES,
559
- );
560
- let content: string;
561
- if (active) {
562
- const res = await getMeshService().readFileBytes(active.deviceId, p);
563
- if ("error" in res) return { ok: false, text: res.error };
564
- if (res.data.length > MAX_READ_FILE_BYTES)
565
- return oversizeReadResult(shown, where, res.data.length, p);
566
- content = res.data.toString("utf8");
567
- } else {
568
- try {
569
- const st = await fsStat(p);
570
- if (st.isFile() && st.size > MAX_READ_FILE_BYTES)
571
- return oversizeReadResult(shown, where, st.size, p);
572
- content = await readFile(p, "utf8");
573
- } catch (err) {
574
- return {
575
- ok: false,
576
- text: `Cannot read ${shown}: ${(err as Error).message}`,
577
- };
578
- }
579
- }
580
- // Binary files decoded as UTF-8 are mojibake the model can't use — same
581
- // rationale as the image branch, but with no viewable representation to
582
- // return. Point at the shell tools that can actually inspect the bytes.
583
- if (content.includes("\0")) {
584
- return {
585
- ok: false,
586
- text:
587
- `${shown} [${where}] looks binary — refusing to render it as text. ` +
588
- `Inspect it with bash instead: \`file '${p}'\`, \`xxd '${p}' | head\`, \`strings '${p}'\`.`,
589
- };
590
- }
591
- const lines = content.split("\n");
592
- if (start >= lines.length) {
593
- return {
594
- ok: false,
595
- text: `${shown} [${where}] has ${lines.length} lines — offset ${start} is past the end.`,
596
- };
597
- }
598
- const slice = lines.slice(start, start + max);
599
- const numbered = slice
600
- .map((line, i) => `${String(start + i + 1).padStart(6)}\t${line}`)
601
- .join("\n");
602
- const more =
603
- lines.length > start + max
604
- ? `\n… (${lines.length - start - max} more lines; raise limit/offset)`
605
- : "";
606
- return {
607
- ok: true,
608
- text: `${shown} [${where}] — ${lines.length} lines\n${numbered}${more}`,
609
- };
610
- }
611
-
612
- async function write(
613
- chatId: number,
614
- path: unknown,
615
- content: unknown,
616
- ): Promise<Result> {
617
- const address = str(path);
618
- if (!address) return { ok: false, text: "A file path is required." };
619
- const body = typeof content === "string" ? content : "";
620
- const active = await getTeleport(chatId);
621
- const p = resolvePathParam(address, active?.deviceName);
622
- const shown = p === address ? p : `${address} → ${p}`;
623
- if (active) {
624
- return getMeshService().writeFileToDevice(active.deviceId, p, body);
625
- }
626
- try {
627
- await mkdir(dirname(p), { recursive: true });
628
- await writeFile(p, body);
629
- } catch (err) {
630
- return {
631
- ok: false,
632
- text: `Cannot write ${shown}: ${(err as Error).message}`,
633
- };
634
- }
635
- return {
636
- ok: true,
637
- text: `Wrote ${Buffer.byteLength(body, "utf8")} bytes to ${shown} [local].`,
638
- };
639
- }
640
-
641
- async function edit(
642
- chatId: number,
643
- path: unknown,
644
- oldString: unknown,
645
- newString: unknown,
646
- replaceAll: unknown,
647
- ): Promise<Result> {
648
- const address = str(path);
649
- if (!address) return { ok: false, text: "A file path is required." };
650
- const from = typeof oldString === "string" ? oldString : "";
651
- const to = typeof newString === "string" ? newString : "";
652
- if (from === to)
653
- return { ok: false, text: "old_string and new_string are identical." };
654
- const active = await getTeleport(chatId);
655
- const p = resolvePathParam(address, active?.deviceName);
656
- const shown = p === address ? p : `${address} → ${p}`;
657
- const svc = getMeshService();
658
-
659
- let content: string;
660
- if (active) {
661
- const res = await svc.readFileBytes(active.deviceId, p);
662
- if ("error" in res) return { ok: false, text: res.error };
663
- content = res.data.toString("utf8");
664
- } else {
665
- try {
666
- content = await readFile(p, "utf8");
667
- } catch (err) {
668
- return {
669
- ok: false,
670
- text: `Cannot read ${shown}: ${(err as Error).message}`,
671
- };
672
- }
673
- }
674
-
675
- // Binary guard: decoding binary as UTF-8 is lossy (invalid sequences
676
- // become U+FFFD), so a read→replace→write round-trip would corrupt every
677
- // non-text byte — even when old_string matches a clean region.
678
- if (content.includes("\0")) {
679
- return {
680
- ok: false,
681
- text: `${shown} looks binary — refusing a text edit that would corrupt it. Use bash for byte-level changes.`,
682
- };
683
- }
684
-
685
- const count = from ? content.split(from).length - 1 : 0;
686
- if (count === 0) {
687
- return {
688
- ok: false,
689
- text: `old_string not found in ${shown}.${nearMissHint(content, from)}`,
690
- };
691
- }
692
- if (count > 1 && replaceAll !== true) {
693
- return {
694
- ok: false,
695
- text: `old_string appears ${count}× in ${shown}; pass replace_all or make it unique.`,
696
- };
697
- }
698
- // Splice, not String.replace: replace() treats dollar-sign substitution
699
- // patterns ($&, $', $BACKTICK, $$) in the replacement as directives, silently
700
- // corrupting any new_string that contains them (shell snippets, regexes,
701
- // Makefiles...). split/join and index-splice are both literal.
702
- const updated =
703
- replaceAll === true
704
- ? content.split(from).join(to)
705
- : spliceOnce(content, from, to);
706
- const firstLine = content.slice(0, content.indexOf(from)).split("\n").length;
707
-
708
- if (active) {
709
- const res = await svc.writeFileToDevice(active.deviceId, p, updated);
710
- return res.ok
711
- ? {
712
- ok: true,
713
- text: `Edited ${shown} [${active.deviceName}] (${count} replacement${count === 1 ? "" : "s"}, first at line ${firstLine}).`,
714
- }
715
- : res;
716
- }
717
- try {
718
- await writeFile(p, updated);
719
- } catch (err) {
720
- return {
721
- ok: false,
722
- text: `Cannot write ${shown}: ${(err as Error).message}`,
723
- };
724
- }
725
- return {
726
- ok: true,
727
- text: `Edited ${shown} [local] (${count} replacement${count === 1 ? "" : "s"}, first at line ${firstLine}).`,
728
- };
729
- }
730
-
731
- // ── glob / search ───────────────────────────────────────────────────────────
732
-
733
- async function glob(
734
- chatId: number,
735
- pattern: unknown,
736
- path: unknown,
737
- ): Promise<Result> {
738
- const pat = str(pattern);
739
- if (!pat) return { ok: false, text: "A glob pattern is required." };
740
- const active = await getTeleport(chatId);
741
- const root = resolvePathParam(str(path) ?? ".", active?.deviceName);
742
- if (active) {
743
- // Prefer rg on the device; fall back to find (basename patterns via
744
- // -name, path patterns via -path). `command -v` gates the choice so a
745
- // no-match rg exit (1) isn't misread as "rg missing, run find too".
746
- const findExpr = pat.includes("/")
747
- ? `-type f -path ${shellQuote(`*${pat}`)}`
748
- : `-type f -name ${shellQuote(pat)}`;
749
- const cmd =
750
- `if command -v rg >/dev/null 2>&1; ` +
751
- `then rg --files -g ${shellQuote(pat)} ${shellQuote(root)}; ` +
752
- `else find ${shellQuote(root)} ${findExpr} 2>/dev/null; fi`;
753
- return bashTeleported(chatId, active.deviceId, cmd, 30_000);
754
- }
755
- const res = await runLocal(rgBin(), ["--files", "-g", pat, root]);
756
- let files: string[];
757
- if (res.code === 127) {
758
- // rg not installed — pure-JS fallback rather than lying "no matches".
759
- files = await globJs(pat, root);
760
- } else if (res.code > 1 && !res.stdout.trim()) {
761
- // exit 2 with output = partial results (e.g. permission-denied subdirs);
762
- // exit 2 with none = a real error worth surfacing.
763
- return {
764
- ok: false,
765
- text: `glob failed: ${res.stderr.trim() || `ripgrep exit ${res.code}`}`,
766
- };
767
- } else {
768
- files = res.stdout.trim().split("\n").filter(Boolean);
769
- }
770
- // rg's parallel directory walk (and the JS fallback's) emit in
771
- // nondeterministic order — sort so identical calls render identically.
772
- files.sort();
773
- return {
774
- ok: true,
775
- text: files.length
776
- ? `${files.length} match(es):\n${files.slice(0, 200).join("\n")}${files.length > 200 ? `\n… (${files.length - 200} more)` : ""}`
777
- : `No files match ${pat} under ${root}.`,
778
- };
779
- }
780
-
781
- async function search(
782
- chatId: number,
783
- pattern: unknown,
784
- path: unknown,
785
- globPat: unknown,
786
- caseInsensitive: unknown,
787
- ): Promise<Result> {
788
- const pat = str(pattern);
789
- if (!pat) return { ok: false, text: "A search pattern is required." };
790
- const active = await getTeleport(chatId);
791
- const root = resolvePathParam(str(path) ?? ".", active?.deviceName);
792
- const g = str(globPat);
793
- const ci = caseInsensitive === true;
794
- // `-e` keeps a pattern that starts with "-" from being parsed as a flag
795
- // (same idiom for rg and grep).
796
- const flags = [
797
- "-n",
798
- "--color=never",
799
- ...(ci ? ["-i"] : []),
800
- ...(g ? ["-g", g] : []),
801
- ];
802
- if (active) {
803
- // Prefer rg on the device, fall back to grep (Android toybox has grep
804
- // but rarely rg). --include is grep's closest analogue of -g.
805
- const grepFlags = [
806
- "-rn",
807
- ...(ci ? ["-i"] : []),
808
- ...(g ? [`--include=${g}`] : []),
809
- ];
810
- const cmd =
811
- `if command -v rg >/dev/null 2>&1; ` +
812
- `then rg ${flags.map(shellQuote).join(" ")} -e ${shellQuote(pat)} ${shellQuote(root)}; ` +
813
- `else grep ${grepFlags.map(shellQuote).join(" ")} -e ${shellQuote(pat)} ${shellQuote(root)} 2>/dev/null; fi`;
814
- return bashTeleported(chatId, active.deviceId, cmd, 30_000);
815
- }
816
- const res = await runLocal(rgBin(), [...flags, "-e", pat, root]);
817
- let lines: string[];
818
- if (res.code === 127) {
819
- // rg not installed — pure-JS fallback rather than lying "no matches".
820
- try {
821
- lines = await searchJs(pat, root, g, ci);
822
- } catch (err) {
823
- return { ok: false, text: `search failed: ${(err as Error).message}` };
824
- }
825
- } else if (res.code > 1 && !res.stdout.trim()) {
826
- // exit 2 with output = partial results; exit 2 with none = real error.
827
- return {
828
- ok: false,
829
- text: `search failed: ${res.stderr.trim() || `ripgrep exit ${res.code}`}`,
830
- };
831
- } else {
832
- const out = res.stdout.trim();
833
- lines = out ? out.split("\n") : [];
834
- }
835
- return {
836
- ok: true,
837
- text: lines.length
838
- ? `${lines.length} match line(s):\n${lines.slice(0, 200).join("\n")}${lines.length > 200 ? `\n… (${lines.length - 200} more)` : ""}`
839
- : `No matches for ${pat} under ${root}.`,
840
- };
841
- }
842
-
843
- // ── pure-JS glob/search fallbacks (no ripgrep on the host) ──────────────────
844
-
845
- /**
846
- * Glob without ripgrep, via node:fs `glob`. Mirrors rg's -g semantics for
847
- * bare names (a pattern without "/" matches at any depth) and skips
848
- * node_modules/.git, which rg would exclude via gitignore.
849
- */
850
- async function globJs(pat: string, root: string): Promise<string[]> {
851
- const pattern = pat.includes("/") ? pat : `**/${pat}`;
852
- const out: string[] = [];
853
- try {
854
- for await (const entry of fsGlob(pattern, {
855
- cwd: root,
856
- exclude: (e: unknown) => {
857
- const name =
858
- typeof e === "string" ? e : ((e as { name?: string }).name ?? "");
859
- return (
860
- SKIP_DIRS_RE.test(name) || name === "node_modules" || name === ".git"
861
- );
862
- },
863
- })) {
864
- out.push(join(root, String(entry)));
865
- if (out.length >= MAX_JS_RESULTS) break;
866
- }
867
- } catch {
868
- // unreadable root etc. — empty result, caller reports "no matches"
869
- }
870
- return out;
871
- }
872
-
873
- /** Content search without ripgrep: walk text files and regex-match lines. */
874
- async function searchJs(
875
- pat: string,
876
- root: string,
877
- globPat: string | undefined,
878
- caseInsensitive: boolean,
879
- ): Promise<string[]> {
880
- const re = new RegExp(pat, caseInsensitive ? "i" : "");
881
- let files: string[];
882
- try {
883
- const st = await fsStat(root);
884
- files = st.isFile() ? [root] : await globJs(globPat ?? "**/*", root);
885
- } catch {
886
- return [];
887
- }
888
- const lines: string[] = [];
889
- for (const f of files) {
890
- let content: string;
891
- try {
892
- const st = await fsStat(f);
893
- if (!st.isFile() || st.size > MAX_JS_FILE_BYTES) continue;
894
- content = await readFile(f, "utf8");
895
- } catch {
896
- continue;
897
- }
898
- if (content.includes("\0")) continue; // binary
899
- const fileLines = content.split("\n");
900
- for (let i = 0; i < fileLines.length; i++) {
901
- if (re.test(fileLines[i])) lines.push(`${f}:${i + 1}:${fileLines[i]}`);
902
- if (lines.length >= MAX_JS_RESULTS) return lines;
903
- }
904
- }
905
- return lines;
906
- }
907
-
908
- function oversizeReadResult(
909
- shown: string,
910
- where: string,
911
- size: number,
912
- p: string,
913
- ): Result {
914
- return {
915
- ok: false,
916
- text:
917
- `${shown} [${where}] is ${(size / 1_048_576).toFixed(1)}MB — over the ` +
918
- `${MAX_READ_FILE_BYTES / 1_048_576}MB read limit. Slice it with bash instead: ` +
919
- `\`sed -n '1,200p' '${p}'\`, \`tail -n 200 '${p}'\`, or \`grep\` for what you need.`,
920
- };
921
- }
922
-
923
- function oversizeImageResult(
924
- shown: string,
925
- where: string,
926
- size: number,
927
- p: string,
928
- ): Result {
929
- return {
930
- ok: false,
931
- text:
932
- `${shown} [${where}] is ${(size / 1_048_576).toFixed(1)}MB — over the ` +
933
- `${MAX_IMAGE_BYTES / 1_048_576}MB image limit. Downscale it first ` +
934
- `(e.g. \`convert '${p}' -resize 1568x /tmp/small.jpg\`) and read that.`,
935
- };
936
- }
937
-
938
- /**
939
- * When an edit's old_string doesn't match verbatim, the cause is almost
940
- * always invisible: indentation, tabs vs spaces, or a trailing space. Point
941
- * at the closest-looking line so the model re-reads that region instead of
942
- * blindly retrying the same string.
943
- */
944
- function nearMissHint(content: string, from: string): string {
945
- const probe =
946
- from
947
- .split("\n")
948
- .map((l) => l.trim())
949
- .find((l) => l.length >= 8) ?? from.trim();
950
- if (!probe) return "";
951
- const lines = content.split("\n");
952
- const idx = lines.findIndex((l) => l.trim().includes(probe));
953
- if (idx === -1) return "";
954
- return (
955
- `\nLine ${idx + 1} looks close — whitespace/indentation must match the file byte-for-byte. ` +
956
- `Re-read that region before retrying:\n${String(idx + 1).padStart(6)}\t${lines[idx]}`
957
- );
958
- }
959
-
960
- /** Replace the first occurrence of `from` with `to`, both taken literally. */
961
- function spliceOnce(content: string, from: string, to: string): string {
962
- const idx = content.indexOf(from);
963
- if (idx === -1) return content;
964
- return content.slice(0, idx) + to + content.slice(idx + from.length);
965
- }
966
-
967
- // ── helpers ─────────────────────────────────────────────────────────────────
968
-
969
- function runLocal(
970
- bin: string,
971
- args: string[],
972
- ): Promise<{ code: number; stdout: string; stderr: string }> {
973
- return new Promise((resolvePromise) => {
974
- const child = spawn(bin, args, { env: process.env });
975
- const stdout = createOutputCapture();
976
- const stderr = createOutputCapture();
977
- child.stdout.on("data", stdout.push);
978
- child.stderr.on("data", stderr.push);
979
- child.on("error", () =>
980
- resolvePromise({
981
- code: 127,
982
- stdout: stdout.value(),
983
- stderr: stderr.value(),
984
- }),
985
- );
986
- child.on("close", (code) =>
987
- resolvePromise({
988
- code: code ?? 0,
989
- stdout: stdout.value(),
990
- stderr: stderr.value(),
991
- }),
992
- );
993
- });
994
- }
995
-
996
- function renderExec(
997
- where: string,
998
- status: string,
999
- stdout: string,
1000
- stderr: string,
1001
- ): string {
1002
- const parts = [`[${where}] ${status}`];
1003
- if (stdout.trim())
1004
- parts.push(
1005
- `--- stdout ---\n${clampExecOutput(stdout.replace(/\s+$/, ""))}`,
1006
- );
1007
- if (stderr.trim())
1008
- parts.push(
1009
- `--- stderr ---\n${clampExecOutput(stderr.replace(/\s+$/, ""))}`,
1010
- );
1011
- if (!stdout.trim() && !stderr.trim()) parts.push("(no output)");
1012
- return parts.join("\n");
1013
- }
1014
-
1015
- function clampTimeout(value: unknown): number {
1016
- const sec = typeof value === "number" ? value : Number(value);
1017
- if (!Number.isFinite(sec) || sec <= 0) return DEFAULT_EXEC_TIMEOUT_MS;
1018
- return Math.min(
1019
- MAX_EXEC_TIMEOUT_MS,
1020
- Math.max(1_000, Math.round(sec * 1_000)),
1021
- );
1022
- }
1023
-
1024
- function shellQuote(s: string): string {
1025
- return `'${s.replace(/'/g, `'\\''`)}'`;
1026
- }
1027
-
1028
- function str(value: unknown): string | undefined {
1029
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
1030
- }
1031
-
1032
- function num(value: unknown): number | undefined {
1033
- const n = typeof value === "number" ? value : Number(value);
1034
- return Number.isFinite(n) ? n : undefined;
1035
- }