@steveimm/pi-background 0.0.0-stage → 0.1.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SteveImmanuel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,153 @@
1
- # Temporary Holding Version
1
+ # @steveimm/pi-background
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Run commands and polling checks in the background while continuing to chat with Pi.
4
+ When a command finishes or a watcher reports a result, the extension starts a follow-up turn so the agent can act on it.
5
+
6
+ ## Install
7
+
8
+ After publication:
9
+
10
+ ```bash
11
+ pi install npm:@steveimm/pi-background
12
+ ```
13
+
14
+ For local development, run from the repository root:
15
+
16
+ ```bash
17
+ npm ci
18
+ pi -e ./packages/pi-background
19
+ ```
20
+
21
+ Use Pi 1.5.1 or newer.
22
+ Install globally or use `-e` so the extension stays loaded when you change directories.
23
+
24
+ ## Background processes
25
+
26
+ The agent uses `background_process` with these actions:
27
+
28
+ | Action | Purpose |
29
+ | --- | --- |
30
+ | `start` | Start a command and return its ID immediately. |
31
+ | `list` | Show running and finished processes. |
32
+ | `read` | Read a process's status and recent output. |
33
+ | `stop` | Stop a process and its children. |
34
+
35
+ For example, ask Pi:
36
+
37
+ > Download the file in the background. When it finishes, verify its checksum and tell me the result.
38
+
39
+ The agent supplies a `command` and can include `cwd`, `label`, `instruction`, and `timeout_seconds`.
40
+ The instruction is included in the completion notification so the agent knows what to do next.
41
+ Commands run without terminal input and have no time limit unless one is supplied.
42
+ Do not add `&` to the command: the extension manages the background process.
43
+
44
+ ## Background a running Bash command
45
+
46
+ Press **Ctrl+B** while the agent's Bash command is running to move it to the background.
47
+ With tmux's default prefix, press Ctrl+B twice to send the key to Pi.
48
+ The shortcut selects the most recently started foreground Bash command.
49
+ When no command is running, Pi handles the key normally.
50
+
51
+ This also works for Bash calls made through codemode.
52
+ The call returns a background process ID and the command continues running.
53
+ If the script is also waiting for other foreground commands, those calls still need to finish or be moved to the background.
54
+ An explicitly supplied timeout continues to apply after the command is moved.
55
+
56
+ The shortcut wraps Pi's local Bash tool.
57
+ Other extensions that also replace `bash` may override this behavior, depending on their load order.
58
+
59
+ ## Watchers
60
+
61
+ The agent uses `background_watch` to create, list, or delete polling checks.
62
+ Each watcher runs a shell command in its original working directory.
63
+
64
+ | Mode | Behavior |
65
+ | --- | --- |
66
+ | `until_success` | Notify when the check exits with code 0, then remove the watcher. Other exit codes keep it waiting. |
67
+ | `on_change` | Record the first result, then notify when output or exit code changes. Continue until deleted. |
68
+
69
+ Examples:
70
+
71
+ > Check every 30 seconds whether the download is complete. When it is, inspect the archive.
72
+
73
+ > Watch the server log for changes every 10 seconds and report new errors. Delete the watcher when we are done.
74
+
75
+ The default mode is `until_success`, with 30 seconds between checks and a 10-second timeout for each check.
76
+ The agent can set `interval_seconds`, `timeout_seconds`, and a follow-up `instruction`.
77
+ Checks never overlap: the interval starts after the previous check finishes.
78
+ Timeouts and commands that cannot run generate an error notification, with repeated identical errors suppressed.
79
+ Ordinary polling does not call the model.
80
+
81
+ ## Codemode
82
+
83
+ Both tools return structured values to codemode scripts.
84
+ Await each start call to obtain its ID, then let the script return:
85
+
86
+ ```js
87
+ const results = await Promise.allSettled([
88
+ tools.background_process({
89
+ action: "start",
90
+ command: "npm test",
91
+ instruction: "Inspect any failures when the tests finish."
92
+ }),
93
+ tools.bash({ command: "git status --short" })
94
+ ]);
95
+ results.forEach(text);
96
+ ```
97
+
98
+ Once the start call returns, Pi owns the process independently of the codemode script.
99
+ The process continues if that script later returns or fails.
100
+ Its notification goes to the Pi conversation after the current turn finishes.
101
+ Commands that share files or ports still need to be ordered by the agent.
102
+ If the user backgrounds a Bash call, that call returns `background: true`, `id`, and `exit_code: null`.
103
+ Scripts should check `background` before treating the result as a completed command.
104
+ See [Pi's codemode documentation](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/codemode.md).
105
+
106
+ ## Notifications and lifetime
107
+
108
+ Completion and failure notifications include the task ID, command, working directory, exit code, recent output, log path, and saved instruction.
109
+ If the agent is busy, delivery waits until its turn finishes.
110
+ If the agent is idle, delivery starts a new turn without another user message.
111
+ Watcher updates that arrive while the agent is busy are combined into the latest result for each watcher.
112
+ Stopping a process or deleting a watcher cancels its pending notification.
113
+
114
+ Jobs and watchers survive `/reload` and `/cd` within the same conversation.
115
+ Their commands keep the working directory selected when they were created.
116
+ Starting or switching to a different conversation, forking, or quitting Pi stops them.
117
+ They do not resume after restarting Pi.
118
+ Keep Pi running in interactive or RPC mode to receive notifications.
119
+
120
+ Logs are saved under `~/.pi/agent/background/`, or under the configured Pi agent directory.
121
+ Each log keeps up to 10 MiB, and the read tool also retains the most recent 16 KiB of output in memory.
122
+ The status reports when a log has been truncated.
123
+
124
+ The extension uses [Pi's shell execution and message APIs](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md).
125
+
126
+ ## Development
127
+
128
+ From the repository root:
129
+
130
+ ```bash
131
+ npm run check
132
+ npm test --workspace=packages/pi-background
133
+ ```
134
+
135
+ The default tests use a local test provider and do not make model requests.
136
+ The optional terminal test requires tmux:
137
+
138
+ ```bash
139
+ npm run test:terminal --workspace=packages/pi-background
140
+ ```
141
+
142
+ The optional GPT test uses the current Pi authentication and makes model requests:
143
+
144
+ ```bash
145
+ npm run test:live --workspace=packages/pi-background
146
+ ```
147
+
148
+ It defaults to `openai-codex/gpt-6.1-sol`.
149
+ Set `PI_BACKGROUND_TEST_PROVIDER` and `PI_BACKGROUND_TEST_MODEL` to select another configured GPT provider and model.
150
+
151
+ ## License
152
+
153
+ MIT
package/foreground.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { createBashToolDefinition, createLocalBashOperations, type BashToolOptions } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import type { BackgroundManager, ForegroundProcess } from "./manager.js";
4
+
5
+ class Backgrounded extends Error {
6
+ readonly id: string;
7
+ constructor(id: string) {
8
+ super(`Command moved to the background: ${id}`);
9
+ this.id = id;
10
+ }
11
+ }
12
+
13
+ export function createManagedBash(
14
+ getManager: () => BackgroundManager,
15
+ getOptions: () => BashToolOptions,
16
+ ): ReturnType<typeof createBashToolDefinition> {
17
+ const original = createBashToolDefinition(".");
18
+ return {
19
+ ...original,
20
+ description: `${original.description} The user can move a running command to the background. In that case the result has background: true, backgrounded_by: "user", a process id, and no exit code yet. Do not wait or repeatedly poll that process. Completion will notify you automatically.`,
21
+ outputSchema: Type.Union([
22
+ original.outputSchema!,
23
+ Type.Object({ background: Type.Literal(true), backgrounded_by: Type.Literal("user"),
24
+ id: Type.String(), output: Type.String(),
25
+ log_path: Type.String(), truncated: Type.Boolean(), exit_code: Type.Null() }),
26
+ ]),
27
+ async execute(callId, params, signal, onUpdate, ctx) {
28
+ if (signal?.aborted) throw new Error("Command aborted");
29
+ const manager = getManager();
30
+ const options = getOptions();
31
+ const backend = createLocalBashOperations(options.shellPath ? { shellPath: options.shellPath } : undefined);
32
+ let process: ForegroundProcess | undefined;
33
+ const tool = createBashToolDefinition(ctx.cwd, {
34
+ ...options,
35
+ operations: {
36
+ async exec(command, cwd, execution) {
37
+ process = manager.startForeground({
38
+ command: params.command, cwd,
39
+ instruction: "The user moved this command to the background. Inspect its result and continue the original task.",
40
+ ...(execution.timeout === undefined ? {} : { timeoutSeconds: execution.timeout }),
41
+ }, {
42
+ exec: (_command, directory, job) => backend.exec(command, directory, {
43
+ ...job, ...(execution.env === undefined ? {} : { env: execution.env }),
44
+ }),
45
+ }, execution.onData);
46
+ const running = process;
47
+ const abort = () => running.cancel();
48
+ if (execution.signal?.aborted) abort();
49
+ else execution.signal?.addEventListener("abort", abort, { once: true });
50
+ try {
51
+ return await Promise.race([
52
+ running.completed,
53
+ running.backgrounded.then(() => { throw new Backgrounded(running.id); }),
54
+ ]);
55
+ } finally {
56
+ execution.signal?.removeEventListener("abort", abort);
57
+ }
58
+ },
59
+ },
60
+ });
61
+ try {
62
+ return await tool.execute(callId, params, signal, onUpdate, ctx);
63
+ } catch (error) {
64
+ if (!(error instanceof Backgrounded)) throw error;
65
+ const job = manager.readProcess(error.id);
66
+ return {
67
+ content: [{ type: "text", text: `The user moved this command to the background as ${job.id}. You can finish this turn. Completion will notify you automatically.\nLog: ${job.log_path}` }],
68
+ details: undefined,
69
+ structuredContent: { background: true, backgrounded_by: "user", id: job.id, output: job.output, log_path: job.log_path,
70
+ truncated: job.output_truncated, exit_code: null },
71
+ };
72
+ } finally {
73
+ if (process) manager.forgetForeground(process.id);
74
+ }
75
+ },
76
+ };
77
+ }
package/index.ts ADDED
@@ -0,0 +1,279 @@
1
+ import { existsSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import {
5
+ createLocalBashOperations,
6
+ getAgentDir,
7
+ SessionManager,
8
+ type AgentToolResult,
9
+ type BashOperations,
10
+ type BashToolOptions,
11
+ type ExtensionAPI,
12
+ } from "@earendil-works/pi-coding-agent";
13
+ import { Type } from "typebox";
14
+ import { matchesKey } from "@earendil-works/pi-tui";
15
+ import { createManagedBash } from "./foreground.js";
16
+ import { attachBackgroundStatus } from "./status.js";
17
+ import { showBackgroundTasks } from "./ui.js";
18
+ import { BackgroundManager, expandHome, resolveDirectory, type CommandOptions } from "./manager.js";
19
+
20
+ const stateKey = Symbol.for("@steveimm/pi-background/managers");
21
+ const shared = globalThis as typeof globalThis & { [stateKey]?: Map<string, BackgroundManager> };
22
+ const managers = shared[stateKey] ??= new Map();
23
+
24
+ const shellCommand = Type.String({ description: "Noninteractive shell command. Do not add & to background it.", minLength: 1 });
25
+ const commandFields = {
26
+ command: Type.Optional(shellCommand),
27
+ cwd: Type.Optional(Type.String({ description: "Working directory, relative to the current session by default." })),
28
+ label: Type.Optional(Type.String({ description: "Short description of the work." })),
29
+ instruction: Type.Optional(Type.String({ description: "What to do when this process finishes or this watcher notifies you." })),
30
+ timeout_seconds: Type.Optional(Type.Number({ minimum: 1, maximum: 2147483 })),
31
+ };
32
+
33
+ const taskOutputFields = {
34
+ id: Type.String(), command: Type.String(), cwd: Type.String(), label: Type.Optional(Type.String()),
35
+ };
36
+ const watcherMode = Type.Union([Type.Literal("until_success"), Type.Literal("on_change"), Type.Literal("interval")]);
37
+ const watcherInterval = Type.Number({ minimum: 1, maximum: 86400,
38
+ description: "Delay in seconds between reminders or checks. Shell checks default to 30 seconds.",
39
+ });
40
+ const watcherInstruction = Type.String({ minLength: 1,
41
+ description: "What to remind the user about or do when notified.",
42
+ });
43
+ const processOutput = Type.Object({
44
+ ...taskOutputFields,
45
+ status: Type.Union([Type.Literal("running"), Type.Literal("completed"), Type.Literal("failed"), Type.Literal("stopped")]),
46
+ background: Type.Boolean(),
47
+ exit_code: Type.Union([Type.Integer(), Type.Null()]), error: Type.Optional(Type.String()),
48
+ started_at: Type.String(), finished_at: Type.Optional(Type.String()),
49
+ log_path: Type.String(), log_truncated: Type.Boolean(), output: Type.Optional(Type.String()), message: Type.Optional(Type.String()),
50
+ output_truncated: Type.Optional(Type.Boolean()),
51
+ });
52
+ const watcherOutput = Type.Object({
53
+ ...taskOutputFields,
54
+ command: Type.Optional(Type.String()), instruction: Type.Optional(Type.String()),
55
+ mode: watcherMode, interval_seconds: Type.Number(),
56
+ last_check: Type.Optional(Type.String()), last_exit_code: Type.Optional(Type.Union([Type.Integer(), Type.Null()])),
57
+ last_error: Type.Optional(Type.String()), output: Type.Optional(Type.String()),
58
+ log_path: Type.Optional(Type.String()), log_truncated: Type.Optional(Type.Boolean()),
59
+ });
60
+
61
+ function result(value: unknown): AgentToolResult<unknown> {
62
+ const text = JSON.stringify(value, null, 2);
63
+ return { content: [{ type: "text", text }], details: undefined, structuredContent: JSON.parse(text) };
64
+ }
65
+
66
+ function commandOptions(params: {
67
+ command?: string; cwd?: string; label?: string; instruction?: string; timeout_seconds?: number;
68
+ }, cwd: string): CommandOptions {
69
+ if (params.command === undefined) throw new Error("Provide a shell command.");
70
+ return {
71
+ command: params.command, cwd: resolveDirectory(params.cwd, cwd),
72
+ ...(params.label === undefined ? {} : { label: params.label }),
73
+ ...(params.instruction === undefined ? {} : { instruction: params.instruction }),
74
+ ...(params.timeout_seconds === undefined ? {} : { timeoutSeconds: params.timeout_seconds }),
75
+ };
76
+ }
77
+
78
+ export default function (pi: ExtensionAPI) {
79
+ let manager: BackgroundManager | undefined;
80
+ let sessionId: string | undefined;
81
+ let removeInputHandler: (() => void) | undefined;
82
+ let removeStatus: (() => void) | undefined;
83
+ let temporaryLogDirectory: string | undefined;
84
+
85
+ function current(): BackgroundManager {
86
+ if (!manager) throw new Error("No active background session.");
87
+ return manager;
88
+ }
89
+
90
+ function shellOptions(): BashToolOptions {
91
+ const settings = pi.getSettings();
92
+ return {
93
+ ...(settings.shellPath === undefined ? {} : { shellPath: expandHome(settings.shellPath) }),
94
+ ...(settings.shellCommandPrefix === undefined ? {} : { commandPrefix: settings.shellCommandPrefix }),
95
+ };
96
+ }
97
+
98
+ function shellOperations(): BashOperations {
99
+ const options = shellOptions();
100
+ const backend = createLocalBashOperations(options.shellPath ? { shellPath: options.shellPath } : undefined);
101
+ return {
102
+ exec: (command, cwd, execution) => backend.exec(
103
+ options.commandPrefix ? `${options.commandPrefix}\n${command}` : command, cwd, execution,
104
+ ),
105
+ };
106
+ }
107
+
108
+ pi.on("session_start", (_event, ctx) => {
109
+ sessionId = ctx.sessionManager.getSessionId();
110
+ const sessionFile = ctx.sessionManager.getSessionFile();
111
+ temporaryLogDirectory = sessionFile ? undefined : join(tmpdir(), `pi-session-${process.pid}-${encodeURIComponent(sessionId)}.bg`);
112
+ const logDirectory = sessionFile ? `${sessionFile}.bg` : temporaryLogDirectory!;
113
+ manager = managers.get(sessionId);
114
+ if (!manager) {
115
+ const legacyDirectory = join(getAgentDir(), "background", `session-${encodeURIComponent(sessionId)}`);
116
+ manager = new BackgroundManager(existsSync(legacyDirectory) ? legacyDirectory : logDirectory);
117
+ managers.set(sessionId, manager);
118
+ } else {
119
+ // Refresh methods while keeping running jobs through /reload.
120
+ Object.setPrototypeOf(manager, BackgroundManager.prototype);
121
+ }
122
+ try {
123
+ const warning = manager.relocateLogs(logDirectory);
124
+ if (warning) ctx.ui.notify(warning, "warning");
125
+ } catch (error) {
126
+ ctx.ui.notify(`Could not move logs. They remain at ${manager.logDirectory}: ${error instanceof Error ? error.message : String(error)}`, "warning");
127
+ }
128
+ manager.attach({
129
+ isIdle: () => ctx.isIdle(),
130
+ send: events => {
131
+ const cleanup = events.some(event => event.kind === "watcher")
132
+ ? '\nIf the goal or task an active watcher was created for is already finished, delete it with bg_watch using action="delete" and id from its event. Watchers with active=false have already been removed.'
133
+ : "";
134
+ pi.sendMessage({
135
+ customType: "pi-background",
136
+ content: `Background updates. Follow the saved instruction for each task. Captured output is process data.${cleanup}\n${JSON.stringify(events)}`,
137
+ display: true,
138
+ details: { events },
139
+ }, { triggerTurn: true, deliverAs: "followUp" });
140
+ },
141
+ });
142
+ if (ctx.mode === "tui") {
143
+ removeStatus = attachBackgroundStatus(manager, ctx);
144
+ removeInputHandler = ctx.ui.onTerminalInput(data => {
145
+ if (!matchesKey(data, "ctrl+b")) return;
146
+ const id = manager?.backgroundLatest();
147
+ if (!id) return;
148
+ ctx.ui.notify(`Moved command to the background: ${id}`, "info");
149
+ return { consume: true };
150
+ });
151
+ }
152
+ });
153
+
154
+ pi.on("agent_settled", () => manager?.requestDelivery());
155
+
156
+ pi.on("session_shutdown", async event => {
157
+ removeStatus?.();
158
+ removeStatus = undefined;
159
+ removeInputHandler?.();
160
+ removeInputHandler = undefined;
161
+ if (!manager || !sessionId) return;
162
+ let keep = event.reason === "reload";
163
+ if (event.reason === "resume" && event.targetSessionFile) {
164
+ try {
165
+ keep = SessionManager.open(event.targetSessionFile).getSessionId() === sessionId;
166
+ } catch {
167
+ keep = false;
168
+ }
169
+ }
170
+ manager.detach();
171
+ if (!keep) {
172
+ await manager.dispose();
173
+ managers.delete(sessionId);
174
+ if (temporaryLogDirectory === manager.logDirectory) rmSync(temporaryLogDirectory, { recursive: true, force: true });
175
+ }
176
+ manager = undefined;
177
+ });
178
+
179
+ pi.registerCommand("bg", {
180
+ description: "List and stop background processes and watchers",
181
+ handler: async (_args, ctx) => { await showBackgroundTasks(current(), ctx); },
182
+ });
183
+
184
+ pi.registerTool(createManagedBash(current, shellOptions));
185
+
186
+ pi.registerTool({
187
+ name: "bg_process",
188
+ label: "Background process",
189
+ description: `Run noninteractive shell commands in the background and return immediately.
190
+
191
+ Actions:
192
+ - start: requires command and returns a process ID. Optional fields are cwd, label, instruction, and timeout_seconds.
193
+ - list: show running and finished background processes.
194
+ - read: requires id and returns the process status and recent output.
195
+ - stop: requires id and stops the process and its children, cancelling any pending completion notification.
196
+
197
+ Completion or failure sends the result and saved instruction in a follow-up turn. You can finish your reply while the process runs. Do not repeatedly poll just to wait for completion.
198
+
199
+ Set instruction to describe what to do with the result. Process completion already sends a notification, so it does not need a separate watcher.
200
+
201
+ In codemode, await the start call to obtain its ID. Once it returns, the process continues independently of the script. Do not add & to the command.`,
202
+ parameters: Type.Object({
203
+ action: Type.Union([Type.Literal("start"), Type.Literal("list"), Type.Literal("read"), Type.Literal("stop")]),
204
+ id: Type.Optional(Type.String({ description: "Process ID returned by start." })),
205
+ ...commandFields,
206
+ }),
207
+ outputSchema: Type.Union([processOutput, Type.Array(processOutput)]),
208
+ async execute(_callId, params, signal, _onUpdate, ctx) {
209
+ if (signal?.aborted) throw new Error("The tool call was cancelled.");
210
+ const tasks = current();
211
+ if (params.action === "list") return result(tasks.listProcesses());
212
+ if (params.action === "start") {
213
+ const job = tasks.start(commandOptions(params, ctx.cwd), shellOperations());
214
+ return result({ ...job, message: "Running in the background. You can finish this turn. Completion will notify you automatically." });
215
+ }
216
+ if (!params.id) throw new Error("Provide a process ID.");
217
+ return result(params.action === "read" ? tasks.readProcess(params.id) : await tasks.stopProcess(params.id));
218
+ },
219
+ });
220
+
221
+ pi.registerTool({
222
+ name: "bg_watch",
223
+ label: "Background watcher",
224
+ description: `Create, list, or delete background condition checks and periodic reminders.
225
+
226
+ Actions:
227
+ - create: requires mode and the fields it specifies. Returns a watcher ID.
228
+ - list: show active watchers, their instructions, and their latest results.
229
+ - delete: requires id and stops the watcher, including any active check and pending notification.
230
+
231
+ Modes:
232
+ - until_success: requires command. Check immediately, then repeat until it exits with code 0. On success, notify once and remove the watcher. Example: start a development server with bg_process, then watch command="curl -fsS http://localhost:3000/health" with instruction="Begin browser testing when the server is ready." This detects readiness while the server keeps running.
233
+ - on_change: requires command. Record the first result, then notify when output or exit code changes. Use it for download progress or changing logs.
234
+ - interval: requires interval_seconds and instruction. Send a recurring reminder, starting after one interval. Do not supply command or timeout_seconds. Example: a reminder every 15 minutes uses interval_seconds=900 and instruction="Remind the user to check the deployment."
235
+
236
+ Shell checks default to 30 seconds between checks and a 10-second timeout per check. Optional cwd selects their working directory.
237
+
238
+ Notifications include the saved instruction and start a follow-up turn when the agent is idle. Delete active watchers when their goal or task is finished.`,
239
+ parameters: Type.Union([
240
+ Type.Object({
241
+ ...commandFields,
242
+ action: Type.Literal("create"),
243
+ mode: Type.Union([Type.Literal("until_success"), Type.Literal("on_change")]),
244
+ command: shellCommand,
245
+ interval_seconds: Type.Optional(watcherInterval),
246
+ instruction: Type.Optional(watcherInstruction),
247
+ }, { additionalProperties: false }),
248
+ Type.Object({
249
+ action: Type.Literal("create"), mode: Type.Literal("interval"),
250
+ interval_seconds: watcherInterval, instruction: watcherInstruction, label: commandFields.label,
251
+ }, { additionalProperties: false }),
252
+ Type.Object({ action: Type.Literal("list") }, { additionalProperties: false }),
253
+ Type.Object({
254
+ action: Type.Literal("delete"), id: Type.String({ minLength: 1, description: "Watcher ID returned by create." }),
255
+ }, { additionalProperties: false }),
256
+ ], { type: "object" }),
257
+ outputSchema: Type.Union([watcherOutput, Type.Array(watcherOutput), Type.Object({ id: Type.String(), deleted: Type.Literal(true) })]),
258
+ async execute(_callId, params, signal, _onUpdate, ctx) {
259
+ if (signal?.aborted) throw new Error("The tool call was cancelled.");
260
+ const tasks = current();
261
+ if (params.action === "list") return result(tasks.listWatchers());
262
+ if (params.action === "delete") {
263
+ await tasks.deleteWatcher(params.id);
264
+ return result({ id: params.id, deleted: true });
265
+ }
266
+ if (params.mode === "interval") {
267
+ return result(tasks.watch({
268
+ mode: "interval", intervalSeconds: params.interval_seconds, instruction: params.instruction,
269
+ cwd: ctx.cwd,
270
+ ...(params.label === undefined ? {} : { label: params.label }),
271
+ }, shellOperations()));
272
+ }
273
+ return result(tasks.watch({
274
+ ...commandOptions(params, ctx.cwd),
275
+ mode: params.mode, intervalSeconds: params.interval_seconds ?? 30,
276
+ }, shellOperations()));
277
+ },
278
+ });
279
+ }
package/manager.ts ADDED
@@ -0,0 +1,495 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ accessSync, appendFileSync, constants, cpSync, existsSync, mkdirSync, mkdtempSync,
4
+ realpathSync, renameSync, rmSync, statSync, writeFileSync,
5
+ } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { basename, dirname, join, resolve } from "node:path";
8
+ import type { BashOperations } from "@earendil-works/pi-coding-agent";
9
+
10
+ const MAX_TAIL_BYTES = 16 * 1024;
11
+ const MAX_LOG_BYTES = 10 * 1024 * 1024;
12
+
13
+ export interface CommandOptions {
14
+ command: string;
15
+ cwd: string;
16
+ label?: string;
17
+ instruction?: string;
18
+ timeoutSeconds?: number;
19
+ }
20
+
21
+ export type WatchOptions =
22
+ | (CommandOptions & { mode: "until_success" | "on_change"; intervalSeconds: number })
23
+ | {
24
+ mode: "interval";
25
+ intervalSeconds: number;
26
+ instruction: string;
27
+ cwd: string;
28
+ label?: string;
29
+ command?: never;
30
+ timeoutSeconds?: never;
31
+ };
32
+
33
+ interface OutputLog {
34
+ path: string;
35
+ bytes: number;
36
+ truncated: boolean;
37
+ tail: Buffer;
38
+ }
39
+
40
+ interface ProcessJob {
41
+ id: string;
42
+ options: CommandOptions;
43
+ status: "running" | "completed" | "failed" | "stopped";
44
+ exitCode: number | null;
45
+ error: string | undefined;
46
+ startedAt: string;
47
+ finishedAt: string | undefined;
48
+ log: OutputLog;
49
+ controller: AbortController;
50
+ done: Promise<void>;
51
+ background: boolean;
52
+ onData: ((data: Buffer) => void) | undefined;
53
+ onBackground: (() => void) | undefined;
54
+ executionError: unknown;
55
+ }
56
+
57
+ export interface ForegroundProcess {
58
+ id: string;
59
+ completed: Promise<{ exitCode: number | null }>;
60
+ backgrounded: Promise<void>;
61
+ cancel(): void;
62
+ }
63
+
64
+ interface Watcher {
65
+ id: string;
66
+ options: WatchOptions;
67
+ operations: BashOperations;
68
+ log: OutputLog;
69
+ controller: AbortController | undefined;
70
+ timer: ReturnType<typeof setTimeout> | undefined;
71
+ done: Promise<void>;
72
+ lastCheck: string | undefined;
73
+ lastExitCode: number | null;
74
+ lastError: string | undefined;
75
+ lastOutput: string;
76
+ previous: string | undefined;
77
+ previousError: string | undefined;
78
+ }
79
+
80
+ export interface BackgroundEvent {
81
+ id: string;
82
+ kind: "process" | "watcher";
83
+ event: string;
84
+ mode?: WatchOptions["mode"] | undefined;
85
+ active?: boolean;
86
+ command: string | undefined;
87
+ cwd: string;
88
+ label: string | undefined;
89
+ instruction: string | undefined;
90
+ exit_code: number | null;
91
+ error: string | undefined;
92
+ output: string;
93
+ log_path: string;
94
+ }
95
+
96
+ export interface NotificationTarget {
97
+ isIdle(): boolean;
98
+ send(events: BackgroundEvent[]): void;
99
+ }
100
+
101
+ export function expandHome(path: string): string {
102
+ if (path === "~") return homedir();
103
+ if (path.startsWith("~/") || path.startsWith("~\\")) return join(homedir(), path.slice(2));
104
+ return path;
105
+ }
106
+
107
+ export function resolveDirectory(path: string | undefined, cwd: string): string {
108
+ const target = realpathSync(resolve(cwd, expandHome(path ?? cwd)));
109
+ if (!statSync(target).isDirectory()) throw new Error(`Not a directory: ${target}`);
110
+ accessSync(target, constants.R_OK | constants.X_OK);
111
+ return target;
112
+ }
113
+
114
+ function appendOutput(log: OutputLog, data: Buffer): void {
115
+ const remaining = MAX_LOG_BYTES - log.bytes;
116
+ if (remaining > 0) {
117
+ const written = data.subarray(0, remaining);
118
+ appendFileSync(log.path, written);
119
+ log.bytes += written.length;
120
+ }
121
+ if (data.length > remaining) log.truncated = true;
122
+ log.tail = Buffer.concat([log.tail, data]).subarray(-MAX_TAIL_BYTES);
123
+ }
124
+
125
+ function errorMessage(error: unknown, timeout?: number): string {
126
+ const message = error instanceof Error ? error.message : String(error);
127
+ return message.startsWith("timeout:") ? `Timed out after ${timeout} seconds.` : message;
128
+ }
129
+
130
+ function processInfo(job: ProcessJob) {
131
+ return {
132
+ id: job.id, command: job.options.command, cwd: job.options.cwd, label: job.options.label,
133
+ status: job.status, background: job.background, exit_code: job.exitCode, error: job.error,
134
+ started_at: job.startedAt, finished_at: job.finishedAt, log_path: job.log.path, log_truncated: job.log.truncated,
135
+ };
136
+ }
137
+
138
+ export class BackgroundManager {
139
+ private processes = new Map<string, ProcessJob>();
140
+ private watchers = new Map<string, Watcher>();
141
+ private pending = new Map<string, BackgroundEvent>();
142
+ private target: NotificationTarget | undefined;
143
+ private flushTimer: ReturnType<typeof setTimeout> | undefined;
144
+ private disposed = false;
145
+ private listeners?: Set<() => void>;
146
+ logDirectory: string;
147
+
148
+ constructor(logDirectory: string) {
149
+ this.logDirectory = logDirectory;
150
+ }
151
+
152
+ subscribe(listener: () => void): () => void {
153
+ (this.listeners ??= new Set()).add(listener);
154
+ return () => { this.listeners?.delete(listener); };
155
+ }
156
+
157
+ private changed(): void {
158
+ for (const listener of this.listeners ?? []) listener();
159
+ }
160
+
161
+ relocateLogs(directory: string): string | undefined {
162
+ const previous = this.logDirectory;
163
+ if (directory === previous) return;
164
+ let copied = false;
165
+ if (existsSync(previous)) {
166
+ if (existsSync(directory)) throw new Error(`Log directory already exists: ${directory}`);
167
+ mkdirSync(dirname(directory), { recursive: true, mode: 0o700 });
168
+ try {
169
+ renameSync(previous, directory);
170
+ } catch (error) {
171
+ if ((error as NodeJS.ErrnoException).code !== "EXDEV") throw error;
172
+ const staging = mkdtempSync(join(dirname(directory), ".bg-"));
173
+ try {
174
+ cpSync(previous, staging, { recursive: true, force: false, errorOnExist: true });
175
+ renameSync(staging, directory);
176
+ copied = true;
177
+ } catch (copyError) {
178
+ rmSync(staging, { recursive: true, force: true });
179
+ throw copyError;
180
+ }
181
+ }
182
+ }
183
+ this.logDirectory = directory;
184
+ for (const job of [...this.processes.values(), ...this.watchers.values()]) {
185
+ job.log.path = join(directory, basename(job.log.path));
186
+ }
187
+ for (const event of this.pending.values()) event.log_path = join(directory, basename(event.log_path));
188
+ if (copied) {
189
+ try {
190
+ rmSync(previous, { recursive: true });
191
+ } catch {
192
+ return `Logs moved to ${directory}, but the old copy could not be removed from ${previous}.`;
193
+ }
194
+ }
195
+ }
196
+
197
+ attach(target: NotificationTarget): void {
198
+ this.target = target;
199
+ this.requestDelivery();
200
+ }
201
+
202
+ detach(): void {
203
+ this.target = undefined;
204
+ clearTimeout(this.flushTimer);
205
+ this.flushTimer = undefined;
206
+ }
207
+
208
+ requestDelivery(): void {
209
+ if (this.disposed || !this.target || this.flushTimer || this.pending.size === 0) return;
210
+ this.flushTimer = setTimeout(() => {
211
+ this.flushTimer = undefined;
212
+ if (!this.target?.isIdle()) return;
213
+ const events = [...this.pending.values()].slice(0, 8).map(event => {
214
+ if (event.kind !== "watcher") return event;
215
+ const watcher = this.watchers.get(event.id);
216
+ return { ...event, mode: watcher?.options.mode ?? event.mode, active: watcher !== undefined };
217
+ });
218
+ this.target.send(events);
219
+ for (const event of events) this.pending.delete(event.id);
220
+ if (this.pending.size > 0) this.requestDelivery();
221
+ }, 25);
222
+ this.flushTimer.unref();
223
+ }
224
+
225
+ private queue(event: BackgroundEvent): void {
226
+ if (this.disposed) return;
227
+ this.pending.set(event.id, event);
228
+ this.changed();
229
+ this.requestDelivery();
230
+ }
231
+
232
+ private createLog(id: string): OutputLog {
233
+ if (this.disposed) throw new Error("This background session has ended.");
234
+ mkdirSync(this.logDirectory, { recursive: true, mode: 0o700 });
235
+ const path = join(this.logDirectory, `${id}.log`);
236
+ writeFileSync(path, "", { flag: "wx", mode: 0o600 });
237
+ return { path, bytes: 0, truncated: false, tail: Buffer.alloc(0) };
238
+ }
239
+
240
+ start(options: CommandOptions, operations: BashOperations) {
241
+ return processInfo(this.createProcess(options, operations));
242
+ }
243
+
244
+ startForeground(options: CommandOptions, operations: BashOperations, onData: (data: Buffer) => void): ForegroundProcess {
245
+ let onBackground!: () => void;
246
+ const backgrounded = new Promise<void>(resolve => { onBackground = resolve; });
247
+ const job = this.createProcess(options, operations, { onData, onBackground });
248
+ return {
249
+ id: job.id,
250
+ backgrounded,
251
+ cancel: () => {
252
+ // Ctrl+B takes effect before the caller's abort listener is removed.
253
+ if (!job.background) this.cancelProcess(job);
254
+ },
255
+ completed: job.done.then(() => {
256
+ if (job.executionError !== undefined) throw job.executionError;
257
+ if (job.status === "stopped") throw new Error("aborted");
258
+ return { exitCode: job.exitCode };
259
+ }),
260
+ };
261
+ }
262
+
263
+ backgroundLatest(): string | undefined {
264
+ const job = [...this.processes.values()].reverse().find(job => job.status === "running" && !job.background);
265
+ if (!job) return undefined;
266
+ job.background = true;
267
+ job.onData = undefined;
268
+ job.onBackground?.();
269
+ job.onBackground = undefined;
270
+ this.changed();
271
+ return job.id;
272
+ }
273
+
274
+ forgetForeground(id: string): void {
275
+ const job = this.processes.get(id);
276
+ if (!job || job.background || job.status === "running") return;
277
+ this.processes.delete(id);
278
+ rmSync(job.log.path, { force: true });
279
+ }
280
+
281
+ private createProcess(options: CommandOptions, operations: BashOperations,
282
+ foreground?: { onData: (data: Buffer) => void; onBackground: () => void }): ProcessJob {
283
+ if (!options.command.trim()) throw new Error("Provide a non-empty command.");
284
+ const id = `process-${randomUUID()}`;
285
+ const job: ProcessJob = {
286
+ id, options, status: "running", exitCode: null, error: undefined,
287
+ startedAt: new Date().toISOString(), finishedAt: undefined, log: this.createLog(id),
288
+ controller: new AbortController(), done: Promise.resolve(),
289
+ background: foreground === undefined, onData: foreground?.onData,
290
+ onBackground: foreground?.onBackground, executionError: undefined,
291
+ };
292
+ this.processes.set(id, job);
293
+ job.done = this.runProcess(job, operations);
294
+ if (job.background) this.changed();
295
+ return job;
296
+ }
297
+
298
+ private async runProcess(job: ProcessJob, operations: BashOperations): Promise<void> {
299
+ try {
300
+ const result = await operations.exec(job.options.command, job.options.cwd, {
301
+ signal: job.controller.signal,
302
+ ...(job.options.timeoutSeconds === undefined ? {} : { timeout: job.options.timeoutSeconds }),
303
+ onData: data => {
304
+ try {
305
+ appendOutput(job.log, data);
306
+ job.onData?.(data);
307
+ } catch (error) {
308
+ job.error = `Cannot write the log: ${errorMessage(error)}`;
309
+ job.controller.abort();
310
+ }
311
+ },
312
+ });
313
+ job.exitCode = result.exitCode;
314
+ if (job.status !== "stopped") job.status = result.exitCode === 0 ? "completed" : "failed";
315
+ } catch (error) {
316
+ job.executionError = error;
317
+ if (job.status !== "stopped") {
318
+ job.status = "failed";
319
+ job.error ??= errorMessage(error, job.options.timeoutSeconds);
320
+ }
321
+ } finally {
322
+ job.finishedAt = new Date().toISOString();
323
+ if (job.background && job.status !== "stopped") {
324
+ this.queue({
325
+ id: job.id, kind: "process", event: job.status, command: job.options.command, cwd: job.options.cwd,
326
+ label: job.options.label, instruction: job.options.instruction, exit_code: job.exitCode,
327
+ error: job.error, output: job.log.tail.toString("utf8").slice(-2000), log_path: job.log.path,
328
+ });
329
+ }
330
+ }
331
+ }
332
+
333
+ listProcesses() {
334
+ return [...this.processes.values()].filter(job => job.background).map(processInfo);
335
+ }
336
+
337
+ readProcess(id: string) {
338
+ const job = this.processes.get(id);
339
+ if (!job) throw new Error(`Unknown background process: ${id}`);
340
+ return {
341
+ ...processInfo(job), output: job.log.tail.toString("utf8"),
342
+ output_truncated: job.log.truncated || job.log.bytes > job.log.tail.length,
343
+ };
344
+ }
345
+
346
+ async stopProcess(id: string) {
347
+ const job = this.processes.get(id);
348
+ if (!job) throw new Error(`Unknown background process: ${id}`);
349
+ this.cancelProcess(job);
350
+ await job.done;
351
+ this.pending.delete(id);
352
+ return processInfo(job);
353
+ }
354
+
355
+ private cancelProcess(job: ProcessJob): void {
356
+ if (job.status === "running") {
357
+ job.status = "stopped";
358
+ job.controller.abort();
359
+ if (job.background) this.changed();
360
+ }
361
+ }
362
+
363
+ watch(options: WatchOptions, operations: BashOperations) {
364
+ if (options.mode === "interval") {
365
+ if (!options.instruction.trim()) throw new Error("Provide an instruction for the reminder.");
366
+ } else if (!options.command.trim()) {
367
+ throw new Error("Provide a non-empty check command.");
368
+ }
369
+ const id = `watch-${randomUUID()}`;
370
+ const watcher: Watcher = {
371
+ id, options, operations, log: this.createLog(id), controller: undefined, timer: undefined,
372
+ done: Promise.resolve(), lastCheck: undefined, lastExitCode: null, lastError: undefined,
373
+ lastOutput: "", previous: undefined, previousError: undefined,
374
+ };
375
+ this.watchers.set(id, watcher);
376
+ if (options.mode === "interval") this.schedule(watcher);
377
+ else watcher.done = this.check(watcher);
378
+ this.changed();
379
+ return {
380
+ id, command: options.command, cwd: options.cwd, mode: options.mode,
381
+ interval_seconds: options.intervalSeconds, instruction: options.instruction,
382
+ };
383
+ }
384
+
385
+ private schedule(watcher: Watcher): void {
386
+ if (this.disposed || !this.watchers.has(watcher.id)) return;
387
+ watcher.timer = setTimeout(() => { watcher.done = this.check(watcher); }, watcher.options.intervalSeconds * 1000);
388
+ watcher.timer.unref();
389
+ }
390
+
391
+ private notifyWatcher(watcher: Watcher, event: string): void {
392
+ this.queue({
393
+ id: watcher.id, kind: "watcher", event, mode: watcher.options.mode,
394
+ command: watcher.options.command, cwd: watcher.options.cwd,
395
+ label: watcher.options.label, instruction: watcher.options.instruction, exit_code: watcher.lastExitCode,
396
+ error: watcher.lastError, output: watcher.lastOutput.slice(-2000), log_path: watcher.log.path,
397
+ });
398
+ }
399
+
400
+ private async check(watcher: Watcher): Promise<void> {
401
+ if (this.disposed || !this.watchers.has(watcher.id)) return;
402
+ const options = watcher.options;
403
+ if (options.mode === "interval") {
404
+ watcher.lastCheck = new Date().toISOString();
405
+ watcher.lastError = undefined;
406
+ try {
407
+ appendOutput(watcher.log, Buffer.from(`\n[${watcher.lastCheck}] Reminder\n`));
408
+ } catch (error) {
409
+ watcher.lastError = `Cannot write the log: ${errorMessage(error)}`;
410
+ }
411
+ this.notifyWatcher(watcher, "reminder");
412
+ this.schedule(watcher);
413
+ return;
414
+ }
415
+ const controller = new AbortController();
416
+ watcher.controller = controller;
417
+ const hash = createHash("sha256");
418
+ let output = Buffer.alloc(0);
419
+ let failure: string | undefined;
420
+ let exitCode: number | null = null;
421
+ const timeout = options.timeoutSeconds ?? 10;
422
+ try {
423
+ appendOutput(watcher.log, Buffer.from(`\n[${new Date().toISOString()}] Checking\n`));
424
+ const result = await watcher.operations.exec(options.command, options.cwd, {
425
+ signal: controller.signal, timeout,
426
+ onData: data => {
427
+ hash.update(data);
428
+ output = Buffer.concat([output, data]).subarray(-MAX_TAIL_BYTES);
429
+ try {
430
+ appendOutput(watcher.log, data);
431
+ } catch (error) {
432
+ failure = `Cannot write the log: ${errorMessage(error)}`;
433
+ controller.abort();
434
+ }
435
+ },
436
+ });
437
+ exitCode = result.exitCode;
438
+ if (exitCode === 126 || exitCode === 127) failure = `Check command could not run (exit ${exitCode}).`;
439
+ } catch (error) {
440
+ failure ??= errorMessage(error, timeout);
441
+ }
442
+ if (this.disposed || !this.watchers.has(watcher.id)) return;
443
+ watcher.lastCheck = new Date().toISOString();
444
+ watcher.lastExitCode = exitCode;
445
+ watcher.lastError = failure;
446
+ watcher.lastOutput = output.toString("utf8");
447
+ watcher.controller = undefined;
448
+ const signature = `${exitCode}:${failure ?? ""}:${hash.digest("hex")}`;
449
+ let event: string | undefined;
450
+ if (failure) {
451
+ if (failure !== watcher.previousError) event = "check_failed";
452
+ } else if (options.mode === "until_success") {
453
+ if (exitCode === 0) {
454
+ event = "matched";
455
+ this.watchers.delete(watcher.id);
456
+ }
457
+ } else {
458
+ if (watcher.previous !== undefined && watcher.previous !== signature) event = "changed";
459
+ }
460
+ watcher.previous = signature;
461
+ watcher.previousError = failure;
462
+ if (event) this.notifyWatcher(watcher, event);
463
+ this.schedule(watcher);
464
+ }
465
+
466
+ listWatchers() {
467
+ return [...this.watchers.values()].map(watcher => ({
468
+ id: watcher.id, command: watcher.options.command, cwd: watcher.options.cwd, label: watcher.options.label,
469
+ mode: watcher.options.mode, interval_seconds: watcher.options.intervalSeconds, instruction: watcher.options.instruction,
470
+ last_check: watcher.lastCheck, last_exit_code: watcher.lastExitCode, last_error: watcher.lastError,
471
+ output: watcher.lastOutput.slice(-2000), log_path: watcher.log.path, log_truncated: watcher.log.truncated,
472
+ }));
473
+ }
474
+
475
+ async deleteWatcher(id: string): Promise<void> {
476
+ const watcher = this.watchers.get(id);
477
+ if (!watcher) throw new Error(`Unknown watcher: ${id}`);
478
+ this.watchers.delete(id);
479
+ this.pending.delete(id);
480
+ clearTimeout(watcher.timer);
481
+ watcher.controller?.abort();
482
+ this.changed();
483
+ await watcher.done;
484
+ }
485
+
486
+ async dispose(): Promise<void> {
487
+ this.disposed = true;
488
+ this.detach();
489
+ this.pending.clear();
490
+ await Promise.all([
491
+ ...[...this.processes.keys()].map(id => this.stopProcess(id)),
492
+ ...[...this.watchers.keys()].map(id => this.deleteWatcher(id)),
493
+ ]);
494
+ }
495
+ }
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@steveimm/pi-background",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Background commands and polling watchers that notify the Pi agent",
5
+ "scripts": {
6
+ "check": "tsc --noEmit",
7
+ "test": "node --test test/*.test.mjs",
8
+ "test:live": "node test/live-gpt.mjs",
9
+ "test:terminal": "node test/terminal-smoke.mjs"
10
+ },
11
+ "keywords": [
12
+ "pi-package"
13
+ ],
14
+ "license": "MIT",
15
+ "type": "module",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/steveimm/pi-ext-collection.git",
19
+ "directory": "packages/pi-background"
20
+ },
21
+ "homepage": "https://github.com/steveimm/pi-ext-collection/tree/main/packages/pi-background#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/steveimm/pi-ext-collection/issues"
24
+ },
25
+ "peerDependencies": {
26
+ "@earendil-works/pi-coding-agent": "*",
27
+ "@earendil-works/pi-tui": "*",
28
+ "typebox": "*"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "pi": {
34
+ "extensions": [
35
+ "./index.ts"
36
+ ]
37
+ },
38
+ "files": [
39
+ "*.ts",
40
+ "README.md",
41
+ "LICENSE"
42
+ ],
43
+ "devDependencies": {
44
+ "@earendil-works/pi-coding-agent": "1.5.1",
45
+ "@earendil-works/pi-tui": "1.5.1",
46
+ "typebox": "1.3.27"
47
+ }
48
+ }
package/status.ts ADDED
@@ -0,0 +1,143 @@
1
+ import type { CustomEditor, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { sliceByColumn, stripTerminalSequences, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
3
+ import type { BackgroundManager } from "./manager.js";
4
+ import { activeTasks, counts, showBackgroundTasks } from "./ui.js";
5
+
6
+ function findFooter(components: Component[]): Component | undefined {
7
+ for (const component of components) {
8
+ if ("setAutoCompactEnabled" in component && typeof component.setAutoCompactEnabled === "function") return component;
9
+ if ("children" in component && Array.isArray(component.children)) {
10
+ const footer = findFooter(component.children);
11
+ if (footer) return footer;
12
+ }
13
+ }
14
+ return undefined;
15
+ }
16
+
17
+ function centerStatus(line: string, status: string, width: number): string {
18
+ const text = stripTerminalSequences(line);
19
+ const gap = / {2,}/.exec(text);
20
+ const leftWidth = gap ? visibleWidth(text.slice(0, gap.index)) : visibleWidth(text);
21
+ const rightStart = gap ? leftWidth + gap[0].length : width;
22
+ const statusWidth = visibleWidth(status);
23
+ const start = Math.max(0, Math.floor((width - statusWidth) / 2));
24
+ const end = start + statusWidth;
25
+ const left = truncateToWidth(sliceByColumn(line, 0, leftWidth), Math.max(0, start - 1));
26
+ const right = truncateToWidth(sliceByColumn(line, rightStart, width - rightStart), Math.max(0, width - end - 1));
27
+ return left + " ".repeat(Math.max(0, start - visibleWidth(left))) + status
28
+ + " ".repeat(Math.max(0, width - end - visibleWidth(right))) + right;
29
+ }
30
+
31
+ export function attachBackgroundStatus(manager: BackgroundManager, ctx: ExtensionContext): () => void {
32
+ const previousFactory = ctx.ui.getEditorComponent();
33
+ let cleanup = () => {};
34
+ const factory: NonNullable<ReturnType<typeof ctx.ui.getEditorComponent>> = (tui, _theme, keys) => {
35
+ if (!("getFocusedComponent" in tui) || typeof tui.getFocusedComponent !== "function") {
36
+ throw new Error("Cannot access Pi's editor for background task navigation.");
37
+ }
38
+ const editor = tui.getFocusedComponent() as CustomEditor | null;
39
+ const footer = findFooter(tui.children);
40
+ if (!editor || typeof editor.getCursor !== "function" || typeof editor.isShowingAutocomplete !== "function" || !footer) {
41
+ throw new Error("Background task navigation requires Pi's standard editor and footer.");
42
+ }
43
+ let tasks = activeTasks(manager);
44
+ let footerDisposed = false;
45
+ let dialogOpen = false;
46
+ const originalInput = editor.handleInput;
47
+ const unsubscribe = manager.subscribe(() => {
48
+ tasks = activeTasks(manager);
49
+ if (!tasks.length && status.focused) tui.setFocus(input);
50
+ tui.requestRender();
51
+ });
52
+ // Render Pi's existing footer to preserve its token, model, and session information.
53
+ const status = {
54
+ focused: false,
55
+ render(width: number) {
56
+ const lines = footer.render(width);
57
+ if (tasks.length && lines[1] !== undefined) {
58
+ const text = ctx.ui.theme.fg("accent", truncateToWidth(counts(tasks), width));
59
+ lines[1] = centerStatus(lines[1], this.focused ? ctx.ui.theme.inverse(text) : text, width);
60
+ }
61
+ return lines;
62
+ },
63
+ handleInput(data: string) {
64
+ if (keys.matches(data, "tui.editor.cursorDown")) return;
65
+ tui.setFocus(input);
66
+ if (keys.matches(data, "tui.input.submit")) {
67
+ dialogOpen = true;
68
+ void showBackgroundTasks(manager, ctx).catch(error => {
69
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
70
+ }).finally(() => { dialogOpen = false; tui.requestRender(); });
71
+ } else if (!keys.matches(data, "tui.editor.cursorUp") && !keys.matches(data, "tui.select.cancel")) {
72
+ input.handleInput(data);
73
+ }
74
+ tui.requestRender();
75
+ },
76
+ invalidate() { footer.invalidate(); },
77
+ dispose() {
78
+ footerDisposed = true;
79
+ unsubscribe();
80
+ if (this.focused) tui.setFocus(input);
81
+ },
82
+ };
83
+ const handleInput = (data: string) => {
84
+ if (dialogOpen && !footerDisposed) return;
85
+ if (footerDisposed || !tasks.length || !keys.matches(data, "tui.editor.cursorDown") || editor.isShowingAutocomplete()) {
86
+ originalInput.call(editor, data);
87
+ return;
88
+ }
89
+ const before = editor.getCursor();
90
+ const text = editor.getText();
91
+ const onChange = editor.onChange;
92
+ let changed = false;
93
+ editor.onChange = value => { changed = true; onChange?.(value); };
94
+ try {
95
+ originalInput.call(editor, data);
96
+ } finally {
97
+ if (onChange) editor.onChange = onChange;
98
+ else delete editor.onChange;
99
+ }
100
+ const after = editor.getCursor();
101
+ if (!changed && text === editor.getText() && before.line === after.line && before.col === after.col) {
102
+ tui.setFocus(status);
103
+ tui.requestRender();
104
+ }
105
+ };
106
+ const input = {
107
+ get focused() { return editor.focused; },
108
+ set focused(value: boolean) { editor.focused = value; },
109
+ get capturesViewportInput() { return editor.capturesViewportInput; },
110
+ get borderColor() { return editor.borderColor; },
111
+ set borderColor(value: typeof editor.borderColor) { editor.borderColor = value; },
112
+ get embedWorkingStatus() { return editor.embedWorkingStatus; },
113
+ setWorkingStatusIndicator: editor.setWorkingStatusIndicator?.bind(editor),
114
+ render: editor.render.bind(editor), invalidate: editor.invalidate.bind(editor), handleInput,
115
+ handleMouse: editor.handleMouse.bind(editor),
116
+ getText: editor.getText.bind(editor), setText: editor.setText.bind(editor),
117
+ getExpandedText: editor.getExpandedText.bind(editor), addToHistory: editor.addToHistory.bind(editor),
118
+ insertTextAtCursor: editor.insertTextAtCursor.bind(editor),
119
+ setAutocompleteProvider: editor.setAutocompleteProvider.bind(editor),
120
+ setPaddingX: editor.setPaddingX.bind(editor), setAutocompleteMaxVisible: editor.setAutocompleteMaxVisible.bind(editor),
121
+ };
122
+ // Forward editor callbacks without triggering CustomEditor's app-handler rewiring.
123
+ for (const key of ["onSubmit", "onChange"] as const) {
124
+ Object.defineProperty(input, key, {
125
+ get: () => editor[key],
126
+ set: (value: (text: string) => void) => { editor[key] = value; },
127
+ });
128
+ }
129
+ ctx.ui.setFooter(() => status);
130
+ cleanup = () => {
131
+ unsubscribe();
132
+ if (!footerDisposed) ctx.ui.setFooter(undefined);
133
+ };
134
+ return input;
135
+ };
136
+ // /reload focuses a loading panel before session_start runs.
137
+ ctx.ui.setEditorComponent(previousFactory);
138
+ ctx.ui.setEditorComponent(factory);
139
+ return () => {
140
+ cleanup();
141
+ if (ctx.ui.getEditorComponent() === factory) ctx.ui.setEditorComponent(previousFactory);
142
+ };
143
+ }
package/ui.ts ADDED
@@ -0,0 +1,93 @@
1
+ import { getSelectListTheme, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { matchesKey, SelectList, Text } from "@earendil-works/pi-tui";
3
+ import type { BackgroundManager } from "./manager.js";
4
+
5
+ export function activeTasks(manager: BackgroundManager) {
6
+ return [
7
+ ...manager.listProcesses().filter(job => job.status === "running").map(job => ({ ...job, kind: "process" as const })),
8
+ ...manager.listWatchers().map(watcher => ({ ...watcher, kind: "watcher" as const })),
9
+ ];
10
+ }
11
+
12
+ export function counts(tasks: ReturnType<typeof activeTasks>): string {
13
+ const processes = tasks.filter(task => task.kind === "process").length;
14
+ const watchers = tasks.length - processes;
15
+ return `${processes} background ${processes === 1 ? "process" : "processes"}, ${watchers} ${watchers === 1 ? "watcher" : "watchers"}`;
16
+ }
17
+
18
+ export async function showBackgroundTasks(manager: BackgroundManager, ctx: ExtensionContext): Promise<void> {
19
+ if (ctx.mode !== "tui") {
20
+ ctx.ui.notify("/bg requires TUI mode.", "error");
21
+ return;
22
+ }
23
+ await ctx.ui.custom<void>((tui, theme, keys, done) => {
24
+ let tasks: ReturnType<typeof activeTasks> = [];
25
+ let list: SelectList;
26
+ let busy = false;
27
+ let disposed = false;
28
+ let message = "";
29
+ const title = new Text("", 1, 1);
30
+ const hint = new Text("", 1, 1);
31
+ const feedback = new Text("", 1, 0);
32
+
33
+ async function stop(id: string) {
34
+ const task = tasks.find(task => task.id === id);
35
+ if (!task || busy) return;
36
+ busy = true;
37
+ message = "Stopping...";
38
+ refresh();
39
+ try {
40
+ if (task.kind === "process") await manager.stopProcess(task.id);
41
+ else await manager.deleteWatcher(task.id);
42
+ message = task.kind === "process" ? "Process stopped." : "Watcher stopped.";
43
+ } catch (error) {
44
+ message = error instanceof Error ? error.message : String(error);
45
+ } finally {
46
+ busy = false;
47
+ if (!disposed) refresh();
48
+ }
49
+ }
50
+
51
+ function refresh() {
52
+ const selected = list?.getSelectedItem()?.value;
53
+ tasks = activeTasks(manager);
54
+ title.setText(theme.fg("accent", counts(tasks)));
55
+ list = new SelectList(tasks.map(task => {
56
+ const name = task.label || task.command || (task.kind === "watcher" ? task.instruction : "") || task.id;
57
+ const kind = task.kind === "process" ? "Process" : `Watcher (${task.mode})`;
58
+ return { value: task.id, label: `${kind}: ${name.replace(/\s+/g, " ")}` };
59
+ }), 8, getSelectListTheme());
60
+ list.setSelectedIndex(Math.max(0, tasks.findIndex(task => task.id === selected)));
61
+ const actions = tasks.length && !busy
62
+ ? `${keys.getKeys("tui.select.up").join("/")}/${keys.getKeys("tui.select.down").join("/")} select | x kill selected task | `
63
+ : "";
64
+ hint.setText(theme.fg("dim", `${actions}esc close`));
65
+ feedback.setText(message);
66
+ tui.requestRender();
67
+ }
68
+
69
+ refresh();
70
+ const unsubscribe = manager.subscribe(refresh);
71
+ return {
72
+ capturesViewportInput: true,
73
+ render(width: number) {
74
+ return [
75
+ ...title.render(width),
76
+ ...(tasks.length ? list.render(width) : new Text("No active background tasks.", 1, 0).render(width)),
77
+ ...hint.render(width),
78
+ ...(message ? feedback.render(width) : []),
79
+ ];
80
+ },
81
+ handleInput(data: string) {
82
+ if (matchesKey(data, "escape")) done();
83
+ else if (!busy && matchesKey(data, "x")) {
84
+ const selected = list.getSelectedItem();
85
+ if (selected) void stop(selected.value);
86
+ } else if (!busy) list.handleInput(data);
87
+ tui.requestRender();
88
+ },
89
+ invalidate() { title.invalidate(); list.invalidate(); hint.invalidate(); feedback.invalidate(); },
90
+ dispose() { disposed = true; unsubscribe(); },
91
+ };
92
+ });
93
+ }