@justjoshn/pi-pstack 0.15.36-pi.13 → 0.15.36-pi.15

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.
@@ -0,0 +1,644 @@
1
+ // Cursor's background shell: a command that keeps running while the agent works, an output file with a status
2
+ // header and an exit footer, a notification when the command finishes or prints a watched line, and AwaitShell to
3
+ // poll or wait. Texts follow what Cursor CLI 2026.10.22 returns and sends (parity/pstack-native-parity-2026-11-04).
4
+ import { type ChildProcess, spawn } from 'node:child_process';
5
+ import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, statSync, writeSync } from 'node:fs';
6
+ import { constants } from 'node:os';
7
+ import { join, resolve } from 'node:path';
8
+ import { StringDecoder } from 'node:string_decoder';
9
+
10
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
11
+ import { getShellConfig, truncateTail } from '@earendil-works/pi-coding-agent';
12
+ import { Text } from '@earendil-works/pi-tui';
13
+ import { Type } from 'typebox';
14
+ import { isOneShot } from './modes.ts';
15
+ import { OutputWatchDependencyError, type OutputWatchRequest, ShellOutputWatch } from './shell-output-watch.ts';
16
+
17
+ export const SHELL_NOTICE_TYPE = 'pstack/shell';
18
+ const BACKGROUND_TOOL = 'bash_background';
19
+ const AWAIT_TOOL = 'AwaitShell';
20
+
21
+ const FIELD_WIDTH = 9;
22
+ const HEADER_REFRESH_MS = 5_000;
23
+ const DEFAULT_AWAIT_MS = 30_000;
24
+ const MAX_AWAIT_MS = 7_140_000;
25
+ const KILL_GRACE_MS = 2_000;
26
+ const FOLLOW_UP = 'Briefly inform the user about the task result and perform any follow-up actions (if needed).';
27
+
28
+ type JobStatus = 'running' | 'succeeded' | 'failed' | 'aborted';
29
+ type NoticeStatus = 'success' | 'error' | 'aborted';
30
+
31
+ interface ShellJob {
32
+ readonly id: string;
33
+ readonly pid: number;
34
+ readonly command: string;
35
+ readonly cwd: string;
36
+ readonly title: string;
37
+ readonly toolCallId: string;
38
+ readonly path: string;
39
+ readonly startedAt: number;
40
+ readonly fd: number;
41
+ readonly headerBytes: number;
42
+ readonly statusOffset: number;
43
+ readonly runningOffset: number;
44
+ readonly child: ChildProcess;
45
+ readonly watch: ShellOutputWatch | undefined;
46
+ readonly listeners: Set<() => void>;
47
+ readonly done: Promise<void>;
48
+ /** Where the next output byte goes; the header is rewritten in place, so the file is never opened for append. */
49
+ size: number;
50
+ /** Where the output ends once the footer is written. */
51
+ bodyEnd: number | undefined;
52
+ status: JobStatus;
53
+ exitCode: number | undefined;
54
+ endedAt: number | undefined;
55
+ /** AwaitShell or a foreground wait saw the end, so no completion notification follows. */
56
+ observed: boolean;
57
+ /** Waits in progress (AwaitShell or a foreground wait); one of them reports the end instead of a notification. */
58
+ awaiting: number;
59
+ refresh: ReturnType<typeof setInterval> | undefined;
60
+ }
61
+
62
+ export interface ShellNotice {
63
+ kind: 'progress' | 'finished';
64
+ status: NoticeStatus;
65
+ taskId: string;
66
+ shellId: string;
67
+ title: string;
68
+ toolCallId: string;
69
+ detail?: string;
70
+ outputPath?: string;
71
+ }
72
+
73
+ const pad = (value: string) => value.padEnd(FIELD_WIDTH, ' ');
74
+
75
+ /** The header Cursor writes: fixed-width status and running time, so both can be rewritten in place. */
76
+ export function terminalHeader(fields: { pid: number; cwd: string; command: string; title: string; startedAt: number }): { text: string; statusOffset: number; runningOffset: number } {
77
+ const before = ['---', `pid: ${fields.pid}`, `cwd: ${JSON.stringify(fields.cwd)}`, `command: ${JSON.stringify(fields.command)}`, `title: ${JSON.stringify(fields.title)}`, 'status: '].join('\n');
78
+ const middle = `${pad('running')}\nstarted_at: ${new Date(fields.startedAt).toISOString()}\nrunning_for_ms: `;
79
+ const text = `${before}${middle}${pad('0')}\n---\n`;
80
+ const statusOffset = Buffer.byteLength(before);
81
+ return { text, statusOffset, runningOffset: statusOffset + Buffer.byteLength(middle) };
82
+ }
83
+
84
+ export function terminalFooter(exitCode: number, elapsedMs: number, endedAt: number): string {
85
+ return `\n---\nexit_code: ${exitCode}\nelapsed_ms: ${elapsedMs}\nended_at: ${new Date(endedAt).toISOString()}\n---\n`;
86
+ }
87
+
88
+ export function launchText(job: Pick<ShellJob, 'id' | 'pid' | 'command' | 'path'>): string {
89
+ return ['Background command started successfully.', `Shell ID: ${job.id}`, `PID: ${job.pid}`, `Command: ${job.command}`, `Output will be written to ${job.path}. Don't mention Shell ID to the user.`].join('\n');
90
+ }
91
+
92
+ export function movedText(job: Pick<ShellJob, 'id' | 'pid' | 'path'>, waitedMs: number, output: string): string {
93
+ return [
94
+ `The command did not complete in ${waitedMs}ms and was sent to the background.`,
95
+ `Shell ID: ${job.id}`,
96
+ `PID: ${job.pid}`,
97
+ `The output is being written to ${job.path}. Don't mention Shell ID to the user.`,
98
+ '',
99
+ 'Output collected before backgrounding:',
100
+ '',
101
+ '```',
102
+ tail(output, job.path),
103
+ '```',
104
+ ].join('\n');
105
+ }
106
+
107
+ export function finishedText(exitCode: number, elapsedMs: number, output: string, path: string): string {
108
+ return [`Exit code: ${exitCode}`, '', 'Command output:', '', '```', tail(output, path), '```', '', `Command completed in ${elapsedMs} ms.`].join('\n');
109
+ }
110
+
111
+ function tail(output: string, path: string): string {
112
+ const result = truncateTail(output);
113
+ return result.truncated ? `[showing the last ${result.outputLines} of ${result.totalLines} lines; the full output is in ${path}]\n${result.content}` : output;
114
+ }
115
+
116
+ export function noticeText(notice: ShellNotice): string {
117
+ return [
118
+ '<system_notification>',
119
+ `The following task has ${notice.kind === 'finished' ? 'finished' : 'notified'}. If you were already aware, ignore this notification and do not restate prior responses.`,
120
+ '',
121
+ '<task>',
122
+ 'kind: shell',
123
+ `status: ${notice.status}`,
124
+ `task_id: ${notice.taskId}`,
125
+ `title: ${notice.title}`,
126
+ `tool_call_id: ${notice.toolCallId}`,
127
+ ...(notice.detail === undefined ? [] : [`detail: ${notice.detail}`]),
128
+ ...(notice.outputPath === undefined ? [] : [`output_path: ${notice.outputPath}`]),
129
+ '</task>',
130
+ '</system_notification>',
131
+ FOLLOW_UP,
132
+ ].join('\n');
133
+ }
134
+
135
+ function exitStatus(code: number | null, signal: NodeJS.Signals | null): { status: JobStatus; exitCode: number } {
136
+ if (signal !== null) return { status: 'aborted', exitCode: 128 + (constants.signals[signal] ?? 0) };
137
+ const exitCode = code ?? 1;
138
+ return { status: exitCode === 0 ? 'succeeded' : 'failed', exitCode };
139
+ }
140
+
141
+ function compile(pattern: string): RegExp {
142
+ try {
143
+ return new RegExp(pattern, 'm');
144
+ } catch (error) {
145
+ throw new Error(`Invalid pattern /${pattern}/: ${error instanceof Error ? error.message : String(error)}`);
146
+ }
147
+ }
148
+
149
+ function sessionEnv(ctx: ExtensionContext): NodeJS.ProcessEnv {
150
+ const env: NodeJS.ProcessEnv = { ...process.env, PI_SESSION_ID: ctx.sessionManager.getSessionId() };
151
+ const file = ctx.sessionManager.getSessionFile();
152
+ if (file !== undefined) env['PI_SESSION_FILE'] = file;
153
+ if (ctx.model !== undefined) {
154
+ env['PI_PROVIDER'] = ctx.model.provider;
155
+ env['PI_MODEL'] = ctx.model.id;
156
+ }
157
+ return env;
158
+ }
159
+
160
+ /** Process groups of running jobs, killed if this process exits before a session shutdown could stop them. */
161
+ const liveGroups = new Set<number>();
162
+ let exitHookInstalled = false;
163
+ function trackGroup(pid: number): void {
164
+ liveGroups.add(pid);
165
+ if (exitHookInstalled) return;
166
+ exitHookInstalled = true;
167
+ process.on('exit', () => {
168
+ for (const group of liveGroups) signalGroupId(group, 'SIGKILL');
169
+ });
170
+ }
171
+
172
+ function signalGroupId(pid: number, signal: NodeJS.Signals): void {
173
+ try {
174
+ process.kill(process.platform === 'win32' ? pid : -pid, signal);
175
+ } catch {
176
+ // The process group is already gone.
177
+ }
178
+ }
179
+
180
+ const sleep = (ms: number, signal: AbortSignal | undefined) =>
181
+ new Promise<void>((done) => {
182
+ if (signal?.aborted === true) return done();
183
+ const timer = setTimeout(finish, ms);
184
+ function finish() {
185
+ clearTimeout(timer);
186
+ signal?.removeEventListener('abort', finish);
187
+ done();
188
+ }
189
+ signal?.addEventListener('abort', finish, { once: true });
190
+ });
191
+
192
+ export interface ShellJobsOptions {
193
+ terminalsDir: (ctx: ExtensionContext) => string;
194
+ shellPath: () => string | undefined;
195
+ }
196
+
197
+ export interface StartRequest {
198
+ command: string;
199
+ cwd: string;
200
+ title: string;
201
+ toolCallId: string;
202
+ watch?: OutputWatchRequest;
203
+ }
204
+
205
+ /** Every background shell this runtime started, and the notifications they owe the agent. */
206
+ export function createShellJobs(options: ShellJobsOptions, deliver: (notice: ShellNotice) => void) {
207
+ const jobs = new Map<string, ShellJob>();
208
+ let nextId: number | undefined;
209
+
210
+ const allocateId = (dir: string): string => {
211
+ nextId ??= 100_000 + Math.floor(Math.random() * 800_000);
212
+ while (existsSync(join(dir, `${nextId}.txt`))) nextId += 1;
213
+ const id = String(nextId);
214
+ nextId += 1;
215
+ return id;
216
+ };
217
+
218
+ const writeAt = (job: ShellJob, text: string, position: number) => {
219
+ const bytes = Buffer.from(text, 'utf8');
220
+ writeSync(job.fd, bytes, 0, bytes.length, position);
221
+ };
222
+ const append = (job: ShellJob, text: string | Buffer) => {
223
+ const bytes = typeof text === 'string' ? Buffer.from(text, 'utf8') : text;
224
+ writeSync(job.fd, bytes, 0, bytes.length, job.size);
225
+ job.size += bytes.length;
226
+ };
227
+ const refreshRunning = (job: ShellJob) => writeAt(job, pad(String((job.endedAt ?? Date.now()) - job.startedAt)), job.runningOffset);
228
+ const notify = (job: ShellJob) => {
229
+ for (const listener of [...job.listeners]) listener();
230
+ };
231
+
232
+ const finish = (job: ShellJob, status: JobStatus, exitCode: number, announce: boolean) => {
233
+ if (job.status !== 'running') return;
234
+ const endedAt = Date.now();
235
+ Object.assign(job, { status, exitCode, endedAt, observed: job.observed || job.awaiting > 0 });
236
+ if (job.refresh !== undefined) clearInterval(job.refresh);
237
+ writeAt(job, pad(status), job.statusOffset);
238
+ refreshRunning(job);
239
+ job.bodyEnd = job.size;
240
+ append(job, terminalFooter(exitCode, endedAt - job.startedAt, endedAt));
241
+ closeSync(job.fd);
242
+ job.watch?.finish(announce);
243
+ if (announce) {
244
+ if (!job.observed) {
245
+ const noticeStatus: NoticeStatus = status === 'succeeded' ? 'success' : status === 'failed' ? 'error' : 'aborted';
246
+ deliver({
247
+ kind: 'finished',
248
+ status: noticeStatus,
249
+ taskId: job.id,
250
+ shellId: job.id,
251
+ title: job.title,
252
+ toolCallId: job.toolCallId,
253
+ ...(noticeStatus === 'error' ? { detail: `exit_code=${exitCode}` } : {}),
254
+ });
255
+ }
256
+ }
257
+ notify(job);
258
+ };
259
+
260
+ const start = (ctx: ExtensionContext, request: StartRequest): ShellJob => {
261
+ if (!existsSync(request.cwd) || !statSync(request.cwd).isDirectory()) throw new Error(`Working directory ${request.cwd} does not exist.`);
262
+ const dir = options.terminalsDir(ctx);
263
+ mkdirSync(dir, { recursive: true });
264
+ const id = allocateId(dir);
265
+ const path = join(dir, `${id}.txt`);
266
+ let watch: ShellOutputWatch | undefined;
267
+ if (request.watch !== undefined && request.watch.pattern.trim() !== '') {
268
+ try {
269
+ watch = new ShellOutputWatch(request.watch, (notice) => {
270
+ deliver({ kind: 'progress', status: 'success', taskId: `${id}:task_progress:${notice.sequence}`, shellId: id, title: notice.title, toolCallId: request.toolCallId, detail: notice.detail, outputPath: path });
271
+ });
272
+ } catch (error) {
273
+ if (error instanceof OutputWatchDependencyError) throw error;
274
+ console.warn('Invalid shell output notification pattern', { shellId: id, error: error instanceof Error ? error.message : String(error) });
275
+ }
276
+ }
277
+ const config = getShellConfig(options.shellPath());
278
+ const viaStdin = config.commandTransport === 'stdin';
279
+ const child = spawn(config.shell, viaStdin ? config.args : [...config.args, request.command], {
280
+ cwd: request.cwd,
281
+ env: sessionEnv(ctx),
282
+ detached: process.platform !== 'win32',
283
+ stdio: [viaStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
284
+ windowsHide: true,
285
+ });
286
+ let spawnError: Error | undefined;
287
+ child.once('error', (error) => {
288
+ spawnError = error;
289
+ });
290
+ if (viaStdin) child.stdin?.end(request.command);
291
+ if (child.pid === undefined) throw new Error(`Could not start ${config.shell}${spawnError === undefined ? '' : `: ${spawnError.message}`}.`);
292
+ const startedAt = Date.now();
293
+ const header = terminalHeader({ pid: child.pid, cwd: request.cwd, command: request.command, title: request.title, startedAt });
294
+ const fd = openSync(path, 'w+');
295
+ let release: () => void = () => undefined;
296
+ const job: ShellJob = {
297
+ id,
298
+ pid: child.pid,
299
+ command: request.command,
300
+ cwd: request.cwd,
301
+ title: request.title,
302
+ toolCallId: request.toolCallId,
303
+ path,
304
+ startedAt,
305
+ fd,
306
+ headerBytes: Buffer.byteLength(header.text),
307
+ statusOffset: header.statusOffset,
308
+ runningOffset: header.runningOffset,
309
+ child,
310
+ watch,
311
+ listeners: new Set(),
312
+ done: new Promise<void>((resolveDone) => {
313
+ release = resolveDone;
314
+ }),
315
+ size: 0,
316
+ bodyEnd: undefined,
317
+ status: 'running',
318
+ exitCode: undefined,
319
+ endedAt: undefined,
320
+ observed: false,
321
+ awaiting: 0,
322
+ refresh: undefined,
323
+ };
324
+ append(job, header.text);
325
+ job.refresh = setInterval(() => refreshRunning(job), HEADER_REFRESH_MS);
326
+ job.refresh.unref();
327
+ for (const stream of [child.stdout, child.stderr]) {
328
+ if (stream === null) continue;
329
+ const decoder = new StringDecoder('utf8');
330
+ stream.on('data', (chunk: Buffer) => {
331
+ if (job.status !== 'running') return;
332
+ const text = decoder.write(chunk);
333
+ append(job, text);
334
+ job.watch?.observe(text);
335
+ notify(job);
336
+ });
337
+ stream.on('end', () => {
338
+ if (job.status !== 'running') return;
339
+ const text = decoder.end();
340
+ append(job, text);
341
+ job.watch?.observe(text);
342
+ notify(job);
343
+ });
344
+ }
345
+ child.on('error', (error) => {
346
+ if (job.status === 'running') append(job, `${error.message}\n`);
347
+ finish(job, 'failed', 127, true);
348
+ release();
349
+ });
350
+ child.on('close', (code, signal) => {
351
+ liveGroups.delete(job.pid);
352
+ const { status, exitCode } = exitStatus(code, signal);
353
+ finish(job, status, exitCode, true);
354
+ release();
355
+ });
356
+ trackGroup(job.pid);
357
+ jobs.set(id, job);
358
+ return job;
359
+ };
360
+
361
+ /** The command's output so far: the file between the header and the footer. */
362
+ const output = (job: ShellJob): string => {
363
+ const length = Math.max(0, (job.bodyEnd ?? job.size) - job.headerBytes);
364
+ const buffer = Buffer.alloc(length);
365
+ const fd = job.status === 'running' ? job.fd : openSync(job.path, 'r');
366
+ try {
367
+ readSync(fd, buffer, 0, length, job.headerBytes);
368
+ } finally {
369
+ if (fd !== job.fd) closeSync(fd);
370
+ }
371
+ return buffer.toString('utf8');
372
+ };
373
+ const fileSize = (job: ShellJob) => (job.status === 'running' ? fstatSync(job.fd).size : statSync(job.path).size);
374
+
375
+ /** Resolves when the job ends, prints, or `ms` passes, or the signal aborts. A job that ends meanwhile counts as observed. */
376
+ const change = (job: ShellJob, ms: number, signal: AbortSignal | undefined) =>
377
+ new Promise<void>((done) => {
378
+ if (job.status !== 'running' || signal?.aborted === true) return done();
379
+ job.awaiting += 1;
380
+ const timer = setTimeout(stop, ms);
381
+ function stop() {
382
+ clearTimeout(timer);
383
+ job.awaiting -= 1;
384
+ job.listeners.delete(stop);
385
+ signal?.removeEventListener('abort', stop);
386
+ done();
387
+ }
388
+ job.listeners.add(stop);
389
+ signal?.addEventListener('abort', stop, { once: true });
390
+ });
391
+
392
+ const awaitJob = async (shellId: string, waitMs: number, pattern: string | undefined, signal: AbortSignal | undefined): Promise<string> => {
393
+ const job = jobs.get(shellId.trim());
394
+ if (job === undefined) throw new Error(`Error awaiting task: No shell found for id ${shellId}`);
395
+ const regex = pattern === undefined || pattern === '' ? undefined : compile(pattern);
396
+ const deadline = Date.now() + waitMs;
397
+ for (;;) {
398
+ if (job.status !== 'running') {
399
+ job.observed = true;
400
+ return [`Task completed in ${(job.endedAt ?? Date.now()) - job.startedAt}ms with exit code: ${job.exitCode}.`, `output_file_path: ${job.path}`, `output_length: ${fileSize(job)}`].join('\n');
401
+ }
402
+ const matched = regex?.exec(output(job))?.[0];
403
+ const remaining = deadline - Date.now();
404
+ if (matched !== undefined || remaining <= 0 || signal?.aborted === true) {
405
+ const suffix = regex === undefined ? '' : matched === undefined ? ' Pattern did NOT match.' : ` Pattern matched: ${matched}`;
406
+ return [`Task still running after ${Date.now() - job.startedAt}ms...${suffix}`, `output_file_path: ${job.path}`, `output_length: ${fileSize(job)}`].join('\n');
407
+ }
408
+ await change(job, remaining, signal);
409
+ }
410
+ };
411
+
412
+ const signalGroup = (job: ShellJob, signal: NodeJS.Signals) => signalGroupId(job.pid, signal);
413
+
414
+ /** Session end: every running job is stopped and recorded as aborted, with no notification. */
415
+ const shutdown = () => {
416
+ for (const job of jobs.values()) {
417
+ if (job.status !== 'running') continue;
418
+ signalGroup(job, 'SIGTERM');
419
+ const kill = setTimeout(() => signalGroup(job, 'SIGKILL'), KILL_GRACE_MS);
420
+ kill.unref();
421
+ finish(job, 'aborted', 128 + constants.signals.SIGTERM, false);
422
+ }
423
+ };
424
+ return {
425
+ start,
426
+ output,
427
+ awaitJob,
428
+ change,
429
+ shutdown,
430
+ running: () => [...jobs.values()].filter((job) => job.status === 'running'),
431
+ stop: (job: ShellJob) => {
432
+ signalGroup(job, 'SIGTERM');
433
+ setTimeout(() => signalGroup(job, 'SIGKILL'), KILL_GRACE_MS).unref();
434
+ },
435
+ };
436
+ }
437
+
438
+ type ShellJobs = ReturnType<typeof createShellJobs>;
439
+
440
+ const NotifyParams = Type.Object({
441
+ pattern: Type.String({ description: 'Regex pattern matched against each line of stdout/stderr output. Output redirected only to a file will not trigger it. Do not match all outputs.' }),
442
+ reason: Type.String({ description: '5 or less words describing why you are watching for this output. It titles the notification.' }),
443
+ debounce_ms: Type.Optional(Type.Number({ description: 'Milliseconds that must elapse between notifications. The minimum is 5000ms.' })),
444
+ });
445
+
446
+ const BackgroundParams = Type.Object({
447
+ command: Type.String({ description: 'The command to execute' }),
448
+ description: Type.Optional(Type.String({ description: 'Very short phrase naming what this command does; it titles the notifications about it. Avoid leading with verbs or using trailing periods' })),
449
+ block_until_ms: Type.Optional(
450
+ Type.Number({
451
+ description:
452
+ 'How long to wait for the command in the foreground before moving it to the background, in milliseconds. 0 (the default) starts it in the background at once. A command that finishes within the wait returns its output and sends no notification.',
453
+ }),
454
+ ),
455
+ notify_on_output: Type.Optional(
456
+ Type.Object(NotifyParams.properties, { description: 'Optional output notification config. Each output line which matches the pattern will notify you. ONLY set this when the user explicitly requests monitoring.' }),
457
+ ),
458
+ working_directory: Type.Optional(Type.String({ description: 'The absolute path to the working directory to execute the command in (defaults to current directory)' })),
459
+ });
460
+
461
+ const AwaitParams = Type.Object({
462
+ shell_id: Type.Optional(Type.String({ description: 'Optional shell id to poll. If omitted, this tool sleeps for the full block_until_ms duration and then returns. Required when block_until_ms is 0.' })),
463
+ block_until_ms: Type.Optional(
464
+ Type.Number({
465
+ maximum: MAX_AWAIT_MS,
466
+ description: 'Max sleep time to block before returning (in milliseconds). Defaults to 30000ms. Set to 0 only together with shell_id, for a non-blocking status check of that shell. Must not exceed 7140000 (119 minutes).',
467
+ }),
468
+ ),
469
+ pattern: Type.Optional(
470
+ Type.String({
471
+ description:
472
+ 'Block until the regex matches the stdout/stderr output (or the command completes). Matches anywhere in the shell output, not just new output. Does not match the output file header or footer. JavaScript regex syntax, compiled in multiline mode so ^ and $ match at line boundaries.',
473
+ }),
474
+ ),
475
+ });
476
+
477
+ const BACKGROUND_DESCRIPTION = [
478
+ 'Run a shell command in the background, or wait up to block_until_ms for it in the foreground and then move it to the background. Use it for work that should keep running while you do other things: dev servers, watchers, slow scans and builds, and a watcher whose output should wake you. Use bash for ordinary commands.',
479
+ '',
480
+ 'The result names a shell ID, a PID, and an output file. The file starts with a header (pid, cwd, command, title, status, started_at, running_for_ms, refreshed every 5000ms) and ends with a footer (exit_code, elapsed_ms, ended_at) once the command finishes. After your turn ends you are notified when the command finishes, and on each notify_on_output match. Do not wait for those: keep working or end your turn. Poll with AwaitShell only when you are blocked on this job. Stop a job with `kill <pid>`.',
481
+ ].join('\n');
482
+
483
+ const AWAIT_DESCRIPTION = [
484
+ 'Check or poll a backgrounded shell job. For work that does not have a shell id, you can omit the shell_id arg to sleep for the full `block_until_ms` duration (prefer this over sleeping in the shell). At the end of your turn, you will be notified about any unawaited jobs that completed. If you think a job completed (e.g. because you killed it), observe it with AwaitShell to skip the notification, because stale notifications can confuse the user.',
485
+ '',
486
+ 'Prefer NOT to poll reflexively with AwaitShell. Multitask on independent work while backgrounded jobs run, or finish your turn and rely on the end-of-turn completion notification. Poll with AwaitShell only when one of the following is true:',
487
+ "- Your very next step is blocked on this specific job's result and you have no other productive work to do, OR",
488
+ '- The task requires close monitoring (see shell guidance below).',
489
+ '- NEVER USE THIS TO POLL OR WAIT VACUOUSLY FOR A SUBAGENT LAUNCHED WITH THE Task TOOL — rely on the end-of-turn completion notification instead (it is delivered as soon as the subagent finishes; guessing a wait time is inefficient).',
490
+ '- Shell: only poll with AwaitShell when the command requires close monitoring. Close monitoring means a long-running job that can silently hang, degrade, or need a course correction before it completes — e.g. training runs, eval runs, deployments, long builds, datagen pipelines, DB migrations, large data transfers. For fire-and-forget commands (tests, installs, dev servers/watchers, short scripts, etc.) the completion notification is enough — start them, keep working, and only poll with AwaitShell later if you end up blocked on the result.',
491
+ "- Shell sanity check (regardless of close monitoring): when you spawn a command directly into the background (`block_until_ms: 0`), do a single status check by reading the output file to confirm the command didn't fail to start. This is a one-shot smoke check, not a polling loop.",
492
+ '- Shell close-monitoring guidance (only applies in the close-monitoring case above):',
493
+ " - HARD STOPPING CONSTRAINT: once you've decided to actively poll, don't stop until (a) the job terminates, (b) the command reaches a healthy steady state (only for non-terminating commands, e.g. dev server/watcher), or (c) the command is hung — follow the hang guidance below.",
494
+ ' - Waiting until a regex matches the output can be useful for e.g. known startup/status/error logs.',
495
+ " - Size `block_until_ms` to the command's expected runtime. When waiting further, avoid round 5-minute waits: prefer slices of 60–270s (keeps prompt cache warm) or 1200s+ (one cache miss buys a long wait).",
496
+ ' - Output file header has `pid` and `running_for_ms` (updated every 5000ms).',
497
+ ' - When finished, footer with `exit_code` and `elapsed_ms` appears (regex only matches the body, not header/footer).',
498
+ ' - If the command is taking longer than expected and appears hung (use judgment based on command type), kill the process if safe to do so using the pid in the header. If possible, fix the hang and proceed.',
499
+ ].join('\n');
500
+
501
+ interface AwaitDetails {
502
+ shellId?: string;
503
+ sleptMs?: number;
504
+ }
505
+
506
+ const SUBAGENT_NOTE = 'This subagent stops its background jobs when it finishes, so await any job whose result you need before you finish.';
507
+
508
+ const clampWait = (value: number | undefined, fallback: number) => Math.min(MAX_AWAIT_MS, Math.max(0, Math.round(value ?? fallback)));
509
+
510
+ function seconds(ms: number): string {
511
+ const value = ms / 1000;
512
+ return Number.isInteger(value) ? `${value}s` : `${value.toFixed(1)}s`;
513
+ }
514
+
515
+ export function shellNoticeCardText(message: { content: string | { type: string; text?: string }[]; details?: unknown }): string {
516
+ const notice = message.details as Partial<ShellNotice> | undefined;
517
+ if (notice?.title === undefined || notice.shellId === undefined) return typeof message.content === 'string' ? message.content : message.content.map((part) => part.text ?? '').join('');
518
+ const head = `${notice.title} · ${notice.kind === 'progress' ? 'output matched' : notice.status} · shell ${notice.shellId}`;
519
+ return notice.detail === undefined ? head : `${head}\n${notice.detail}`;
520
+ }
521
+
522
+ export interface BackgroundShellOptions {
523
+ terminalsDir: (ctx: ExtensionContext) => string;
524
+ /** One-shot runs that may wait at settle for their jobs, as Cursor's print mode does: print and JSON runs, not subagents. */
525
+ waitsForJobs: (ctx: ExtensionContext) => boolean;
526
+ /** A subagent's jobs end with it. */
527
+ endsWithTurn: boolean;
528
+ }
529
+
530
+ /** bash_background and AwaitShell, plus the notifications their jobs send. */
531
+ export function registerBackgroundShell(pi: ExtensionAPI, options: BackgroundShellOptions): { hasRunningJobs: () => boolean } {
532
+ const pending: ShellNotice[] = [];
533
+ const arrivals = new Set<() => void>();
534
+ let mode: ExtensionContext['mode'] | undefined;
535
+ let retired = false;
536
+ const deliver = (notice: ShellNotice) => {
537
+ if (retired) return;
538
+ if (mode !== undefined && isOneShot(mode)) {
539
+ pending.push(notice);
540
+ for (const arrived of [...arrivals]) arrived();
541
+ } else pi.sendMessage({ customType: SHELL_NOTICE_TYPE, content: noticeText(notice), display: true, details: notice }, { triggerTurn: true, deliverAs: 'followUp' });
542
+ };
543
+ const nextArrival = () =>
544
+ new Promise<void>((done) => {
545
+ const arrived = () => {
546
+ arrivals.delete(arrived);
547
+ done();
548
+ };
549
+ arrivals.add(arrived);
550
+ });
551
+ const jobs: ShellJobs = createShellJobs(
552
+ {
553
+ terminalsDir: options.terminalsDir,
554
+ shellPath: () => {
555
+ const value = (pi.getSettings() as { shellPath?: unknown }).shellPath;
556
+ return typeof value === 'string' && value !== '' ? value.replace(/^~(?=$|\/)/, process.env['HOME'] ?? '~') : undefined;
557
+ },
558
+ },
559
+ deliver,
560
+ );
561
+
562
+ pi.registerMessageRenderer?.(SHELL_NOTICE_TYPE, (message) => new Text(shellNoticeCardText(message), 0, 0));
563
+ pi.on('session_start', (_event, ctx) => {
564
+ mode = ctx.mode;
565
+ retired = false;
566
+ });
567
+ // Cursor's print mode holds its exit for background shells and reports each notification as a turn.
568
+ pi.on('agent_before_settle', async (event, ctx) => {
569
+ if (!isOneShot(ctx.mode) || event.outcome === 'aborted') return undefined;
570
+ if (pending.length === 0 && (!options.waitsForJobs(ctx) || jobs.running().length === 0)) return undefined;
571
+ if (pending.length === 0) await nextArrival();
572
+ const ready = pending.splice(0);
573
+ if (ready.length === 0) return undefined;
574
+ return {
575
+ entries: [...event.entries, ...ready.map((notice) => ({ type: 'custom_message' as const, customType: SHELL_NOTICE_TYPE, content: noticeText(notice), display: true, details: notice }))],
576
+ continue: true,
577
+ };
578
+ });
579
+ pi.on('session_shutdown', () => {
580
+ retired = true;
581
+ pending.splice(0);
582
+ for (const arrived of [...arrivals]) arrived();
583
+ jobs.shutdown();
584
+ });
585
+
586
+ pi.registerTool({
587
+ name: BACKGROUND_TOOL,
588
+ label: 'bash (background)',
589
+ description: BACKGROUND_DESCRIPTION,
590
+ promptSnippet: 'Run a shell command in the background, with notifications when it finishes or prints a watched line',
591
+ parameters: BackgroundParams,
592
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
593
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
594
+ const cwd = params.working_directory === undefined || params.working_directory.trim() === '' ? ctx.cwd : resolve(ctx.cwd, params.working_directory);
595
+ const title = params.description?.trim() || params.command.trim().split('\n')[0]?.slice(0, 80) || params.command;
596
+ const watch = params.notify_on_output;
597
+ const job = jobs.start(ctx, {
598
+ command: params.command,
599
+ cwd,
600
+ title,
601
+ toolCallId,
602
+ ...(watch === undefined ? {} : { watch: { pattern: watch.pattern, reason: watch.reason, ...(watch.debounce_ms === undefined ? {} : { debounceMs: watch.debounce_ms }) } }),
603
+ });
604
+ const blockMs = clampWait(params.block_until_ms, 0);
605
+ const details = { shellId: job.id, pid: job.pid, outputPath: job.path };
606
+ const note = options.endsWithTurn ? `\n${SUBAGENT_NOTE}` : '';
607
+ if (blockMs === 0) return { content: [{ type: 'text', text: `${launchText(job)}${note}` }], details };
608
+ const deadline = Date.now() + blockMs;
609
+ while (job.status === 'running' && Date.now() < deadline && signal?.aborted !== true) await jobs.change(job, deadline - Date.now(), signal);
610
+ if (signal?.aborted === true && job.status === 'running') {
611
+ jobs.stop(job);
612
+ throw new Error('The command was stopped because the call was cancelled.');
613
+ }
614
+ if (job.status !== 'running') {
615
+ job.observed = true;
616
+ return { content: [{ type: 'text', text: finishedText(job.exitCode ?? 1, (job.endedAt ?? Date.now()) - job.startedAt, jobs.output(job), job.path) }], details: { ...details, exitCode: job.exitCode } };
617
+ }
618
+ return { content: [{ type: 'text', text: `${movedText(job, blockMs, jobs.output(job))}${note}` }], details };
619
+ },
620
+ });
621
+
622
+ pi.registerTool({
623
+ name: AWAIT_TOOL,
624
+ label: 'AwaitShell',
625
+ description: AWAIT_DESCRIPTION,
626
+ promptSnippet: 'Poll or wait for a background shell job, or sleep',
627
+ parameters: AwaitParams,
628
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
629
+ async execute(_toolCallId, params, signal): Promise<{ content: { type: 'text'; text: string }[]; details: AwaitDetails }> {
630
+ const waitMs = clampWait(params.block_until_ms, DEFAULT_AWAIT_MS);
631
+ const shellId = params.shell_id?.trim();
632
+ if (shellId === undefined || shellId === '') {
633
+ if (waitMs === 0) throw new Error('block_until_ms 0 needs shell_id: it checks one shell without waiting.');
634
+ const started = Date.now();
635
+ await sleep(waitMs, signal);
636
+ return { content: [{ type: 'text', text: `Slept for ${seconds(Date.now() - started >= waitMs ? waitMs : Date.now() - started)}.` }], details: { sleptMs: Date.now() - started } };
637
+ }
638
+ const text = await jobs.awaitJob(shellId, waitMs, params.pattern, signal);
639
+ return { content: [{ type: 'text', text }], details: { shellId } };
640
+ },
641
+ });
642
+
643
+ return { hasRunningJobs: () => jobs.running().length > 0 };
644
+ }