@runminton/pi-async-bash 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 +21 -0
- package/README.md +123 -2
- package/index.ts +141 -0
- package/package.json +42 -4
- package/src/status.ts +78 -0
- package/src/tasks.ts +275 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RunMintOn
|
|
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,124 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Async Bash
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Long bash commands no longer hold a tool call hostage.
|
|
4
|
+
|
|
5
|
+
- Finishes within 63 seconds: returns the normal bash result.
|
|
6
|
+
- Still running after 63 seconds: returns a task ID, the command keeps running, and the original session is notified when it ends.
|
|
7
|
+
- Adds `bash_task` with `status` and `kill`.
|
|
8
|
+
- No `background` parameter. The model never has to guess how long a command will take.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pi install npm:@runminton/pi-async-bash
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or load it for one invocation without changing your settings:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pi -e npm:@runminton/pi-async-bash
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Installing modifies your pi configuration explicitly through `pi install`. Loading with `-e` does not.
|
|
23
|
+
|
|
24
|
+
Verified on pi 1.3.0, Node 22.23.2 and macOS. Implementation reuses pi's local shell backend; Windows is untested.
|
|
25
|
+
|
|
26
|
+
Do not load another extension that overrides `bash` at the same time. pi 1.3.0 reports duplicate tools as a conflict and selects a definition by load order; it does not compose two execution backends. This extension honours the standard `shellPath`, `shellCommandPrefix` and `tool_call` restrictions. It does not compose another extension's private spawn hook, SSH or sandbox execution backend.
|
|
27
|
+
|
|
28
|
+
With a version of `pi-compact-tool` that no longer overrides bash execution, async-bash executes and compact renders; both load orders work. Older compact versions still register bash and must be updated together.
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
Normal bash parameters are unchanged:
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
bash({ command: "npm run e2e", timeout: 600 })
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
If the command is still running after 63 seconds, the call returns:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
Still running after 63 seconds. Task: <task_id>
|
|
42
|
+
Log: <absolute_log_path>
|
|
43
|
+
You will be notified when it finishes. Do not poll.
|
|
44
|
+
Continue work that does not depend on this result. If none remains, end your turn and resume when notified.
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Then, on demand:
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
bash_task({ task_id: "...", action: "status" })
|
|
51
|
+
bash_task({ task_id: "...", action: "kill" })
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
New task IDs are 8 random hexadecimal characters, for example `a3f8c921`, regenerated on collision. Existing UUID tasks remain readable and controllable.
|
|
55
|
+
|
|
56
|
+
| Action | Behaviour |
|
|
57
|
+
|---|---|
|
|
58
|
+
| status | Returns the status, log location and a bounded log tail |
|
|
59
|
+
| kill | Stops the managed process group and returns after observing the result; a finished task keeps its original result |
|
|
60
|
+
|
|
61
|
+
`timeout` is the execution limit for the whole command. A background handoff does not restart it. The 63 seconds are a waiting threshold, not an execution timeout.
|
|
62
|
+
|
|
63
|
+
`bash_task` no longer supports `wait`, and `bash` gains no synchronous waiting parameter. After a running result, continue work that does not depend on it; when nothing is left, end the turn and resume when notified.
|
|
64
|
+
|
|
65
|
+
## Footer
|
|
66
|
+
|
|
67
|
+
In the TUI, current-session background tasks appear through pi's native status API. The footer itself is not replaced and compact is not required:
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
BG 2 · 3m 12s / 1m 05s
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Each duration counts from when its command actually started, including the foreground 63 seconds. Entries are ordered by start time and refresh every second; IDs are not shown. A finished task is removed immediately, and the status disappears when nothing is running. Foreground commands and `unknown` tasks from an earlier runtime are never counted.
|
|
74
|
+
|
|
75
|
+
Narrow terminals drop spaces inside durations first, then report omitted entries as `+N`, for example `BG 2 · 3m12s / +1`. The native footer still applies its single-line truncation to all extension statuses.
|
|
76
|
+
|
|
77
|
+
Switching sessions only shows tasks the current session owns or inherits by reference. The compact tool row shows `Backgrounded` to record that a handoff happened at that point; the footer shows what is running now, and the completion notification carries the final result.
|
|
78
|
+
|
|
79
|
+
## Lifecycle
|
|
80
|
+
|
|
81
|
+
- Before a task ID is returned, Escape cancels the running command.
|
|
82
|
+
- After a task ID is returned, Escape does not stop that task; use kill.
|
|
83
|
+
- `/tree`, session switching, fork/clone and reload do not stop tasks.
|
|
84
|
+
- Completion notifications wake only the owning session. Returning to another branch adds state but does not replay the automatic notification.
|
|
85
|
+
- fork/clone inherits the same task ID without copying the process; an inheriting session can query and stop it explicitly.
|
|
86
|
+
- A normal pi exit stops tasks managed by the current process.
|
|
87
|
+
- After a crash, task survival and cleanup are not promised. A new runtime marks executions it cannot confirm as `unknown`; it does not guess an exit code or adopt or stop anything based on a stale PID.
|
|
88
|
+
- Long-running services usually never exit on their own. A `running` task does not mean a service is ready to accept requests.
|
|
89
|
+
|
|
90
|
+
Automatic handoff releases only that one bash call. When the same batch contains other blocking tools, the current turn may still have to wait.
|
|
91
|
+
|
|
92
|
+
## Output and state
|
|
93
|
+
|
|
94
|
+
Complete stdout/stderr is saved to a log. Task metadata lives under pi's agent data directory in `extension-data/async-bash/` and is never written into the project. Ordinary short commands also produce a raw output log, but expose no task ID and create no background completion notification.
|
|
95
|
+
|
|
96
|
+
Completion notifications and `status` include at most 100 log lines and 16 KiB. Restored context has a separate 16 KiB total budget and does not repeatedly inject full logs.
|
|
97
|
+
|
|
98
|
+
Statuses are `running`, `exited`, `killed`, `timed_out`, `failed` and `unknown`. A non-zero exit is still `exited`, with the exit code kept separately. Querying a failed task with `status` is not itself a tool error; `kill` on a failing terminal state is marked as an error.
|
|
99
|
+
|
|
100
|
+
Inside codemode:
|
|
101
|
+
|
|
102
|
+
- Short bash calls return the original structured fields such as `output` and `exit_code`.
|
|
103
|
+
- Long bash calls return `task_id`, `status: "running"`, `log_path` and similar fields, with a null exit code.
|
|
104
|
+
- `bash_task` returns task status and a bounded `output`; scripts should check status and exit code.
|
|
105
|
+
|
|
106
|
+
There is no automatic log cleanup, PTY, automatic retry, task list or cross-process task adoption. Termination is guaranteed only for the managed process group, excluding daemon descendants that deliberately leave it.
|
|
107
|
+
|
|
108
|
+
## Development
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
npm ci
|
|
112
|
+
npm run check
|
|
113
|
+
npm test
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Tests use a local deterministic provider, real AgentSessions, real CLI/RPC and real child processes. No external model API is called. The suite performs several real 63-second handoffs and usually takes four to five minutes.
|
|
117
|
+
|
|
118
|
+
RPC tests print a temporary artifact directory that keeps each process's `events-<pid>.jsonl`, `stderr-<pid>.log`, session and command logs.
|
|
119
|
+
|
|
120
|
+
Local implementation notes are in `TICKETS.md`, the acceptance contract in `SPEC.md`, and research evidence in `INVESTIGATION.md`.
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
package/index.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { createBashToolDefinition, createLocalBashOperations, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { eventId, getManager, type TaskRecord } from "./src/tasks.ts";
|
|
5
|
+
import { installTaskStatus, taskReferences } from "./src/status.ts";
|
|
6
|
+
|
|
7
|
+
function details(value: unknown): { task_id?: string; task_event_id?: string } {
|
|
8
|
+
return value && typeof value === "object" ? value : {};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function asyncBash(pi: ExtensionAPI) {
|
|
12
|
+
const definition = createBashToolDefinition(process.cwd());
|
|
13
|
+
const manager = getManager(join(getAgentDir(), "extension-data", "async-bash"));
|
|
14
|
+
const taskSchema = Type.Object({
|
|
15
|
+
task_id: Type.String(), status: Type.String(), log_path: Type.String(),
|
|
16
|
+
exit_code: Type.Union([Type.Number(), Type.Null()]), wall_time_seconds: Type.Number(),
|
|
17
|
+
}, { additionalProperties: true });
|
|
18
|
+
pi.registerTool<typeof definition.parameters, unknown>({
|
|
19
|
+
...definition,
|
|
20
|
+
renderResult: undefined,
|
|
21
|
+
description: definition.description + " After 63 seconds, returns a running task instead of waiting. Completion is notified automatically.",
|
|
22
|
+
outputSchema: Type.Union([definition.outputSchema!, taskSchema]),
|
|
23
|
+
execute(id, input, signal, update, ctx) {
|
|
24
|
+
const settings = pi.getSettings();
|
|
25
|
+
const operations = createLocalBashOperations({ shellPath: settings.shellPath });
|
|
26
|
+
let foreground = true;
|
|
27
|
+
return manager.execute({
|
|
28
|
+
owner: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, timeout: input.timeout, signal,
|
|
29
|
+
run: (taskSignal, log, started) => createBashToolDefinition(ctx.cwd, {
|
|
30
|
+
shellPath: settings.shellPath, commandPrefix: settings.shellCommandPrefix,
|
|
31
|
+
operations: {
|
|
32
|
+
exec(command, cwd, options) {
|
|
33
|
+
started();
|
|
34
|
+
return operations.exec(command, cwd, {
|
|
35
|
+
...options,
|
|
36
|
+
onData(data) { log(data); options.onData(data); },
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
}).execute(id, input, taskSignal, (value) => { if (foreground) update?.(value); }, ctx),
|
|
41
|
+
}).then((result) => {
|
|
42
|
+
const id = details(result.details).task_id;
|
|
43
|
+
// Nested codemode results are not transcript toolResults; persist the reference on the branch too.
|
|
44
|
+
if (id) pi.appendEntry("async-bash-task", { task_id: id });
|
|
45
|
+
return result;
|
|
46
|
+
}).finally(() => { foreground = false; });
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
pi.registerTool({
|
|
50
|
+
name: "bash_task",
|
|
51
|
+
label: "bash task",
|
|
52
|
+
description: "Inspect or kill a bash task. Completion is notified automatically.",
|
|
53
|
+
parameters: Type.Object({
|
|
54
|
+
task_id: Type.String({ description: "Task ID returned by bash" }),
|
|
55
|
+
action: Type.Union([Type.Literal("status"), Type.Literal("kill")]),
|
|
56
|
+
}),
|
|
57
|
+
outputSchema: taskSchema,
|
|
58
|
+
execute: (_id, args, signal) => manager.control(args.task_id, args.action, signal),
|
|
59
|
+
});
|
|
60
|
+
let unbind = () => {};
|
|
61
|
+
pi.on("session_start", (_event, ctx) => {
|
|
62
|
+
unbind();
|
|
63
|
+
const disposeStatus = installTaskStatus(pi, ctx, manager);
|
|
64
|
+
const owner = ctx.sessionManager.getSessionId();
|
|
65
|
+
let active = true;
|
|
66
|
+
const queued = new Set<string>();
|
|
67
|
+
const seen = new Set<string>();
|
|
68
|
+
const pending = new Map<string, TaskRecord>();
|
|
69
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
70
|
+
const data = entry.type === "custom_message" ? details(entry.details) :
|
|
71
|
+
entry.type === "message" && entry.message.role === "toolResult" ? details(entry.message.details) : {};
|
|
72
|
+
if (data.task_event_id) seen.add(data.task_event_id);
|
|
73
|
+
}
|
|
74
|
+
const flush = (atTurnBoundary = false) => {
|
|
75
|
+
if (!active || (!atTurnBoundary && !ctx.isIdle())) return;
|
|
76
|
+
for (const [id, record] of pending) {
|
|
77
|
+
if (seen.has(id) || queued.has(id)) { pending.delete(id); continue; }
|
|
78
|
+
try {
|
|
79
|
+
const result = manager.describe(record);
|
|
80
|
+
queued.add(id);
|
|
81
|
+
pi.sendMessage({
|
|
82
|
+
customType: "async-bash-completion", content: [
|
|
83
|
+
{ type: "text", text: "Background bash task finished. Use the result below to continue any dependent work." },
|
|
84
|
+
...result.content,
|
|
85
|
+
],
|
|
86
|
+
display: true, details: result.details,
|
|
87
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
88
|
+
pending.delete(id);
|
|
89
|
+
} catch {
|
|
90
|
+
queued.delete(id);
|
|
91
|
+
// The persisted terminal state remains available to context/status if delivery fails.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const unsubscribe = manager.subscribe((record: TaskRecord) => {
|
|
96
|
+
if (record.owner_session_id !== owner) return;
|
|
97
|
+
const id = eventId(record);
|
|
98
|
+
if (seen.has(id) || queued.has(id)) return;
|
|
99
|
+
pending.set(id, record);
|
|
100
|
+
setImmediate(() => flush());
|
|
101
|
+
});
|
|
102
|
+
const offMessage = pi.on("message_end", (event) => {
|
|
103
|
+
const message = event.message;
|
|
104
|
+
const data = message.role === "custom" || message.role === "toolResult" ? details(message.details) : {};
|
|
105
|
+
if (data.task_event_id) {
|
|
106
|
+
seen.add(data.task_event_id);
|
|
107
|
+
pending.delete(data.task_event_id);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
// Task results must reach the transcript before deciding whether another turn is necessary.
|
|
111
|
+
const offTurn = pi.on("turn_end", () => flush(true));
|
|
112
|
+
const offSettled = pi.on("agent_before_settle", () => flush(true));
|
|
113
|
+
unbind = () => { active = false; disposeStatus(); unsubscribe(); offMessage(); offTurn(); offSettled(); };
|
|
114
|
+
});
|
|
115
|
+
pi.on("context", (event, ctx) => {
|
|
116
|
+
const references = taskReferences(ctx.sessionManager.getBranch());
|
|
117
|
+
const visible = new Set(event.messages.flatMap((m) => {
|
|
118
|
+
const id = m.role === "custom" || m.role === "toolResult" ? details(m.details).task_event_id : undefined;
|
|
119
|
+
return id ? [id] : [];
|
|
120
|
+
}));
|
|
121
|
+
const summaries: string[] = [];
|
|
122
|
+
let bytes = 0;
|
|
123
|
+
for (const task of manager.forSession(ctx.sessionManager.getSessionId(), references)) {
|
|
124
|
+
if (task.status !== "running" && visible.has(eventId(task))) continue;
|
|
125
|
+
const line = `Task ${task.task_id}: ${task.status}; exit_code=${task.exit_code}; log=${task.log_path}`;
|
|
126
|
+
const size = Buffer.byteLength(line) + 1;
|
|
127
|
+
if (bytes + size > 16384) break;
|
|
128
|
+
summaries.push(line);
|
|
129
|
+
bytes += size;
|
|
130
|
+
}
|
|
131
|
+
if (!summaries.length) return;
|
|
132
|
+
return { messages: [{
|
|
133
|
+
role: "custom" as const, customType: "async-bash-state",
|
|
134
|
+
content: summaries.join("\n"), display: false, timestamp: Date.now(),
|
|
135
|
+
}, ...event.messages] };
|
|
136
|
+
});
|
|
137
|
+
pi.on("session_shutdown", async (event) => {
|
|
138
|
+
unbind();
|
|
139
|
+
if (event.reason === "quit") await manager.shutdown();
|
|
140
|
+
});
|
|
141
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runminton/pi-async-bash",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Automatically hand long-running pi bash commands to a managed background task and resume the session when it finishes.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "RunMintOn",
|
|
8
|
+
"homepage": "https://github.com/RunMintOn/pi-async-bash#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/RunMintOn/pi-async-bash.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/RunMintOn/pi-async-bash/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi-extension",
|
|
19
|
+
"bash",
|
|
20
|
+
"async",
|
|
21
|
+
"background-task"
|
|
22
|
+
],
|
|
23
|
+
"pi": {
|
|
24
|
+
"extensions": ["./index.ts"]
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"index.ts",
|
|
28
|
+
"src",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "node --test --test-concurrency=1 test/*.test.mjs",
|
|
34
|
+
"check": "tsc --noEmit"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
38
|
+
"typebox": "*"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^22.19.19",
|
|
42
|
+
"typescript": "^5.9.3"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TaskManager, TaskRecord } from "./tasks.ts";
|
|
3
|
+
|
|
4
|
+
export function taskReferences(entries: readonly SessionEntry[]): Set<string> {
|
|
5
|
+
const ids = new Set<string>();
|
|
6
|
+
for (const entry of entries) {
|
|
7
|
+
const value = entry.type === "custom" && entry.customType === "async-bash-task" ? entry.data :
|
|
8
|
+
entry.type === "message" && entry.message.role === "toolResult" ? entry.message.details : undefined;
|
|
9
|
+
const id = value && typeof value === "object" && "task_id" in value ? value.task_id : undefined;
|
|
10
|
+
if (typeof id === "string") ids.add(id);
|
|
11
|
+
}
|
|
12
|
+
return ids;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function duration(milliseconds: number): string {
|
|
16
|
+
const seconds = Math.max(0, Math.floor(milliseconds / 1000));
|
|
17
|
+
if (seconds < 60) return `${seconds}s`;
|
|
18
|
+
const minutes = Math.floor(seconds / 60);
|
|
19
|
+
if (minutes < 60) return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
|
|
20
|
+
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m ${String(seconds % 60).padStart(2, "0")}s`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function backgroundStatus(tasks: readonly TaskRecord[], now: number, width: number): string | undefined {
|
|
24
|
+
if (!tasks.length) return undefined;
|
|
25
|
+
const ages = [...tasks].sort((a, b) => a.started_at - b.started_at)
|
|
26
|
+
.map(task => duration(now - task.started_at));
|
|
27
|
+
const variants = [ages, ages.map(age => age.replaceAll(" ", ""))];
|
|
28
|
+
const prefix = `BG ${tasks.length}`;
|
|
29
|
+
for (let shown = ages.length; shown > 0; shown--) {
|
|
30
|
+
for (const durations of variants) {
|
|
31
|
+
const text = `${prefix} · ${durations.slice(0, shown).join(" / ")}${shown < ages.length ? ` / +${ages.length - shown}` : ""}`;
|
|
32
|
+
if (text.length <= width) return text;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const compact = `${prefix} · +${ages.length}`;
|
|
36
|
+
return compact.length <= width ? compact : prefix;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function installTaskStatus(pi: ExtensionAPI, ctx: ExtensionContext, manager: TaskManager): () => void {
|
|
40
|
+
if (ctx.mode !== "tui" || !ctx.hasUI) return () => {};
|
|
41
|
+
const owner = ctx.sessionManager.getSessionId();
|
|
42
|
+
let tasks: TaskRecord[] = [];
|
|
43
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
44
|
+
let last: string | undefined;
|
|
45
|
+
const update = () => {
|
|
46
|
+
// Keep the omission count visible if the native footer truncates other statuses.
|
|
47
|
+
const text = backgroundStatus(tasks, Date.now(), Math.max(0, (process.stdout.columns ?? 80) - 3));
|
|
48
|
+
if (text !== last) {
|
|
49
|
+
ctx.ui.setStatus("async-bash", text);
|
|
50
|
+
last = text;
|
|
51
|
+
}
|
|
52
|
+
if (tasks.length && !timer) timer = setInterval(update, 1000).unref();
|
|
53
|
+
if (!tasks.length && timer) { clearInterval(timer); timer = undefined; }
|
|
54
|
+
};
|
|
55
|
+
const refresh = () => {
|
|
56
|
+
const references = taskReferences(ctx.sessionManager.getBranch());
|
|
57
|
+
// Rebuild at lifecycle boundaries; ticking ages needs no disk or log reads.
|
|
58
|
+
tasks = [...manager.forSession(owner, references)].filter(task => task.status === "running");
|
|
59
|
+
update();
|
|
60
|
+
};
|
|
61
|
+
const off = [
|
|
62
|
+
pi.on("tool_execution_end", event => { if (event.toolName === "bash") refresh(); }),
|
|
63
|
+
pi.on("session_tree", refresh),
|
|
64
|
+
pi.on("session_compact", refresh),
|
|
65
|
+
manager.subscribe(record => {
|
|
66
|
+
tasks = tasks.filter(task => task.task_id !== record.task_id);
|
|
67
|
+
update();
|
|
68
|
+
}),
|
|
69
|
+
];
|
|
70
|
+
process.stdout.on("resize", update);
|
|
71
|
+
refresh();
|
|
72
|
+
return () => {
|
|
73
|
+
for (const unsubscribe of off) unsubscribe();
|
|
74
|
+
process.stdout.off("resize", update);
|
|
75
|
+
if (timer) clearInterval(timer);
|
|
76
|
+
ctx.ui.setStatus("async-bash", undefined);
|
|
77
|
+
};
|
|
78
|
+
}
|
package/src/tasks.ts
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { truncateTail } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
6
|
+
|
|
7
|
+
type Result = AgentToolResult<unknown> & { structuredContent?: unknown; isError?: boolean };
|
|
8
|
+
export type TaskStatus = "running" | "exited" | "killed" | "timed_out" | "failed" | "unknown";
|
|
9
|
+
export interface TaskRecord {
|
|
10
|
+
task_id: string;
|
|
11
|
+
owner_session_id: string;
|
|
12
|
+
cwd: string;
|
|
13
|
+
status: TaskStatus;
|
|
14
|
+
log_path: string;
|
|
15
|
+
started_at: number;
|
|
16
|
+
finished_at?: number;
|
|
17
|
+
exit_code: number | null;
|
|
18
|
+
total_bytes: number;
|
|
19
|
+
total_lines: number | null;
|
|
20
|
+
error?: string;
|
|
21
|
+
}
|
|
22
|
+
interface LiveTask {
|
|
23
|
+
record: TaskRecord;
|
|
24
|
+
controller: AbortController;
|
|
25
|
+
handed: boolean;
|
|
26
|
+
stopRequested: boolean;
|
|
27
|
+
done: Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
type Outcome = { result: Result } | { error: Error };
|
|
30
|
+
const handoffMs = 63_000;
|
|
31
|
+
const taskIdPattern = /^(?:[0-9a-f]{8}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/;
|
|
32
|
+
|
|
33
|
+
export function eventId(task: TaskRecord) {
|
|
34
|
+
return `${task.task_id}:terminal`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const abort = () => { cleanup(); reject(new Error("Task control aborted.")); };
|
|
40
|
+
const cleanup = () => signal?.removeEventListener("abort", abort);
|
|
41
|
+
if (signal?.aborted) return abort();
|
|
42
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
43
|
+
promise.then((value) => { cleanup(); resolve(value); }, (error) => { cleanup(); reject(error); });
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class TaskManager {
|
|
48
|
+
private readonly instance = randomUUID();
|
|
49
|
+
private readonly live = new Map<string, LiveTask>();
|
|
50
|
+
private readonly listeners = new Set<(record: TaskRecord) => void>();
|
|
51
|
+
private closing = false;
|
|
52
|
+
|
|
53
|
+
constructor(private readonly directory: string) {
|
|
54
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private save(record: TaskRecord) {
|
|
58
|
+
const path = join(this.directory, `${record.task_id}.json`);
|
|
59
|
+
const temporary = `${path}.${this.instance}.tmp`;
|
|
60
|
+
writeFileSync(temporary, JSON.stringify(record), { mode: 0o600 });
|
|
61
|
+
renameSync(temporary, path);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
subscribe(listener: (record: TaskRecord) => void) {
|
|
65
|
+
this.listeners.add(listener);
|
|
66
|
+
return () => { this.listeners.delete(listener); };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async execute(options: {
|
|
70
|
+
owner: string;
|
|
71
|
+
cwd: string;
|
|
72
|
+
timeout?: number;
|
|
73
|
+
signal?: AbortSignal;
|
|
74
|
+
run: (signal: AbortSignal, log: (data: Buffer) => void, started: () => void) => Promise<Result>;
|
|
75
|
+
}): Promise<Result> {
|
|
76
|
+
if (this.closing) throw new Error("pi is shutting down.");
|
|
77
|
+
let id: string;
|
|
78
|
+
let fd: number;
|
|
79
|
+
while (true) {
|
|
80
|
+
id = randomBytes(4).toString("hex");
|
|
81
|
+
if (this.live.has(id) || existsSync(join(this.directory, `${id}.json`))) continue;
|
|
82
|
+
try {
|
|
83
|
+
// Exclusive creation also reserves the ID against other pi processes.
|
|
84
|
+
fd = openSync(join(this.directory, `${id}.log`), "wx", 0o600);
|
|
85
|
+
break;
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const record: TaskRecord = {
|
|
91
|
+
task_id: id, owner_session_id: options.owner,
|
|
92
|
+
cwd: options.cwd, status: "running", log_path: join(this.directory, `${id}.log`),
|
|
93
|
+
started_at: Date.now(), exit_code: null, total_bytes: 0, total_lines: 0,
|
|
94
|
+
};
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
let finish!: () => void;
|
|
97
|
+
const task: LiveTask = {
|
|
98
|
+
record, controller, handed: false, stopRequested: false,
|
|
99
|
+
done: new Promise<void>((resolve) => { finish = resolve; }),
|
|
100
|
+
};
|
|
101
|
+
this.live.set(id, task);
|
|
102
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
103
|
+
let lastByte: number | undefined;
|
|
104
|
+
let newlines = 0;
|
|
105
|
+
let logError: Error | undefined;
|
|
106
|
+
let release!: () => void;
|
|
107
|
+
const threshold = new Promise<"handoff">((resolve) => { release = () => resolve("handoff"); });
|
|
108
|
+
const abort = () => controller.abort();
|
|
109
|
+
if (options.signal?.aborted) abort();
|
|
110
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
111
|
+
const started = () => {
|
|
112
|
+
record.started_at = Date.now();
|
|
113
|
+
// A shorter execution timeout must settle as a foreground timeout.
|
|
114
|
+
if (options.timeout === undefined || options.timeout * 1000 > handoffMs) timer = setTimeout(release, handoffMs);
|
|
115
|
+
};
|
|
116
|
+
const log = (data: Buffer) => {
|
|
117
|
+
if (logError) return;
|
|
118
|
+
try {
|
|
119
|
+
let offset = 0;
|
|
120
|
+
while (offset < data.length) offset += writeSync(fd, data, offset, data.length - offset);
|
|
121
|
+
record.total_bytes += data.length;
|
|
122
|
+
for (const byte of data) if (byte === 10) newlines++;
|
|
123
|
+
if (data.length) lastByte = data[data.length - 1];
|
|
124
|
+
record.total_lines = newlines + (lastByte !== undefined && lastByte !== 10 ? 1 : 0);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
logError = error instanceof Error ? error : new Error(String(error));
|
|
127
|
+
controller.abort();
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const settle = (outcome: Outcome): Outcome => {
|
|
131
|
+
if (timer) clearTimeout(timer);
|
|
132
|
+
record.finished_at = Date.now();
|
|
133
|
+
if ("result" in outcome) {
|
|
134
|
+
const data = outcome.result.structuredContent as { exit_code?: number } | undefined;
|
|
135
|
+
record.exit_code = data?.exit_code ?? null;
|
|
136
|
+
record.status = "exited";
|
|
137
|
+
} else {
|
|
138
|
+
record.error = truncateTail(logError?.message ?? outcome.error.message, { maxLines: 1, maxBytes: 1024 }).content;
|
|
139
|
+
record.status = logError ? "failed" : task.stopRequested ? "killed" :
|
|
140
|
+
/Command timed out after [\d.]+ seconds$/.test(outcome.error.message) ? "timed_out" : "failed";
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
closeSync(fd);
|
|
144
|
+
if (task.handed) this.save(record);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
outcome = { error: error instanceof Error ? error : new Error(String(error)) };
|
|
147
|
+
record.error = `Cannot save task result: ${outcome.error.message}`;
|
|
148
|
+
record.status = "failed";
|
|
149
|
+
} finally {
|
|
150
|
+
this.live.delete(id);
|
|
151
|
+
finish();
|
|
152
|
+
}
|
|
153
|
+
if (task.handed) {
|
|
154
|
+
for (const listener of this.listeners) listener({ ...record });
|
|
155
|
+
}
|
|
156
|
+
return outcome;
|
|
157
|
+
};
|
|
158
|
+
const execution = Promise.resolve()
|
|
159
|
+
.then(() => options.run(controller.signal, log, started))
|
|
160
|
+
.then((result) => settle({ result }), (error: unknown) =>
|
|
161
|
+
settle({ error: error instanceof Error ? error : new Error(String(error)) }));
|
|
162
|
+
try {
|
|
163
|
+
const outcome = await Promise.race([execution, threshold]);
|
|
164
|
+
if (outcome !== "handoff" || record.status !== "running" || controller.signal.aborted || this.closing) {
|
|
165
|
+
const completed = outcome === "handoff" ? await execution : outcome;
|
|
166
|
+
if ("error" in completed) throw completed.error;
|
|
167
|
+
return completed.result;
|
|
168
|
+
}
|
|
169
|
+
// Persist before dropping the turn's abort listener: the reference is recoverable even if the turn aborts now.
|
|
170
|
+
this.save(record);
|
|
171
|
+
task.handed = true;
|
|
172
|
+
return {
|
|
173
|
+
content: [{ type: "text", text:
|
|
174
|
+
`Still running after 63 seconds. Task: ${id}\nLog: ${record.log_path}\n` +
|
|
175
|
+
"You will be notified when it finishes. Do not poll. " +
|
|
176
|
+
"Continue work that does not depend on this result. If none remains, end your turn and resume when notified.\n" +
|
|
177
|
+
"The task stops when pi exits; any execution timeout still applies." }],
|
|
178
|
+
details: { ...record },
|
|
179
|
+
structuredContent: { ...record, wall_time_seconds: (Date.now() - record.started_at) / 1000 },
|
|
180
|
+
};
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (record.status === "running") {
|
|
183
|
+
task.stopRequested = true;
|
|
184
|
+
controller.abort();
|
|
185
|
+
await execution;
|
|
186
|
+
}
|
|
187
|
+
throw error;
|
|
188
|
+
} finally {
|
|
189
|
+
options.signal?.removeEventListener("abort", abort);
|
|
190
|
+
if (timer) clearTimeout(timer);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
get(id: string): TaskRecord {
|
|
195
|
+
if (!taskIdPattern.test(id)) throw new Error("Invalid task_id.");
|
|
196
|
+
const active = this.live.get(id);
|
|
197
|
+
if (active?.handed) return { ...active.record };
|
|
198
|
+
const path = join(this.directory, `${id}.json`);
|
|
199
|
+
if (!existsSync(path)) throw new Error(`Unknown task: ${id}`);
|
|
200
|
+
const record = JSON.parse(readFileSync(path, "utf8")) as TaskRecord;
|
|
201
|
+
if (record.task_id !== id) throw new Error(`Invalid task record: ${id}`);
|
|
202
|
+
if (record.status === "running") {
|
|
203
|
+
// Another pi process may still own it. Never adopt or kill based on a persisted PID.
|
|
204
|
+
return { ...record, status: "unknown", total_lines: null, error: "Execution belongs to an unavailable pi runtime; exit result is unknown." };
|
|
205
|
+
}
|
|
206
|
+
return record;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async control(id: string, action: "status" | "kill", signal?: AbortSignal): Promise<Result> {
|
|
210
|
+
let record = this.get(id);
|
|
211
|
+
if (action === "kill" && record.status === "running") {
|
|
212
|
+
const task = this.live.get(id)!;
|
|
213
|
+
task.stopRequested = true;
|
|
214
|
+
task.controller.abort();
|
|
215
|
+
await abortable(task.done, signal);
|
|
216
|
+
record = this.get(id);
|
|
217
|
+
}
|
|
218
|
+
return this.describe(record, action !== "status");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
describe(record: TaskRecord, reportFailure = false): Result {
|
|
222
|
+
const size = statSync(record.log_path).size;
|
|
223
|
+
const fd = openSync(record.log_path, "r");
|
|
224
|
+
let tail: string;
|
|
225
|
+
try {
|
|
226
|
+
const bytes = Buffer.alloc(Math.min(size, 16384 + 4));
|
|
227
|
+
readSync(fd, bytes, 0, bytes.length, Math.max(0, size - bytes.length));
|
|
228
|
+
let offset = 0;
|
|
229
|
+
while (offset < bytes.length && (bytes[offset] & 0xc0) === 0x80) offset++;
|
|
230
|
+
tail = truncateTail(bytes.subarray(offset).toString("utf8"), { maxLines: 100, maxBytes: 16384 }).content;
|
|
231
|
+
} finally { closeSync(fd); }
|
|
232
|
+
const data = { ...record, total_bytes: size, output: tail, wall_time_seconds:
|
|
233
|
+
((record.finished_at ?? Date.now()) - record.started_at) / 1000 };
|
|
234
|
+
return {
|
|
235
|
+
content: [{ type: "text", text: `Task ${record.task_id}: ${record.status}; exit_code=${record.exit_code}; ` +
|
|
236
|
+
`${data.wall_time_seconds.toFixed(1)}s\nLog: ${record.log_path} (${record.total_lines ?? "unknown"} lines, ${size} bytes)\n${tail}` }],
|
|
237
|
+
details: { ...data, ...(record.status !== "running" ? { task_event_id: eventId(record) } : {}) },
|
|
238
|
+
structuredContent: data,
|
|
239
|
+
...(reportFailure && (record.status !== "exited" || record.exit_code !== 0) ? { isError: true } : {}),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
*forSession(owner: string, references: Set<string>): Generator<TaskRecord> {
|
|
244
|
+
const emitted = new Set<string>();
|
|
245
|
+
for (const task of this.live.values()) {
|
|
246
|
+
if (task.handed && (task.record.owner_session_id === owner || references.has(task.record.task_id))) {
|
|
247
|
+
emitted.add(task.record.task_id);
|
|
248
|
+
yield { ...task.record };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
for (const file of readdirSync(this.directory)) {
|
|
252
|
+
if (!file.endsWith(".json") || !taskIdPattern.test(file.slice(0, -5))) continue;
|
|
253
|
+
const id = file.slice(0, -5);
|
|
254
|
+
if (emitted.has(id)) continue;
|
|
255
|
+
const record = this.get(id);
|
|
256
|
+
if (record.owner_session_id === owner || references.has(id)) yield record;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async shutdown() {
|
|
261
|
+
this.closing = true;
|
|
262
|
+
const tasks = [...this.live.values()];
|
|
263
|
+
for (const task of tasks) { task.stopRequested = true; task.controller.abort(); }
|
|
264
|
+
await Promise.all(tasks.map((task) => task.done));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const key = Symbol.for("pi-async-bash.managers.v1");
|
|
269
|
+
export function getManager(directory: string): TaskManager {
|
|
270
|
+
const globals = globalThis as typeof globalThis & { [key]?: Map<string, TaskManager> };
|
|
271
|
+
const managers = globals[key] ??= new Map();
|
|
272
|
+
let manager = managers.get(directory);
|
|
273
|
+
if (!manager) { manager = new TaskManager(directory); managers.set(directory, manager); }
|
|
274
|
+
return manager;
|
|
275
|
+
}
|