@bacnh85/pi-subagent 0.14.0 → 0.15.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/CHANGELOG.md +71 -0
- package/README.md +34 -1
- package/extensions/background.ts +351 -0
- package/extensions/history.ts +117 -0
- package/extensions/index.ts +267 -12
- package/extensions/render.ts +62 -31
- package/extensions/result.ts +109 -0
- package/extensions/runner.ts +3 -0
- package/extensions/security.ts +24 -1
- package/extensions/widget.ts +338 -0
- package/package.json +13 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,76 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.15.0 (2026-08-09)
|
|
4
|
+
|
|
5
|
+
### Packaging
|
|
6
|
+
|
|
7
|
+
- Added `extensions/background.ts`, `history.ts`, `result.ts`, `widget.ts`
|
|
8
|
+
to `files[]` — they were imported by `index.ts` but missing from the
|
|
9
|
+
manifest, so the published package would have failed to load.
|
|
10
|
+
|
|
11
|
+
### Compact collapsed result (no tool-call trace in conversation)
|
|
12
|
+
|
|
13
|
+
Completed single subagent results no longer dump the tool-call trace
|
|
14
|
+
(`→ ls`, `→ grep`, `→ read …`) into the conversation block. The collapsed
|
|
15
|
+
view now shows the answer preview + usage, matching Claude Code's
|
|
16
|
+
`⎿ Done (N tool uses · tokens)` and pi-task's `⎿ <summary> (Ctrl+O to expand)`
|
|
17
|
+
UX. The full trace remains available via Ctrl+O (expanded) and `/agent`
|
|
18
|
+
(thread viewer); the hint text now points to both.
|
|
19
|
+
|
|
20
|
+
- `renderSingleResult` collapsed branch: `✓ agent` + `⎿ <first ~200 chars of
|
|
21
|
+
final output>` + usage + `(Ctrl+O to expand · /agent for full thread)`.
|
|
22
|
+
- Removed the dead `renderDisplayItems` / `COLLAPSED_ITEM_COUNT` — the
|
|
23
|
+
collapsed path no longer lists tool calls (was the noise source).
|
|
24
|
+
- Parallel and chain collapsed views were already compact (per-task/step
|
|
25
|
+
one-liners) — unchanged.
|
|
26
|
+
- New tests: `render.test.ts` (5 cases) asserting collapsed shows the answer
|
|
27
|
+
preview and NOT the tool-call trace, plus error/no-output/hint paths.
|
|
28
|
+
|
|
29
|
+
### Live progress widget (Phase 1)
|
|
30
|
+
|
|
31
|
+
A persistent above-editor widget now shows what each running subagent is doing
|
|
32
|
+
right now — spinner, agent name, elapsed time, tool-call count, and the latest
|
|
33
|
+
tool call with ✓/✗/⟳ status. Fed by live `threadStore` subscriptions (per SDK
|
|
34
|
+
session event), not JSONL polling. Replaces the old 30s plain-text
|
|
35
|
+
"still running…" heartbeat.
|
|
36
|
+
|
|
37
|
+
- One block per running thread (single, parallel, chain), capped at 8 +
|
|
38
|
+
`+N more running`.
|
|
39
|
+
- Latest tool-call line with `done`/`error`/`in_progress` status derived by
|
|
40
|
+
pairing assistant `toolCall` parts against later `toolResult` messages.
|
|
41
|
+
- Clears automatically when no threads are running.
|
|
42
|
+
- Inspired by [pi-task](https://github.com/heyhuynhgiabuu/pi-task)'s widget UX,
|
|
43
|
+
but cheaper: in-process SDK gives per-event live data without polling.
|
|
44
|
+
|
|
45
|
+
### Background mode + task control (Phase 2)
|
|
46
|
+
|
|
47
|
+
- `background: true` (single mode only) runs the subagent detached: `execute`
|
|
48
|
+
returns immediately with a receipt, and completion arrives as a follow-up
|
|
49
|
+
turn via `sendMessage({ triggerTurn: true, deliverAs: "followUp" })`.
|
|
50
|
+
- `operation: "status"` / `operation: "cancel"` with `taskId` inspects or
|
|
51
|
+
cancels a running background task without relaunching.
|
|
52
|
+
- A `pi-subagent-complete` message renderer renders the follow-up turn
|
|
53
|
+
compactly (status icon, agent, output, usage).
|
|
54
|
+
- Background tasks are aborted and the widget is disposed on `session_shutdown`.
|
|
55
|
+
|
|
56
|
+
### Structured result + history registry (Phase 3)
|
|
57
|
+
|
|
58
|
+
- New `result.ts`: parent-side structured extraction of the child's final
|
|
59
|
+
message (summary, findings, files, caveats, next steps) by detecting
|
|
60
|
+
markdown headers. No child XML contract — we structure the output ourselves.
|
|
61
|
+
- New `history.ts`: durable metadata under `.pi/subagent-history.json`. Every
|
|
62
|
+
completed task (foreground and background) is recorded.
|
|
63
|
+
- `/subagent history` lists recent delegations with status and timestamp.
|
|
64
|
+
- On restart, prior-session `running` entries are marked `interrupted` (honest
|
|
65
|
+
about the in-process ceiling: we cannot resume a live SDK session).
|
|
66
|
+
|
|
67
|
+
## 0.14.1 (2026-08-07)
|
|
68
|
+
|
|
69
|
+
### Improvements
|
|
70
|
+
|
|
71
|
+
- Widen peer dependency range to support Pi 0.84.0 (`>=0.80.0 <0.85.0`).
|
|
72
|
+
No code changes — verified compatible against the 0.84.0 SDK types.
|
|
73
|
+
|
|
3
74
|
## 0.14.0 (2026-08-05)
|
|
4
75
|
|
|
5
76
|
### Git worktree isolation (`sandbox: worktree`)
|
package/README.md
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
# @bacnh85/pi-subagent
|
|
2
2
|
|
|
3
|
-
Isolated in-process subagents for Pi. The `subagent` tool supports single, parallel (8 tasks, 4 concurrent), and chained execution; `/agent` opens inspectable child threads.
|
|
3
|
+
Isolated in-process subagents for Pi. The `subagent` tool supports single, parallel (8 tasks, 4 concurrent), and chained execution; `/agent` opens inspectable child threads. A live progress widget shows running tasks above the editor; `background: true` runs detached with follow-up-turn completion.
|
|
4
|
+
|
|
5
|
+
## Live progress widget
|
|
6
|
+
|
|
7
|
+
When subagents run, a persistent widget appears above the editor showing each
|
|
8
|
+
running task: spinner, agent name, elapsed time, tool count, and the latest tool
|
|
9
|
+
call with ✓/✗/⟳ status. The widget clears when no tasks are running. Fed by live
|
|
10
|
+
SDK session events — no polling.
|
|
11
|
+
|
|
12
|
+
## Background mode
|
|
13
|
+
|
|
14
|
+
Single-mode tasks can run detached with `background: true`:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
subagent({ agent: "planner", task: "Design the cache layer", background: true })
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The tool returns immediately with a receipt (including a `taskId`), and when
|
|
21
|
+
the subagent finishes, the result arrives as a follow-up turn that the parent
|
|
22
|
+
agent reads and acts on. Use task control to inspect or cancel:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
subagent({ operation: "status", taskId: "bg-..." }) // read-only snapshot
|
|
26
|
+
subagent({ operation: "cancel", taskId: "bg-..." }) // abort a running task
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
You will be notified on completion — do not poll or sleep.
|
|
30
|
+
|
|
31
|
+
## History
|
|
32
|
+
|
|
33
|
+
Every completed task (foreground and background) is recorded to
|
|
34
|
+
`.pi/subagent-history.json`. Use `/subagent history` to list recent
|
|
35
|
+
delegations with status and timestamp. Prior-session running tasks are marked
|
|
36
|
+
`interrupted` on restart (in-process SDK sessions cannot be resumed).
|
|
4
37
|
|
|
5
38
|
## Install
|
|
6
39
|
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background task execution for pi-subagent.
|
|
3
|
+
*
|
|
4
|
+
* A background task runs detached from the parent tool call: `execute` returns
|
|
5
|
+
* immediately with a receipt, and when the child finishes, a follow-up turn is
|
|
6
|
+
* delivered via sendMessage({ triggerTurn: true, deliverAs: "followUp" }).
|
|
7
|
+
*
|
|
8
|
+
* The in-memory registry supports task control (status/cancel) by taskId.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import type { SubAgentResult, SubAgentProgress } from "./runner.ts";
|
|
15
|
+
import { isFailedResult, getResultOutput, getFinalOutput } from "./runner.ts";
|
|
16
|
+
import type { threadStore as ThreadStoreType } from "./threads.ts";
|
|
17
|
+
import type { AgentScope } from "./agents.ts";
|
|
18
|
+
import { parseStructuredResult } from "./result.ts";
|
|
19
|
+
import { appendHistory } from "./history.ts";
|
|
20
|
+
|
|
21
|
+
/** Mirrors SubagentDetails from index.ts without a circular import. */
|
|
22
|
+
interface BackgroundDetails {
|
|
23
|
+
mode: "single" | "parallel" | "chain";
|
|
24
|
+
agentScope: AgentScope;
|
|
25
|
+
projectAgentsDir: string | null;
|
|
26
|
+
results: SubAgentResult[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Types
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export type BackgroundStatus = "running" | "completed" | "failed" | "aborted" | "timeout";
|
|
34
|
+
|
|
35
|
+
export interface BackgroundTask {
|
|
36
|
+
id: string;
|
|
37
|
+
threadId: string;
|
|
38
|
+
agent: string;
|
|
39
|
+
task: string;
|
|
40
|
+
startedAt: number;
|
|
41
|
+
status: BackgroundStatus;
|
|
42
|
+
controller: AbortController;
|
|
43
|
+
result?: SubAgentResult;
|
|
44
|
+
completedAt?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface BackgroundDeps {
|
|
48
|
+
pi: ExtensionAPI;
|
|
49
|
+
ctx: ExtensionContext;
|
|
50
|
+
/** Run a single agent (the existing runOne closure). */
|
|
51
|
+
runOne: (
|
|
52
|
+
agent: string,
|
|
53
|
+
task: string,
|
|
54
|
+
cwd: string | undefined,
|
|
55
|
+
signal: AbortSignal,
|
|
56
|
+
timeoutMs: number | undefined,
|
|
57
|
+
onProgress: (partial: SubAgentResult) => void,
|
|
58
|
+
onActivity: (progress: SubAgentProgress) => void,
|
|
59
|
+
onHeartbeatDetails: () => BackgroundDetails,
|
|
60
|
+
onHeartbeat: () => void,
|
|
61
|
+
isReadOnly?: boolean,
|
|
62
|
+
) => Promise<SubAgentResult>;
|
|
63
|
+
threadStore: typeof ThreadStoreType;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Registry (in-memory, per-session)
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
const backgroundTasks = new Map<string, BackgroundTask>();
|
|
71
|
+
|
|
72
|
+
export function getBackgroundTask(id: string): BackgroundTask | undefined {
|
|
73
|
+
return backgroundTasks.get(id);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function getAllBackgroundTasks(): BackgroundTask[] {
|
|
77
|
+
return Array.from(backgroundTasks.values());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function clearBackgroundTasks(): void {
|
|
81
|
+
for (const task of backgroundTasks.values()) {
|
|
82
|
+
task.controller.abort();
|
|
83
|
+
}
|
|
84
|
+
backgroundTasks.clear();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Status snapshot (for operation: "status")
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
export interface TaskSnapshot {
|
|
92
|
+
id: string;
|
|
93
|
+
agent: string;
|
|
94
|
+
task: string;
|
|
95
|
+
status: BackgroundStatus;
|
|
96
|
+
startedAt: number;
|
|
97
|
+
completedAt?: number;
|
|
98
|
+
elapsedMs: number;
|
|
99
|
+
threadId: string;
|
|
100
|
+
result?: { output: string; model?: string; usage?: unknown };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function snapshotTask(task: BackgroundTask, now = Date.now()): TaskSnapshot {
|
|
104
|
+
const result = task.result;
|
|
105
|
+
return {
|
|
106
|
+
id: task.id,
|
|
107
|
+
agent: task.agent,
|
|
108
|
+
task: task.task,
|
|
109
|
+
status: task.status,
|
|
110
|
+
startedAt: task.startedAt,
|
|
111
|
+
completedAt: task.completedAt,
|
|
112
|
+
elapsedMs: now - task.startedAt,
|
|
113
|
+
threadId: task.threadId,
|
|
114
|
+
result: result
|
|
115
|
+
? { output: getResultOutput(result), model: result.model, usage: result.usage }
|
|
116
|
+
: undefined,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Start a background task
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
export interface StartBackgroundInput {
|
|
125
|
+
agent: string;
|
|
126
|
+
task: string;
|
|
127
|
+
cwd?: string;
|
|
128
|
+
timeout?: number;
|
|
129
|
+
agentColor?: string;
|
|
130
|
+
toolCallId?: string;
|
|
131
|
+
deps: BackgroundDeps;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface StartBackgroundResult {
|
|
135
|
+
taskId: string;
|
|
136
|
+
receipt: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Start a detached background task. Returns immediately with a receipt.
|
|
141
|
+
* On completion, delivers a follow-up turn to the parent session.
|
|
142
|
+
*/
|
|
143
|
+
export function startBackgroundTask(input: StartBackgroundInput): StartBackgroundResult {
|
|
144
|
+
const { agent, task, cwd, timeout, agentColor, toolCallId, deps } = input;
|
|
145
|
+
const taskId = `bg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
|
146
|
+
const controller = new AbortController();
|
|
147
|
+
const startedAt = Date.now();
|
|
148
|
+
|
|
149
|
+
// Create a thread so the task shows in the widget + /agent viewer.
|
|
150
|
+
const thread = deps.threadStore.createThread({
|
|
151
|
+
agentName: agent,
|
|
152
|
+
task,
|
|
153
|
+
mode: "single",
|
|
154
|
+
toolCallId,
|
|
155
|
+
color: agentColor,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const bgTask: BackgroundTask = {
|
|
159
|
+
id: taskId,
|
|
160
|
+
threadId: thread.id,
|
|
161
|
+
agent,
|
|
162
|
+
task,
|
|
163
|
+
startedAt,
|
|
164
|
+
status: "running",
|
|
165
|
+
controller,
|
|
166
|
+
};
|
|
167
|
+
backgroundTasks.set(taskId, bgTask);
|
|
168
|
+
|
|
169
|
+
// Run detached — the parent does NOT await this.
|
|
170
|
+
void deps
|
|
171
|
+
.runOne(
|
|
172
|
+
agent,
|
|
173
|
+
task,
|
|
174
|
+
cwd,
|
|
175
|
+
controller.signal,
|
|
176
|
+
timeout,
|
|
177
|
+
(partial) => deps.threadStore.updateThread(thread.id, { result: partial }),
|
|
178
|
+
(progress) => deps.threadStore.updateProgress(thread.id, progress),
|
|
179
|
+
() => ({ mode: "single" as const, agentScope: "user" as const, projectAgentsDir: null, results: [] }),
|
|
180
|
+
() => deps.threadStore.refreshHeartbeat(thread.id),
|
|
181
|
+
)
|
|
182
|
+
.then((result) => {
|
|
183
|
+
bgTask.result = result;
|
|
184
|
+
bgTask.completedAt = Date.now();
|
|
185
|
+
const failed = isFailedResult(result);
|
|
186
|
+
bgTask.status = failed
|
|
187
|
+
? result.stopReason === "timeout"
|
|
188
|
+
? "timeout"
|
|
189
|
+
: result.stopReason === "aborted"
|
|
190
|
+
? "aborted"
|
|
191
|
+
: "failed"
|
|
192
|
+
: "completed";
|
|
193
|
+
try {
|
|
194
|
+
deps.threadStore.updateThread(thread.id, {
|
|
195
|
+
status: failed ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
196
|
+
result,
|
|
197
|
+
});
|
|
198
|
+
} catch {
|
|
199
|
+
/* thread store unavailable — non-fatal; status/history already set */
|
|
200
|
+
}
|
|
201
|
+
recordHistory(bgTask, result, deps);
|
|
202
|
+
// Delivery failure must NOT cascade into .catch: the task genuinely
|
|
203
|
+
// completed, so its status/history must reflect that. Swallow sendMessage
|
|
204
|
+
// errors (e.g. session already shut down) as non-fatal.
|
|
205
|
+
try {
|
|
206
|
+
deliverCompletion(bgTask, result, deps);
|
|
207
|
+
} catch {
|
|
208
|
+
/* delivery failed — non-fatal; status/history already recorded */
|
|
209
|
+
}
|
|
210
|
+
scheduleEviction(taskId);
|
|
211
|
+
})
|
|
212
|
+
.catch((err: unknown) => {
|
|
213
|
+
// Reached when runOne itself rejects (delivery is isolated above; any
|
|
214
|
+
// other side-effect throw in .then is also caught here defensively).
|
|
215
|
+
bgTask.completedAt = Date.now();
|
|
216
|
+
bgTask.status = "failed";
|
|
217
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
218
|
+
try {
|
|
219
|
+
deps.threadStore.updateThread(thread.id, { status: "failed" });
|
|
220
|
+
} catch {
|
|
221
|
+
/* thread store unavailable — non-fatal */
|
|
222
|
+
}
|
|
223
|
+
recordHistory(bgTask, undefined, deps, errMsg);
|
|
224
|
+
try {
|
|
225
|
+
deliverError(bgTask, errMsg, deps);
|
|
226
|
+
} catch {
|
|
227
|
+
/* delivery failed — non-fatal */
|
|
228
|
+
}
|
|
229
|
+
scheduleEviction(taskId);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const receipt = [
|
|
233
|
+
`Started background task ${taskId} (${agent}).`,
|
|
234
|
+
`Use subagent operation:"status" taskId:"${taskId}" to inspect progress.`,
|
|
235
|
+
"You will be notified automatically when it completes — DO NOT poll or sleep.",
|
|
236
|
+
].join("\n");
|
|
237
|
+
|
|
238
|
+
return { taskId, receipt };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Completion delivery (follow-up turn)
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
function deliverCompletion(task: BackgroundTask, result: SubAgentResult, deps: BackgroundDeps): void {
|
|
246
|
+
const output = getFinalOutput(result.messages) || getResultOutput(result) || "(no output)";
|
|
247
|
+
const failed = isFailedResult(result);
|
|
248
|
+
const phase = task.status;
|
|
249
|
+
const summary = output.split("\n").slice(0, 1)[0]!.slice(0, 200);
|
|
250
|
+
const elapsedMs = (task.completedAt ?? Date.now()) - task.startedAt;
|
|
251
|
+
|
|
252
|
+
deps.pi.sendMessage(
|
|
253
|
+
{
|
|
254
|
+
customType: "pi-subagent-complete",
|
|
255
|
+
content: failed
|
|
256
|
+
? `Background task ${task.id} (${task.agent}) ${phase}.\n\n${getResultOutput(result)}`
|
|
257
|
+
: `Background task ${task.id} (${task.agent}) completed.\n\n${output}`,
|
|
258
|
+
display: true,
|
|
259
|
+
details: {
|
|
260
|
+
task_id: task.id,
|
|
261
|
+
agent: task.agent,
|
|
262
|
+
status: task.status,
|
|
263
|
+
summary,
|
|
264
|
+
full_output: output,
|
|
265
|
+
elapsed_ms: elapsedMs,
|
|
266
|
+
model: result.model,
|
|
267
|
+
usage: result.usage,
|
|
268
|
+
background: true,
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function deliverError(task: BackgroundTask, errMsg: string, deps: BackgroundDeps): void {
|
|
276
|
+
deps.pi.sendMessage(
|
|
277
|
+
{
|
|
278
|
+
customType: "pi-subagent-complete",
|
|
279
|
+
content: `Background task ${task.id} (${task.agent}) failed: ${errMsg}`,
|
|
280
|
+
display: true,
|
|
281
|
+
details: {
|
|
282
|
+
task_id: task.id,
|
|
283
|
+
agent: task.agent,
|
|
284
|
+
status: "failed" as const,
|
|
285
|
+
summary: errMsg,
|
|
286
|
+
full_output: errMsg,
|
|
287
|
+
background: true,
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// History recording (durable metadata for /subagent history)
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
function recordHistory(
|
|
299
|
+
task: BackgroundTask,
|
|
300
|
+
result: SubAgentResult | undefined,
|
|
301
|
+
deps: BackgroundDeps,
|
|
302
|
+
errMsg?: string,
|
|
303
|
+
): void {
|
|
304
|
+
try {
|
|
305
|
+
const output = errMsg ?? (result ? getFinalOutput(result.messages) || getResultOutput(result) : "");
|
|
306
|
+
const structured = parseStructuredResult(output);
|
|
307
|
+
const piDir = join(deps.ctx.cwd, CONFIG_DIR_NAME);
|
|
308
|
+
appendHistory(piDir, {
|
|
309
|
+
id: task.id,
|
|
310
|
+
agent: task.agent,
|
|
311
|
+
task: task.task,
|
|
312
|
+
status: task.status === "running" ? "interrupted" : (task.status as "completed" | "failed" | "aborted" | "timeout"),
|
|
313
|
+
startedAt: task.startedAt,
|
|
314
|
+
completedAt: task.completedAt,
|
|
315
|
+
summary: structured.summary,
|
|
316
|
+
background: true,
|
|
317
|
+
model: result?.model,
|
|
318
|
+
});
|
|
319
|
+
} catch {
|
|
320
|
+
// History file not writable — non-fatal.
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// Cancellation
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
export type CancelOutcome = "cancelled" | "not_found" | "already_done";
|
|
329
|
+
|
|
330
|
+
export function cancelBackgroundTask(id: string): { outcome: CancelOutcome; task?: BackgroundTask } {
|
|
331
|
+
const task = backgroundTasks.get(id);
|
|
332
|
+
if (!task) return { outcome: "not_found" };
|
|
333
|
+
if (task.status !== "running") return { outcome: "already_done", task };
|
|
334
|
+
task.controller.abort();
|
|
335
|
+
// Status will be finalized by the runOne promise resolving with aborted.
|
|
336
|
+
return { outcome: "cancelled", task };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// Eviction — completed tasks are retained briefly for status queries, then
|
|
341
|
+
// dropped so the in-memory registry doesn't grow unbounded across a session.
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
const TASK_RETENTION_MS = 60_000;
|
|
345
|
+
|
|
346
|
+
function scheduleEviction(taskId: string): void {
|
|
347
|
+
const timer = setTimeout(() => {
|
|
348
|
+
backgroundTasks.delete(taskId);
|
|
349
|
+
}, TASK_RETENTION_MS);
|
|
350
|
+
timer.unref?.();
|
|
351
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task history registry — durable metadata for listing and re-run.
|
|
3
|
+
*
|
|
4
|
+
* Persists completed task metadata under .pi/subagent-history.json so the user
|
|
5
|
+
* can review past delegations via `/subagent history`. This is metadata ONLY:
|
|
6
|
+
* in-process SDK cannot resume a live session, so we never advertise "resume".
|
|
7
|
+
*
|
|
8
|
+
* On session start, any `running` entries from a prior session (crash/restart)
|
|
9
|
+
* are marked `interrupted` — honest about the architectural ceiling.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Types
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export type HistoryStatus = "completed" | "failed" | "aborted" | "timeout" | "interrupted" | "running";
|
|
20
|
+
|
|
21
|
+
export interface HistoryEntry {
|
|
22
|
+
id: string;
|
|
23
|
+
agent: string;
|
|
24
|
+
task: string;
|
|
25
|
+
status: HistoryStatus;
|
|
26
|
+
startedAt: number;
|
|
27
|
+
completedAt?: number;
|
|
28
|
+
summary?: string;
|
|
29
|
+
cwd?: string;
|
|
30
|
+
background?: boolean;
|
|
31
|
+
model?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Path resolution
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
const HISTORY_FILE = "subagent-history.json";
|
|
39
|
+
|
|
40
|
+
/** Maximum history entries kept on disk. */
|
|
41
|
+
export const MAX_HISTORY_ENTRIES = 200;
|
|
42
|
+
|
|
43
|
+
export function getHistoryPath(piDir: string): string {
|
|
44
|
+
return join(piDir, HISTORY_FILE);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Read / write
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
function readJsonArray(file: string): HistoryEntry[] {
|
|
52
|
+
try {
|
|
53
|
+
if (!existsSync(file)) return [];
|
|
54
|
+
const parsed = JSON.parse(readFileSync(file, "utf-8")) as unknown;
|
|
55
|
+
return Array.isArray(parsed) ? (parsed as HistoryEntry[]) : [];
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function readHistory(piDir: string): HistoryEntry[] {
|
|
62
|
+
return readJsonArray(getHistoryPath(piDir));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function appendHistory(piDir: string, entry: HistoryEntry): void {
|
|
66
|
+
const file = getHistoryPath(piDir);
|
|
67
|
+
const entries = readJsonArray(file);
|
|
68
|
+
// Upsert by id — if the id exists, replace it.
|
|
69
|
+
const idx = entries.findIndex((e) => e.id === entry.id);
|
|
70
|
+
if (idx >= 0) entries[idx] = entry;
|
|
71
|
+
else entries.push(entry);
|
|
72
|
+
writeHistory(piDir, entries);
|
|
73
|
+
// ponytail: bound the file inline — every append trims, so the file can
|
|
74
|
+
// never grow unbounded no matter which caller forgets to trim.
|
|
75
|
+
trimHistory(piDir, MAX_HISTORY_ENTRIES);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function writeHistory(piDir: string, entries: HistoryEntry[]): void {
|
|
79
|
+
const file = getHistoryPath(piDir);
|
|
80
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
81
|
+
writeFileSync(file, `${JSON.stringify(entries, null, 2)}\n`, "utf-8");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function findHistory(piDir: string, id: string): HistoryEntry | undefined {
|
|
85
|
+
return readHistory(piDir).find((e) => e.id === id);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* On session start, mark any `running` entries from a prior session as
|
|
90
|
+
* `interrupted`. We cannot resume them (in-process SDK sessions don't survive
|
|
91
|
+
* a restart). Honest about the architectural ceiling.
|
|
92
|
+
*/
|
|
93
|
+
export function markInterruptedOnRestart(piDir: string): number {
|
|
94
|
+
const entries = readHistory(piDir);
|
|
95
|
+
let changed = 0;
|
|
96
|
+
for (const e of entries) {
|
|
97
|
+
if (e.status === "running") {
|
|
98
|
+
e.status = "interrupted";
|
|
99
|
+
changed++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (changed > 0) writeHistory(piDir, entries);
|
|
103
|
+
return changed;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Keep the history file bounded — trim to the most recent N entries by
|
|
108
|
+
* completedAt (or startedAt as fallback). Returns the new length.
|
|
109
|
+
*/
|
|
110
|
+
export function trimHistory(piDir: string, maxEntries = 200): number {
|
|
111
|
+
const entries = readHistory(piDir);
|
|
112
|
+
if (entries.length <= maxEntries) return entries.length;
|
|
113
|
+
entries.sort((a, b) => (b.completedAt ?? b.startedAt) - (a.completedAt ?? a.startedAt));
|
|
114
|
+
const trimmed = entries.slice(0, maxEntries);
|
|
115
|
+
writeHistory(piDir, trimmed);
|
|
116
|
+
return trimmed.length;
|
|
117
|
+
}
|