@felan-ai/ext-background-bash 0.5.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,34 +1,39 @@
1
- import { StringEnum, createRuntimeCodingTools, } from '@felan-ai/agent-core';
1
+ import { StringEnum, associateExtensionConfig, configField, createRuntimeCodingTools, defineExtensionConfig, } from '@felan-ai/agent-core';
2
2
  import { Key, Text } from '@earendil-works/pi-tui';
3
3
  import { Type } from 'typebox';
4
4
  import { normalizeBackgroundCommand } from './command-normalizer.js';
5
5
  import { isTerminalStatus, } from './job-store.js';
6
6
  import { BackgroundBashManager } from './process-manager.js';
7
+ import { BackgroundBashCoordinator } from './coordinator.js';
7
8
  import { inspectBackgroundBashRuntime } from './runtime-support.js';
8
9
  import { BackgroundBashView } from './ui/background-bash-view.js';
9
10
  import { BACKGROUND_BASH_COMPLETION_MESSAGE_TYPE, registerBackgroundBashCompletionRenderer, } from './ui/completion-message.js';
10
11
  const STATUS_VALUES = ['running', 'completed', 'failed', 'killed', 'unknown', 'all'];
11
12
  const SIGNAL_VALUES = ['SIGTERM', 'SIGKILL'];
12
- const OPENAI_PROVIDER_IDS = new Set(['openai', 'openai-codex']);
13
13
  const COMPLETION_POLL_MS = 500;
14
14
  const BACKGROUND_TOOL_NAMES = [
15
15
  'list_background_bash',
16
16
  'read_background_bash',
17
17
  'wait_background_bash',
18
18
  'stop_background_bash',
19
+ 'write_background_bash',
19
20
  ];
21
+ export function supportsBackgroundBashModel(model) {
22
+ return model !== undefined;
23
+ }
20
24
  const BashParams = Type.Object({
21
25
  command: Type.String({ description: 'Bash command to execute' }),
22
- timeout: Type.Optional(Type.Number({ description: 'Timeout in seconds for foreground commands' })),
26
+ timeout: Type.Optional(Type.Number({ minimum: 0, description: 'Seconds to wait before promoting the same process to the background. Defaults to 120; 0 backgrounds immediately.' })),
23
27
  background: Type.Optional(Type.Boolean({
24
- description: 'Start a detached background Bash process and return immediately',
28
+ description: 'Start a background process and return immediately',
25
29
  })),
30
+ tty: Type.Optional(Type.Boolean({ description: 'Start with a PTY so write_background_bash can send stdin and control bytes' })),
26
31
  }, { additionalProperties: false });
27
32
  const ListBackgroundBashParams = Type.Object({
28
33
  status: Type.Optional(StringEnum(STATUS_VALUES, { description: 'Filter processes by status' })),
29
34
  }, { additionalProperties: false });
