@felan-ai/ext-background-bash 0.3.0

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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Felan contributors
4
+ Copyright (c) 2026 Milko Slavov
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,12 @@
1
+ @felan-ai/ext-background-bash
2
+
3
+ Includes code adapted from packages/pi-background-bash in
4
+ https://github.com/mslavov/pi-extensions at source commit
5
+ 7e72e509fe45a5a87c4c2e176cb711de994a8c1d.
6
+
7
+ Copyright (c) 2026 Milko Slavov
8
+ Licensed under the MIT License included in this package.
9
+
10
+ This package uses TypeBox 1.1.38 from
11
+ https://github.com/sinclairzx81/typebox, licensed under the MIT License.
12
+ Copyright (c) 2017-2026 Haydn Paterson.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # @felan-ai/ext-background-bash
2
+
3
+ Detached project-local Bash execution for Felan sessions. The extension augments
4
+ `bash` with `background: true` and adds tools to list, wait for, and stop processes.
5
+ Output and process metadata live under
6
+ `<runtime-storage>/background-bash/<workspace-key>/jobs`. Local Felan maps
7
+ `<runtime-storage>` to `$FELAN_AGENT_DIR` (`~/.felan` by default), while cloud
8
+ adapters map it to their durable state root.
9
+
10
+ The implementation routes filesystem and process operations through the active
11
+ `AgentRuntime`. Background launch uses a detached POSIX shell runner. Host,
12
+ Docker, and Daytona runtimes need `nohup`, `ps`, and either `setsid` or shell job
13
+ control so each process receives an isolated process group. `runtime.storage.root`
14
+ must be durable and visible in the same filesystem namespace as `runtime.shell`.
15
+
16
+ The extension activates for selected models outside the OpenAI provider family.
17
+ Providers `openai` and `openai-codex` are reserved for a separate background Bash
18
+ extension. Keeping this policy here gives cloud and local consumers identical
19
+ behavior without duplicating model filters in their composition roots.
20
+
21
+ ## Tools and controls
22
+
23
+ - `bash({ command, background: true })` starts a detached process.
24
+ - `list_background_bash` lists workspace processes and statuses.
25
+ - `read_background_bash` reads trailing process output.
26
+ - `wait_background_bash` waits for terminal status without returning log output.
27
+ - `stop_background_bash` sends `SIGTERM` or `SIGKILL`.
28
+ - `/background-bash` and `Ctrl+Shift+J` open the interactive process/log overlay in the TUI.
29
+
30
+ The footer status shows the number of running processes. Foreground `bash` continues
31
+ to use the active `AgentRuntime`. Processes started by the active session deliver a
32
+ completion message automatically. The message steers an active run at its next
33
+ model-call boundary or triggers a turn when the agent is idle. A terminal result
34
+ returned directly by `list_background_bash`, `read_background_bash`,
35
+ `wait_background_bash`, or `stop_background_bash` suppresses the duplicate
36
+ completion message.
37
+
38
+ ## Development
39
+
40
+ Source: `packages/ext-background-bash` in <https://github.com/felan-ai/felan>.
41
+
42
+ ```sh
43
+ corepack enable
44
+ pnpm install --frozen-lockfile
45
+ pnpm --filter @felan-ai/ext-background-bash build
46
+ pnpm --filter @felan-ai/ext-background-bash test
47
+ ```
48
+
49
+ ## Attribution
50
+
51
+ This package adapts the MIT-licensed `pi-background-bash` implementation. See
52
+ [NOTICE](NOTICE) for source details.
@@ -0,0 +1,7 @@
1
+ export interface NormalizedBackgroundCommand {
2
+ command: string;
3
+ originalCommand?: string;
4
+ rtkRewriteRemoved: boolean;
5
+ }
6
+ export declare function normalizeBackgroundCommand(command: string): NormalizedBackgroundCommand;
7
+ //# sourceMappingURL=command-normalizer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"command-normalizer.d.ts","sourceRoot":"","sources":["../src/command-normalizer.ts"],"names":[],"mappings":"AAUA,MAAM,WAAW,2BAA2B;IAC1C,OAAO,EAAE,MAAM,CAAC;IAChB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iBAAiB,EAAE,OAAO,CAAC;CAC5B;AAED,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,GAAG,2BAA2B,CAkBvF"}
@@ -0,0 +1,23 @@
1
+ const SHELL_VALUE_PATTERN = String.raw `(?:"(?:\\.|[^"])*"|'(?:'\\''|[^'])*'|[^\s;]+)`;
2
+ const RTK_DB_EXPORT_PATTERN = new RegExp(String.raw `^\s*export\s+RTK_DB_PATH=${SHELL_VALUE_PATTERN}\s*;\s*([\s\S]+)$`, 'u');
3
+ const LEADING_ENV_ASSIGNMENTS_BEFORE_RTK_PATTERN = new RegExp(String.raw `^((?:[A-Za-z_][A-Za-z0-9_]*=${SHELL_VALUE_PATTERN}\s+)*)rtk\s+([\s\S]+)$`, 'u');
4
+ export function normalizeBackgroundCommand(command) {
5
+ const exportMatch = command.match(RTK_DB_EXPORT_PATTERN);
6
+ if (!exportMatch)
7
+ return { command, rtkRewriteRemoved: false };
8
+ const afterExport = exportMatch[1] ?? '';
9
+ const rtkMatch = afterExport.match(LEADING_ENV_ASSIGNMENTS_BEFORE_RTK_PATTERN);
10
+ if (!rtkMatch)
11
+ return { command, rtkRewriteRemoved: false };
12
+ const envPrefix = rtkMatch[1] ?? '';
13
+ const rawCommand = rtkMatch[2] ?? '';
14
+ const normalized = `${envPrefix}${rawCommand}`.trim();
15
+ if (!normalized)
16
+ return { command, rtkRewriteRemoved: false };
17
+ return {
18
+ command: normalized,
19
+ originalCommand: command,
20
+ rtkRewriteRemoved: true,
21
+ };
22
+ }
23
+ //# sourceMappingURL=command-normalizer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"command-normalizer.js","sourceRoot":"","sources":["../src/command-normalizer.ts"],"names":[],"mappings":"AAAA,MAAM,mBAAmB,GAAG,MAAM,CAAC,GAAG,CAAA,+CAA+C,CAAC;AACtF,MAAM,qBAAqB,GAAG,IAAI,MAAM,CACtC,MAAM,CAAC,GAAG,CAAA,4BAA4B,mBAAmB,mBAAmB,EAC5E,GAAG,CACJ,CAAC;AACF,MAAM,0CAA0C,GAAG,IAAI,MAAM,CAC3D,MAAM,CAAC,GAAG,CAAA,+BAA+B,mBAAmB,wBAAwB,EACpF,GAAG,CACJ,CAAC;AAQF,MAAM,UAAU,0BAA0B,CAAC,OAAe;IACxD,MAAM,WAAW,GAAG,OAAO,CAAC,KAAK,CAAC,qBAAqB,CAAC,CAAC;IACzD,IAAI,CAAC,WAAW;QAAE,OAAO,EAAE,OAAO,EAAE,iBAAiB,EAAE,KAAK,EAAE,CAAC;IAE/D,MAAM,WAAW,GAAG,WAAW,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IACzC,MAAM,QAAQ,GAAG,WAAW,CAAC,KAAK,CAAC,0CAA0C,CAAC,CAAC;IAC/E,IAAI,CAAC,QAAQ;QAAE,OAAO,EAAE,OAAO,EAAE,iBAAiB,EAAE,KAAK,EAAE,CAAC;IAE5D,MAAM,SAAS,GAAG,QAAQ,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IACpC,MAAM,UAAU,GAAG,QAAQ,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IACrC,MAAM,UAAU,GAAG,GAAG,SAAS,GAAG,UAAU,EAAE,CAAC,IAAI,EAAE,CAAC;IACtD,IAAI,CAAC,UAAU;QAAE,OAAO,EAAE,OAAO,EAAE,iBAAiB,EAAE,KAAK,EAAE,CAAC;IAE9D,OAAO;QACL,OAAO,EAAE,UAAU;QACnB,eAAe,EAAE,OAAO;QACxB,iBAAiB,EAAE,IAAI;KACxB,CAAC;AACJ,CAAC"}
@@ -0,0 +1,22 @@
1
+ import { type Api, type FelanExtension, type Model } from '@felan-ai/agent-core';
2
+ interface BackgroundBashDetails {
3
+ background: true;
4
+ id: string;
5
+ pid?: number;
6
+ status: string;
7
+ logPath: string;
8
+ infoPath: string;
9
+ jobDir: string;
10
+ command: string;
11
+ originalCommand?: string;
12
+ rtkRewriteRemoved?: boolean;
13
+ cwd: string;
14
+ startedAt: number;
15
+ }
16
+ export declare function supportsBackgroundBashModel(model: Model<Api> | undefined): boolean;
17
+ declare const backgroundBashExtension: FelanExtension;
18
+ export type { BackgroundBashDetails };
19
+ export { BackgroundBashManager } from './process-manager.js';
20
+ export type { BackgroundBashInfo, BackgroundBashJob, BackgroundBashMeta, BackgroundBashStatus, BackgroundBashStatusFile, BackgroundBashStatusFilter, } from './job-store.js';
21
+ export default backgroundBashExtension;
22
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,GAAG,EAER,KAAK,cAAc,EACnB,KAAK,KAAK,EACX,MAAM,sBAAsB,CAAC;AA8D9B,UAAU,qBAAqB;IAC7B,UAAU,EAAE,IAAI,CAAC;IACjB,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;CACnB;AAOD,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,GAAG,SAAS,GAAG,OAAO,CAElF;AAED,QAAA,MAAM,uBAAuB,EAAE,cAqZ9B,CAAC;AAiFF,YAAY,EAAE,qBAAqB,EAAE,CAAC;AACtC,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAC7D,YAAY,EACV,kBAAkB,EAClB,iBAAiB,EACjB,kBAAkB,EAClB,oBAAoB,EACpB,wBAAwB,EACxB,0BAA0B,GAC3B,MAAM,gBAAgB,CAAC;AACxB,eAAe,uBAAuB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,515 @@
1
+ import { StringEnum, createRuntimeCodingTools, } from '@felan-ai/agent-core';
2
+ import { Key, Text } from '@earendil-works/pi-tui';
3
+ import { Type } from 'typebox';
4
+ import { normalizeBackgroundCommand } from './command-normalizer.js';
5
+ import { isTerminalStatus, } from './job-store.js';
6
+ import { BackgroundBashManager } from './process-manager.js';
7
+ import { BackgroundBashOverlay } from './ui/background-bash-overlay.js';
8
+ const STATUS_VALUES = ['running', 'completed', 'failed', 'killed', 'unknown', 'all'];
9
+ const SIGNAL_VALUES = ['SIGTERM', 'SIGKILL'];
10
+ const OPENAI_PROVIDER_IDS = new Set(['openai', 'openai-codex']);
11
+ const COMPLETION_MESSAGE_TYPE = 'felan-background-bash-completion';
12
+ const COMPLETION_POLL_MS = 500;
13
+ const BACKGROUND_TOOL_NAMES = [
14
+ 'list_background_bash',
15
+ 'read_background_bash',
16
+ 'wait_background_bash',
17
+ 'stop_background_bash',
18
+ ];
19
+ const BashParams = Type.Object({
20
+ command: Type.String({ description: 'Bash command to execute' }),
21
+ timeout: Type.Optional(Type.Number({ description: 'Timeout in seconds for foreground commands' })),
22
+ background: Type.Optional(Type.Boolean({
23
+ description: 'Start a detached background Bash process and return immediately',
24
+ })),
25
+ }, { additionalProperties: false });
26
+ const ListBackgroundBashParams = Type.Object({
27
+ status: Type.Optional(StringEnum(STATUS_VALUES, { description: 'Filter processes by status' })),
28
+ }, { additionalProperties: false });
29
+ const ReadBackgroundBashParams = Type.Object({
30
+ id: Type.String({ description: 'Background Bash process id returned by bash(background: true)' }),
31
+ lines: Type.Optional(Type.Integer({
32
+ minimum: 1,
33
+ maximum: 1_000,
34
+ description: 'Number of trailing log lines to return. Default: 80.',
35
+ })),
36
+ }, { additionalProperties: false });
37
+ const WaitBackgroundBashParams = Type.Object({
38
+ id: Type.String({ description: 'Background Bash process id returned by bash(background: true)' }),
39
+ timeout: Type.Optional(Type.Number({ description: 'Maximum seconds to wait before returning current status' })),
40
+ }, { additionalProperties: false });
41
+ const StopBackgroundBashParams = Type.Object({
42
+ id: Type.String({ description: 'Background Bash process id returned by bash(background: true)' }),
43
+ signal: Type.Optional(StringEnum(SIGNAL_VALUES, { description: 'Signal to send. Default: SIGTERM.' })),
44
+ }, { additionalProperties: false });
45
+ export function supportsBackgroundBashModel(model) {
46
+ return model !== undefined && !OPENAI_PROVIDER_IDS.has(model.provider);
47
+ }
48
+ const backgroundBashExtension = (pi) => {
49
+ const manager = new BackgroundBashManager(pi.runtime);
50
+ const foregroundBash = createRuntimeCodingTools(pi.runtime)
51
+ .find((tool) => tool.name === 'bash');
52
+ let helperToolsRegistered = false;
53
+ let backgroundBashActive = false;
54
+ let controlsRegistered = false;
55
+ let statusTarget;
56
+ let statusPollTimer;
57
+ let statusUpdateRunning = false;
58
+ let statusGeneration = 0;
59
+ let completionPollingEnabled = false;
60
+ let completionPollTimer;
61
+ let completionPollGeneration = 0;
62
+ let completionPollRunningGeneration;
63
+ const watchedJobIds = new Set();
64
+ const createStatusTarget = (ctx) => {
65
+ if (ctx.mode !== 'tui')
66
+ return undefined;
67
+ return { ui: ctx.ui, generation: statusGeneration };
68
+ };
69
+ const updateStatus = async (target) => {
70
+ if (!target || target.generation !== statusGeneration || statusUpdateRunning)
71
+ return;
72
+ statusUpdateRunning = true;
73
+ try {
74
+ const running = await manager.list('running');
75
+ if (target.generation !== statusGeneration)
76
+ return;
77
+ const count = running.length;
78
+ const label = count === 1 ? '1 process' : `${count} processes`;
79
+ const icon = count > 0
80
+ ? target.ui.theme.fg('accent', '●')
81
+ : target.ui.theme.fg('dim', '○');
82
+ const text = count > 0
83
+ ? target.ui.theme.fg('accent', label)
84
+ : target.ui.theme.fg('dim', label);
85
+ target.ui.setStatus('background-bash', `${icon} ${text}`);
86
+ }
87
+ catch {
88
+ if (target.generation !== statusGeneration)
89
+ return;
90
+ target.ui.setStatus('background-bash', target.ui.theme.fg('warning', 'bash ?'));
91
+ }
92
+ finally {
93
+ statusUpdateRunning = false;
94
+ }
95
+ };
96
+ const startStatusPolling = (ctx) => {
97
+ statusGeneration += 1;
98
+ if (statusPollTimer)
99
+ clearInterval(statusPollTimer);
100
+ const target = createStatusTarget(ctx);
101
+ statusTarget = target;
102
+ void updateStatus(target);
103
+ statusPollTimer = target ? setInterval(() => void updateStatus(target), 5_000) : undefined;
104
+ };
105
+ const stopStatusPolling = () => {
106
+ statusGeneration += 1;
107
+ if (statusPollTimer) {
108
+ clearInterval(statusPollTimer);
109
+ statusPollTimer = undefined;
110
+ }
111
+ statusTarget?.ui.setStatus('background-bash', undefined);
112
+ statusTarget = undefined;
113
+ };
114
+ const clearCompletionPollTimer = () => {
115
+ if (!completionPollTimer)
116
+ return;
117
+ clearInterval(completionPollTimer);
118
+ completionPollTimer = undefined;
119
+ };
120
+ const deliverCompletion = (job) => {
121
+ pi.sendMessage({
122
+ customType: COMPLETION_MESSAGE_TYPE,
123
+ content: formatCompletionNotice(job),
124
+ display: true,
125
+ details: { job: completionDetails(job) },
126
+ }, {
127
+ triggerTurn: true,
128
+ deliverAs: 'steer',
129
+ });
130
+ };
131
+ const pollCompletions = async (generation) => {
132
+ if (generation !== completionPollGeneration
133
+ || completionPollRunningGeneration === generation)
134
+ return;
135
+ completionPollRunningGeneration = generation;
136
+ try {
137
+ for (const id of [...watchedJobIds]) {
138
+ let job;
139
+ try {
140
+ job = await manager.get(id);
141
+ }
142
+ catch {
143
+ continue;
144
+ }
145
+ if (generation !== completionPollGeneration
146
+ || !completionPollingEnabled
147
+ || !watchedJobIds.has(id))
148
+ return;
149
+ if (!isTerminalStatus(job.status.status))
150
+ continue;
151
+ watchedJobIds.delete(id);
152
+ deliverCompletion(job);
153
+ void updateStatus(statusTarget);
154
+ }
155
+ }
156
+ finally {
157
+ if (completionPollRunningGeneration === generation) {
158
+ completionPollRunningGeneration = undefined;
159
+ }
160
+ if (watchedJobIds.size === 0)
161
+ clearCompletionPollTimer();
162
+ }
163
+ };
164
+ const ensureCompletionPolling = () => {
165
+ if (!completionPollingEnabled || watchedJobIds.size === 0 || completionPollTimer)
166
+ return;
167
+ const generation = completionPollGeneration;
168
+ completionPollTimer = setInterval(() => void pollCompletions(generation), COMPLETION_POLL_MS);
169
+ completionPollTimer.unref?.();
170
+ void pollCompletions(generation);
171
+ };
172
+ const watchCompletion = (id) => {
173
+ watchedJobIds.add(id);
174
+ ensureCompletionPolling();
175
+ };
176
+ const suppressCompletion = (id) => {
177
+ const removed = watchedJobIds.delete(id);
178
+ if (watchedJobIds.size === 0)
179
+ clearCompletionPollTimer();
180
+ return removed;
181
+ };
182
+ const resumeCompletionPolling = () => {
183
+ completionPollingEnabled = true;
184
+ ensureCompletionPolling();
185
+ };
186
+ const pauseCompletionPolling = () => {
187
+ completionPollingEnabled = false;
188
+ completionPollGeneration += 1;
189
+ completionPollRunningGeneration = undefined;
190
+ clearCompletionPollTimer();
191
+ };
192
+ const clearCompletionWatches = () => {
193
+ pauseCompletionPolling();
194
+ watchedJobIds.clear();
195
+ };
196
+ const openOverlay = async (ctx) => {
197
+ if (!supportsBackgroundBashModel(ctx.model)) {
198
+ if (ctx.mode === 'tui')
199
+ ctx.ui.notify('Background Bash is unavailable for this model.', 'info');
200
+ return;
201
+ }
202
+ const target = createStatusTarget(ctx);
203
+ if (!target)
204
+ return;
205
+ await target.ui.custom((tui, theme, _keybindings, done) => new BackgroundBashOverlay(manager, theme, () => done(undefined), () => tui.requestRender()), {
206
+ overlay: true,
207
+ overlayOptions: {
208
+ width: '85%',
209
+ minWidth: 72,
210
+ maxHeight: '90%',
211
+ margin: 2,
212
+ },
213
+ });
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 by any Felan session in the same workspace.',
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 workspace, including processes started by other Felan sessions.',
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) {
298
+ if (isTerminalStatus(job.status.status))
299
+ 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)
342
+ 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) => openOverlay(ctx),
388
+ });
389
+ pi.registerShortcut(Key.ctrlShift('j'), {
390
+ description: 'View Background Bash processes and logs',
391
+ handler: openOverlay,
392
+ });
393
+ };
394
+ const activateRegisteredTools = () => {
395
+ if (!helperToolsRegistered || !backgroundBashActive)
396
+ return;
397
+ pi.setActiveTools([...new Set([...pi.getActiveTools(), 'bash', ...BACKGROUND_TOOL_NAMES])]);
398
+ };
399
+ const enableExtension = (ctx) => {
400
+ if (!supportsBackgroundBashModel(ctx.model)) {
401
+ stopStatusPolling();
402
+ pauseCompletionPolling();
403
+ return;
404
+ }
405
+ registerBackgroundBash();
406
+ registerHelperTools();
407
+ activateRegisteredTools();
408
+ resumeCompletionPolling();
409
+ if (ctx.mode === 'tui') {
410
+ registerControls();
411
+ startStatusPolling(ctx);
412
+ }
413
+ };
414
+ const disableExtension = () => {
415
+ stopStatusPolling();
416
+ pauseCompletionPolling();
417
+ if (!helperToolsRegistered || !backgroundBashActive)
418
+ return;
419
+ pi.registerTool({ ...foregroundBash });
420
+ backgroundBashActive = false;
421
+ const backgroundNames = new Set(BACKGROUND_TOOL_NAMES);
422
+ pi.setActiveTools([
423
+ ...new Set([
424
+ ...pi.getActiveTools().filter((name) => !backgroundNames.has(name)),
425
+ 'bash',
426
+ ]),
427
+ ]);
428
+ };
429
+ pi.on('session_start', (_event, ctx) => enableExtension(ctx));
430
+ pi.on('model_select', (event, ctx) => {
431
+ if (supportsBackgroundBashModel(event.model))
432
+ enableExtension(ctx);
433
+ else
434
+ disableExtension();
435
+ });
436
+ pi.on('session_shutdown', () => {
437
+ stopStatusPolling();
438
+ clearCompletionWatches();
439
+ });
440
+ };
441
+ function formatDate(ms) {
442
+ return ms ? new Date(ms).toISOString() : '-';
443
+ }
444
+ function formatDuration(job) {
445
+ const end = job.status.completedAt ?? Date.now();
446
+ const seconds = Math.max(0, Math.round((end - job.meta.startedAt) / 1_000));
447
+ if (seconds < 60)
448
+ return `${seconds}s`;
449
+ const minutes = Math.floor(seconds / 60);
450
+ return `${minutes}m${seconds % 60}s`;
451
+ }
452
+ function oneLine(value, max = 90) {
453
+ const compact = value.replace(/\s+/gu, ' ').trim();
454
+ return compact.length > max ? `${compact.slice(0, max - 1)}…` : compact;
455
+ }
456
+ function formatJobDetails(job) {
457
+ return [
458
+ `ID: ${job.meta.id}`,
459
+ `Status: ${job.status.status}`,
460
+ `PID: ${job.status.pid ?? job.meta.pid ?? '-'}`,
461
+ `Exit code: ${job.status.exitCode ?? '-'}`,
462
+ `Signal: ${job.status.signal ?? '-'}`,
463
+ `Error: ${job.status.error ?? '-'}`,
464
+ `Started: ${formatDate(job.meta.startedAt)}`,
465
+ `Completed: ${formatDate(job.status.completedAt)}`,
466
+ `Duration: ${formatDuration(job)}`,
467
+ `Log: ${job.meta.logPath}`,
468
+ `Info: ${job.meta.infoPath}`,
469
+ `Process dir: ${job.meta.jobDir}`,
470
+ `Command: ${job.meta.command}`,
471
+ ].join('\n');
472
+ }
473
+ function formatStarted(job) {
474
+ 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.`;
475
+ }
476
+ function formatCompletionNotice(job) {
477
+ 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.`;
478
+ }
479
+ function completionDetails(job) {
480
+ return {
481
+ id: job.meta.id,
482
+ status: job.status.status,
483
+ command: job.meta.command,
484
+ cwd: job.meta.cwd,
485
+ logPath: job.meta.logPath,
486
+ infoPath: job.meta.infoPath,
487
+ startedAt: job.meta.startedAt,
488
+ ...(job.status.completedAt === undefined ? {} : { completedAt: job.status.completedAt }),
489
+ ...(job.status.exitCode === undefined ? {} : { exitCode: job.status.exitCode }),
490
+ ...(job.status.signal === undefined ? {} : { signal: job.status.signal }),
491
+ ...(job.status.error === undefined ? {} : { error: job.status.error }),
492
+ };
493
+ }
494
+ function formatJobList(jobs) {
495
+ if (jobs.length === 0)
496
+ return 'No Background Bash processes found for this workspace.';
497
+ return jobs.map((job) => {
498
+ const pid = String(job.status.pid ?? job.meta.pid ?? '-');
499
+ const exit = job.status.exitCode ?? job.status.signal ?? '-';
500
+ return [
501
+ `${job.meta.id} ${job.status.status} pid=${pid} exit=${exit} started=${formatDate(job.meta.startedAt)} duration=${formatDuration(job)}`,
502
+ ` log: ${job.meta.logPath}`,
503
+ ` command: ${oneLine(job.meta.command)}`,
504
+ ].join('\n');
505
+ }).join('\n\n');
506
+ }
507
+ function formatWaitResult(job, timedOut) {
508
+ const heading = timedOut
509
+ ? 'Background Bash process is still running.'
510
+ : 'Background Bash process finished.';
511
+ return `${heading}\n\n${formatJobDetails(job)}\n\nUse read_background_bash with id "${job.meta.id}" to inspect output.`;
512
+ }
513
+ export { BackgroundBashManager } from './process-manager.js';
514
+ export default backgroundBashExtension;
515
+ //# sourceMappingURL=index.js.map