@dyyz1993/pi-coding-agent 0.74.45 → 0.74.47

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 (33) hide show
  1. package/dist/core/agent-session.d.ts.map +1 -1
  2. package/dist/core/agent-session.js +13 -0
  3. package/dist/core/agent-session.js.map +1 -1
  4. package/dist/extensions/auto-memory/__tests__/extract-result.test.ts +42 -0
  5. package/dist/extensions/auto-memory/__tests__/prefetch-history.test.ts +136 -0
  6. package/dist/extensions/auto-memory/__tests__/prompts.test.ts +29 -0
  7. package/dist/extensions/auto-memory/__tests__/skip-rules.test.ts +366 -0
  8. package/dist/extensions/auto-memory/contract.d.ts +16 -0
  9. package/dist/extensions/auto-memory/contract.d.ts.map +1 -1
  10. package/dist/extensions/auto-memory/contract.js.map +1 -1
  11. package/dist/extensions/auto-memory/contract.ts +16 -0
  12. package/dist/extensions/auto-memory/index.ts +134 -13
  13. package/dist/extensions/auto-memory/prompts.ts +10 -0
  14. package/dist/extensions/auto-memory/skip-rules.ts +2 -0
  15. package/dist/extensions/bash-ext/index.ts +855 -845
  16. package/dist/extensions/claude-hooks-compat/index.ts +12 -7
  17. package/dist/extensions/coordinator/handler.test.ts +388 -123
  18. package/dist/extensions/coordinator/handler.ts +78 -12
  19. package/dist/extensions/coordinator/index.ts +267 -198
  20. package/dist/extensions/coordinator/types.d.ts +16 -0
  21. package/dist/extensions/coordinator/types.d.ts.map +1 -1
  22. package/dist/extensions/coordinator/types.js.map +1 -1
  23. package/dist/extensions/coordinator/types.ts +57 -49
  24. package/dist/extensions/lsp/lsp/index.ts +15 -9
  25. package/dist/extensions/lsp/lsp/lsp-clangd-e2e.test.ts +229 -0
  26. package/dist/extensions/message-bridge/index.ts +14 -11
  27. package/dist/extensions/session-supervisor/index.ts +14 -8
  28. package/dist/extensions/subagent-v2/index.ts +58 -42
  29. package/dist/extensions/todo-ext/index.ts +7 -3
  30. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  31. package/dist/modes/rpc/rpc-mode.js +9 -1
  32. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  33. package/package.json +1 -1
@@ -32,44 +32,44 @@ import type { ChildProcess } from "child_process";
32
32
  import { Type } from "typebox";
33
33
  import type { BashToolDetails as _BashToolDetails, ExtensionAPI, ExtensionContext } from "@dyyz1993/pi-coding-agent";
34
34
  import {
35
- DEFAULT_MAX_BYTES,
36
- DEFAULT_MAX_LINES,
37
- OutputCollector,
38
- ServerChannel,
39
- createTypedChannel,
40
- killProcessTree,
41
- sanitizeBinaryOutput,
42
- spawnManagedProcess,
43
- waitForChildProcess,
35
+ DEFAULT_MAX_BYTES,
36
+ DEFAULT_MAX_LINES,
37
+ OutputCollector,
38
+ ServerChannel,
39
+ createTypedChannel,
40
+ killProcessTree,
41
+ sanitizeBinaryOutput,
42
+ spawnManagedProcess,
43
+ waitForChildProcess,
44
44
  } from "@dyyz1993/pi-coding-agent";
45
45
  import { BASH_CHANNEL_NAME, type BashChannelContract, type BashProcess } from "./contract.js";
46
46
  export type { BashProcess, BashChannelEvent } from "./contract.js";
47
47
 
48
48
  interface TerminatedDetails {
49
- reason: string;
50
- pid?: number;
51
- command: string;
52
- startedAt: number;
53
- endedAt?: number;
54
- durationMs: number;
55
- logPath?: string;
56
- exitCode?: number | null;
57
- timeoutSecs?: number;
58
- error?: string;
49
+ reason: string;
50
+ pid?: number;
51
+ command: string;
52
+ startedAt: number;
53
+ endedAt?: number;
54
+ durationMs: number;
55
+ logPath?: string;
56
+ exitCode?: number | null;
57
+ timeoutSecs?: number;
58
+ error?: string;
59
59
  }
60
60
 
61
61
  interface BackgroundDetails {
62
- pid?: number;
63
- command: string;
64
- startedAt: number;
65
- durationMs: number;
66
- logPath?: string;
67
- detached: boolean;
62
+ pid?: number;
63
+ command: string;
64
+ startedAt: number;
65
+ durationMs: number;
66
+ logPath?: string;
67
+ detached: boolean;
68
68
  }
69
69
 
70
70
  type BashToolDetails = _BashToolDetails & {
71
- terminated?: TerminatedDetails;
72
- background?: BackgroundDetails;
71
+ terminated?: TerminatedDetails;
72
+ background?: BackgroundDetails;
73
73
  };
74
74
 
75
75
  const DEFAULT_TIMEOUT_SECONDS = 300;
@@ -77,40 +77,40 @@ const MAX_TIMEOUT_SECONDS = 14400; // 4 hours — foreground commands shouldn't
77
77
  const DEFAULT_BACKGROUND_AFTER_SECONDS = 600;
78
78
 
79
79
  const bashSchema = Type.Object({
80
- command: Type.String({ description: "Bash command to execute" }),
81
- description: Type.String({ description: "Clear, concise description of what this command does in 5-10 words" }),
82
- timeout: Type.Optional(
83
- Type.Number({
84
- description: `Hard timeout in seconds (max ${MAX_TIMEOUT_SECONDS}s = 4h). Process is killed if still running after this duration. Defaults to ${DEFAULT_TIMEOUT_SECONDS}s (5 minutes). Acts as a safety net to prevent zombie processes.`,
85
- }),
86
- ),
87
- backgroundAfter: Type.Optional(
88
- Type.Number({
89
- description:
90
- `Soft limit in seconds. If the command runs longer than this, it is automatically moved to background instead of blocking the agent. Default: ${DEFAULT_BACKGROUND_AFTER_SECONDS}s (10 min). Must be less than timeout if set. Use for long-running tasks where the agent should stay productive.`,
91
- }),
92
- ),
93
- cwd: Type.Optional(
94
- Type.String({
95
- description:
96
- "Working directory for the command. Defaults to the agent's current working directory. Use this to run commands in a specific project or directory without cd.",
97
- }),
98
- ),
80
+ command: Type.String({ description: "Bash command to execute" }),
81
+ description: Type.String({ description: "Clear, concise description of what this command does in 5-10 words" }),
82
+ timeout: Type.Optional(
83
+ Type.Number({
84
+ description: `Hard timeout in seconds (max ${MAX_TIMEOUT_SECONDS}s = 4h). Process is killed if still running after this duration. Defaults to ${DEFAULT_TIMEOUT_SECONDS}s (5 minutes). Acts as a safety net to prevent zombie processes.`,
85
+ }),
86
+ ),
87
+ backgroundAfter: Type.Optional(
88
+ Type.Number({
89
+ description:
90
+ `Soft limit in seconds. If the command runs longer than this, it is automatically moved to background instead of blocking the agent. Default: ${DEFAULT_BACKGROUND_AFTER_SECONDS}s (10 min). Must be less than timeout if set. Use for long-running tasks where the agent should stay productive.`,
91
+ }),
92
+ ),
93
+ cwd: Type.Optional(
94
+ Type.String({
95
+ description:
96
+ "Working directory for the command. Defaults to the agent's current working directory. Use this to run commands in a specific project or directory without cd.",
97
+ }),
98
+ ),
99
99
  });
100
100
 
101
101
 
102
102
 
103
103
  interface ManagedBash {
104
- proc: BashProcess;
105
- resolve: (result: AgentToolResult<BashToolDetails>) => void;
106
- reject: (error: Error) => void;
107
- child: ChildProcess;
108
- resolved: boolean;
109
- backgrounded: boolean;
110
- killedByUser?: boolean;
111
- logStream: ReturnType<typeof createWriteStream> | undefined;
112
- outputSubscribed: boolean;
113
- stdin: ChildProcess["stdin"];
104
+ proc: BashProcess;
105
+ resolve: (result: AgentToolResult<BashToolDetails>) => void;
106
+ reject: (error: Error) => void;
107
+ child: ChildProcess;
108
+ resolved: boolean;
109
+ backgrounded: boolean;
110
+ killedByUser?: boolean;
111
+ logStream: ReturnType<typeof createWriteStream> | undefined;
112
+ outputSubscribed: boolean;
113
+ stdin: ChildProcess["stdin"];
114
114
  }
115
115
 
116
116
  const managed = new Map<string, ManagedBash>();
@@ -118,809 +118,819 @@ const history: BashProcess[] = [];
118
118
  const deletedIds = new Set<string>();
119
119
 
120
120
  function generateBashId(): string {
121
- const id = randomBytes(3).toString("hex");
122
- return `bash-${id}`;
121
+ const id = randomBytes(3).toString("hex");
122
+ return `bash-${id}`;
123
123
  }
124
124
 
125
125
  function getLogPath(bashId: string): string {
126
- return join(tmpdir(), `pi-${bashId}.log`);
126
+ return join(tmpdir(), `pi-${bashId}.log`);
127
127
  }