30
35
  const ReadBackgroundBashParams = Type.Object({
31
- id: Type.String({ description: 'Background Bash process id returned by bash(background: true)' }),
36
+ id: Type.String({ description: 'Background process id returned by bash' }),
32
37
  lines: Type.Optional(Type.Integer({
33
38
  minimum: 1,
34
39
  maximum: 1_000,
@@ -36,427 +41,502 @@ const ReadBackgroundBashParams = Type.Object({
36
41
  })),
37
42
  }, { additionalProperties: false });
38
43
  const WaitBackgroundBashParams = Type.Object({
39
- id: Type.String({ description: 'Background Bash process id returned by bash(background: true)' }),
44
+ id: Type.String({ description: 'Background process id returned by bash' }),
40
45
  timeout: Type.Optional(Type.Number({ description: 'Maximum seconds to wait before returning current status' })),
41
46
  }, { additionalProperties: false });
42
47
  const StopBackgroundBashParams = Type.Object({
43
- id: Type.String({ description: 'Background Bash process id returned by bash(background: true)' }),
48
+ id: Type.String({ description: 'Background process id returned by bash' }),
44
49
  signal: Type.Optional(StringEnum(SIGNAL_VALUES, { description: 'Signal to send. Default: SIGTERM.' })),
45
50
  }, { additionalProperties: false });
46
- export function supportsBackgroundBashModel(model) {
47
- return model !== undefined && !OPENAI_PROVIDER_IDS.has(model.provider);
48
- }
49
- const backgroundBashExtension = (pi) => {
50
- registerBackgroundBashCompletionRenderer(pi);
51
- const manager = new BackgroundBashManager(pi.runtime);
52
- const foregroundBash = createRuntimeCodingTools(pi.runtime, { shellFlavor: 'posix' })
53
- .find((tool) => tool.name === 'bash');
54
- let helperToolsRegistered = false;
55
- let backgroundBashActive = false;
56
- let controlsRegistered = false;
57
- let statusTarget;
58
- let statusPollTimer;
59
- let statusUpdateRunning = false;
60
- let statusGeneration = 0;
61
- let completionPollingEnabled = false;
62
- let completionPollTimer;
63
- let completionPollGeneration = 0;
64
- let completionPollRunningGeneration;
65
- let runtimeAvailable;
66
- let runtimeCheck;
67
- const watchedJobIds = new Set();
68
- const createStatusTarget = (ctx) => {
69
- if (ctx.mode !== 'tui')
70
- return undefined;
71
- return { ui: ctx.ui, generation: statusGeneration };
72
- };
73
- const updateStatus = async (target) => {
74
- if (!target || target.generation !== statusGeneration || statusUpdateRunning)
75
- return;
76
- statusUpdateRunning = true;
77
- try {
78
- const running = await manager.list('running');
79
- if (target.generation !== statusGeneration)
80
- return;
81
- if (running.length === 0) {
82
- target.ui.setStatus('background-bash', undefined);
51
+ const WriteBackgroundBashParams = Type.Object({
52
+ id: Type.String({ description: 'Background process id returned by bash' }),
53
+ chars: Type.String({ description: 'Exact text or control bytes to write to a PTY process' }),
54
+ }, { additionalProperties: false });
55
+ export const BACKGROUND_BASH_CONFIG = defineExtensionConfig({
56
+ id: 'backgroundBash',
57
+ title: 'Background processes',
58
+ fields: {
59
+ foregroundTimeoutSeconds: configField.number({
60
+ default: 120,
61
+ description: 'Seconds before a foreground Bash command is promoted to the background',
62
+ validate: (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0
63
+ ? undefined
64
+ : 'must be a finite non-negative number',
65
+ }),
66
+ },
67
+ });
68
+ export function createBackgroundBashExtension(coordinator, ownsCoordinator = coordinator === undefined) {
69
+ const extension = (pi) => {
70
+ const config = {
71
+ foregroundTimeoutSeconds: Number(pi.config?.foregroundTimeoutSeconds ?? 120),
72
+ };
73
+ registerBackgroundBashCompletionRenderer(pi);
74
+ const manager = new BackgroundBashManager(pi.runtime, coordinator);
75
+ const foregroundBash = createRuntimeCodingTools(pi.runtime, { shellFlavor: 'posix' })
76
+ .find((tool) => tool.name === 'bash');
77
+ let helperToolsRegistered = false;
78
+ let backgroundBashActive = false;
79
+ let controlsRegistered = false;
80
+ let statusTarget;
81
+ let statusPollTimer;
82
+ let statusUpdateRunning = false;
83
+ let statusGeneration = 0;
84
+ let completionPollingEnabled = false;
85
+ let completionPollTimer;
86
+ let completionPollGeneration = 0;
87
+ let completionPollRunningGeneration;
88
+ let runtimeAvailable;
89
+ let runtimeCheck;
90
+ const watchedJobIds = new Set();
91
+ const createStatusTarget = (ctx) => {
92
+ if (ctx.mode !== 'tui')
93
+ return undefined;
94
+ return { ui: ctx.ui, generation: statusGeneration };
95
+ };
96
+ const updateStatus = async (target) => {
97
+ if (!target || target.generation !== statusGeneration || statusUpdateRunning)
83
98
  return;
99
+ statusUpdateRunning = true;
100
+ try {
101
+ const running = await manager.list('running');
102
+ if (target.generation !== statusGeneration)
103
+ return;
104
+ if (running.length === 0) {
105
+ target.ui.setStatus('background-bash', undefined);
106
+ return;
107
+ }
108
+ const label = running.length === 1 ? '1 process' : `${running.length} processes`;
109
+ const icon = target.ui.theme.fg('accent', '●');
110
+ const text = target.ui.theme.fg('accent', label);
111
+ target.ui.setStatus('background-bash', `${icon} ${text}`);
84
112
  }
85
- const label = running.length === 1 ? '1 process' : `${running.length} processes`;
86
- const icon = target.ui.theme.fg('accent', '●');
87
- const text = target.ui.theme.fg('accent', label);
88
- target.ui.setStatus('background-bash', `${icon} ${text}`);
89
- }
90
- catch {
91
- if (target.generation !== statusGeneration)
113
+ catch {
114
+ if (target.generation !== statusGeneration)
115
+ return;
116
+ target.ui.setStatus('background-bash', target.ui.theme.fg('warning', 'bash ?'));
117
+ }
118
+ finally {
119
+ statusUpdateRunning = false;
120
+ }
121
+ };
122
+ const startStatusPolling = (ctx) => {
123
+ statusGeneration += 1;
124
+ if (statusPollTimer)
125
+ clearInterval(statusPollTimer);
126
+ const target = createStatusTarget(ctx);
127
+ statusTarget = target;
128
+ void updateStatus(target);
129
+ statusPollTimer = target ? setInterval(() => void updateStatus(target), 5_000) : undefined;
130
+ };
131
+ const stopStatusPolling = () => {
132
+ statusGeneration += 1;
133
+ if (statusPollTimer) {
134
+ clearInterval(statusPollTimer);
135
+ statusPollTimer = undefined;
136
+ }
137
+ statusTarget?.ui.setStatus('background-bash', undefined);
138
+ statusTarget = undefined;
139
+ };
140
+ const clearCompletionPollTimer = () => {
141
+ if (!completionPollTimer)
92
142
  return;
93
- target.ui.setStatus('background-bash', target.ui.theme.fg('warning', 'bash ?'));
94
- }
95
- finally {
96
- statusUpdateRunning = false;
97
- }
98
- };
99
- const startStatusPolling = (ctx) => {
100
- statusGeneration += 1;
101
- if (statusPollTimer)
102
- clearInterval(statusPollTimer);
103
- const target = createStatusTarget(ctx);
104
- statusTarget = target;
105
- void updateStatus(target);
106
- statusPollTimer = target ? setInterval(() => void updateStatus(target), 5_000) : undefined;
107
- };
108
- const stopStatusPolling = () => {
109
- statusGeneration += 1;
110
- if (statusPollTimer) {
111
- clearInterval(statusPollTimer);
112
- statusPollTimer = undefined;
113
- }
114
- statusTarget?.ui.setStatus('background-bash', undefined);
115
- statusTarget = undefined;
116
- };
117
- const clearCompletionPollTimer = () => {
118
- if (!completionPollTimer)
119
- return;
120
- clearInterval(completionPollTimer);
121
- completionPollTimer = undefined;
122
- };
123
- const deliverCompletion = (job) => {
124
- pi.sendMessage({
125
- customType: BACKGROUND_BASH_COMPLETION_MESSAGE_TYPE,
126
- content: formatCompletionNotice(job),
127
- display: true,
128
- details: { job: completionDetails(job) },
129
- }, {
130
- triggerTurn: true,
131
- deliverAs: 'steer',
132
- });
133
- };
134
- const pollCompletions = async (generation) => {
135
- if (generation !== completionPollGeneration
136
- || completionPollRunningGeneration === generation)
137
- return;
138
- completionPollRunningGeneration = generation;
139
- try {
140
- for (const id of [...watchedJobIds]) {
141
- let job;
142
- try {
143
- job = await manager.get(id);
144
- }
145
- catch {
146
- continue;
143
+ clearInterval(completionPollTimer);
144
+ completionPollTimer = undefined;
145
+ };
146
+ const deliverCompletion = (job) => {
147
+ pi.sendMessage({
148
+ customType: BACKGROUND_BASH_COMPLETION_MESSAGE_TYPE,
149
+ content: formatCompletionNotice(job),
150
+ display: true,
151
+ details: { job: completionDetails(job) },
152
+ }, {
153
+ triggerTurn: true,
154
+ deliverAs: 'steer',
155
+ });
156
+ };
157
+ const pollCompletions = async (generation) => {
158
+ if (generation !== completionPollGeneration
159
+ || completionPollRunningGeneration === generation)
160
+ return;
161
+ completionPollRunningGeneration = generation;
162
+ try {
163
+ for (const id of [...watchedJobIds]) {
164
+ let job;
165
+ try {
166
+ job = await manager.get(id);
167
+ }
168
+ catch {
169
+ continue;
170
+ }
171
+ if (generation !== completionPollGeneration
172
+ || !completionPollingEnabled
173
+ || !watchedJobIds.has(id))
174
+ return;
175
+ if (!isTerminalStatus(job.status.status))
176
+ continue;
177
+ watchedJobIds.delete(id);
178
+ deliverCompletion(job);
179
+ void updateStatus(statusTarget);
147
180
  }
148
- if (generation !== completionPollGeneration
149
- || !completionPollingEnabled
150
- || !watchedJobIds.has(id))
151
- return;
152
- if (!isTerminalStatus(job.status.status))
153
- continue;
154
- watchedJobIds.delete(id);
155
- deliverCompletion(job);
156
- void updateStatus(statusTarget);
157
181
  }
158
- }
159
- finally {
160
- if (completionPollRunningGeneration === generation) {
161
- completionPollRunningGeneration = undefined;
182
+ finally {
183
+ if (completionPollRunningGeneration === generation) {
184
+ completionPollRunningGeneration = undefined;
185
+ }
186
+ if (watchedJobIds.size === 0)
187
+ clearCompletionPollTimer();
162
188
  }
189
+ };
190
+ const ensureCompletionPolling = () => {
191
+ if (!completionPollingEnabled || watchedJobIds.size === 0 || completionPollTimer)
192
+ return;
193
+ const generation = completionPollGeneration;
194
+ completionPollTimer = setInterval(() => void pollCompletions(generation), COMPLETION_POLL_MS);
195
+ completionPollTimer.unref?.();
196
+ void pollCompletions(generation);
197
+ };
198
+ const watchCompletion = (id) => {
199
+ watchedJobIds.add(id);
200
+ ensureCompletionPolling();
201
+ };
202
+ const suppressCompletion = (id) => {
203
+ const removed = watchedJobIds.delete(id);
163
204
  if (watchedJobIds.size === 0)
164
205
  clearCompletionPollTimer();
165
- }
166
- };
167
- const ensureCompletionPolling = () => {
168
- if (!completionPollingEnabled || watchedJobIds.size === 0 || completionPollTimer)
169
- return;
170
- const generation = completionPollGeneration;
171
- completionPollTimer = setInterval(() => void pollCompletions(generation), COMPLETION_POLL_MS);
172
- completionPollTimer.unref?.();
173
- void pollCompletions(generation);
174
- };
175
- const watchCompletion = (id) => {
176
- watchedJobIds.add(id);
177
- ensureCompletionPolling();
178
- };
179
- const suppressCompletion = (id) => {
180
- const removed = watchedJobIds.delete(id);
181
- if (watchedJobIds.size === 0)
206
+ return removed;
207
+ };
208
+ const resumeCompletionPolling = () => {
209
+ completionPollingEnabled = true;
210
+ ensureCompletionPolling();
211
+ };
212
+ const pauseCompletionPolling = () => {
213
+ completionPollingEnabled = false;
214
+ completionPollGeneration += 1;
215
+ completionPollRunningGeneration = undefined;
182
216
  clearCompletionPollTimer();
183
- return removed;
184
- };
185
- const resumeCompletionPolling = () => {
186
- completionPollingEnabled = true;
187
- ensureCompletionPolling();
188
- };
189
- const pauseCompletionPolling = () => {
190
- completionPollingEnabled = false;
191
- completionPollGeneration += 1;
192
- completionPollRunningGeneration = undefined;
193
- clearCompletionPollTimer();
194
- };
195
- const clearCompletionWatches = () => {
196
- pauseCompletionPolling();
197
- watchedJobIds.clear();
198
- };
199
- const openProcessView = async (ctx) => {
200
- if (!supportsBackgroundBashModel(ctx.model)) {
201
- if (ctx.mode === 'tui')
202
- ctx.ui.notify('Background Bash is unavailable for this model.', 'info');
203
- return;
204
- }
205
- if (!backgroundBashActive) {
206
- if (ctx.mode === 'tui')
207
- ctx.ui.notify('Background Bash is unavailable in this runtime.', 'info');
208
- return;
209
- }
210
- const target = createStatusTarget(ctx);
211
- if (!target)
212
- return;
213
- await target.ui.custom((tui, theme, _keybindings, done) => new BackgroundBashView(manager, theme, () => done(undefined), () => tui.requestRender()));
214
- await updateStatus(target);
215
- };
216
- const assertSupportedModel = (ctx) => {
217
- if (!supportsBackgroundBashModel(ctx.model)) {
218
- throw new Error('Background Bash is unavailable for this model.');
219
- }
220
- };
221
- const registerBackgroundBash = () => {
222
- if (backgroundBashActive)
223
- return;
224
- pi.registerTool({
225
- name: 'bash',
226
- label: 'bash',
227
- description: 'Execute a Bash command in the current working directory. Set background: true for long-running commands and inspect output with read_background_bash.',
228
- promptSnippet: 'Execute Bash commands, optionally as detached processes with background: true',
229
- promptGuidelines: [
230
- 'Use bash with background: true for long-running commands such as dev servers, watchers, and scripts the agent should not block on.',
231
- 'Background Bash completion messages arrive automatically; continue useful work instead of polling when the current task does not need to block.',
232
- 'After starting Background Bash, inspect output with read_background_bash; do not use wait_background_bash just to read output.',
233
- 'Use wait_background_bash only when you need to wait for the process to finish or check whether it has finished.',
234
- 'Use list_background_bash to discover detached Bash processes started in this root session and workspace, including its subagents.',
235
- ],
236
- parameters: BashParams,
237
- async execute(toolCallId, params, signal, onUpdate, ctx) {
238
- if (!params.background) {
239
- return foregroundBash.execute(toolCallId, {
240
- command: params.command,
241
- ...(params.timeout === undefined ? {} : { timeout: params.timeout }),
242
- }, signal, onUpdate, ctx);
243
- }
244
- assertSupportedModel(ctx);
245
- const normalized = normalizeBackgroundCommand(params.command);
246
- const target = createStatusTarget(ctx);
247
- const job = await manager.start(normalized.command);
248
- watchCompletion(job.meta.id);
249
- await updateStatus(target);
250
- const notice = normalized.rtkRewriteRemoved
251
- ? '\n\nRTK rewrite was removed for this background process so output streams directly to the log file.'
252
- : '';
253
- const details = {
254
- background: true,
255
- id: job.meta.id,
256
- status: job.status.status,
257
- logPath: job.meta.logPath,
258
- infoPath: job.meta.infoPath,
259
- jobDir: job.meta.jobDir,
260
- command: job.meta.command,
261
- cwd: job.meta.cwd,
262
- startedAt: job.meta.startedAt,
263
- ...(job.status.pid ?? job.meta.pid) === undefined
264
- ? {}
265
- : { pid: job.status.pid ?? job.meta.pid },
266
- ...(normalized.originalCommand === undefined
267
- ? {}
268
- : { originalCommand: normalized.originalCommand }),
269
- ...(normalized.rtkRewriteRemoved ? { rtkRewriteRemoved: true } : {}),
270
- };
271
- return {
272
- content: [{ type: 'text', text: `${formatStarted(job)}${notice}` }],
273
- details,
274
- };
275
- },
276
- renderCall(args, theme) {
277
- const suffix = args.background ? theme.fg('muted', ' (background)') : '';
278
- return new Text(theme.fg('toolTitle', theme.bold(`$ ${args.command}`)) + suffix, 0, 0);
279
- },
280
- });
281
- backgroundBashActive = true;
282
- };
283
- const registerHelperTools = () => {
284
- if (helperToolsRegistered)
285
- return;
286
- helperToolsRegistered = true;
287
- pi.registerTool({
288
- name: 'list_background_bash',
289
- label: 'List Background Bash',
290
- description: 'List detached Bash processes started in this root session and workspace, including processes started by its subagents.',
291
- promptSnippet: 'List workspace Background Bash processes and their status',
292
- parameters: ListBackgroundBashParams,
293
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
294
- assertSupportedModel(ctx);
295
- const target = createStatusTarget(ctx);
296
- const jobs = await manager.list((params.status ?? 'all'));
297
- for (const job of jobs) {
217
+ };
218
+ const clearCompletionWatches = () => {
219
+ pauseCompletionPolling();
220
+ watchedJobIds.clear();
221
+ };
222
+ const openProcessView = async (ctx) => {
223
+ if (!backgroundBashActive) {
224
+ if (ctx.mode === 'tui')
225
+ ctx.ui.notify('Background processes are unavailable in this runtime.', 'info');
226
+ return;
227
+ }
228
+ const target = createStatusTarget(ctx);
229
+ if (!target)
230
+ return;
231
+ await target.ui.custom((tui, theme, _keybindings, done) => new BackgroundBashView(manager, theme, () => done(undefined), () => tui.requestRender()));
232
+ await updateStatus(target);
233
+ };
234
+ const registerBackgroundBash = () => {
235
+ if (backgroundBashActive)
236
+ return;
237
+ pi.registerTool({
238
+ name: 'bash',
239
+ label: 'bash',
240
+ description: 'Execute a Bash command in the current working directory. Set background: true for long-running commands and inspect output with read_background_bash.',
241
+ promptSnippet: 'Execute Bash commands, optionally as background processes with background: true',
242
+ promptGuidelines: [
243
+ 'Use bash with background: true for long-running commands such as dev servers, watchers, and scripts the agent should not block on.',
244
+ 'Use tty: true when a command needs interactive stdin or control bytes; write_background_bash sends exact input to it.',
245
+ 'Foreground commands are promoted to the background after their timeout without restarting; timeout: 0 promotes immediately.',
246
+ 'Background process completion messages arrive automatically; continue useful work instead of polling when the current task does not need to block.',
247
+ 'After starting a background process, inspect output with read_background_bash; do not use wait_background_bash just to read output.',
248
+ 'Use wait_background_bash only when you need to wait for the process to finish or check whether it has finished.',
249
+ 'Use list_background_bash to discover processes started in this root session and workspace, including its subagents.',
250
+ ],
251
+ parameters: BashParams,
252
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
253
+ if (signal?.aborted)
254
+ throw new Error('Command cancelled');
255
+ const timeoutSeconds = params.timeout ?? config.foregroundTimeoutSeconds;
256
+ const interactive = params.tty === true;
257
+ const normalized = interactive
258
+ ? { command: params.command, rtkRewriteRemoved: false }
259
+ : normalizeBackgroundCommand(params.command);
260
+ const target = createStatusTarget(ctx);
261
+ const started = interactive
262
+ ? await manager.startInteractive(normalized.command)
263
+ : { job: await manager.start(normalized.command) };
264
+ const job = started.job;
265
+ if (signal?.aborted) {
266
+ if (interactive)
267
+ await manager.stopInteractive(job.meta.id).catch(() => { });
268
+ else
269
+ await manager.stop(job.meta.id).catch(() => { });
270
+ throw new Error('Command cancelled');
271
+ }
272
+ if (!params.background && timeoutSeconds > 0) {
273
+ try {
274
+ if (interactive) {
275
+ const deadline = Date.now() + timeoutSeconds * 1_000;
276
+ let result = await manager.readInteractive(job.meta.id, Math.max(0, deadline - Date.now()), signal);
277
+ if (signal?.aborted)
278
+ throw new Error('Command cancelled');
279
+ while (result.running && Date.now() < deadline) {
280
+ result = await manager.readInteractive(job.meta.id, Math.max(0, deadline - Date.now()), signal);
281
+ if (signal?.aborted)
282
+ throw new Error('Command cancelled');
283
+ }
284
+ if (!result.running) {
285
+ const completed = await manager.get(job.meta.id);
286
+ return {
287
+ content: [{ type: 'text', text: formatForegroundOutput(completed, result.output) }],
288
+ details: { background: false, id: job.meta.id, status: completed.status.status, output: result.output },
289
+ };
290
+ }
291
+ }
292
+ else {
293
+ const result = await manager.wait(job.meta.id, timeoutSeconds, signal);
294
+ if (!result.timedOut) {
295
+ const output = await manager.tail(job.meta.id);
296
+ return {
297
+ content: [{ type: 'text', text: formatForegroundOutput(result.job, output) }],
298
+ details: { background: false, id: job.meta.id, status: result.job.status.status, output },
299
+ };
300
+ }
301
+ }
302
+ }
303
+ catch (error) {
304
+ if (signal?.aborted) {
305
+ if (interactive)
306
+ await manager.stopInteractive(job.meta.id, 'SIGTERM').catch(() => { });
307
+ else
308
+ await manager.stop(job.meta.id, 'SIGTERM').catch(() => { });
309
+ throw new Error('Command cancelled', { cause: error });
310
+ }
311
+ throw error;
312
+ }
313
+ }
314
+ if (!params.background)
315
+ await manager.markPromoted(job.meta.id, interactive ? 'pty' : 'detached');
316
+ watchCompletion(job.meta.id);
317
+ await updateStatus(target);
318
+ const notice = normalized.rtkRewriteRemoved
319
+ ? '\n\nRTK rewrite was removed for this background process so output streams directly to the log file.'
320
+ : '';
321
+ const details = {
322
+ background: true,
323
+ id: job.meta.id,
324
+ status: job.status.status,
325
+ logPath: job.meta.logPath,
326
+ infoPath: job.meta.infoPath,
327
+ jobDir: job.meta.jobDir,
328
+ command: job.meta.command,
329
+ cwd: job.meta.cwd,
330
+ startedAt: job.meta.startedAt,
331
+ ...(job.status.pid ?? job.meta.pid) === undefined
332
+ ? {}
333
+ : { pid: job.status.pid ?? job.meta.pid },
334
+ ...(normalized.originalCommand === undefined
335
+ ? {}
336
+ : { originalCommand: normalized.originalCommand }),
337
+ ...(normalized.rtkRewriteRemoved ? { rtkRewriteRemoved: true } : {}),
338
+ ...(interactive ? { tty: true } : {}),
339
+ };
340
+ return {
341
+ content: [{ type: 'text', text: `${formatStarted(job)}${notice}` }],
342
+ details,
343
+ };
344
+ },
345
+ renderCall(args, theme) {
346
+ const suffix = args.background || args.tty ? theme.fg('muted', args.tty ? ' (pty)' : ' (background)') : '';
347
+ return new Text(theme.fg('toolTitle', theme.bold(`$ ${args.command}`)) + suffix, 0, 0);
348
+ },
349
+ });
350
+ backgroundBashActive = true;
351
+ };
352
+ const registerHelperTools = () => {
353
+ if (helperToolsRegistered)
354
+ return;
355
+ helperToolsRegistered = true;
356
+ pi.registerTool({
357
+ name: 'list_background_bash',
358
+ label: 'List background processes',
359
+ description: 'List processes started in this root session and workspace, including processes started by its subagents.',
360
+ promptSnippet: 'List workspace background processes and their status',
361
+ parameters: ListBackgroundBashParams,
362
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
363
+ const target = createStatusTarget(ctx);
364
+ const jobs = await manager.list((params.status ?? 'all'));
365
+ for (const job of jobs) {
366
+ if (isTerminalStatus(job.status.status))
367
+ suppressCompletion(job.meta.id);
368
+ }
369
+ await updateStatus(target);
370
+ return {
371
+ content: [{ type: 'text', text: formatJobList(jobs) }],
372
+ details: { jobs: jobs.map((job) => ({ meta: job.meta, status: job.status })) },
373
+ };
374
+ },
375
+ });
376
+ pi.registerTool({
377
+ name: 'read_background_bash',
378
+ label: 'Read background process',
379
+ description: 'Read the trailing output of a background process by id.',
380
+ promptSnippet: 'Read output from a background process by id',
381
+ parameters: ReadBackgroundBashParams,
382
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
383
+ const output = await manager.tail(params.id, params.lines ?? 80);
384
+ const job = await manager.get(params.id);
298
385
  if (isTerminalStatus(job.status.status))
299
386
  suppressCompletion(job.meta.id);
300
- }
301
- await updateStatus(target);
302
- return {
303
- content: [{ type: 'text', text: formatJobList(jobs) }],
304
- details: { jobs: jobs.map((job) => ({ meta: job.meta, status: job.status })) },
305
- };
306
- },
307
- });
308
- pi.registerTool({
309
- name: 'read_background_bash',
310
- label: 'Read Background Bash',
311
- description: 'Read the trailing output of a detached Bash process by id.',
312
- promptSnippet: 'Read output from a Background Bash process by id',
313
- parameters: ReadBackgroundBashParams,
314
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
315
- assertSupportedModel(ctx);
316
- const output = await manager.tail(params.id, params.lines ?? 80);
317
- const job = await manager.get(params.id);
318
- if (isTerminalStatus(job.status.status))
319
- suppressCompletion(job.meta.id);
320
- return {
321
- content: [{ type: 'text', text: output }],
322
- details: { id: params.id, status: job.status.status, lines: params.lines ?? 80 },
323
- };
324
- },
325
- });
326
- pi.registerTool({
327
- name: 'wait_background_bash',
328
- label: 'Wait Background Bash',
329
- description: 'Wait for a detached Bash process to finish, or return current status after a timeout. Use read_background_bash for output.',
330
- promptSnippet: 'Wait for a Background Bash process and return its status',
331
- parameters: WaitBackgroundBashParams,
332
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
333
- assertSupportedModel(ctx);
334
- const target = createStatusTarget(ctx);
335
- const wasWatched = suppressCompletion(params.id);
336
- let result;
337
- try {
338
- result = await manager.wait(params.id, params.timeout, signal);
339
- }
340
- catch (error) {
341
- if (wasWatched)
387
+ return {
388
+ content: [{ type: 'text', text: output }],
389
+ details: { id: params.id, status: job.status.status, lines: params.lines ?? 80 },
390
+ };
391
+ },
392
+ });
393
+ pi.registerTool({
394
+ name: 'wait_background_bash',
395
+ label: 'Wait for background process',
396
+ description: 'Wait for a background process to finish, or return current status after a timeout. Use read_background_bash for output.',
397
+ promptSnippet: 'Wait for a background process and return its status',
398
+ parameters: WaitBackgroundBashParams,
399
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
400
+ const target = createStatusTarget(ctx);
401
+ const wasWatched = suppressCompletion(params.id);
402
+ let result;
403
+ try {
404
+ result = await manager.wait(params.id, params.timeout, signal);
405
+ }
406
+ catch (error) {
407
+ if (wasWatched)
408
+ watchCompletion(params.id);
409
+ throw error;
410
+ }
411
+ if (wasWatched && result.timedOut)
342
412
  watchCompletion(params.id);
343
- throw error;
344
- }
345
- if (wasWatched && result.timedOut)
346
- watchCompletion(params.id);
347
- await updateStatus(target);
348
- return {
349
- content: [{ type: 'text', text: formatWaitResult(result.job, result.timedOut) }],
350
- details: { job: result.job, timedOut: result.timedOut },
351
- };
352
- },
353
- });
354
- pi.registerTool({
355
- name: 'stop_background_bash',
356
- label: 'Stop Background Bash',
357
- description: 'Stop a running Background Bash process by id and mark it as killed.',
358
- promptSnippet: 'Stop a running Background Bash process by id',
359
- parameters: StopBackgroundBashParams,
360
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
361
- assertSupportedModel(ctx);
362
- const target = createStatusTarget(ctx);
363
- const wasWatched = suppressCompletion(params.id);
364
- let job;
365
- try {
366
- job = await manager.stop(params.id, params.signal ?? 'SIGTERM');
367
- }
368
- catch (error) {
369
- if (wasWatched)
370
- watchCompletion(params.id);
371
- throw error;
372
- }
373
- await updateStatus(target);
374
- return {
375
- content: [{ type: 'text', text: `Background Bash stop result.\n\n${formatJobDetails(job)}` }],
376
- details: { job },
377
- };
378
- },
379
- });
380
- };
381
- const registerControls = () => {
382
- if (controlsRegistered)
383
- return;
384
- controlsRegistered = true;
385
- pi.registerCommand('background-bash', {
386
- description: 'View Background Bash processes and logs',
387
- handler: async (_args, ctx) => openProcessView(ctx),
388
- });
389
- pi.registerShortcut(Key.ctrlShift('j'), {
390
- description: 'View Background Bash processes and logs',
391
- handler: openProcessView,
413
+ await updateStatus(target);
414
+ return {
415
+ content: [{ type: 'text', text: formatWaitResult(result.job, result.timedOut) }],
416
+ details: { job: result.job, timedOut: result.timedOut },
417
+ };
418
+ },
419
+ });
420
+ pi.registerTool({
421
+ name: 'stop_background_bash',
422
+ label: 'Stop background process',
423
+ description: 'Stop a running background process by id and mark it as killed.',
424
+ promptSnippet: 'Stop a running background process by id',
425
+ parameters: StopBackgroundBashParams,
426
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
427
+ const target = createStatusTarget(ctx);
428
+ const wasWatched = suppressCompletion(params.id);
429
+ let job;
430
+ try {
431
+ job = await manager.stop(params.id, params.signal ?? 'SIGTERM');
432
+ }
433
+ catch (error) {
434
+ if (wasWatched)
435
+ watchCompletion(params.id);
436
+ throw error;
437
+ }
438
+ await updateStatus(target);
439
+ return {
440
+ content: [{ type: 'text', text: `Background process stop result.\n\n${formatJobDetails(job)}` }],
441
+ details: { job },
442
+ };
443
+ },
444
+ });
445
+ pi.registerTool({
446
+ name: 'write_background_bash',
447
+ label: 'Write to background process',
448
+ description: 'Write exact text or control bytes to a running background process with a PTY.',
449
+ promptSnippet: 'Send stdin or control bytes to a background process with a PTY',
450
+ parameters: WriteBackgroundBashParams,
451
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
452
+ const result = await manager.writeInteractive(params.id, params.chars);
453
+ const job = await manager.get(params.id);
454
+ if (!result.running)
455
+ suppressCompletion(job.meta.id);
456
+ await updateStatus(createStatusTarget(ctx));
457
+ return {
458
+ content: [{ type: 'text', text: result.output || '(no output)' }],
459
+ details: { id: params.id, status: job.status.status, tty: true },
460
+ };
461
+ },
462
+ });
463
+ };
464
+ const registerControls = () => {
465
+ if (controlsRegistered)
466
+ return;
467
+ controlsRegistered = true;
468
+ pi.registerCommand('processes', {
469
+ description: 'View processes and logs',
470
+ handler: async (_args, ctx) => openProcessView(ctx),
471
+ });
472
+ pi.registerShortcut(Key.ctrlShift('j'), {
473
+ description: 'View processes and logs',
474
+ handler: openProcessView,
475
+ });
476
+ };
477
+ const activateRegisteredTools = () => {
478
+ if (!helperToolsRegistered || !backgroundBashActive)
479
+ return;
480
+ pi.setActiveTools([...new Set([...pi.getActiveTools(), 'bash', ...BACKGROUND_TOOL_NAMES])]);
481
+ };
482
+ const ensureRuntimeAvailable = async () => {
483
+ if (runtimeAvailable !== undefined)
484
+ return runtimeAvailable;
485
+ runtimeCheck ??= inspectBackgroundBashRuntime(pi.runtime)
486
+ .then((status) => {
487
+ runtimeAvailable = status.available;
488
+ return status.available;
489
+ })
490
+ .finally(() => {
491
+ runtimeCheck = undefined;
492
+ });
493
+ return runtimeCheck;
494
+ };
495
+ const enableExtension = async (ctx) => {
496
+ if (!await ensureRuntimeAvailable()) {
497
+ disableExtension();
498
+ return;
499
+ }
500
+ registerBackgroundBash();
501
+ registerHelperTools();
502
+ activateRegisteredTools();
503
+ resumeCompletionPolling();
504
+ if (ctx.mode === 'tui')
505
+ startStatusPolling(ctx);
506
+ };
507
+ const disableExtension = () => {
508
+ stopStatusPolling();
509
+ pauseCompletionPolling();
510
+ if (!helperToolsRegistered || !backgroundBashActive)
511
+ return;
512
+ pi.registerTool({ ...foregroundBash });
513
+ backgroundBashActive = false;
514
+ const backgroundNames = new Set(BACKGROUND_TOOL_NAMES);
515
+ pi.setActiveTools([
516
+ ...new Set([
517
+ ...pi.getActiveTools().filter((name) => !backgroundNames.has(name)),
518
+ 'bash',
519
+ ]),
520
+ ]);
521
+ };
522
+ pi.on('session_start', async (_event, ctx) => {
523
+ if (ctx.mode === 'tui')
524
+ registerControls();
525
+ await enableExtension(ctx);
392
526
  });
393
- };
394
- const activateRegisteredTools = () => {
395
- if (!helperToolsRegistered || !backgroundBashActive)
396
- return;
397
- pi.setActiveTools([...new Set([...pi.getActiveTools(), 'bash', ...BACKGROUND_TOOL_NAMES])]);
398
- };
399
- const ensureRuntimeAvailable = async () => {
400
- if (runtimeAvailable !== undefined)
401
- return runtimeAvailable;
402
- runtimeCheck ??= inspectBackgroundBashRuntime(pi.runtime)
403
- .then((status) => {
404
- runtimeAvailable = status.available;
405
- return status.available;
406
- })
407
- .finally(() => {
408
- runtimeCheck = undefined;
527
+ pi.on('model_select', async (_event, ctx) => {
528
+ await enableExtension(ctx);
409
529
  });
410
- return runtimeCheck;
411
- };
412
- const enableExtension = async (ctx) => {
413
- if (!supportsBackgroundBashModel(ctx.model)) {
530
+ pi.on('session_shutdown', (event) => {
414
531
  stopStatusPolling();
415
- pauseCompletionPolling();
416
- return;
417
- }
418
- if (!await ensureRuntimeAvailable()) {
419
- disableExtension();
420
- return;
421
- }
422
- registerBackgroundBash();
423
- registerHelperTools();
424
- activateRegisteredTools();
425
- resumeCompletionPolling();
426
- if (ctx.mode === 'tui')
427
- startStatusPolling(ctx);
428
- };
429
- const disableExtension = () => {
430
- stopStatusPolling();
431
- pauseCompletionPolling();
432
- if (!helperToolsRegistered || !backgroundBashActive)
433
- return;
434
- pi.registerTool({ ...foregroundBash });
435
- backgroundBashActive = false;
436
- const backgroundNames = new Set(BACKGROUND_TOOL_NAMES);
437
- pi.setActiveTools([
438
- ...new Set([
439
- ...pi.getActiveTools().filter((name) => !backgroundNames.has(name)),
440
- 'bash',
441
- ]),
442
- ]);
532
+ clearCompletionWatches();
533
+ return ownsCoordinator && event.reason !== 'reload' ? manager.shutdownInteractive() : undefined;
534
+ });
443
535
  };
444
- pi.on('session_start', async (_event, ctx) => {
445
- if (ctx.mode === 'tui')
446
- registerControls();
447
- await enableExtension(ctx);
448
- });
449
- pi.on('model_select', async (event, ctx) => {
450
- if (supportsBackgroundBashModel(event.model))
451
- await enableExtension(ctx);
452
- else
453
- disableExtension();
454
- });
455
- pi.on('session_shutdown', () => {
456
- stopStatusPolling();
457
- clearCompletionWatches();
458
- });
459
- };
536
+ associateExtensionConfig(extension, BACKGROUND_BASH_CONFIG);
537
+ return extension;
538
+ }
539
+ const backgroundBashExtension = createBackgroundBashExtension();
460
540
  export { inspectBackgroundBashRuntime } from './runtime-support.js';
461
541
  function formatDate(ms) {
462
542
  return ms ? new Date(ms).toISOString() : '-';
@@ -491,10 +571,10 @@ function formatJobDetails(job) {
491
571
  ].join('\n');
492
572
  }
493
573
  function formatStarted(job) {
494
- return `Started Background Bash process.\n\n${formatJobDetails(job)}\n\nCompletion will be delivered automatically. Use list_background_bash to see processes, read_background_bash with id "${job.meta.id}" to inspect output, wait_background_bash when the current task must block, or stop_background_bash to stop it.`;
574
+ return `Started background process.\n\n${formatJobDetails(job)}\n\nCompletion will be delivered automatically. Use list_background_bash to see processes, read_background_bash with id "${job.meta.id}" to inspect output, wait_background_bash when the current task must block, or stop_background_bash to stop it.`;
495
575
  }
496
576
  function formatCompletionNotice(job) {
497
- return `Background Bash process reached terminal status: ${job.status.status}.\n\n${formatJobDetails(job)}\n\nUse read_background_bash with id "${job.meta.id}" if its output is needed.`;
577
+ return `Background process reached terminal status: ${job.status.status}.\n\n${formatJobDetails(job)}\n\nUse read_background_bash with id "${job.meta.id}" if its output is needed.`;
498
578
  }
499
579
  function completionDetails(job) {
500
580
  return {
@@ -513,7 +593,7 @@ function completionDetails(job) {
513
593
  }
514
594
  function formatJobList(jobs) {
515
595
  if (jobs.length === 0)
516
- return 'No Background Bash processes found for this workspace.';
596
+ return 'No background processes found for this workspace.';
517
597
  return jobs.map((job) => {
518
598
  const pid = String(job.status.pid ?? job.meta.pid ?? '-');
519
599
  const exit = job.status.exitCode ?? job.status.signal ?? '-';
@@ -526,10 +606,16 @@ function formatJobList(jobs) {
526
606
  }
527
607
  function formatWaitResult(job, timedOut) {
528
608
  const heading = timedOut
529
- ? 'Background Bash process is still running.'
530
- : 'Background Bash process finished.';
609
+ ? 'Background process is still running.'
610
+ : 'Background process finished.';
531
611
  return `${heading}\n\n${formatJobDetails(job)}\n\nUse read_background_bash with id "${job.meta.id}" to inspect output.`;
532
612
  }
613
+ function formatForegroundOutput(job, output) {
614
+ const exit = job.status.exitCode ?? job.status.signal ?? '-';
615
+ return `Exit: ${exit}\n\n${output || '(no output)'}`;
616
+ }
533
617
  export { BackgroundBashManager } from './process-manager.js';
618
+ export { BackgroundBashCoordinator } from './coordinator.js';
534
619
  export default backgroundBashExtension;
620
+ associateExtensionConfig(backgroundBashExtension, BACKGROUND_BASH_CONFIG);
535
621
  //# sourceMappingURL=index.js.map