pi-subagents 0.17.4 → 0.18.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 +28 -0
- package/README.md +19 -19
- package/agents/context-builder.md +1 -1
- package/agents/oracle-executor.md +1 -1
- package/agents/oracle.md +1 -1
- package/agents/planner.md +1 -1
- package/agents/researcher.md +1 -1
- package/agents/reviewer.md +1 -1
- package/agents/worker.md +1 -1
- package/async-execution.ts +29 -2
- package/async-job-tracker.ts +74 -7
- package/async-status.ts +74 -17
- package/chain-execution.ts +162 -26
- package/execution.ts +122 -4
- package/index.ts +124 -128
- package/install.mjs +2 -3
- package/intercom-bridge.ts +9 -0
- package/notify.ts +25 -6
- package/package.json +3 -6
- package/pi-args.ts +4 -0
- package/pi-spawn.ts +9 -6
- package/render.ts +20 -12
- package/result-watcher.ts +3 -5
- package/run-status.ts +134 -0
- package/schemas.ts +22 -7
- package/skills/pi-subagents/SKILL.md +50 -10
- package/subagent-control.ts +148 -0
- package/subagent-executor.ts +348 -6
- package/subagent-prompt-runtime.ts +6 -0
- package/subagent-runner.ts +218 -25
- package/subagents-status.ts +8 -1
- package/types.ts +74 -2
- package/utils.ts +1 -0
package/index.ts
CHANGED
|
@@ -17,22 +17,24 @@ import * as os from "node:os";
|
|
|
17
17
|
import * as path from "node:path";
|
|
18
18
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
|
19
19
|
import { type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@mariozechner/pi-coding-agent";
|
|
20
|
-
import { Box, Container, Spacer, Text } from "@mariozechner/pi-tui";
|
|
20
|
+
import { Box, Container, Spacer, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@mariozechner/pi-tui";
|
|
21
21
|
import { discoverAgents } from "./agents.ts";
|
|
22
22
|
import { cleanupAllArtifactDirs, cleanupOldArtifacts, getArtifactsDir } from "./artifacts.ts";
|
|
23
23
|
import { cleanupOldChainDirs } from "./settings.ts";
|
|
24
24
|
import { renderWidget, renderSubagentResult } from "./render.ts";
|
|
25
|
-
import { SubagentParams
|
|
26
|
-
import { findByPrefix, readStatus } from "./utils.ts";
|
|
25
|
+
import { SubagentParams } from "./schemas.ts";
|
|
27
26
|
import { createSubagentExecutor } from "./subagent-executor.ts";
|
|
28
27
|
import { createAsyncJobTracker } from "./async-job-tracker.ts";
|
|
28
|
+
import { controlNotificationKey, formatControlNoticeMessage } from "./subagent-control.ts";
|
|
29
29
|
import { createResultWatcher } from "./result-watcher.ts";
|
|
30
30
|
import { registerSlashCommands } from "./slash-commands.ts";
|
|
31
31
|
import { registerPromptTemplateDelegationBridge } from "./prompt-template-bridge.ts";
|
|
32
32
|
import { registerSlashSubagentBridge } from "./slash-bridge.ts";
|
|
33
33
|
import { clearSlashSnapshots, getSlashRenderableSnapshot, resolveSlashMessageDetails, restoreSlashFinalSnapshots, type SlashMessageDetails } from "./slash-live-state.ts";
|
|
34
|
-
import {
|
|
34
|
+
import { inspectSubagentStatus } from "./run-status.ts";
|
|
35
|
+
import registerSubagentNotify from "./notify.ts";
|
|
35
36
|
import {
|
|
37
|
+
type ControlEvent,
|
|
36
38
|
type Details,
|
|
37
39
|
type ExtensionConfig,
|
|
38
40
|
type SubagentState,
|
|
@@ -40,6 +42,9 @@ import {
|
|
|
40
42
|
DEFAULT_ARTIFACT_CONFIG,
|
|
41
43
|
RESULTS_DIR,
|
|
42
44
|
SLASH_RESULT_TYPE,
|
|
45
|
+
SUBAGENT_ASYNC_COMPLETE_EVENT,
|
|
46
|
+
SUBAGENT_ASYNC_STARTED_EVENT,
|
|
47
|
+
SUBAGENT_CONTROL_EVENT,
|
|
43
48
|
WIDGET_KEY,
|
|
44
49
|
} from "./types.ts";
|
|
45
50
|
|
|
@@ -139,6 +144,52 @@ function createSlashResultComponent(
|
|
|
139
144
|
return container;
|
|
140
145
|
}
|
|
141
146
|
|
|
147
|
+
const SUBAGENT_CONTROL_MESSAGE_TYPE = "subagent_control_notice";
|
|
148
|
+
|
|
149
|
+
interface SubagentControlMessageDetails {
|
|
150
|
+
event: ControlEvent;
|
|
151
|
+
source?: "foreground" | "async";
|
|
152
|
+
asyncDir?: string;
|
|
153
|
+
childIntercomTarget?: string;
|
|
154
|
+
noticeText?: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function controlNoticeTarget(details: SubagentControlMessageDetails): string | undefined {
|
|
158
|
+
return details.childIntercomTarget;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function formatSubagentControlNotice(details: SubagentControlMessageDetails, content?: string): string {
|
|
162
|
+
return details.noticeText ?? content ?? formatControlNoticeMessage(details.event, controlNoticeTarget(details));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
class SubagentControlNoticeComponent implements Component {
|
|
166
|
+
constructor(
|
|
167
|
+
private readonly details: SubagentControlMessageDetails,
|
|
168
|
+
private readonly theme: ExtensionContext["ui"]["theme"],
|
|
169
|
+
) {}
|
|
170
|
+
|
|
171
|
+
invalidate(): void {}
|
|
172
|
+
|
|
173
|
+
render(width: number): string[] {
|
|
174
|
+
const eventLabel = this.details.event.type.replaceAll("_", " ");
|
|
175
|
+
if (width < 3) return [truncateToWidth(`Subagent ${eventLabel}`, width)];
|
|
176
|
+
const bodyWidth = Math.max(1, Math.min(width - 2, 68));
|
|
177
|
+
const borderChar = "─";
|
|
178
|
+
const header = ` ⚠ Subagent ${eventLabel}: ${this.details.event.agent} `;
|
|
179
|
+
const headerText = truncateToWidth(header, bodyWidth, "");
|
|
180
|
+
const headerPadding = Math.max(0, bodyWidth - visibleWidth(headerText));
|
|
181
|
+
const lines = [this.theme.fg("accent", `╭${headerText}${borderChar.repeat(headerPadding)}╮`)];
|
|
182
|
+
|
|
183
|
+
for (const line of wrapTextWithAnsi(formatSubagentControlNotice(this.details), bodyWidth)) {
|
|
184
|
+
const text = truncateToWidth(line, bodyWidth, "");
|
|
185
|
+
const padding = Math.max(0, bodyWidth - visibleWidth(text));
|
|
186
|
+
lines.push(this.theme.fg("accent", `│${text}${" ".repeat(padding)}│`));
|
|
187
|
+
}
|
|
188
|
+
lines.push(this.theme.fg("accent", `╰${borderChar.repeat(bodyWidth)}╯`));
|
|
189
|
+
return lines;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
142
193
|
export default function registerSubagentExtension(pi: ExtensionAPI): void {
|
|
143
194
|
ensureAccessibleDir(RESULTS_DIR);
|
|
144
195
|
ensureAccessibleDir(ASYNC_DIR);
|
|
@@ -153,6 +204,8 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
|
|
|
153
204
|
baseCwd: process.cwd(),
|
|
154
205
|
currentSessionId: null,
|
|
155
206
|
asyncJobs: new Map(),
|
|
207
|
+
foregroundControls: new Map(),
|
|
208
|
+
lastForegroundControlId: null,
|
|
156
209
|
cleanupTimers: new Map(),
|
|
157
210
|
lastUiContext: null,
|
|
158
211
|
poller: null,
|
|
@@ -174,7 +227,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
|
|
|
174
227
|
startResultWatcher();
|
|
175
228
|
primeExistingResults();
|
|
176
229
|
|
|
177
|
-
const { ensurePoller, handleStarted, handleComplete, resetJobs } = createAsyncJobTracker(state, ASYNC_DIR);
|
|
230
|
+
const { ensurePoller, handleStarted, handleComplete, resetJobs } = createAsyncJobTracker(pi, state, ASYNC_DIR);
|
|
178
231
|
const executor = createSubagentExecutor({
|
|
179
232
|
pi,
|
|
180
233
|
state,
|
|
@@ -192,6 +245,13 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
|
|
|
192
245
|
return createSlashResultComponent(details, options, theme);
|
|
193
246
|
});
|
|
194
247
|
|
|
248
|
+
pi.registerMessageRenderer<SubagentControlMessageDetails>(SUBAGENT_CONTROL_MESSAGE_TYPE, (message, _options, theme) => {
|
|
249
|
+
const details = message.details as SubagentControlMessageDetails | undefined;
|
|
250
|
+
if (!details?.event) return undefined;
|
|
251
|
+
const content = typeof message.content === "string" ? message.content : undefined;
|
|
252
|
+
return new SubagentControlNoticeComponent({ ...details, noticeText: formatSubagentControlNotice(details, content) }, theme);
|
|
253
|
+
});
|
|
254
|
+
|
|
195
255
|
const slashBridge = registerSlashSubagentBridge({
|
|
196
256
|
events: pi.events,
|
|
197
257
|
getContext: () => state.lastUiContext,
|
|
@@ -265,11 +325,15 @@ Example: { chain: [{agent:"scout", task:"Analyze {task}"}, {agent:"planner", tas
|
|
|
265
325
|
|
|
266
326
|
MANAGEMENT (use action field, omit agent/task/chain/tasks):
|
|
267
327
|
• { action: "list" } - discover agents/chains
|
|
268
|
-
• { action: "get", agent: "name" } - full
|
|
328
|
+
• { action: "get", agent: "name" } - full detail
|
|
269
329
|
• { action: "create", config: { name, systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, ... } }
|
|
270
330
|
• { action: "update", agent: "name", config: { ... } } - merge
|
|
271
331
|
• { action: "delete", agent: "name" }
|
|
272
|
-
• Use chainName for chain operations
|
|
332
|
+
• Use chainName for chain operations
|
|
333
|
+
|
|
334
|
+
CONTROL:
|
|
335
|
+
• { action: "status", id: "..." } - inspect an async/background run by id or prefix
|
|
336
|
+
• { action: "interrupt", id?: "..." } - soft-interrupt the current child turn and leave the run paused`,
|
|
273
337
|
parameters: SubagentParams,
|
|
274
338
|
|
|
275
339
|
execute(id, params, signal, onUpdate, ctx) {
|
|
@@ -312,130 +376,52 @@ MANAGEMENT (use action field, omit agent/task/chain/tasks):
|
|
|
312
376
|
|
|
313
377
|
};
|
|
314
378
|
|
|
315
|
-
const statusTool: ToolDefinition<typeof StatusParams, Details> = {
|
|
316
|
-
name: "subagent_status",
|
|
317
|
-
label: "Subagent Status",
|
|
318
|
-
description: "Inspect async subagent run status and artifacts",
|
|
319
|
-
parameters: StatusParams,
|
|
320
|
-
|
|
321
|
-
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
322
|
-
if (params.action === "list") {
|
|
323
|
-
try {
|
|
324
|
-
const runs = listAsyncRuns(ASYNC_DIR, { states: ["queued", "running"] });
|
|
325
|
-
return {
|
|
326
|
-
content: [{ type: "text", text: formatAsyncRunList(runs) }],
|
|
327
|
-
details: { mode: "single", results: [] },
|
|
328
|
-
};
|
|
329
|
-
} catch (error) {
|
|
330
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
331
|
-
return {
|
|
332
|
-
content: [{ type: "text", text: message }],
|
|
333
|
-
isError: true,
|
|
334
|
-
details: { mode: "single", results: [] },
|
|
335
|
-
};
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
let asyncDir: string | null = null;
|
|
340
|
-
let resolvedId = params.id;
|
|
341
|
-
|
|
342
|
-
if (params.dir) {
|
|
343
|
-
asyncDir = path.resolve(params.dir);
|
|
344
|
-
} else if (params.id) {
|
|
345
|
-
const direct = path.join(ASYNC_DIR, params.id);
|
|
346
|
-
if (fs.existsSync(direct)) {
|
|
347
|
-
asyncDir = direct;
|
|
348
|
-
} else {
|
|
349
|
-
const match = findByPrefix(ASYNC_DIR, params.id);
|
|
350
|
-
if (match) {
|
|
351
|
-
asyncDir = match;
|
|
352
|
-
resolvedId = path.basename(match);
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
const resultPath =
|
|
358
|
-
params.id && !asyncDir ? findByPrefix(RESULTS_DIR, params.id, ".json") : null;
|
|
359
|
-
|
|
360
|
-
if (!asyncDir && !resultPath) {
|
|
361
|
-
return {
|
|
362
|
-
content: [{ type: "text", text: "Async run not found. Provide id or dir." }],
|
|
363
|
-
isError: true,
|
|
364
|
-
details: { mode: "single" as const, results: [] },
|
|
365
|
-
};
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
if (asyncDir) {
|
|
369
|
-
let status;
|
|
370
|
-
try {
|
|
371
|
-
status = readStatus(asyncDir);
|
|
372
|
-
} catch (error) {
|
|
373
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
374
|
-
return {
|
|
375
|
-
content: [{ type: "text", text: message }],
|
|
376
|
-
isError: true,
|
|
377
|
-
details: { mode: "single" as const, results: [] },
|
|
378
|
-
};
|
|
379
|
-
}
|
|
380
|
-
const logPath = path.join(asyncDir, `subagent-log-${resolvedId ?? "unknown"}.md`);
|
|
381
|
-
const eventsPath = path.join(asyncDir, "events.jsonl");
|
|
382
|
-
if (status) {
|
|
383
|
-
const stepsTotal = status.steps?.length ?? 1;
|
|
384
|
-
const current = status.currentStep !== undefined ? status.currentStep + 1 : undefined;
|
|
385
|
-
const stepLine =
|
|
386
|
-
current !== undefined ? `Step: ${current}/${stepsTotal}` : `Steps: ${stepsTotal}`;
|
|
387
|
-
const started = new Date(status.startedAt).toISOString();
|
|
388
|
-
const updated = status.lastUpdate ? new Date(status.lastUpdate).toISOString() : "n/a";
|
|
389
|
-
|
|
390
|
-
const lines = [
|
|
391
|
-
`Run: ${status.runId}`,
|
|
392
|
-
`State: ${status.state}`,
|
|
393
|
-
`Mode: ${status.mode}`,
|
|
394
|
-
stepLine,
|
|
395
|
-
`Started: ${started}`,
|
|
396
|
-
`Updated: ${updated}`,
|
|
397
|
-
`Dir: ${asyncDir}`,
|
|
398
|
-
];
|
|
399
|
-
if (status.sessionFile) lines.push(`Session: ${status.sessionFile}`);
|
|
400
|
-
if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
|
|
401
|
-
if (fs.existsSync(eventsPath)) lines.push(`Events: ${eventsPath}`);
|
|
402
|
-
|
|
403
|
-
return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [] } };
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
if (resultPath) {
|
|
408
|
-
try {
|
|
409
|
-
const raw = fs.readFileSync(resultPath, "utf-8");
|
|
410
|
-
const data = JSON.parse(raw) as { id?: string; success?: boolean; summary?: string };
|
|
411
|
-
const status = data.success ? "complete" : "failed";
|
|
412
|
-
const lines = [`Run: ${data.id ?? params.id}`, `State: ${status}`, `Result: ${resultPath}`];
|
|
413
|
-
if (data.summary) lines.push("", data.summary);
|
|
414
|
-
return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [] } };
|
|
415
|
-
} catch (error) {
|
|
416
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
417
|
-
return {
|
|
418
|
-
content: [{ type: "text", text: `Failed to read async result file: ${message}` }],
|
|
419
|
-
isError: true,
|
|
420
|
-
details: { mode: "single" as const, results: [] },
|
|
421
|
-
};
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
return {
|
|
426
|
-
content: [{ type: "text", text: "Status file not found." }],
|
|
427
|
-
isError: true,
|
|
428
|
-
details: { mode: "single" as const, results: [] },
|
|
429
|
-
};
|
|
430
|
-
},
|
|
431
|
-
};
|
|
432
|
-
|
|
433
379
|
pi.registerTool(tool);
|
|
434
|
-
pi.registerTool(statusTool);
|
|
435
380
|
registerSlashCommands(pi, state);
|
|
436
381
|
|
|
437
|
-
|
|
438
|
-
|
|
382
|
+
const eventUnsubscribeStoreKey = "__piSubagentEventUnsubscribes";
|
|
383
|
+
const controlNoticeSeenStoreKey = "__piSubagentVisibleControlNotices";
|
|
384
|
+
const globalStore = globalThis as Record<string, unknown>;
|
|
385
|
+
const previousEventUnsubscribes = globalStore[eventUnsubscribeStoreKey];
|
|
386
|
+
if (Array.isArray(previousEventUnsubscribes)) {
|
|
387
|
+
for (const unsubscribe of previousEventUnsubscribes) {
|
|
388
|
+
if (typeof unsubscribe !== "function") continue;
|
|
389
|
+
try {
|
|
390
|
+
unsubscribe();
|
|
391
|
+
} catch {
|
|
392
|
+
// Best effort cleanup for stale handlers from an older reload.
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
registerSubagentNotify(pi);
|
|
397
|
+
|
|
398
|
+
const existingVisibleControlNotices = globalStore[controlNoticeSeenStoreKey];
|
|
399
|
+
const visibleControlNotices = existingVisibleControlNotices instanceof Set ? existingVisibleControlNotices as Set<string> : new Set<string>();
|
|
400
|
+
globalStore[controlNoticeSeenStoreKey] = visibleControlNotices;
|
|
401
|
+
const controlEventHandler = (payload: unknown) => {
|
|
402
|
+
const details = payload as SubagentControlMessageDetails;
|
|
403
|
+
if (!details?.event) return;
|
|
404
|
+
const childIntercomTarget = controlNoticeTarget(details);
|
|
405
|
+
const key = controlNotificationKey(details.event, childIntercomTarget);
|
|
406
|
+
if (visibleControlNotices.has(key)) return;
|
|
407
|
+
visibleControlNotices.add(key);
|
|
408
|
+
const noticeText = details.noticeText ?? formatControlNoticeMessage(details.event, childIntercomTarget);
|
|
409
|
+
pi.sendMessage(
|
|
410
|
+
{
|
|
411
|
+
customType: SUBAGENT_CONTROL_MESSAGE_TYPE,
|
|
412
|
+
content: noticeText,
|
|
413
|
+
display: true,
|
|
414
|
+
details: { ...details, childIntercomTarget, noticeText },
|
|
415
|
+
},
|
|
416
|
+
{ triggerTurn: true },
|
|
417
|
+
);
|
|
418
|
+
};
|
|
419
|
+
const eventUnsubscribes = [
|
|
420
|
+
pi.events.on(SUBAGENT_ASYNC_STARTED_EVENT, handleStarted),
|
|
421
|
+
pi.events.on(SUBAGENT_ASYNC_COMPLETE_EVENT, handleComplete),
|
|
422
|
+
pi.events.on(SUBAGENT_CONTROL_EVENT, controlEventHandler),
|
|
423
|
+
];
|
|
424
|
+
globalStore[eventUnsubscribeStoreKey] = eventUnsubscribes;
|
|
439
425
|
|
|
440
426
|
pi.on("tool_result", (event, ctx) => {
|
|
441
427
|
if (event.toolName !== "subagent") return;
|
|
@@ -471,6 +457,16 @@ MANAGEMENT (use action field, omit agent/task/chain/tasks):
|
|
|
471
457
|
resetSessionState(ctx);
|
|
472
458
|
});
|
|
473
459
|
pi.on("session_shutdown", () => {
|
|
460
|
+
for (const unsubscribe of eventUnsubscribes) {
|
|
461
|
+
try {
|
|
462
|
+
unsubscribe();
|
|
463
|
+
} catch {
|
|
464
|
+
// Best effort cleanup during shutdown.
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (globalStore[eventUnsubscribeStoreKey] === eventUnsubscribes) {
|
|
468
|
+
delete globalStore[eventUnsubscribeStoreKey];
|
|
469
|
+
}
|
|
474
470
|
stopResultWatcher();
|
|
475
471
|
if (state.poller) clearInterval(state.poller);
|
|
476
472
|
state.poller = null;
|
package/install.mjs
CHANGED
|
@@ -85,9 +85,8 @@ if (fs.existsSync(EXTENSION_DIR)) {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
console.log(`
|
|
88
|
-
The extension is now available in pi.
|
|
89
|
-
• subagent
|
|
90
|
-
• subagent_status - Check async run status
|
|
88
|
+
The extension is now available in pi. Tool added:
|
|
89
|
+
• subagent - Delegate tasks to agents and inspect run status
|
|
91
90
|
|
|
92
91
|
Documentation: ${EXTENSION_DIR}/README.md
|
|
93
92
|
`);
|
package/intercom-bridge.ts
CHANGED
|
@@ -41,6 +41,15 @@ export function resolveIntercomSessionTarget(sessionName: string | undefined, se
|
|
|
41
41
|
return `${DEFAULT_INTERCOM_TARGET_PREFIX}-${normalizedSessionId.slice(0, 8)}`;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function sanitizeIntercomTargetPart(value: string): string {
|
|
45
|
+
return value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveSubagentIntercomTarget(runId: string, agent: string, index?: number): string {
|
|
49
|
+
const stepSuffix = index !== undefined ? `-${index + 1}` : "";
|
|
50
|
+
return `subagent-${sanitizeIntercomTargetPart(agent)}-${sanitizeIntercomTargetPart(runId)}${stepSuffix}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
export function resolveIntercomBridgeMode(value: unknown): IntercomBridgeMode {
|
|
45
54
|
if (value === "off" || value === "always" || value === "fork-only") return value;
|
|
46
55
|
return "always";
|
package/notify.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Subagent completion notifications
|
|
2
|
+
* Subagent completion notifications.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
6
6
|
import { buildCompletionKey, getGlobalSeenMap, markSeenWithTtl } from "./completion-dedupe.ts";
|
|
7
|
+
import { SUBAGENT_ASYNC_COMPLETE_EVENT } from "./types.ts";
|
|
7
8
|
|
|
8
9
|
interface ChainStepResult {
|
|
9
10
|
agent: string;
|
|
@@ -16,7 +17,8 @@ interface SubagentResult {
|
|
|
16
17
|
agent: string | null;
|
|
17
18
|
success: boolean;
|
|
18
19
|
summary: string;
|
|
19
|
-
exitCode
|
|
20
|
+
exitCode?: number;
|
|
21
|
+
state?: string;
|
|
20
22
|
timestamp: number;
|
|
21
23
|
sessionFile?: string;
|
|
22
24
|
shareUrl?: string;
|
|
@@ -28,6 +30,17 @@ interface SubagentResult {
|
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export default function registerSubagentNotify(pi: ExtensionAPI): void {
|
|
33
|
+
const unsubscribeStoreKey = "__pi_subagents_notify_unsubscribe__";
|
|
34
|
+
const globalStore = globalThis as Record<string, unknown>;
|
|
35
|
+
const previousUnsubscribe = globalStore[unsubscribeStoreKey];
|
|
36
|
+
if (typeof previousUnsubscribe === "function") {
|
|
37
|
+
try {
|
|
38
|
+
previousUnsubscribe();
|
|
39
|
+
} catch {
|
|
40
|
+
// Best effort cleanup for stale handlers from an older reload.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
const seen = getGlobalSeenMap("__pi_subagents_notify_seen__");
|
|
32
45
|
const ttlMs = 10 * 60 * 1000;
|
|
33
46
|
|
|
@@ -38,7 +51,13 @@ export default function registerSubagentNotify(pi: ExtensionAPI): void {
|
|
|
38
51
|
if (markSeenWithTtl(seen, key, now, ttlMs)) return;
|
|
39
52
|
|
|
40
53
|
const agent = result.agent ?? "unknown";
|
|
41
|
-
const
|
|
54
|
+
const summary = typeof result.summary === "string" ? result.summary : "";
|
|
55
|
+
const paused = !result.success && (
|
|
56
|
+
result.exitCode === 0
|
|
57
|
+
|| result.state === "paused"
|
|
58
|
+
|| summary.startsWith("Paused after interrupt.")
|
|
59
|
+
);
|
|
60
|
+
const status = paused ? "paused" : result.success ? "completed" : "failed";
|
|
42
61
|
|
|
43
62
|
const taskInfo =
|
|
44
63
|
result.taskIndex !== undefined && result.totalTasks !== undefined
|
|
@@ -54,11 +73,11 @@ export default function registerSubagentNotify(pi: ExtensionAPI): void {
|
|
|
54
73
|
extra.push(`Session file: ${result.sessionFile}`);
|
|
55
74
|
}
|
|
56
75
|
|
|
57
|
-
const
|
|
76
|
+
const displaySummary = summary.trim() ? summary : "(no output)";
|
|
58
77
|
const content = [
|
|
59
78
|
`Background task ${status}: **${agent}**${taskInfo}`,
|
|
60
79
|
"",
|
|
61
|
-
|
|
80
|
+
displaySummary,
|
|
62
81
|
extra.length ? "" : undefined,
|
|
63
82
|
extra.length ? extra.join("\n") : undefined,
|
|
64
83
|
]
|
|
@@ -75,5 +94,5 @@ export default function registerSubagentNotify(pi: ExtensionAPI): void {
|
|
|
75
94
|
);
|
|
76
95
|
};
|
|
77
96
|
|
|
78
|
-
pi.events.on(
|
|
97
|
+
globalStore[unsubscribeStoreKey] = pi.events.on(SUBAGENT_ASYNC_COMPLETE_EVENT, handleComplete);
|
|
79
98
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Pi extension for delegating tasks to subagents with chains, parallel execution, and TUI clarification",
|
|
5
5
|
"author": "Nico Bailon",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,13 +38,11 @@
|
|
|
38
38
|
"test": "npm run test:unit",
|
|
39
39
|
"test:unit": "node --experimental-strip-types --test test/unit/*.test.ts",
|
|
40
40
|
"test:integration": "node --experimental-transform-types --import ./test/support/register-loader.mjs --test test/integration/*.test.ts",
|
|
41
|
-
"test:
|
|
42
|
-
"test:all": "npm run test:unit && npm run test:integration && npm run test:e2e"
|
|
41
|
+
"test:all": "npm run test:unit && npm run test:integration"
|
|
43
42
|
},
|
|
44
43
|
"pi": {
|
|
45
44
|
"extensions": [
|
|
46
|
-
"./index.ts"
|
|
47
|
-
"./notify.ts"
|
|
45
|
+
"./index.ts"
|
|
48
46
|
],
|
|
49
47
|
"skills": [
|
|
50
48
|
"./skills"
|
|
@@ -60,7 +58,6 @@
|
|
|
60
58
|
"typebox": "^1.1.24"
|
|
61
59
|
},
|
|
62
60
|
"devDependencies": {
|
|
63
|
-
"@marcfargas/pi-test-harness": "^0.5.0",
|
|
64
61
|
"@mariozechner/pi-agent-core": "^0.65.0",
|
|
65
62
|
"@mariozechner/pi-ai": "^0.65.0",
|
|
66
63
|
"@mariozechner/pi-coding-agent": "^0.65.0"
|
package/pi-args.ts
CHANGED
|
@@ -23,6 +23,7 @@ export interface BuildPiArgsInput {
|
|
|
23
23
|
systemPrompt?: string | null;
|
|
24
24
|
mcpDirectTools?: string[];
|
|
25
25
|
promptFileStem?: string;
|
|
26
|
+
intercomSessionName?: string;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export interface BuildPiArgsResult {
|
|
@@ -112,6 +113,9 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
|
|
|
112
113
|
const env: Record<string, string | undefined> = {};
|
|
113
114
|
env.PI_SUBAGENT_INHERIT_PROJECT_CONTEXT = input.inheritProjectContext ? "1" : "0";
|
|
114
115
|
env.PI_SUBAGENT_INHERIT_SKILLS = input.inheritSkills ? "1" : "0";
|
|
116
|
+
if (input.intercomSessionName) {
|
|
117
|
+
env.PI_SUBAGENT_INTERCOM_SESSION_NAME = input.intercomSessionName;
|
|
118
|
+
}
|
|
115
119
|
if (input.mcpDirectTools?.length) {
|
|
116
120
|
env.MCP_DIRECT_TOOLS = input.mcpDirectTools.join(",");
|
|
117
121
|
} else {
|
package/pi-spawn.ts
CHANGED
|
@@ -83,12 +83,15 @@ export function resolveWindowsPiCliScript(deps: PiSpawnDeps = {}): string | unde
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
export function getPiSpawnCommand(args: string[], deps: PiSpawnDeps = {}): PiSpawnCommand {
|
|
86
|
-
const
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
86
|
+
const platform = deps.platform ?? process.platform;
|
|
87
|
+
if (platform === "win32") {
|
|
88
|
+
const piCliPath = resolveWindowsPiCliScript(deps);
|
|
89
|
+
if (piCliPath) {
|
|
90
|
+
return {
|
|
91
|
+
command: deps.execPath ?? process.execPath,
|
|
92
|
+
args: [piCliPath, ...args],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
return { command: "pi", args };
|
package/render.ts
CHANGED
|
@@ -83,7 +83,7 @@ let lastWidgetHash = "";
|
|
|
83
83
|
|
|
84
84
|
function computeWidgetHash(jobs: AsyncJobState[]): string {
|
|
85
85
|
return jobs.slice(0, MAX_WIDGET_JOBS).map(job =>
|
|
86
|
-
`${job.asyncId}:${job.status}:${job.currentStep}:${job.updatedAt}:${job.totalTokens?.total ?? 0}`
|
|
86
|
+
`${job.asyncId}:${job.status}:${job.activityState}:${job.currentStep}:${job.updatedAt}:${job.totalTokens?.total ?? 0}`
|
|
87
87
|
).join("|");
|
|
88
88
|
}
|
|
89
89
|
|
|
@@ -114,12 +114,16 @@ function getToolCallLines(
|
|
|
114
114
|
return result.toolCalls?.map((toolCall) => expanded ? toolCall.expandedText : toolCall.text) ?? [];
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
function
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
117
|
+
function formatActivityAge(ms: number): string {
|
|
118
|
+
if (ms < 1000) return "now";
|
|
119
|
+
if (ms < 60000) return `${Math.floor(ms / 1000)}s`;
|
|
120
|
+
return `${Math.floor(ms / 60000)}m`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function formatActivityLabel(lastActivityAt: number | undefined, needsAttention?: boolean, now = Date.now()): string | undefined {
|
|
124
|
+
if (lastActivityAt === undefined) return needsAttention ? "needs attention" : undefined;
|
|
125
|
+
const age = formatActivityAge(Math.max(0, now - lastActivityAt));
|
|
126
|
+
return needsAttention ? `no activity for ${age}` : age === "now" ? "active now" : `active ${age} ago`;
|
|
123
127
|
}
|
|
124
128
|
|
|
125
129
|
function formatCurrentToolLine(progress: Pick<AgentProgress, "currentTool" | "currentToolArgs" | "currentToolStartedAt">, availableWidth: number, expanded: boolean): string | undefined {
|
|
@@ -138,8 +142,8 @@ function formatCurrentToolLine(progress: Pick<AgentProgress, "currentTool" | "cu
|
|
|
138
142
|
: `${progress.currentTool}${durationSuffix}`;
|
|
139
143
|
}
|
|
140
144
|
|
|
141
|
-
function buildLiveStatusLine(progress: Pick<AgentProgress, "lastActivityAt">): string | undefined {
|
|
142
|
-
return formatActivityLabel(progress.lastActivityAt);
|
|
145
|
+
function buildLiveStatusLine(progress: Pick<AgentProgress, "activityState" | "lastActivityAt">): string | undefined {
|
|
146
|
+
return formatActivityLabel(progress.lastActivityAt, progress.activityState === "needs_attention");
|
|
143
147
|
}
|
|
144
148
|
|
|
145
149
|
/**
|
|
@@ -175,7 +179,9 @@ export function renderWidget(ctx: ExtensionContext, jobs: AsyncJobState[]): void
|
|
|
175
179
|
? theme.fg("success", "complete")
|
|
176
180
|
: job.status === "failed"
|
|
177
181
|
? theme.fg("error", "failed")
|
|
178
|
-
:
|
|
182
|
+
: job.status === "paused"
|
|
183
|
+
? theme.fg("warning", "paused")
|
|
184
|
+
: theme.fg("warning", "running");
|
|
179
185
|
|
|
180
186
|
const stepsTotal = job.stepsTotal ?? (job.agents?.length ?? 1);
|
|
181
187
|
const stepIndex = job.currentStep !== undefined ? job.currentStep + 1 : undefined;
|
|
@@ -185,12 +191,14 @@ export function renderWidget(ctx: ExtensionContext, jobs: AsyncJobState[]): void
|
|
|
185
191
|
const agentLabel = job.agents ? job.agents.join(" -> ") : (job.mode ?? "single");
|
|
186
192
|
|
|
187
193
|
const tokenText = job.totalTokens ? ` | ${formatTokens(job.totalTokens.total)} tok` : "";
|
|
188
|
-
const activityText = job.
|
|
194
|
+
const activityText = job.currentTool && job.currentToolStartedAt
|
|
195
|
+
? `tool ${job.currentTool} ${formatDuration(Math.max(0, Date.now() - job.currentToolStartedAt))}`
|
|
196
|
+
: formatActivityLabel(job.lastActivityAt, job.activityState === "needs_attention") ?? (job.status === "running" ? getLastActivity(job.outputFile) : "");
|
|
189
197
|
const activitySuffix = activityText ? ` | ${theme.fg("dim", activityText)}` : "";
|
|
190
198
|
|
|
191
199
|
lines.push(truncLine(`- ${id} ${status} | ${agentLabel} | ${stepText}${elapsed ? ` | ${elapsed}` : ""}${tokenText}${activitySuffix}`, w));
|
|
192
200
|
|
|
193
|
-
if (job.status === "running" && job.outputFile) {
|
|
201
|
+
if ((job.status === "running" || job.status === "paused") && job.outputFile) {
|
|
194
202
|
const tail = getOutputTail(job.outputFile, 3);
|
|
195
203
|
for (const line of tail) {
|
|
196
204
|
lines.push(truncLine(theme.fg("dim", ` > ${line}`), w));
|
package/result-watcher.ts
CHANGED
|
@@ -3,7 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
4
4
|
import { buildCompletionKey, markSeenWithTtl } from "./completion-dedupe.js";
|
|
5
5
|
import { createFileCoalescer } from "./file-coalescer.js";
|
|
6
|
-
import type
|
|
6
|
+
import { SUBAGENT_ASYNC_COMPLETE_EVENT, type SubagentState } from "./types.js";
|
|
7
7
|
|
|
8
8
|
function isNotFoundError(error: unknown): boolean {
|
|
9
9
|
return typeof error === "object"
|
|
@@ -36,13 +36,11 @@ export function createResultWatcher(
|
|
|
36
36
|
const now = Date.now();
|
|
37
37
|
const completionKey = buildCompletionKey(data, `result:${file}`);
|
|
38
38
|
if (markSeenWithTtl(state.completionSeen, completionKey, now, completionTtlMs)) {
|
|
39
|
-
|
|
40
|
-
fs.unlinkSync(resultPath);
|
|
41
|
-
} catch {}
|
|
39
|
+
fs.unlinkSync(resultPath);
|
|
42
40
|
return;
|
|
43
41
|
}
|
|
44
42
|
|
|
45
|
-
pi.events.emit(
|
|
43
|
+
pi.events.emit(SUBAGENT_ASYNC_COMPLETE_EVENT, data);
|
|
46
44
|
fs.unlinkSync(resultPath);
|
|
47
45
|
} catch (error) {
|
|
48
46
|
if (isNotFoundError(error)) return;
|