128
128
 
129
129
  const BG_PREVIEW_LINES = 20;
130
130
 
131
131
  function takeLastLines(text: string, n: number): string {
132
- const lines = text.split("\n");
133
- if (lines.length <= n) return text;
134
- return `... (${lines.length - n} earlier lines)\n${lines.slice(-n).join("\n")}`;
132
+ const lines = text.split("\n");
133
+ if (lines.length <= n) return text;
134
+ return `... (${lines.length - n} earlier lines)\n${lines.slice(-n).join("\n")}`;
135
135
  }
136
136
 
137
137
  function grepLines(text: string, pattern: string): string {
138
- const lines = text.split("\n");
139
- const matched = lines.filter((l) => l.toLowerCase().includes(pattern.toLowerCase()));
140
- if (matched.length === 0) return `(no lines matching "${pattern}")`;
141
- return matched.join("\n");
138
+ const lines = text.split("\n");
139
+ const matched = lines.filter((l) => l.toLowerCase().includes(pattern.toLowerCase()));
140
+ if (matched.length === 0) return `(no lines matching "${pattern}")`;
141
+ return matched.join("\n");
142
142
  }
143
143
 
144
144
  function formatDuration(ms: number): string {
145
- const s = Math.floor(ms / 1000);
146
- if (s < 60) return `${s}s`;
147
- const m = Math.floor(s / 60);
148
- return `${m}m${s % 60}s`;
145
+ const s = Math.floor(ms / 1000);
146
+ if (s < 60) return `${s}s`;
147
+ const m = Math.floor(s / 60);
148
+ return `${m}m${s % 60}s`;
149
149
  }
150
150
 
