talon-agent 1.36.0 → 1.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.36.0",
3
+ "version": "1.37.1",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -336,7 +336,7 @@ export async function* runChatTurn(
336
336
  }
337
337
 
338
338
  for (const tool of result.tools) {
339
- recordToolCall(tool.name, "claude");
339
+ recordToolCall(chatId, tool.name, "claude");
340
340
  captureIntoState(tool.name, tool.input);
341
341
  if (isTurnTerminator(tool.name, tool.input)) {
342
342
  state.turnTerminated = true;
@@ -487,6 +487,7 @@ export async function* runChatTurn(
487
487
 
488
488
  const durationMs = Date.now() - t0;
489
489
  recordTurnMetrics({
490
+ chatId,
490
491
  backend: "claude",
491
492
  durationMs,
492
493
  toolCalls: state.toolCalls,
@@ -543,7 +544,10 @@ export async function* runChatTurn(
543
544
  : ({ violated: false } as const);
544
545
 
545
546
  if (violation.violated) {
546
- recordFlowViolation(violation.shouldRetry ? "retried" : "cap_exhausted");
547
+ recordFlowViolation(
548
+ chatId,
549
+ violation.shouldRetry ? "retried" : "cap_exhausted",
550
+ );
547
551
  log(
548
552
  "agent",
549
553
  `[${chatId}] flow violation: ${violation.reason}. ${
@@ -182,7 +182,7 @@ function recordCodexToolMetric(
182
182
  // Shared vocabulary: `tool_calls.<bare>` (prefix-stripped, so codex
183
183
  // MCP calls land on the same keys as every other backend) plus the
184
184
  // `backend.codex.tool_calls` dimension.
185
- recordToolCall(toolName, "codex");
185
+ recordToolCall(ctx.chatId, toolName, "codex");
186
186
  ctx.codexToolMetrics.count += 1;
187
187
  }
188
188
 
@@ -571,6 +571,7 @@ export async function handleMessage(
571
571
  const responseText = finalizeResponseText(streamState);
572
572
  const durationMs = Date.now() - t0;
573
573
  recordTurnMetrics({
574
+ chatId,
574
575
  backend: "codex",
575
576
  durationMs,
576
577
  toolCalls: codexToolMetrics.count,
@@ -220,6 +220,7 @@ export async function handleMessage(
220
220
  const responseText = finalizeResponseText(state);
221
221
  const durationMs = Date.now() - t0;
222
222
  recordTurnMetrics({
223
+ chatId,
223
224
  backend: "kilo",
224
225
  durationMs,
225
226
  toolCalls: state.toolCalls,
@@ -289,6 +289,7 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
289
289
  }
290
290
 
291
291
  const outcome = await processStreamEvent(event, {
292
+ chatId,
292
293
  sessionId,
293
294
  state,
294
295
  seenToolCallIds,
@@ -103,7 +103,7 @@ function handleToolCalled(item: unknown, ctx: HandleRunItemContext): void {
103
103
  }
104
104
  }
105
105
 
106
- recordToolCall(toolName, "openai-agents");
106
+ recordToolCall(ctx.chatId, toolName, "openai-agents");
107
107
  recordToolUse(ctx.state, toolName, input);
108
108
 
109
109
  if (ctx.onToolUse) {
@@ -398,6 +398,7 @@ export async function handleMessage(
398
398
  const responseText = finalizeResponseText(streamState);
399
399
  const durationMs = Date.now() - t0;
400
400
  recordTurnMetrics({
401
+ chatId,
401
402
  backend: "openai-agents",
402
403
  durationMs,
403
404
  toolCalls: streamState.toolCalls,
@@ -443,7 +444,10 @@ export async function handleMessage(
443
444
  : ({ violated: false } as const);
444
445
 
445
446
  if (violation.violated) {
446
- recordFlowViolation(violation.shouldRetry ? "retried" : "cap_exhausted");
447
+ recordFlowViolation(
448
+ chatId,
449
+ violation.shouldRetry ? "retried" : "cap_exhausted",
450
+ );
447
451
  log(
448
452
  "agent",
449
453
  `[${chatId}] flow violation: trailing prose (${violation.trailing.length} chars) without end_turn/send. ${
@@ -232,6 +232,7 @@ export async function handleMessage(
232
232
  const responseText = finalizeResponseText(state);
233
233
  const durationMs = Date.now() - t0;
234
234
  recordTurnMetrics({
235
+ chatId,
235
236
  backend: "opencode",
236
237
  durationMs,
237
238
  toolCalls: state.toolCalls,
@@ -277,6 +277,7 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
277
277
  }
278
278
 
279
279
  const outcome = await processStreamEvent(event, {
280
+ chatId,
280
281
  sessionId,
281
282
  state,
282
283
  seenToolCallIds,
@@ -63,6 +63,8 @@ export type ProcessEventOutcome =
63
63
 
64
64
  /** Common context passed to the per-event helper. */
65
65
  export interface EventProcessingContext {
66
+ /** Chat id for session-scoped metrics. */
67
+ chatId: string;
66
68
  /** Session id we're scoped to — events for other sessions are dropped. */
67
69
  sessionId: string;
68
70
  /** Stream state accumulator (shared/). */
@@ -277,7 +279,7 @@ async function processPartUpdate(
277
279
  }
278
280
  // Count every tool the model calls — shared vocabulary across
279
281
  // backends (prefix-stripped name + backend dimension).
280
- recordToolCall(toolName, ctx.backendLabel.toLowerCase());
282
+ recordToolCall(ctx.chatId, toolName, ctx.backendLabel.toLowerCase());
281
283
  recordToolUse(ctx.state, toolName, input);
282
284
  if (ctx.onToolUse) {
283
285
  try {
@@ -38,10 +38,14 @@
38
38
  * registry. Never interpolate chat ids, model ids, or user input.
39
39
  */
40
40
 
41
- import { incrementCounter, recordHistogram } from "../../util/metrics.js";
42
41
  import { stripMcpPrefix } from "../../core/tools/index.js";
43
- import { cacheHitPercent, type TokenUsageSnapshot } from "./usage.js";
44
- import { clearLiveTurn, recordUsage } from "../../storage/sessions.js";
42
+ import type { TokenUsageSnapshot } from "./usage.js";
43
+ import {
44
+ clearLiveTurn,
45
+ recordSessionMetricEvent,
46
+ recordSessionMetrics,
47
+ recordUsage,
48
+ } from "../../storage/sessions.js";
45
49
 
46
50
  /**
47
51
  * Count one tool call. `toolName` may be raw from any backend —
@@ -49,13 +53,21 @@ import { clearLiveTurn, recordUsage } from "../../storage/sessions.js";
49
53
  * (`talon-tools-123_send`), or bare — it is normalised so every
50
54
  * backend lands on the same `tool_calls.<bare>` key.
51
55
  */
52
- export function recordToolCall(toolName: string, backend?: string): void {
53
- incrementCounter(`tool_calls.${stripMcpPrefix(toolName)}`);
54
- if (backend) incrementCounter(`backend.${backend}.tool_calls`);
56
+ export function recordToolCall(
57
+ chatId: string,
58
+ toolName: string,
59
+ backend?: string,
60
+ ): void {
61
+ recordSessionMetricEvent(chatId, {
62
+ toolCalls: 1,
63
+ toolName: stripMcpPrefix(toolName),
64
+ backend,
65
+ });
55
66
  }
56
67
 
57
68
  /** Per-turn rollup recorded once at the end of every chat turn. */
58
69
  export type TurnMetricInputs = {
70
+ chatId: string;
59
71
  /** Backend id, e.g. "claude", "codex", "kilo", "openai-agents", "opencode". */
60
72
  backend: string;
61
73
  durationMs: number;
@@ -78,45 +90,7 @@ export type TurnMetricInputs = {
78
90
  * ("queries_total")` pairs (and codex's private `codex.*` family).
79
91
  */
80
92
  export function recordTurnMetrics(inputs: TurnMetricInputs): void {
81
- recordHistogram("response_latency_ms", inputs.durationMs);
82
- recordHistogram(
83
- `backend.${inputs.backend}.response_latency_ms`,
84
- inputs.durationMs,
85
- );
86
- incrementCounter("queries_total");
87
- incrementCounter(`backend.${inputs.backend}.queries`);
88
-
89
- if (inputs.failed) incrementCounter(`backend.${inputs.backend}.turn_failed`);
90
-
91
- if (inputs.toolCalls !== undefined) {
92
- recordHistogram("tool_calls_per_turn", inputs.toolCalls);
93
- if (inputs.toolCalls > 0) incrementCounter("turns_with_tools_total");
94
- }
95
- if (inputs.apiCalls !== undefined && inputs.apiCalls > 0) {
96
- incrementCounter("api_calls_total", inputs.apiCalls);
97
- recordHistogram("api_calls_per_turn", inputs.apiCalls);
98
- }
99
-
100
- if (inputs.usage) {
101
- const u = inputs.usage;
102
- const b = `backend.${inputs.backend}`;
103
- incrementCounter("tokens.input_total", u.inputTokens);
104
- incrementCounter("tokens.output_total", u.outputTokens);
105
- incrementCounter("tokens.cache_read_total", u.cacheRead);
106
- incrementCounter("tokens.cache_write_total", u.cacheWrite);
107
- incrementCounter(`${b}.tokens.input`, u.inputTokens);
108
- incrementCounter(`${b}.tokens.output`, u.outputTokens);
109
- incrementCounter(`${b}.tokens.cache_read`, u.cacheRead);
110
- incrementCounter(`${b}.tokens.cache_write`, u.cacheWrite);
111
- // Only meaningful when the turn actually consumed input — an
112
- // all-zero snapshot (failed turn, usage-less backend) would skew
113
- // the distribution with hard zeros.
114
- if (u.inputTokens + u.cacheRead > 0) {
115
- const pct = cacheHitPercent(u);
116
- recordHistogram("cache_hit_percent", pct);
117
- recordHistogram(`${b}.cache_hit_percent`, pct);
118
- }
119
- }
93
+ recordSessionMetrics(inputs.chatId, inputs);
120
94
  }
121
95
 
122
96
  /**
@@ -152,6 +126,7 @@ export function recordFailedTurnAccounting(inputs: {
152
126
  contextWindow?: number;
153
127
  }): void {
154
128
  recordTurnMetrics({
129
+ chatId: inputs.chatId,
155
130
  backend: inputs.backend,
156
131
  durationMs: inputs.durationMs,
157
132
  toolCalls: inputs.toolCalls,
@@ -188,12 +163,12 @@ export function recordFailedTurnAccounting(inputs: {
188
163
  * reminder" unanswerable for that backend.
189
164
  */
190
165
  export function recordFlowViolation(
166
+ chatId: string,
191
167
  outcome: "retried" | "cap_exhausted",
192
168
  ): void {
193
- incrementCounter("scratchpad.trailing_text_dropped");
194
- incrementCounter(
195
- outcome === "retried"
196
- ? "scratchpad.flow_violation_retried"
197
- : "scratchpad.flow_violation_cap_exhausted",
198
- );
169
+ recordSessionMetricEvent(chatId, {
170
+ trailingTextDropped: 1,
171
+ flowViolationRetries: outcome === "retried" ? 1 : 0,
172
+ flowViolationCapExhausted: outcome === "cap_exhausted" ? 1 : 0,
173
+ });
199
174
  }
@@ -2,11 +2,11 @@
2
2
  * Native tools — Talon's own shell/filesystem tools, replacing the SDK's
3
3
  * built-in Bash/Read/Write/Edit/Glob/Grep when `config.nativeTools` is on.
4
4
  *
5
- * Their defining feature: every one checks the active `teleport` target. With
6
- * no teleport, they run on the daemon host (local spawn / local fs / local
7
- * ripgrep). With a teleport engaged, they run ON the companion device via the
8
- * mesh exec/fs channel — so `bash`/`read`/`write`/… transparently operate on
9
- * the phone. Talon acts as if it were running on whichever node is active.
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
10
  *
11
11
  * teleport(device) → native tools target that device
12
12
  * teleport_back() → native tools run locally again
@@ -31,6 +31,10 @@ import {
31
31
  setTeleport,
32
32
  setTeleportCwd,
33
33
  } from "../../mesh/teleport.js";
34
+ import {
35
+ clampExecOutput,
36
+ createOutputCapture,
37
+ } from "../../../util/exec-output.js";
34
38
  import type { SharedActionHandlers } from "./types.js";
35
39
 
36
40
  type Result = { ok: boolean; text: string };
@@ -56,33 +60,28 @@ const CWD_OPEN = "__TALON_CWD__";
56
60
  const CWD_CLOSE = "__TALON_CWD_END__";
57
61
 
58
62
  export const nativeHandlers: SharedActionHandlers = {
59
- teleport: (body) => teleport(body.device),
60
- teleport_back: () => teleportBack(),
61
- native_bash: (body) => bash(body.command, body.cwd, body.timeout_sec),
62
- native_read: (body) => read(body.path, body.offset, body.limit),
63
- native_write: (body) => write(body.path, body.content),
64
- native_edit: (body) =>
65
- edit(body.path, body.old_string, body.new_string, body.replace_all),
66
- native_glob: (body) => glob(body.pattern, body.path),
67
- native_search: (body) =>
68
- search(body.pattern, body.path, body.glob, body.case_insensitive),
63
+ teleport: (body, chatId) => teleport(chatId, body.device),
64
+ teleport_back: (_body, chatId) => teleportBack(chatId),
65
+ native_bash: (body, chatId) =>
66
+ bash(chatId, body.command, body.cwd, body.timeout_sec),
67
+ native_read: (body, chatId) =>
68
+ read(chatId, body.path, body.offset, body.limit),
69
+ native_write: (body, chatId) => write(chatId, body.path, body.content),
70
+ native_edit: (body, chatId) =>
71
+ edit(chatId, body.path, body.old_string, body.new_string, body.replace_all),
72
+ native_glob: (body, chatId) => glob(chatId, body.pattern, body.path),
73
+ native_search: (body, chatId) =>
74
+ search(chatId, body.pattern, body.path, body.glob, body.case_insensitive),
69
75
  };
70
76
 
71
77
  // ── Teleport control ────────────────────────────────────────────────────────
72
78
 
73
- async function teleport(query: unknown): Promise<Result> {
79
+ async function teleport(chatId: number, query: unknown): Promise<Result> {
74
80
  const svc = getMeshService();
75
81
  await svc.list();
76
- const target = svc.chooseDevice(query);
77
- if (!target) {
78
- return {
79
- ok: false,
80
- text:
81
- typeof query === "string" && query.trim()
82
- ? `No mesh device matches "${query}". Use list_devices to see them.`
83
- : "No mesh device to teleport to. Register a companion first.",
84
- };
85
- }
82
+ const resolved = svc.resolveDevice(query);
83
+ if ("error" in resolved) return { ok: false, text: resolved.error };
84
+ const target = resolved.target;
86
85
  if (!target.online) {
87
86
  return {
88
87
  ok: false,
@@ -95,7 +94,7 @@ async function teleport(query: unknown): Promise<Result> {
95
94
  text: `${target.name} does not advertise the "exec" capability, so teleport can't run commands on it.`,
96
95
  };
97
96
  }
98
- await setTeleport(target.id, target.name);
97
+ await setTeleport(chatId, target.id, target.name);
99
98
  return {
100
99
  ok: true,
101
100
  text: [
@@ -106,8 +105,8 @@ async function teleport(query: unknown): Promise<Result> {
106
105
  };
107
106
  }
108
107
 
109
- async function teleportBack(): Promise<Result> {
110
- const prior = await clearTeleport();
108
+ async function teleportBack(chatId: number): Promise<Result> {
109
+ const prior = await clearTeleport(chatId);
111
110
  return {
112
111
  ok: true,
113
112
  text: prior
@@ -119,6 +118,7 @@ async function teleportBack(): Promise<Result> {
119
118
  // ── bash ────────────────────────────────────────────────────────────────────
120
119
 
121
120
  async function bash(
121
+ chatId: number,
122
122
  command: unknown,
123
123
  cwd: unknown,
124
124
  timeoutSec: unknown,
@@ -126,8 +126,8 @@ async function bash(
126
126
  const cmd = typeof command === "string" ? command : "";
127
127
  if (!cmd.trim()) return { ok: false, text: "No command given." };
128
128
  const timeoutMs = clampTimeout(timeoutSec);
129
- const active = await getTeleport();
130
- if (active) return bashTeleported(active.deviceId, cmd, timeoutMs);
129
+ const active = await getTeleport(chatId);
130
+ if (active) return bashTeleported(chatId, active.deviceId, cmd, timeoutMs);
131
131
  return bashLocal(cmd, typeof cwd === "string" ? cwd : undefined, timeoutMs);
132
132
  }
133
133
 
@@ -145,8 +145,8 @@ function bashLocal(
145
145
  env: process.env,
146
146
  detached,
147
147
  });
148
- let stdout = "";
149
- let stderr = "";
148
+ const stdout = createOutputCapture();
149
+ const stderr = createOutputCapture();
150
150
  let killed = false;
151
151
  const timer = setTimeout(() => {
152
152
  killed = true;
@@ -160,8 +160,8 @@ function bashLocal(
160
160
  }
161
161
  child.kill("SIGKILL");
162
162
  }, timeoutMs);
163
- child.stdout.on("data", (d) => (stdout += d.toString()));
164
- child.stderr.on("data", (d) => (stderr += d.toString()));
163
+ child.stdout.on("data", stdout.push);
164
+ child.stderr.on("data", stderr.push);
165
165
  child.on("error", (err) => {
166
166
  clearTimeout(timer);
167
167
  resolvePromise({ ok: false, text: `Failed to run: ${err.message}` });
@@ -173,18 +173,19 @@ function bashLocal(
173
173
  : `exit ${code ?? 0}`;
174
174
  resolvePromise({
175
175
  ok: !killed && (code ?? 0) === 0,
176
- text: renderExec("local", exit, stdout, stderr),
176
+ text: renderExec("local", exit, stdout.value(), stderr.value()),
177
177
  });
178
178
  });
179
179
  });
180
180
  }
181
181
 
182
182
  async function bashTeleported(
183
+ chatId: number,
183
184
  deviceId: string,
184
185
  cmd: string,
185
186
  timeoutMs: number,
186
187
  ): Promise<Result> {
187
- const active = await getTeleport();
188
+ const active = await getTeleport(chatId);
188
189
  const cwd = active?.cwd;
189
190
  // Wrap so the resulting working dir is reported back and persists across
190
191
  // calls (a `cd` in `cmd` carries forward), while the real exit code is
@@ -204,6 +205,8 @@ async function bashTeleported(
204
205
  const data = result.data ?? {};
205
206
  let stdout = typeof data.stdout === "string" ? data.stdout : "";
206
207
  const stderr = typeof data.stderr === "string" ? data.stderr : "";
208
+ const via =
209
+ typeof data.via === "string" && data.via ? ` via ${data.via}` : "";
207
210
  const exitCode =
208
211
  typeof data.exitCode === "number" ? data.exitCode : undefined;
209
212
  // Recover + strip the trailing cwd marker.
@@ -213,7 +216,7 @@ async function bashTeleported(
213
216
  if (close !== -1) {
214
217
  const newCwd = stdout.slice(open + CWD_OPEN.length, close).trim();
215
218
  stdout = stdout.slice(0, open);
216
- if (newCwd) await setTeleportCwd(newCwd);
219
+ if (newCwd) await setTeleportCwd(chatId, newCwd);
217
220
  }
218
221
  }
219
222
  if (!result.ok && exitCode === undefined) {
@@ -224,13 +227,19 @@ async function bashTeleported(
224
227
  }
225
228
  return {
226
229
  ok: exitCode === 0,
227
- text: renderExec(target.name, `exit ${exitCode ?? "?"}`, stdout, stderr),
230
+ text: renderExec(
231
+ `${target.name}${via}`,
232
+ `exit ${exitCode ?? "?"}`,
233
+ stdout,
234
+ stderr,
235
+ ),
228
236
  };
229
237
  }
230
238
 
231
239
  // ── read / write / edit ─────────────────────────────────────────────────────
232
240
 
233
241
  async function read(
242
+ chatId: number,
234
243
  path: unknown,
235
244
  offset: unknown,
236
245
  limit: unknown,
@@ -239,7 +248,7 @@ async function read(
239
248
  if (!p) return { ok: false, text: "A file path is required." };
240
249
  const start = num(offset) ?? 0;
241
250
  const max = Math.min(num(limit) ?? MAX_READ_LINES, MAX_READ_LINES);
242
- const active = await getTeleport();
251
+ const active = await getTeleport(chatId);
243
252
  let content: string;
244
253
  let where: string;
245
254
  if (active) {
@@ -270,11 +279,15 @@ async function read(
270
279
  };
271
280
  }
272
281
 
273
- async function write(path: unknown, content: unknown): Promise<Result> {
282
+ async function write(
283
+ chatId: number,
284
+ path: unknown,
285
+ content: unknown,
286
+ ): Promise<Result> {
274
287
  const p = str(path);
275
288
  if (!p) return { ok: false, text: "A file path is required." };
276
289
  const body = typeof content === "string" ? content : "";
277
- const active = await getTeleport();
290
+ const active = await getTeleport(chatId);
278
291
  if (active) {
279
292
  return getMeshService().writeFileToDevice(active.deviceId, p, body);
280
293
  }
@@ -288,6 +301,7 @@ async function write(path: unknown, content: unknown): Promise<Result> {
288
301
  }
289
302
 
290
303
  async function edit(
304
+ chatId: number,
291
305
  path: unknown,
292
306
  oldString: unknown,
293
307
  newString: unknown,
@@ -299,7 +313,7 @@ async function edit(
299
313
  const to = typeof newString === "string" ? newString : "";
300
314
  if (from === to)
301
315
  return { ok: false, text: "old_string and new_string are identical." };
302
- const active = await getTeleport();
316
+ const active = await getTeleport(chatId);
303
317
  const svc = getMeshService();
304
318
 
305
319
  let content: string;
@@ -350,11 +364,15 @@ async function edit(
350
364
 
351
365
  // ── glob / search ───────────────────────────────────────────────────────────
352
366
 
353
- async function glob(pattern: unknown, path: unknown): Promise<Result> {
367
+ async function glob(
368
+ chatId: number,
369
+ pattern: unknown,
370
+ path: unknown,
371
+ ): Promise<Result> {
354
372
  const pat = str(pattern);
355
373
  if (!pat) return { ok: false, text: "A glob pattern is required." };
356
374
  const root = str(path) ?? ".";
357
- const active = await getTeleport();
375
+ const active = await getTeleport(chatId);
358
376
  if (active) {
359
377
  // Prefer rg on the device; fall back to find (basename patterns via
360
378
  // -name, path patterns via -path). `command -v` gates the choice so a
@@ -366,7 +384,7 @@ async function glob(pattern: unknown, path: unknown): Promise<Result> {
366
384
  `if command -v rg >/dev/null 2>&1; ` +
367
385
  `then rg --files -g ${shellQuote(pat)} ${shellQuote(root)}; ` +
368
386
  `else find ${shellQuote(root)} ${findExpr} 2>/dev/null; fi`;
369
- return bashTeleported(active.deviceId, cmd, 30_000);
387
+ return bashTeleported(chatId, active.deviceId, cmd, 30_000);
370
388
  }
371
389
  const res = await runLocal(rgBin(), ["--files", "-g", pat, root]);
372
390
  let files: string[];
@@ -392,6 +410,7 @@ async function glob(pattern: unknown, path: unknown): Promise<Result> {
392
410
  }
393
411
 
394
412
  async function search(
413
+ chatId: number,
395
414
  pattern: unknown,
396
415
  path: unknown,
397
416
  globPat: unknown,
@@ -410,7 +429,7 @@ async function search(
410
429
  ...(ci ? ["-i"] : []),
411
430
  ...(g ? ["-g", g] : []),
412
431
  ];
413
- const active = await getTeleport();
432
+ const active = await getTeleport(chatId);
414
433
  if (active) {
415
434
  // Prefer rg on the device, fall back to grep (Android toybox has grep
416
435
  // but rarely rg). --include is grep's closest analogue of -g.
@@ -423,7 +442,7 @@ async function search(
423
442
  `if command -v rg >/dev/null 2>&1; ` +
424
443
  `then rg ${flags.map(shellQuote).join(" ")} -e ${shellQuote(pat)} ${shellQuote(root)}; ` +
425
444
  `else grep ${grepFlags.map(shellQuote).join(" ")} -e ${shellQuote(pat)} ${shellQuote(root)} 2>/dev/null; fi`;
426
- return bashTeleported(active.deviceId, cmd, 30_000);
445
+ return bashTeleported(chatId, active.deviceId, cmd, 30_000);
427
446
  }
428
447
  const res = await runLocal(rgBin(), [...flags, "-e", pat, root]);
429
448
  let lines: string[];
@@ -525,13 +544,23 @@ function runLocal(
525
544
  ): Promise<{ code: number; stdout: string; stderr: string }> {
526
545
  return new Promise((resolvePromise) => {
527
546
  const child = spawn(bin, args, { env: process.env });
528
- let stdout = "";
529
- let stderr = "";
530
- child.stdout.on("data", (d) => (stdout += d.toString()));
531
- child.stderr.on("data", (d) => (stderr += d.toString()));
532
- child.on("error", () => resolvePromise({ code: 127, stdout, stderr }));
547
+ const stdout = createOutputCapture();
548
+ const stderr = createOutputCapture();
549
+ child.stdout.on("data", stdout.push);
550
+ child.stderr.on("data", stderr.push);
551
+ child.on("error", () =>
552
+ resolvePromise({
553
+ code: 127,
554
+ stdout: stdout.value(),
555
+ stderr: stderr.value(),
556
+ }),
557
+ );
533
558
  child.on("close", (code) =>
534
- resolvePromise({ code: code ?? 0, stdout, stderr }),
559
+ resolvePromise({
560
+ code: code ?? 0,
561
+ stdout: stdout.value(),
562
+ stderr: stderr.value(),
563
+ }),
535
564
  );
536
565
  });
537
566
  }
@@ -544,9 +573,13 @@ function renderExec(
544
573
  ): string {
545
574
  const parts = [`[${where}] ${status}`];
546
575
  if (stdout.trim())
547
- parts.push(`--- stdout ---\n${stdout.replace(/\s+$/, "")}`);
576
+ parts.push(
577
+ `--- stdout ---\n${clampExecOutput(stdout.replace(/\s+$/, ""))}`,
578
+ );
548
579
  if (stderr.trim())
549
- parts.push(`--- stderr ---\n${stderr.replace(/\s+$/, "")}`);
580
+ parts.push(
581
+ `--- stderr ---\n${clampExecOutput(stderr.replace(/\s+$/, ""))}`,
582
+ );
550
583
  if (!stdout.trim() && !stderr.trim()) parts.push("(no output)");
551
584
  return parts.join("\n");
552
585
  }