151
- export default function (pi: ExtensionAPI) {
152
- let channel: ServerChannel<BashChannelContract> | null = null;
153
-
154
- function createLogStream(m: ManagedBash): void {
155
- if (m.logStream) return;
156
- const logPath = getLogPath(m.proc.bashId);
157
- const logStream = createWriteStream(logPath);
158
- if (m.proc.output) logStream.write(m.proc.output);
159
- m.proc.logPath = logPath;
160
- m.logStream = logStream;
161
- }
162
-
163
- pi.on("session_start", async () => {
164
- // Kill all managed processes from previous session before clearing references.
165
- // Without this, background processes become orphans when the session switches.
166
- for (const m of managed.values()) {
167
- if (!m.resolved && m.proc.pid) {
168
- try {
169
- killProcessTree(m.proc.pid);
170
- } catch {
171
- // Process may have already exited — ignore
172
- }
173
- }
174
- if (m.logStream) m.logStream.end();
175
- }
176
-
177
- const rawChannel = pi.registerChannel(BASH_CHANNEL_NAME);
178
- channel = createTypedChannel<BashChannelContract>(rawChannel).server;
179
- managed.clear();
180
- history.length = 0;
181
- deletedIds.clear();
182
- channel.emit("list", { type: "list", processes: [], timestamp: Date.now() });
183
-
184
- channel.handle("list", () => {
185
- const activeBg = Array.from(managed.values())
186
- .filter((m) => m.backgrounded)
187
- .map((m) => m.proc);
188
- const hist = history.filter((p) => !deletedIds.has(p.toolCallId));
189
- return {
190
- type: "list" as const,
191
- processes: [...activeBg, ...hist],
192
- timestamp: Date.now(),
193
- };
194
- });
195
-
196
- channel.handle("kill", ({ toolCallId }) => {
197
- if (!toolCallId) return { ok: false, reason: "not_found" };
198
- const m = managed.get(toolCallId);
199
- if (!m) {
200
- // Process already exited emit terminated event so frontend can sync state
201
- channel?.emit("terminated", {
202
- type: "terminated",
203
- toolCallId,
204
- pid: undefined,
205
- processes: Array.from(managed.values()).map((x) => x.proc),
206
- timestamp: Date.now(),
207
- });
208
- return { ok: true, alreadyExited: true };
209
- }
210
- if (m.proc.pid) {
211
- killProcessTree(m.proc.pid);
212
- }
213
- m.proc.status = "terminated";
214
- m.proc.endedAt = Date.now();
215
- m.resolved = true;
216
- m.killedByUser = true;
217
- const durationMs = m.proc.endedAt - m.proc.startedAt;
218
- if (m.logStream) m.logStream.end();
219
- channel?.emit("terminated", {
220
- type: "terminated",
221
- toolCallId,
222
- pid: m.proc.pid,
223
- processes: Array.from(managed.values()).map((x) => x.proc),
224
- timestamp: Date.now(),
225
- });
226
- m.resolve({
227
- content: [
228
- {
229
- type: "text",
230
- text: `${m.proc.output || "(no output)"}\n\n[User cancelled after ${formatDuration(durationMs)}, PID: ${m.proc.pid ?? "unknown"}${m.proc.logPath ? `. Log: ${m.proc.logPath}` : ""}]`,
231
- },
232
- ],
233
- details: {
234
- terminated: {
235
- reason: "user_cancel",
236
- pid: m.proc.pid,
237
- command: m.proc.command,
238
- startedAt: m.proc.startedAt,
239
- endedAt: m.proc.endedAt,
240
- durationMs,
241
- logPath: m.proc.logPath,
242
- },
243
- },
244
- });
245
- return { ok: true };
246
- });
247
-
248
- channel.handle("background", ({ toolCallId }) => {
249
- if (!toolCallId) return { ok: false, reason: "not_found" };
250
- const m = managed.get(toolCallId);
251
- if (!m) {
252
- // Process already exited emit terminated event so frontend can sync state
253
- channel?.emit("terminated", {
254
- type: "terminated",
255
- toolCallId,
256
- pid: undefined,
257
- processes: Array.from(managed.values()).map((x) => x.proc),
258
- timestamp: Date.now(),
259
- });
260
- return { ok: true, alreadyExited: true };
261
- }
262
- m.proc.status = "background";
263
- m.resolved = true;
264
- m.backgrounded = true;
265
- m.outputSubscribed = false;
266
- createLogStream(m);
267
- const durationMs = Date.now() - m.proc.startedAt;
268
- channel?.emit("background", {
269
- type: "background",
270
- toolCallId,
271
- pid: m.proc.pid,
272
- data: m.proc.output.slice(-2000),
273
- processes: Array.from(managed.values()).map((x) => x.proc),
274
- timestamp: Date.now(),
275
- });
276
- const outputPreview = m.proc.output ? takeLastLines(m.proc.output, BG_PREVIEW_LINES) : "(no output yet)";
277
- m.resolve({
278
- content: [
279
- {
280
- type: "text",
281
- text: `${outputPreview}\n\n[Moved to background after ${formatDuration(durationMs)}, PID: ${m.proc.pid ?? "unknown"}. <bashId>${m.proc.bashId}</bashId>. Log: ${m.proc.logPath}. Use get_background_process with <bashId>${m.proc.bashId}</bashId> to check progress.]`,
282
- },
283
- ],
284
- details: {
285
- background: {
286
- pid: m.proc.pid,
287
- command: m.proc.command,
288
- startedAt: m.proc.startedAt,
289
- durationMs,
290
- logPath: m.proc.logPath,
291
- detached: false,
292
- },
293
- },
294
- });
295
- return { ok: true };
296
- });
297
-
298
- channel.handle("subscribe_output", ({ toolCallId }) => {
299
- if (!toolCallId) return;
300
- const m = managed.get(toolCallId);
301
- if (m?.backgrounded) m.outputSubscribed = true;
302
- });
303
-
304
- channel.handle("unsubscribe_output", ({ toolCallId }) => {
305
- if (!toolCallId) return;
306
- const m = managed.get(toolCallId);
307
- if (m) m.outputSubscribed = false;
308
- });
309
-
310
- channel.handle("remove", ({ toolCallId }) => {
311
- if (!toolCallId) return;
312
- deletedIds.add(toolCallId);
313
- managed.delete(toolCallId);
314
- const idx = history.findIndex((p) => p.toolCallId === toolCallId);
315
- if (idx >= 0) history.splice(idx, 1);
316
- });
317
-
318
- channel.handle("write_stdin", ({ toolCallId, data }) => {
319
- if (!toolCallId || !data) return;
320
- const m = managed.get(toolCallId);
321
- if (m?.stdin && !m.stdin.destroyed) {
322
- m.stdin.write(data);
323
- }
324
- });
325
- });
326
-
327
- pi.registerTool({
328
- name: "bash",
329
- label: "bash",
330
- description: [
331
- `Execute a bash command. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file.`,
332
- "",
333
- "Timeout and background behavior:",
334
- `- timeout: Hard limit in seconds. Process is killed after this duration. Default: ${DEFAULT_TIMEOUT_SECONDS}s (5 min). Max: ${MAX_TIMEOUT_SECONDS}s (4h). This is a safety net — always present.`,
335
- "- backgroundAfter: Soft limit in seconds. If the command runs longer, it is automatically moved to background. Default: ${DEFAULT_BACKGROUND_AFTER_SECONDS}s (10 min). The process keeps running, the agent receives a notification and can continue other work.",
336
- "- If backgroundAfter < timeout: command goes to background first, then gets killed if it reaches timeout.",
337
- "- If backgroundAfter >= timeout (or not set): command runs until timeout, then gets killed.",
338
- "",
339
- "When to use backgroundAfter:",
340
- "- Long builds (npm install, cargo build, docker build): set backgroundAfter to a reasonable time so the agent stays productive.",
341
- "- Quick commands (ls, grep, echo): no need for backgroundAfter, they finish fast.",
342
- "",
343
- "Rules:",
344
- "- ALWAYS provide a description (5-10 words explaining what the command does).",
345
- "- Use cwd to run commands in a specific directory instead of cd.",
346
- "- When a command is moved to background, the result includes a <bashId>. Use get_background_process with that ID to poll progress before running dependent commands.",
347
- ].join("\n"),
348
- promptSnippet: "Execute bash commands (ls, grep, find, etc.)",
349
- parameters: bashSchema,
350
- async execute(
351
- toolCallId: string,
352
- { command, description, timeout, backgroundAfter, cwd: cwdParam }: { command: string; description: string; timeout?: number; backgroundAfter?: number; cwd?: string },
353
- signal?: AbortSignal,
354
- onUpdate?: AgentToolUpdateCallback<BashToolDetails>,
355
- _ctx?: ExtensionContext,
356
- ): Promise<AgentToolResult<BashToolDetails>> {
357
- return new Promise((resolve, reject) => {
358
- const effectiveTimeout = Math.min(timeout ?? DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS);
359
- const rawBackgroundAfter = backgroundAfter ?? DEFAULT_BACKGROUND_AFTER_SECONDS;
360
- const effectiveBackgroundAfter = rawBackgroundAfter < effectiveTimeout ? rawBackgroundAfter : undefined;
361
- const cwd = cwdParam ?? _ctx?.cwd ?? process.cwd();
362
- const bashId = generateBashId();
363
-
364
- const spawnResult = spawnManagedProcess({
365
- command,
366
- cwd,
367
- timeout: effectiveTimeout,
368
- signal,
369
- stdin: "pipe",
370
- });
371
-
372
- if (spawnResult instanceof Error) {
373
- reject(spawnResult);
374
- return;
375
- }
376
-
377
- const { child, cleanup: spawnCleanup, isTimedOut } = spawnResult;
378
-
379
- // Immediately send EOF on stdin so CLI tools that read stdin (e.g. xbrowser readStdin())
380
- // don't hang forever waiting for input. Interactive stdin is handled via write_stdin channel.
381
- if (child.stdin && !child.stdin.destroyed) {
382
- child.stdin.end();
383
- }
384
-
385
- const proc: BashProcess = {
386
- bashId,
387
- toolCallId,
388
- command,
389
- cwd,
390
- pid: child.pid ?? undefined,
391
- startedAt: Date.now(),
392
- output: "",
393
- status: "running",
394
- };
395
-
396
- managed.set(toolCallId, {
397
- proc,
398
- resolve,
399
- reject,
400
- child,
401
- resolved: false,
402
- backgrounded: false,
403
- logStream: undefined,
404
- outputSubscribed: false,
405
- stdin: child.stdin,
406
- });
407
-
408
- const logPath = getLogPath(bashId);
409
- const logStream = createWriteStream(logPath);
410
- proc.logPath = logPath;
411
- const m = managed.get(toolCallId)!;
412
- m.logStream = logStream;
413
-
414
- channel?.emit("start", {
415
- type: "start",
416
- toolCallId,
417
- pid: child.pid ?? undefined,
418
- data: command,
419
- processes: Array.from(managed.values()).map((m) => m.proc),
420
- timestamp: proc.startedAt,
421
- });
422
-
423
- const collector = new OutputCollector();
424
-
425
- const handleData = (data: Buffer) => {
426
- const m = managed.get(toolCallId);
427
- if (m?.logStream) m.logStream.write(data);
428
-
429
- if (m?.backgrounded) {
430
- if (m.outputSubscribed) {
431
- const text = sanitizeBinaryOutput(stripAnsi(data.toString("utf-8"))).replace(/\r/g, "");
432
- channel?.emit("output", {
433
- type: "output",
434
- toolCallId,
435
- data: text,
436
- processes: Array.from(managed.values()).map((x) => x.proc),
437
- timestamp: Date.now(),
438
- });
439
- }
440
- return;
441
- }
442
-
443
- collector.push(data);
444
-
445
- const rawText = data.toString("utf-8");
446
- const text = sanitizeBinaryOutput(stripAnsi(rawText)).replace(/\r/g, "");
447
- proc.output += text;
448
-
449
- channel?.emit("output", {
450
- type: "output",
451
- toolCallId,
452
- data: text,
453
- processes: Array.from(managed.values()).map((x) => x.proc),
454
- timestamp: Date.now(),
455
- });
456
-
457
- if (onUpdate) {
458
- const truncation = collector.getTruncation();
459
- onUpdate({
460
- content: [{ type: "text", text: truncation.content || "" }],
461
- details: {
462
- truncation: truncation.truncated ? truncation : undefined,
463
- fullOutputPath: collector.fullOutputPath,
464
- },
465
- });
466
- }
467
- };
468
-
469
- child.stdout?.on("data", handleData);
470
- child.stderr?.on("data", handleData);
471
-
472
- let backgroundAfterHandle: NodeJS.Timeout | undefined;
473
- if (effectiveBackgroundAfter !== undefined) {
474
- backgroundAfterHandle = setTimeout(() => {
475
- const m = managed.get(toolCallId);
476
- if (!m || m.resolved || m.backgrounded) return;
477
- m.proc.status = "background";
478
- m.resolved = true;
479
- m.backgrounded = true;
480
- m.outputSubscribed = false;
481
- createLogStream(m);
482
- const durationMs = Date.now() - m.proc.startedAt;
483
- channel?.emit("background", {
484
- type: "background",
485
- toolCallId,
486
- pid: m.proc.pid,
487
- data: m.proc.output.slice(-2000),
488
- processes: Array.from(managed.values()).map((x) => x.proc),
489
- timestamp: Date.now(),
490
- });
491
- const outputPreview = m.proc.output ? takeLastLines(m.proc.output, BG_PREVIEW_LINES) : "(no output yet)";
492
- m.resolve({
493
- content: [
494
- {
495
- type: "text",
496
- text: `${outputPreview}\n\n[Automatically moved to background after ${formatDuration(durationMs)} (backgroundAfter=${effectiveBackgroundAfter}s), PID: ${m.proc.pid ?? "unknown"}. <bashId>${m.proc.bashId}</bashId>. Log: ${m.proc.logPath}. Use get_background_process with <bashId>${m.proc.bashId}</bashId> to check progress.]`,
497
- },
498
- ],
499
- details: {
500
- background: {
501
- pid: m.proc.pid,
502
- command: m.proc.command,
503
- startedAt: m.proc.startedAt,
504
- durationMs,
505
- logPath: m.proc.logPath,
506
- detached: false,
507
- },
508
- },
509
- });
510
- }, effectiveBackgroundAfter * 1000);
511
- }
512
-
513
- waitForChildProcess(child)
514
- .then((code) => {
515
- spawnCleanup();
516
- if (backgroundAfterHandle) clearTimeout(backgroundAfterHandle);
517
- collector.close();
518
-
519
- const m = managed.get(toolCallId);
520
- if (m?.resolved) {
521
- if (m.logStream) m.logStream.end();
522
- proc.exitCode = code;
523
- proc.endedAt = Date.now();
524
- proc.status = code === 0 ? "done" : "error";
525
- if (m.killedByUser) {
526
- if (!deletedIds.has(toolCallId)) history.push({ ...proc });
527
- managed.delete(toolCallId);
528
- return;
529
- }
530
- channel?.emit(proc.status === "done" ? "end" : "error", {
531
- type: proc.status === "done" ? "end" : "error",
532
- toolCallId,
533
- data: proc.output.slice(-2000),
534
- processes: Array.from(managed.values()).map((x) => x.proc),
535
- timestamp: Date.now(),
536
- });
537
- if (!deletedIds.has(toolCallId)) history.push({ ...proc });
538
- managed.delete(toolCallId);
539
- try {
540
- pi.sendUserMessage(
541
- `[system] Background process "${proc.command}" (PID: ${proc.pid ?? "unknown"}) exited with code ${code ?? "unknown"} after ${formatDuration((proc.endedAt ?? Date.now()) - proc.startedAt)}.${proc.logPath ? ` Log: ${proc.logPath}` : ""}`,
542
- );
543
- } catch (err) {
544
- console.debug("[bash-ext] background exit notification failed:", err instanceof Error ? err.message : err);
545
- }
546
- return;
547
- }
548
-
549
- if (signal?.aborted) {
550
- proc.status = "terminated";
551
- proc.endedAt = Date.now();
552
- const durationMs = proc.endedAt - proc.startedAt;
553
- const outputText = proc.output || "(no output)";
554
- channel?.emit("terminated", {
555
- type: "terminated",
556
- toolCallId,
557
- processes: Array.from(managed.values()).map((m) => m.proc),
558
- timestamp: Date.now(),
559
- });
560
- managed.delete(toolCallId);
561
- resolve({
562
- content: [
563
- {
564
- type: "text",
565
- text: `${outputText}\n\n[Aborted after ${formatDuration(durationMs)}, PID: ${proc.pid ?? "unknown"}]`,
566
- },
567
- ],
568
- details: {
569
- terminated: {
570
- reason: "signal",
571
- pid: proc.pid,
572
- command: proc.command,
573
- startedAt: proc.startedAt,
574
- endedAt: proc.endedAt,
575
- durationMs,
576
- logPath: collector.fullOutputPath,
577
- },
578
- },
579
- });
580
- return;
581
- }
582
- if (isTimedOut()) {
583
- proc.status = "error";
584
- proc.endedAt = Date.now();
585
- const durationMs = proc.endedAt - proc.startedAt;
586
- const outputText = proc.output || "(no output)";
587
- channel?.emit("error", {
588
- type: "error",
589
- toolCallId,
590
- data: `Timed out after ${effectiveTimeout}s`,
591
- processes: Array.from(managed.values()).map((m) => m.proc),
592
- timestamp: Date.now(),
593
- });
594
- managed.delete(toolCallId);
595
- resolve({
596
- content: [
597
- {
598
- type: "text",
599
- text: `${outputText}\n\n[Timed out after ${effectiveTimeout}s, PID: ${proc.pid ?? "unknown"}]`,
600
- },
601
- ],
602
- details: {
603
- terminated: {
604
- reason: "timeout",
605
- pid: proc.pid,
606
- command: proc.command,
607
- startedAt: proc.startedAt,
608
- endedAt: proc.endedAt,
609
- durationMs,
610
- timeoutSecs: effectiveTimeout,
611
- logPath: collector.fullOutputPath,
612
- },
613
- },
614
- });
615
- return;
616
- }
617
-
618
- proc.exitCode = code;
619
- proc.endedAt = Date.now();
620
-
621
- const truncation = collector.finalize();
622
-
623
- let outputText = truncation.content || "(no output)";
624
- let details: BashToolDetails | undefined;
625
- if (truncation.truncated) {
626
- details = { truncation, fullOutputPath: collector.fullOutputPath };
627
- const startLine = truncation.totalLines - truncation.outputLines + 1;
628
- const endLine = truncation.totalLines;
629
- outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${collector.fullOutputPath}]`;
630
- }
631
-
632
- if (code !== 0 && code !== null) {
633
- proc.status = "error";
634
- const durationMs = proc.endedAt - proc.startedAt;
635
- outputText += `\n\n[Command failed with exit code ${code} after ${formatDuration(durationMs)}, PID: ${proc.pid ?? "unknown"}]`;
636
- channel?.emit("error", {
637
- type: "error",
638
- toolCallId,
639
- data: outputText,
640
- processes: Array.from(managed.values()).map((m) => m.proc),
641
- timestamp: Date.now(),
642
- });
643
- managed.delete(toolCallId);
644
- resolve({
645
- content: [{ type: "text", text: outputText }],
646
- details: {
647
- terminated: {
648
- reason: "error",
649
- pid: proc.pid,
650
- command: proc.command,
651
- startedAt: proc.startedAt,
652
- endedAt: proc.endedAt,
653
- durationMs,
654
- exitCode: code,
655
- logPath: collector.fullOutputPath,
656
- },
657
- },
658
- });
659
- } else {
660
- proc.status = "done";
661
- channel?.emit("end", {
662
- type: "end",
663
- toolCallId,
664
- data: outputText,
665
- processes: Array.from(managed.values()).map((m) => m.proc),
666
- timestamp: Date.now(),
667
- });
668
- managed.delete(toolCallId);
669
- resolve({
670
- content: [{ type: "text", text: outputText }],
671
- details: details as BashToolDetails,
672
- } as AgentToolResult<BashToolDetails>);
673
- }
674
- })
675
- .catch((err: Error) => {
676
- spawnCleanup();
677
- if (backgroundAfterHandle) clearTimeout(backgroundAfterHandle);
678
- collector.close();
679
-
680
- const m = managed.get(toolCallId);
681
- if (m?.resolved) {
682
- if (m.logStream) m.logStream.end();
683
- proc.status = "error";
684
- proc.endedAt = Date.now();
685
- proc.exitCode = null;
686
- proc.error = err.message;
687
- if (m.killedByUser) {
688
- if (!deletedIds.has(toolCallId)) history.push({ ...proc });
689
- managed.delete(toolCallId);
690
- return;
691
- }
692
- channel?.emit("error", {
693
- type: "error",
694
- toolCallId,
695
- data: proc.output.slice(-2000),
696
- processes: Array.from(managed.values()).map((x) => x.proc),
697
- timestamp: Date.now(),
698
- });
699
- if (!deletedIds.has(toolCallId)) history.push({ ...proc });
700
- managed.delete(toolCallId);
701
- try {
702
- pi.sendUserMessage(
703
- `[system] Background process "${proc.command}" (PID: ${proc.pid ?? "unknown"}) crashed: ${err.message}${proc.logPath ? `. Log: ${proc.logPath}` : ""}`,
704
- );
705
- } catch (err) {
706
- console.debug("[bash-ext] background crash notification failed:", err instanceof Error ? err.message : err);
707
- }
708
- return;
709
- }
710
-
711
- const durationMs = (proc.endedAt || Date.now()) - proc.startedAt;
712
- const outputText = proc.output || "(no output)";
713
-
714
- if (err.message === "aborted") {
715
- proc.status = "terminated";
716
- channel?.emit("terminated", {
717
- type: "terminated",
718
- toolCallId,
719
- data: outputText,
720
- processes: Array.from(managed.values()).map((m) => m.proc),
721
- timestamp: Date.now(),
722
- });
723
- managed.delete(toolCallId);
724
- resolve({
725
- content: [
726
- {
727
- type: "text",
728
- text: `${outputText}\n\n[Aborted after ${formatDuration(durationMs)}, PID: ${proc.pid ?? "unknown"}]`,
729
- },
730
- ],
731
- details: {
732
- terminated: {
733
- reason: "signal",
734
- pid: proc.pid,
735
- command: proc.command,
736
- startedAt: proc.startedAt,
737
- endedAt: proc.endedAt,
738
- durationMs,
739
- logPath: collector.fullOutputPath,
740
- },
741
- },
742
- });
743
- } else if (err.message.startsWith("timeout:")) {
744
- const timeoutSecs = Number(err.message.split(":")[1]);
745
- channel?.emit("error", {
746
- type: "error",
747
- toolCallId,
748
- data: outputText,
749
- processes: Array.from(managed.values()).map((m) => m.proc),
750
- timestamp: Date.now(),
751
- });
752
- managed.delete(toolCallId);
753
- resolve({
754
- content: [
755
- {
756
- type: "text",
757
- text: `${outputText}\n\n[Timed out after ${timeoutSecs}s, PID: ${proc.pid ?? "unknown"}]`,
758
- },
759
- ],
760
- details: {
761
- terminated: {
762
- reason: "timeout",
763
- pid: proc.pid,
764
- command: proc.command,
765
- startedAt: proc.startedAt,
766
- endedAt: proc.endedAt,
767
- durationMs,
768
- timeoutSecs,
769
- logPath: collector.fullOutputPath,
770
- },
771
- },
772
- });
773
- } else {
774
- channel?.emit("error", {
775
- type: "error",
776
- toolCallId,
777
- data: outputText,
778
- processes: Array.from(managed.values()).map((m) => m.proc),
779
- timestamp: Date.now(),
780
- });
781
- managed.delete(toolCallId);
782
- resolve({
783
- content: [
784
- {
785
- type: "text",
786
- text: `${outputText}\n\n[Command crashed after ${formatDuration(durationMs)}, PID: ${proc.pid ?? "unknown"}: ${err.message}]`,
787
- },
788
- ],
789
- details: {
790
- terminated: {
791
- reason: "error",
792
- pid: proc.pid,
793
- command: proc.command,
794
- startedAt: proc.startedAt,
795
- endedAt: proc.endedAt,
796
- durationMs,
797
- error: err.message,
798
- logPath: collector.fullOutputPath,
799
- },
800
- },
801
- });
802
- }
803
- });
804
- });
805
- },
806
- });
807
-
808
- const bashStatusSchema = Type.Object({
809
- bashId: Type.String({ description: "The bashId returned when a command was moved to background. Example: bash-abc123" }),
810
- lastLines: Type.Optional(Type.Number({ description: "Only show the last N lines of output. Useful for checking tail of long-running builds. Default: show last 2000 chars." })),
811
- grep: Type.Optional(Type.String({ description: "Filter output to only lines containing this keyword (case-insensitive). Useful for finding errors, warnings, or specific patterns in build output." })),
812
- });
813
-
814
- function findProcess(bashId: string): { proc: BashProcess; isLive: boolean } | null {
815
- for (const m of managed.values()) {
816
- if (m.proc.bashId === bashId) return { proc: m.proc, isLive: !m.proc.endedAt };
817
- }
818
- const histProc = history.find((p) => p.bashId === bashId);
819
- if (histProc) return { proc: histProc, isLive: false };
820
- return null;
821
- }
822
-
823
- pi.registerTool({
824
- name: "get_background_process",
825
- label: "get_background_process",
826
- description: [
827
- "Query the status and output of a backgrounded bash process by its bashId.",
828
- "",
829
- "When a bash command is moved to background (manually or via backgroundAfter), it returns a <bashId>. Use this tool to:",
830
- "- Check if the process is still running, finished, or errored",
831
- "- Get the accumulated output (filtered if needed)",
832
- "- Get the exit code (if finished)",
833
- "",
834
- "Filtering options:",
835
- "- lastLines: show only the last N lines (e.g. lastLines=5 for quick status check)",
836
- "- grep: filter output to lines containing a keyword (e.g. grep='error' to find failures)",
837
- "- Both can be combined: lastLines=10 + grep='warning'",
838
- "",
839
- "Typical flow:",
840
- "1. Start long command: bash({ command: 'npm install', backgroundAfter: 60 })",
841
- "2. Do other work while it runs",
842
- "3. Poll: get_background_process({ bashId: 'bash-abc123' })",
843
- "4. If status='done', proceed. If status='running', poll again later.",
844
- ].join("\n"),
845
- promptSnippet: "Check status of a backgrounded bash command",
846
- parameters: bashStatusSchema,
847
- async execute(
848
- _toolCallId: string,
849
- { bashId, lastLines, grep: grepPattern }: { bashId: string; lastLines?: number; grep?: string },
850
- ): Promise<AgentToolResult<BashToolDetails>> {
851
- const result = findProcess(bashId);
852
-
853
- if (!result) {
854
- return {
855
- content: [
856
- {
857
- type: "text",
858
- text: `No process found with <bashId>${bashId}</bashId>. It may have never existed, been removed, or the session has been reset.`,
859
- },
860
- ],
861
- details: undefined as unknown as BashToolDetails,
862
- };
863
- }
864
-
865
- const { proc, isLive } = result;
866
- const durationMs = (proc.endedAt ?? Date.now()) - proc.startedAt;
867
-
868
- const rawOutput = proc.output || "(no output yet)";
869
- const allLines = rawOutput.split("\n");
870
- const totalLines = allLines.length;
871
-
872
- let displayLines: string[];
873
- let startLine: number;
874
- let endLine: number;
875
-
876
- if (grepPattern) {
877
- // Grep mode: filter matching lines, keep line numbers
878
- const matched = allLines
879
- .map((line, i) => ({ line, num: i + 1 }))
880
- .filter((e) => e.line.toLowerCase().includes(grepPattern.toLowerCase()));
881
- if (matched.length === 0) {
882
- displayLines = [`(no lines matching "${grepPattern}")`];
883
- startLine = 0;
884
- endLine = 0;
885
- } else {
886
- // Apply lastLines to grep results if specified
887
- const sliced = lastLines && lastLines > 0 ? matched.slice(-lastLines) : matched;
888
- displayLines = sliced.map((e) => `L${e.num}: ${e.line}`);
889
- startLine = sliced[0].num;
890
- endLine = sliced[sliced.length - 1].num;
891
- }
892
- } else {
893
- // Normal mode: take last N lines with line numbers
894
- const n = lastLines && lastLines > 0 ? lastLines : 50;
895
- startLine = Math.max(1, totalLines - n + 1);
896
- endLine = totalLines;
897
- const selected = allLines.slice(-n);
898
- displayLines = selected.map((line, i) => `L${startLine + i}: ${line}`);
899
- }
900
-
901
- const output = displayLines.join("\n");
902
-
903
- const header = [
904
- `Process: ${proc.command}`,
905
- `<bashId>${proc.bashId}</bashId>`,
906
- `Status: ${proc.status}${isLive ? " (still running)" : ""}`,
907
- `PID: ${proc.pid ?? "unknown"}`,
908
- `Duration: ${formatDuration(durationMs)}`,
909
- proc.exitCode !== undefined ? `Exit code: ${proc.exitCode}` : null,
910
- proc.logPath ? `Log: ${proc.logPath}` : null,
911
- proc.error ? `Error: ${proc.error}` : null,
912
- totalLines > 0 ? `Lines: ${startLine}-${endLine} of ${totalLines} total` : null,
913
- grepPattern ? `Filtered by: "${grepPattern}"` : null,
914
- "",
915
- isLive ? "Output so far:" : "Output:",
916
- ]
917
- .filter(Boolean)
918
- .join("\n");
919
-
920
- return {
921
- content: [{ type: "text", text: `${header}\n${output}` }],
922
- details: undefined as unknown as BashToolDetails,
923
- };
924
- },
925
- });
151
+ export default function(pi: ExtensionAPI) {
152
+ let channel: ServerChannel<BashChannelContract> | null = null;
153
+
154
+ function createLogStream(m: ManagedBash): void {
155
+ if (m.logStream) return;
156
+ const logPath = getLogPath(m.proc.bashId);
157
+ const logStream = createWriteStream(logPath);
158
+ if (m.proc.output) logStream.write(m.proc.output);
159
+ m.proc.logPath = logPath;
160
+ m.logStream = logStream;
161
+ }
162
+
163
+ pi.on("session_start", async () => {
164
+ // Kill all managed processes from previous session before clearing references.
165
+ // Without this, background processes become orphans when the session switches.
166
+ for (const m of managed.values()) {
167
+ if (!m.resolved && m.proc.pid) {
168
+ try {
169
+ killProcessTree(m.proc.pid);
170
+ } catch {
171
+ // Process may have already exited — ignore
172
+ }
173
+ }
174
+ if (m.logStream) m.logStream.end();
175
+ }
176
+
177
+ try {
178
+ const rawChannel = pi.registerChannel(BASH_CHANNEL_NAME);
179
+ channel = createTypedChannel<BashChannelContract>(rawChannel).server;
180
+ } catch {
181
+ // registerChannel only available in RPC mode — skip in interactive mode
182
+ }
183
+ managed.clear();
184
+ history.length = 0;
185
+ deletedIds.clear();
186
+ channel?.emit("list", { type: "list", processes: [], timestamp: Date.now() });
187
+
188
+ channel?.handle("list", () => {
189
+ const activeBg = Array.from(managed.values())
190
+ .filter((m) => m.backgrounded)
191
+ .map((m) => m.proc);
192
+ const hist = history.filter((p) => !deletedIds.has(p.toolCallId));
193
+ return {
194
+ type: "list" as const,
195
+ processes: [...activeBg, ...hist],
196
+ timestamp: Date.now(),
197
+ };
198
+ });
199
+
200
+ channel?.handle("kill", ({ toolCallId }) => {
201
+ if (!toolCallId) return { ok: false, reason: "not_found" };
202
+ const m = managed.get(toolCallId);
203
+ if (!m) {
204
+ // Process already exited — emit terminated event so frontend can sync state
205
+ channel?.emit("terminated", {
206
+ type: "terminated",
207
+ toolCallId,
208
+ pid: undefined,
209
+ processes: Array.from(managed.values()).map((x) => x.proc),
210
+ timestamp: Date.now(),
211
+ });
212
+ return { ok: true, alreadyExited: true };
213
+ }
214
+ if (m.proc.pid) {
215
+ killProcessTree(m.proc.pid);
216
+ }
217
+ m.proc.status = "terminated";
218
+ m.proc.endedAt = Date.now();
219
+ m.resolved = true;
220
+ m.killedByUser = true;
221
+ const durationMs = m.proc.endedAt - m.proc.startedAt;
222
+ if (m.logStream) m.logStream.end();
223
+ channel?.emit("terminated", {
224
+ type: "terminated",
225
+ toolCallId,
226
+ pid: m.proc.pid,
227
+ processes: Array.from(managed.values()).map((x) => x.proc),
228
+ timestamp: Date.now(),
229
+ });
230
+ m.resolve({
231
+ content: [
232
+ {
233
+ type: "text",
234
+ text: `${m.proc.output || "(no output)"}\n\n[User cancelled after ${formatDuration(durationMs)}, PID: ${m.proc.pid ?? "unknown"}${m.proc.logPath ? `. Log: ${m.proc.logPath}` : ""}]`,
235
+ },
236
+ ],
237
+ details: {
238
+ terminated: {
239
+ reason: "user_cancel",
240
+ pid: m.proc.pid,
241
+ command: m.proc.command,
242
+ startedAt: m.proc.startedAt,
243
+ endedAt: m.proc.endedAt,
244
+ durationMs,
245
+ logPath: m.proc.logPath,
246
+ },
247
+ },
248
+ });
249
+ return { ok: true };
250
+ });
251
+
252
+ channel?.handle("background", ({ toolCallId }) => {
253
+ if (!toolCallId) return { ok: false, reason: "not_found" };
254
+ const m = managed.get(toolCallId);
255
+ if (!m) {
256
+ // Process already exited — emit terminated event so frontend can sync state
257
+ channel?.emit("terminated", {
258
+ type: "terminated",
259
+ toolCallId,
260
+ pid: undefined,
261
+ processes: Array.from(managed.values()).map((x) => x.proc),
262
+ timestamp: Date.now(),
263
+ });
264
+ return { ok: true, alreadyExited: true };
265
+ }
266
+ m.proc.status = "background";
267
+ m.resolved = true;
268
+ m.backgrounded = true;
269
+ m.outputSubscribed = false;
270
+ createLogStream(m);
271
+ const durationMs = Date.now() - m.proc.startedAt;
272
+ channel?.emit("background", {
273
+ type: "background",
274
+ toolCallId,
275
+ pid: m.proc.pid,
276
+ data: m.proc.output.slice(-2000),
277
+ processes: Array.from(managed.values()).map((x) => x.proc),
278
+ timestamp: Date.now(),
279
+ });
280
+ const outputPreview = m.proc.output ? takeLastLines(m.proc.output, BG_PREVIEW_LINES) : "(no output yet)";
281
+ m.resolve({
282
+ content: [
283
+ {
284
+ type: "text",
285
+ text: `${outputPreview}\n\n[Moved to background after ${formatDuration(durationMs)}, PID: ${m.proc.pid ?? "unknown"}. <bashId>${m.proc.bashId}</bashId>. Log: ${m.proc.logPath}. Use get_background_process with <bashId>${m.proc.bashId}</bashId> to check progress.]`,
286
+ },
287
+ ],
288
+ details: {
289
+ background: {
290
+ pid: m.proc.pid,
291
+ command: m.proc.command,
292
+ startedAt: m.proc.startedAt,
293
+ durationMs,
294
+ logPath: m.proc.logPath,
295
+ detached: false,
296
+ },
297
+ },
298
+ });
299
+ return { ok: true };
300
+ });
301
+
302
+ channel?.handle("subscribe_output", ({ toolCallId }) => {
303
+ if (!toolCallId) return;
304
+ const m = managed.get(toolCallId);
305
+ if (m?.backgrounded) m.outputSubscribed = true;
306
+ });
307
+
308
+ channel?.handle("unsubscribe_output", ({ toolCallId }) => {
309
+ if (!toolCallId) return;
310
+ const m = managed.get(toolCallId);
311
+ if (m) m.outputSubscribed = false;
312
+ });
313
+
314
+ channel?.handle("remove", ({ toolCallId }) => {
315
+ if (!toolCallId) return;
316
+ deletedIds.add(toolCallId);
317
+ managed.delete(toolCallId);
318
+ const idx = history.findIndex((p) => p.toolCallId === toolCallId);
319
+ if (idx >= 0) history.splice(idx, 1);
320
+ });
321
+
322
+ channel?.handle("write_stdin", ({ toolCallId, data }) => {
323
+ if (!toolCallId || !data) return;
324
+ const m = managed.get(toolCallId);
325
+ if (m?.stdin && !m.stdin.destroyed) {
326
+ m.stdin.write(data);
327
+ }
328
+ });
329
+ });
330
+
331
+ pi.registerTool({
332
+ name: "bash",
333
+ label: "bash",
334
+ description: [
335
+ `Execute a bash command. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file.`,
336
+ "",
337
+ "Timeout and background behavior:",
338
+ `- timeout: Hard limit in seconds. Process is killed after this duration. Default: ${DEFAULT_TIMEOUT_SECONDS}s (5 min). Max: ${MAX_TIMEOUT_SECONDS}s (4h). This is a safety net — always present.`,
339
+ "- backgroundAfter: Soft limit in seconds. If the command runs longer, it is automatically moved to background. Default: ${DEFAULT_BACKGROUND_AFTER_SECONDS}s (10 min). The process keeps running, the agent receives a notification and can continue other work.",
340
+ "- If backgroundAfter < timeout: command goes to background first, then gets killed if it reaches timeout.",
341
+ "- If backgroundAfter >= timeout (or not set): command runs until timeout, then gets killed.",
342
+ "",
343
+ "When to use backgroundAfter:",
344
+ "- Long builds (npm install, cargo build, docker build): set backgroundAfter to a reasonable time so the agent stays productive.",
345
+ "- Quick commands (ls, grep, echo): no need for backgroundAfter, they finish fast.",
346
+ "",
347
+ "Rules:",
348
+ "- ALWAYS provide a description (5-10 words explaining what the command does).",
349
+ "- Use cwd to run commands in a specific directory instead of cd.",
350
+ "- When a command is moved to background, the result includes a <bashId>. Use get_background_process with that ID to poll progress before running dependent commands.",
351
+ ].join("\n"),
352
+ promptSnippet: "Execute bash commands (ls, grep, find, etc.)",
353
+ parameters: bashSchema,
354
+ async execute(
355
+ toolCallId: string,
356
+ { command, description, timeout, backgroundAfter, cwd: cwdParam }: { command: string; description: string; timeout?: number; backgroundAfter?: number; cwd?: string },
357
+ signal?: AbortSignal,
358
+ onUpdate?: AgentToolUpdateCallback<BashToolDetails>,
359
+ _ctx?: ExtensionContext,
360
+ ): Promise<AgentToolResult<BashToolDetails>> {
361
+ return new Promise((resolve, reject) => {
362
+ const effectiveTimeout = Math.min(timeout ?? DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS);
363
+ const rawBackgroundAfter = backgroundAfter ?? DEFAULT_BACKGROUND_AFTER_SECONDS;
364
+ const effectiveBackgroundAfter = rawBackgroundAfter < effectiveTimeout ? rawBackgroundAfter : undefined;
365
+ const cwd = cwdParam ?? _ctx?.cwd ?? process.cwd();
366
+ const bashId = generateBashId();
367
+
368
+ const spawnResult = spawnManagedProcess({
369
+ command,
370
+ cwd,
371
+ timeout: effectiveTimeout,
372
+ signal,
373
+ stdin: "pipe",
374
+ });
375
+
376
+ if (spawnResult instanceof Error) {
377
+ reject(spawnResult);
378
+ return;
379
+ }
380
+
381
+ const { child, cleanup: spawnCleanup, isTimedOut } = spawnResult;
382
+
383
+ // Immediately send EOF on stdin so CLI tools that read stdin (e.g. xbrowser readStdin())
384
+ // don't hang forever waiting for input. Interactive stdin is handled via write_stdin channel.
385
+ if (child.stdin && !child.stdin.destroyed) {
386
+ child.stdin.end();
387
+ }
388
+
389
+ const proc: BashProcess = {
390
+ bashId,
391
+ toolCallId,
392
+ command,
393
+ cwd,
394
+ pid: child.pid ?? undefined,
395
+ startedAt: Date.now(),
396
+ output: "",
397
+ status: "running",
398
+ };
399
+
400
+ managed.set(toolCallId, {
401
+ proc,
402
+ resolve,
403
+ reject,
404
+ child,
405
+ resolved: false,
406
+ backgrounded: false,
407
+ logStream: undefined,
408
+ outputSubscribed: false,
409
+ stdin: child.stdin,
410
+ });
411
+
412
+ const logPath = getLogPath(bashId);
413
+ const logStream = createWriteStream(logPath);
414
+ proc.logPath = logPath;
415
+ const m = managed.get(toolCallId)!;
416
+ m.logStream = logStream;
417
+
418
+ channel?.emit("start", {
419
+ type: "start",
420
+ toolCallId,
421
+ pid: child.pid ?? undefined,
422
+ data: command,
423
+ processes: Array.from(managed.values()).map((m) => m.proc),
424
+ timestamp: proc.startedAt,
425
+ });
426
+
427
+ const collector = new OutputCollector();
428
+
429
+ const handleData = (data: Buffer) => {
430
+ const m = managed.get(toolCallId);
431
+ if (m?.logStream) m.logStream.write(data);
432
+
433
+ if (m?.backgrounded) {
434
+ if (m.outputSubscribed) {
435
+ const text = sanitizeBinaryOutput(stripAnsi(data.toString("utf-8"))).replace(/\r/g, "");
436
+ channel?.emit("output", {
437
+ type: "output",
438
+ toolCallId,
439
+ data: text,
440
+ processes: Array.from(managed.values()).map((x) => x.proc),
441
+ timestamp: Date.now(),
442
+ });
443
+ }
444
+ return;
445
+ }
446
+
447
+ collector.push(data);
448
+
449
+ const rawText = data.toString("utf-8");
450
+ const text = sanitizeBinaryOutput(stripAnsi(rawText)).replace(/\r/g, "");
451
+ proc.output += text;
452
+
453
+ channel?.emit("output", {
454
+ type: "output",
455
+ toolCallId,
456
+ data: text,
457
+ processes: Array.from(managed.values()).map((x) => x.proc),
458
+ timestamp: Date.now(),
459
+ });
460
+
461
+ if (onUpdate) {
462
+ const truncation = collector.getTruncation();
463
+ onUpdate({
464
+ content: [{ type: "text", text: truncation.content || "" }],
465
+ details: {
466
+ truncation: truncation.truncated ? truncation : undefined,
467
+ fullOutputPath: collector.fullOutputPath,
468
+ },
469
+ });
470
+ }
471
+ };
472
+
473
+ child.stdout?.on("data", handleData);
474
+ child.stderr?.on("data", handleData);
475
+
476
+ let backgroundAfterHandle: NodeJS.Timeout | undefined;
477
+ if (effectiveBackgroundAfter !== undefined) {
478
+ backgroundAfterHandle = setTimeout(() => {
479
+ const m = managed.get(toolCallId);
480
+ if (!m || m.resolved || m.backgrounded) return;
481
+ m.proc.status = "background";
482
+ m.resolved = true;
483
+ m.backgrounded = true;
484
+ m.outputSubscribed = false;
485
+ createLogStream(m);
486
+ const durationMs = Date.now() - m.proc.startedAt;
487
+ channel?.emit("background", {
488
+ type: "background",
489
+ toolCallId,
490
+ pid: m.proc.pid,
491
+ data: m.proc.output.slice(-2000),
492
+ processes: Array.from(managed.values()).map((x) => x.proc),
493
+ timestamp: Date.now(),
494
+ });
495
+ const outputPreview = m.proc.output ? takeLastLines(m.proc.output, BG_PREVIEW_LINES) : "(no output yet)";
496
+ m.resolve({
497
+ content: [
498
+ {
499
+ type: "text",
500
+ text: `${outputPreview}\n\n[Automatically moved to background after ${formatDuration(durationMs)} (backgroundAfter=${effectiveBackgroundAfter}s), PID: ${m.proc.pid ?? "unknown"}. <bashId>${m.proc.bashId}</bashId>. Log: ${m.proc.logPath}. Use get_background_process with <bashId>${m.proc.bashId}</bashId> to check progress.]`,
501
+ },
502
+ ],
503
+ details: {
504
+ background: {
505
+ pid: m.proc.pid,
506
+ command: m.proc.command,
507
+ startedAt: m.proc.startedAt,
508
+ durationMs,
509
+ logPath: m.proc.logPath,
510
+ detached: false,
511
+ },
512
+ },
513
+ });
514
+ }, effectiveBackgroundAfter * 1000);
515
+ }
516
+
517
+ waitForChildProcess(child)
518
+ .then((code) => {
519
+ spawnCleanup();
520
+ if (backgroundAfterHandle) clearTimeout(backgroundAfterHandle);
521
+ collector.close();
522
+
523
+ const m = managed.get(toolCallId);
524
+ if (m?.resolved) {
525
+ if (m.logStream) m.logStream.end();
526
+ proc.exitCode = code;
527
+ proc.endedAt = Date.now();
528
+ proc.status = code === 0 ? "done" : "error";
529
+ if (m.killedByUser) {
530
+ if (!deletedIds.has(toolCallId)) history.push({ ...proc });
531
+ managed.delete(toolCallId);
532
+ return;
533
+ }
534
+ channel?.emit(proc.status === "done" ? "end" : "error", {
535
+ type: proc.status === "done" ? "end" : "error",
536
+ toolCallId,
537
+ data: proc.output.slice(-2000),
538
+ processes: Array.from(managed.values()).map((x) => x.proc),
539
+ timestamp: Date.now(),
540
+ });
541
+ if (!deletedIds.has(toolCallId)) history.push({ ...proc });
542
+ managed.delete(toolCallId);
543
+ try {
544
+ pi.sendUserMessage(
545
+ `[system] Background process "${proc.command}" (PID: ${proc.pid ?? "unknown"}) exited with code ${code ?? "unknown"} after ${formatDuration((proc.endedAt ?? Date.now()) - proc.startedAt)}.${proc.logPath ? ` Log: ${proc.logPath}` : ""}. Use get_background_process with <bashId>${proc.bashId}</bashId> to retrieve the output and continue your task.`,
546
+ { deliverAs: "followUp" },
547
+ );
548
+ } catch (err) {
549
+ const msg = err instanceof Error ? err.message : String(err);
550
+ if (msg.includes("stale")) return;
551
+ console.debug("[bash-ext] background exit notification failed:", msg);
552
+ }
553
+ return;
554
+ }
555
+
556
+ if (signal?.aborted) {
557
+ proc.status = "terminated";
558
+ proc.endedAt = Date.now();
559
+ const durationMs = proc.endedAt - proc.startedAt;
560
+ const outputText = proc.output || "(no output)";
561
+ channel?.emit("terminated", {
562
+ type: "terminated",
563
+ toolCallId,
564
+ processes: Array.from(managed.values()).map((m) => m.proc),
565
+ timestamp: Date.now(),
566
+ });
567
+ managed.delete(toolCallId);
568
+ resolve({
569
+ content: [
570
+ {
571
+ type: "text",
572
+ text: `${outputText}\n\n[Aborted after ${formatDuration(durationMs)}, PID: ${proc.pid ?? "unknown"}]`,
573
+ },
574
+ ],
575
+ details: {
576
+ terminated: {
577
+ reason: "signal",
578
+ pid: proc.pid,
579
+ command: proc.command,
580
+ startedAt: proc.startedAt,
581
+ endedAt: proc.endedAt,
582
+ durationMs,
583
+ logPath: collector.fullOutputPath,
584
+ },
585
+ },
586
+ });
587
+ return;
588
+ }
589
+ if (isTimedOut()) {
590
+ proc.status = "error";
591
+ proc.endedAt = Date.now();
592
+ const durationMs = proc.endedAt - proc.startedAt;
593
+ const outputText = proc.output || "(no output)";
594
+ channel?.emit("error", {
595
+ type: "error",
596
+ toolCallId,
597
+ data: `Timed out after ${effectiveTimeout}s`,
598
+ processes: Array.from(managed.values()).map((m) => m.proc),
599
+ timestamp: Date.now(),
600
+ });
601
+ managed.delete(toolCallId);
602
+ resolve({
603
+ content: [
604
+ {
605
+ type: "text",
606
+ text: `${outputText}\n\n[Timed out after ${effectiveTimeout}s, PID: ${proc.pid ?? "unknown"}]`,
607
+ },
608
+ ],
609
+ details: {
610
+ terminated: {
611
+ reason: "timeout",
612
+ pid: proc.pid,
613
+ command: proc.command,
614
+ startedAt: proc.startedAt,
615
+ endedAt: proc.endedAt,
616
+ durationMs,
617
+ timeoutSecs: effectiveTimeout,
618
+ logPath: collector.fullOutputPath,
619
+ },
620
+ },
621
+ });
622
+ return;
623
+ }
624
+
625
+ proc.exitCode = code;
626
+ proc.endedAt = Date.now();
627
+
628
+ const truncation = collector.finalize();
629
+
630
+ let outputText = truncation.content || "(no output)";
631
+ let details: BashToolDetails | undefined;
632
+ if (truncation.truncated) {
633
+ details = { truncation, fullOutputPath: collector.fullOutputPath };
634
+ const startLine = truncation.totalLines - truncation.outputLines + 1;
635
+ const endLine = truncation.totalLines;
636
+ outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${collector.fullOutputPath}]`;
637
+ }
638
+
639
+ if (code !== 0 && code !== null) {
640
+ proc.status = "error";
641
+ const durationMs = proc.endedAt - proc.startedAt;
642
+ outputText += `\n\n[Command failed with exit code ${code} after ${formatDuration(durationMs)}, PID: ${proc.pid ?? "unknown"}]`;
643
+ channel?.emit("error", {
644
+ type: "error",
645
+ toolCallId,
646
+ data: outputText,
647
+ processes: Array.from(managed.values()).map((m) => m.proc),
648
+ timestamp: Date.now(),
649
+ });
650
+ managed.delete(toolCallId);
651
+ resolve({
652
+ content: [{ type: "text", text: outputText }],
653
+ details: {
654
+ terminated: {
655
+ reason: "error",
656
+ pid: proc.pid,
657
+ command: proc.command,
658
+ startedAt: proc.startedAt,
659
+ endedAt: proc.endedAt,
660
+ durationMs,
661
+ exitCode: code,
662
+ logPath: collector.fullOutputPath,
663
+ },
664
+ },
665
+ });
666
+ } else {
667
+ proc.status = "done";
668
+ channel?.emit("end", {
669
+ type: "end",
670
+ toolCallId,
671
+ data: outputText,
672
+ processes: Array.from(managed.values()).map((m) => m.proc),
673
+ timestamp: Date.now(),
674
+ });
675
+ managed.delete(toolCallId);
676
+ resolve({
677
+ content: [{ type: "text", text: outputText }],
678
+ details: details as BashToolDetails,
679
+ } as AgentToolResult<BashToolDetails>);
680
+ }
681
+ })
682
+ .catch((err: Error) => {
683
+ spawnCleanup();
684
+ if (backgroundAfterHandle) clearTimeout(backgroundAfterHandle);
685
+ collector.close();
686
+
687
+ const m = managed.get(toolCallId);
688
+ if (m?.resolved) {
689
+ if (m.logStream) m.logStream.end();
690
+ proc.status = "error";
691
+ proc.endedAt = Date.now();
692
+ proc.exitCode = null;
693
+ proc.error = err.message;
694
+ if (m.killedByUser) {
695
+ if (!deletedIds.has(toolCallId)) history.push({ ...proc });
696
+ managed.delete(toolCallId);
697
+ return;
698
+ }
699
+ channel?.emit("error", {
700
+ type: "error",
701
+ toolCallId,
702
+ data: proc.output.slice(-2000),
703
+ processes: Array.from(managed.values()).map((x) => x.proc),
704
+ timestamp: Date.now(),
705
+ });
706
+ if (!deletedIds.has(toolCallId)) history.push({ ...proc });
707
+ managed.delete(toolCallId);
708
+ try {
709
+ pi.sendUserMessage(
710
+ `[system] Background process "${proc.command}" (PID: ${proc.pid ?? "unknown"}) crashed: ${err.message}${proc.logPath ? `. Log: ${proc.logPath}` : ""}. Use get_background_process with <bashId>${proc.bashId}</bashId> to retrieve the output and continue your task.`,
711
+ { deliverAs: "followUp" },
712
+ );
713
+ } catch (innerErr) {
714
+ const msg = innerErr instanceof Error ? innerErr.message : String(innerErr);
715
+ if (msg.includes("stale")) return;
716
+ console.debug("[bash-ext] background crash notification failed:", msg);
717
+ }
718
+ return;
719
+ }
720
+
721
+ const durationMs = (proc.endedAt || Date.now()) - proc.startedAt;
722
+ const outputText = proc.output || "(no output)";
723
+
724
+ if (err.message === "aborted") {
725
+ proc.status = "terminated";
726
+ channel?.emit("terminated", {
727
+ type: "terminated",
728
+ toolCallId,
729
+ data: outputText,
730
+ processes: Array.from(managed.values()).map((m) => m.proc),
731
+ timestamp: Date.now(),
732
+ });
733
+ managed.delete(toolCallId);
734
+ resolve({
735
+ content: [
736
+ {
737
+ type: "text",
738
+ text: `${outputText}\n\n[Aborted after ${formatDuration(durationMs)}, PID: ${proc.pid ?? "unknown"}]`,
739
+ },
740
+ ],
741
+ details: {
742
+ terminated: {
743
+ reason: "signal",
744
+ pid: proc.pid,
745
+ command: proc.command,
746
+ startedAt: proc.startedAt,
747
+ endedAt: proc.endedAt,
748
+ durationMs,
749
+ logPath: collector.fullOutputPath,
750
+ },
751
+ },
752
+ });
753
+ } else if (err.message.startsWith("timeout:")) {
754
+ const timeoutSecs = Number(err.message.split(":")[1]);
755
+ channel?.emit("error", {
756
+ type: "error",
757
+ toolCallId,
758
+ data: outputText,
759
+ processes: Array.from(managed.values()).map((m) => m.proc),
760
+ timestamp: Date.now(),
761
+ });
762
+ managed.delete(toolCallId);
763
+ resolve({
764
+ content: [
765
+ {
766
+ type: "text",
767
+ text: `${outputText}\n\n[Timed out after ${timeoutSecs}s, PID: ${proc.pid ?? "unknown"}]`,
768
+ },
769
+ ],
770
+ details: {
771
+ terminated: {
772
+ reason: "timeout",
773
+ pid: proc.pid,
774
+ command: proc.command,
775
+ startedAt: proc.startedAt,
776
+ endedAt: proc.endedAt,
777
+ durationMs,
778
+ timeoutSecs,
779
+ logPath: collector.fullOutputPath,
780
+ },
781
+ },
782
+ });
783
+ } else {
784
+ channel?.emit("error", {
785
+ type: "error",
786
+ toolCallId,
787
+ data: outputText,
788
+ processes: Array.from(managed.values()).map((m) => m.proc),
789
+ timestamp: Date.now(),
790
+ });
791
+ managed.delete(toolCallId);
792
+ resolve({
793
+ content: [
794
+ {
795
+ type: "text",
796
+ text: `${outputText}\n\n[Command crashed after ${formatDuration(durationMs)}, PID: ${proc.pid ?? "unknown"}: ${err.message}]`,
797
+ },
798
+ ],
799
+ details: {
800
+ terminated: {
801
+ reason: "error",
802
+ pid: proc.pid,
803
+ command: proc.command,
804
+ startedAt: proc.startedAt,
805
+ endedAt: proc.endedAt,
806
+ durationMs,
807
+ error: err.message,
808
+ logPath: collector.fullOutputPath,
809
+ },
810
+ },
811
+ });
812
+ }
813
+ });
814
+ });
815
+ },
816
+ });
817
+
818
+ const bashStatusSchema = Type.Object({
819
+ bashId: Type.String({ description: "The bashId returned when a command was moved to background. Example: bash-abc123" }),
820
+ lastLines: Type.Optional(Type.Number({ description: "Only show the last N lines of output. Useful for checking tail of long-running builds. Default: show last 2000 chars." })),
821
+ grep: Type.Optional(Type.String({ description: "Filter output to only lines containing this keyword (case-insensitive). Useful for finding errors, warnings, or specific patterns in build output." })),
822
+ });
823
+
824
+ function findProcess(bashId: string): { proc: BashProcess; isLive: boolean } | null {
825
+ for (const m of managed.values()) {
826
+ if (m.proc.bashId === bashId) return { proc: m.proc, isLive: !m.proc.endedAt };
827
+ }
828
+ const histProc = history.find((p) => p.bashId === bashId);
829
+ if (histProc) return { proc: histProc, isLive: false };
830
+ return null;
831
+ }
832
+
833
+ pi.registerTool({
834
+ name: "get_background_process",
835
+ label: "get_background_process",
836
+ description: [
837
+ "Query the status and output of a backgrounded bash process by its bashId.",
838
+ "",
839
+ "When a bash command is moved to background (manually or via backgroundAfter), it returns a <bashId>. Use this tool to:",
840
+ "- Check if the process is still running, finished, or errored",
841
+ "- Get the accumulated output (filtered if needed)",
842
+ "- Get the exit code (if finished)",
843
+ "",
844
+ "Filtering options:",
845
+ "- lastLines: show only the last N lines (e.g. lastLines=5 for quick status check)",
846
+ "- grep: filter output to lines containing a keyword (e.g. grep='error' to find failures)",
847
+ "- Both can be combined: lastLines=10 + grep='warning'",
848
+ "",
849
+ "Typical flow:",
850
+ "1. Start long command: bash({ command: 'npm install', backgroundAfter: 60 })",
851
+ "2. Do other work while it runs",
852
+ "3. Poll: get_background_process({ bashId: 'bash-abc123' })",
853
+ "4. If status='done', proceed. If status='running', poll again later.",
854
+ ].join("\n"),
855
+ promptSnippet: "Check status of a backgrounded bash command",
856
+ parameters: bashStatusSchema,
857
+ async execute(
858
+ _toolCallId: string,
859
+ { bashId, lastLines, grep: grepPattern }: { bashId: string; lastLines?: number; grep?: string },
860
+ ): Promise<AgentToolResult<BashToolDetails>> {
861
+ const result = findProcess(bashId);
862
+
863
+ if (!result) {
864
+ return {
865
+ content: [
866
+ {
867
+ type: "text",
868
+ text: `No process found with <bashId>${bashId}</bashId>. It may have never existed, been removed, or the session has been reset.`,
869
+ },
870
+ ],
871
+ details: undefined as unknown as BashToolDetails,
872
+ };
873
+ }
874
+
875
+ const { proc, isLive } = result;
876
+ const durationMs = (proc.endedAt ?? Date.now()) - proc.startedAt;
877
+
878
+ const rawOutput = proc.output || "(no output yet)";
879
+ const allLines = rawOutput.split("\n");
880
+ const totalLines = allLines.length;
881
+
882
+ let displayLines: string[];
883
+ let startLine: number;
884
+ let endLine: number;
885
+
886
+ if (grepPattern) {
887
+ // Grep mode: filter matching lines, keep line numbers
888
+ const matched = allLines
889
+ .map((line, i) => ({ line, num: i + 1 }))
890
+ .filter((e) => e.line.toLowerCase().includes(grepPattern.toLowerCase()));
891
+ if (matched.length === 0) {
892
+ displayLines = [`(no lines matching "${grepPattern}")`];
893
+ startLine = 0;
894
+ endLine = 0;
895
+ } else {
896
+ // Apply lastLines to grep results if specified
897
+ const sliced = lastLines && lastLines > 0 ? matched.slice(-lastLines) : matched;
898
+ displayLines = sliced.map((e) => `L${e.num}: ${e.line}`);
899
+ startLine = sliced[0].num;
900
+ endLine = sliced[sliced.length - 1].num;
901
+ }
902
+ } else {
903
+ // Normal mode: take last N lines with line numbers
904
+ const n = lastLines && lastLines > 0 ? lastLines : 50;
905
+ startLine = Math.max(1, totalLines - n + 1);
906
+ endLine = totalLines;
907
+ const selected = allLines.slice(-n);
908
+ displayLines = selected.map((line, i) => `L${startLine + i}: ${line}`);
909
+ }
910
+
911
+ const output = displayLines.join("\n");
912
+
913
+ const header = [
914
+ `Process: ${proc.command}`,
915
+ `<bashId>${proc.bashId}</bashId>`,
916
+ `Status: ${proc.status}${isLive ? " (still running)" : ""}`,
917
+ `PID: ${proc.pid ?? "unknown"}`,
918
+ `Duration: ${formatDuration(durationMs)}`,
919
+ proc.exitCode !== undefined ? `Exit code: ${proc.exitCode}` : null,
920
+ proc.logPath ? `Log: ${proc.logPath}` : null,
921
+ proc.error ? `Error: ${proc.error}` : null,
922
+ totalLines > 0 ? `Lines: ${startLine}-${endLine} of ${totalLines} total` : null,
923
+ grepPattern ? `Filtered by: "${grepPattern}"` : null,
924
+ "",
925
+ isLive ? "Output so far:" : "Output:",
926
+ ]
927
+ .filter(Boolean)
928
+ .join("\n");
929
+
930
+ return {
931
+ content: [{ type: "text", text: `${header}\n${output}` }],
932
+ details: undefined as unknown as BashToolDetails,
933
+ };
934
+ },
935
+ });
926
936
  }