pi-subagents 0.17.5 → 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 +16 -0
- package/README.md +19 -19
- package/agents/oracle-executor.md +1 -1
- package/agents/scout.md +1 -1
- package/async-execution.ts +22 -2
- package/async-job-tracker.ts +70 -7
- package/async-status.ts +37 -15
- package/chain-execution.ts +29 -4
- package/execution.ts +18 -30
- package/index.ts +118 -131
- 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/render.ts +15 -22
- package/result-watcher.ts +3 -5
- package/run-status.ts +134 -0
- package/schemas.ts +16 -12
- package/skills/pi-subagents/SKILL.md +21 -21
- package/slash-live-state.ts +0 -4
- package/subagent-control.ts +84 -42
- package/subagent-executor.ts +122 -11
- package/subagent-prompt-runtime.ts +6 -0
- package/subagent-runner.ts +118 -10
- package/subagents-status.ts +5 -1
- package/types.ts +29 -9
package/execution.ts
CHANGED
|
@@ -28,8 +28,10 @@ import {
|
|
|
28
28
|
import {
|
|
29
29
|
DEFAULT_CONTROL_CONFIG,
|
|
30
30
|
buildControlEvent,
|
|
31
|
+
claimControlNotification,
|
|
31
32
|
deriveActivityState,
|
|
32
33
|
shouldEmitControlEvent,
|
|
34
|
+
shouldNotifyControlEvent,
|
|
33
35
|
} from "./subagent-control.ts";
|
|
34
36
|
import {
|
|
35
37
|
getFinalOutput,
|
|
@@ -135,6 +137,7 @@ async function runSingleAttempt(
|
|
|
135
137
|
systemPrompt: shared.systemPrompt,
|
|
136
138
|
mcpDirectTools: agent.mcpDirectTools,
|
|
137
139
|
promptFileStem: agent.name,
|
|
140
|
+
intercomSessionName: options.intercomSessionName,
|
|
138
141
|
});
|
|
139
142
|
|
|
140
143
|
const result: SingleResult = {
|
|
@@ -150,8 +153,6 @@ async function runSingleAttempt(
|
|
|
150
153
|
};
|
|
151
154
|
const startTime = Date.now();
|
|
152
155
|
const controlConfig = options.controlConfig ?? DEFAULT_CONTROL_CONFIG;
|
|
153
|
-
let hasSeenActivity = false;
|
|
154
|
-
let pausedByInterrupt = false;
|
|
155
156
|
let interruptedByControl = false;
|
|
156
157
|
const allControlEvents: ControlEvent[] = [];
|
|
157
158
|
let pendingControlEvents: ControlEvent[] = [];
|
|
@@ -160,7 +161,6 @@ async function runSingleAttempt(
|
|
|
160
161
|
index: options.index ?? 0,
|
|
161
162
|
agent: agent.name,
|
|
162
163
|
status: "running",
|
|
163
|
-
activityState: controlConfig.enabled ? "starting" : undefined,
|
|
164
164
|
task,
|
|
165
165
|
skills: shared.resolvedSkillNames,
|
|
166
166
|
recentTools: [],
|
|
@@ -274,34 +274,39 @@ async function runSingleAttempt(
|
|
|
274
274
|
return events;
|
|
275
275
|
};
|
|
276
276
|
|
|
277
|
+
const emittedControlEventKeys = new Set<string>();
|
|
278
|
+
const emitControlEvent = (event: ControlEvent) => {
|
|
279
|
+
if (shouldNotifyControlEvent(controlConfig, event) && !claimControlNotification(controlConfig, event, emittedControlEventKeys)) return;
|
|
280
|
+
allControlEvents.push(event);
|
|
281
|
+
pendingControlEvents.push(event);
|
|
282
|
+
options.onControlEvent?.(event);
|
|
283
|
+
};
|
|
284
|
+
|
|
277
285
|
const updateActivityState = (now: number): boolean => {
|
|
278
286
|
const next = deriveActivityState({
|
|
279
287
|
config: controlConfig,
|
|
280
288
|
startedAt: startTime,
|
|
281
289
|
lastActivityAt: progress.lastActivityAt,
|
|
282
|
-
hasSeenActivity,
|
|
283
|
-
paused: pausedByInterrupt,
|
|
284
290
|
now,
|
|
285
291
|
});
|
|
286
|
-
if (
|
|
292
|
+
if (next === progress.activityState) return false;
|
|
287
293
|
const previous = progress.activityState;
|
|
288
294
|
progress.activityState = next;
|
|
289
295
|
if (shouldEmitControlEvent(controlConfig, previous, next)) {
|
|
290
|
-
|
|
296
|
+
emitControlEvent(buildControlEvent({
|
|
291
297
|
from: previous,
|
|
292
298
|
to: next,
|
|
293
299
|
runId: options.runId,
|
|
294
300
|
agent: agent.name,
|
|
295
301
|
index: options.index,
|
|
296
302
|
ts: now,
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
pendingControlEvents.push(event);
|
|
300
|
-
options.onControlEvent?.(event);
|
|
303
|
+
lastActivityAt: progress.lastActivityAt,
|
|
304
|
+
}));
|
|
301
305
|
}
|
|
302
306
|
return true;
|
|
303
307
|
};
|
|
304
308
|
|
|
309
|
+
|
|
305
310
|
const emitUpdateSnapshot = (text: string) => {
|
|
306
311
|
if (!options.onUpdate || processClosed) return;
|
|
307
312
|
const progressSnapshot = snapshotProgress(progress);
|
|
@@ -338,7 +343,6 @@ async function runSingleAttempt(
|
|
|
338
343
|
const now = Date.now();
|
|
339
344
|
progress.durationMs = now - startTime;
|
|
340
345
|
progress.lastActivityAt = now;
|
|
341
|
-
hasSeenActivity = true;
|
|
342
346
|
updateActivityState(now);
|
|
343
347
|
|
|
344
348
|
if (evt.type === "tool_execution_start") {
|
|
@@ -481,27 +485,11 @@ async function runSingleAttempt(
|
|
|
481
485
|
const interrupt = () => {
|
|
482
486
|
if (processClosed || detached || settled) return;
|
|
483
487
|
interruptedByControl = true;
|
|
484
|
-
pausedByInterrupt = true;
|
|
485
488
|
progress.status = "running";
|
|
486
489
|
progress.durationMs = Date.now() - startTime;
|
|
487
490
|
result.interrupted = true;
|
|
488
491
|
result.finalOutput = "Interrupted. Waiting for explicit next action.";
|
|
489
|
-
|
|
490
|
-
const previous = progress.activityState;
|
|
491
|
-
progress.activityState = "paused";
|
|
492
|
-
if (shouldEmitControlEvent(controlConfig, previous, "paused")) {
|
|
493
|
-
const event = buildControlEvent({
|
|
494
|
-
from: previous,
|
|
495
|
-
to: "paused",
|
|
496
|
-
runId: options.runId,
|
|
497
|
-
agent: agent.name,
|
|
498
|
-
index: options.index,
|
|
499
|
-
ts: now,
|
|
500
|
-
});
|
|
501
|
-
allControlEvents.push(event);
|
|
502
|
-
pendingControlEvents.push(event);
|
|
503
|
-
options.onControlEvent?.(event);
|
|
504
|
-
}
|
|
492
|
+
progress.activityState = undefined;
|
|
505
493
|
fireUpdate();
|
|
506
494
|
trySignalChild(proc, "SIGINT");
|
|
507
495
|
setTimeout(() => {
|
|
@@ -523,7 +511,7 @@ async function runSingleAttempt(
|
|
|
523
511
|
result.error = undefined;
|
|
524
512
|
result.finalOutput = result.finalOutput || "Interrupted. Waiting for explicit next action.";
|
|
525
513
|
result.controlEvents = allControlEvents.length ? allControlEvents : undefined;
|
|
526
|
-
progress.activityState =
|
|
514
|
+
progress.activityState = undefined;
|
|
527
515
|
progress.durationMs = Date.now() - startTime;
|
|
528
516
|
result.progressSummary = {
|
|
529
517
|
toolCount: progress.toolCount,
|
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);
|
|
@@ -176,7 +227,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
|
|
|
176
227
|
startResultWatcher();
|
|
177
228
|
primeExistingResults();
|
|
178
229
|
|
|
179
|
-
const { ensurePoller, handleStarted, handleComplete, resetJobs } = createAsyncJobTracker(state, ASYNC_DIR);
|
|
230
|
+
const { ensurePoller, handleStarted, handleComplete, resetJobs } = createAsyncJobTracker(pi, state, ASYNC_DIR);
|
|
180
231
|
const executor = createSubagentExecutor({
|
|
181
232
|
pi,
|
|
182
233
|
state,
|
|
@@ -194,6 +245,13 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
|
|
|
194
245
|
return createSlashResultComponent(details, options, theme);
|
|
195
246
|
});
|
|
196
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
|
+
|
|
197
255
|
const slashBridge = registerSlashSubagentBridge({
|
|
198
256
|
events: pi.events,
|
|
199
257
|
getContext: () => state.lastUiContext,
|
|
@@ -274,7 +332,8 @@ MANAGEMENT (use action field, omit agent/task/chain/tasks):
|
|
|
274
332
|
• Use chainName for chain operations
|
|
275
333
|
|
|
276
334
|
CONTROL:
|
|
277
|
-
• { action: "
|
|
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`,
|
|
278
337
|
parameters: SubagentParams,
|
|
279
338
|
|
|
280
339
|
execute(id, params, signal, onUpdate, ctx) {
|
|
@@ -317,134 +376,52 @@ CONTROL:
|
|
|
317
376
|
|
|
318
377
|
};
|
|
319
378
|
|
|
320
|
-
const statusTool: ToolDefinition<typeof StatusParams, Details> = {
|
|
321
|
-
name: "subagent_status",
|
|
322
|
-
label: "Subagent Status",
|
|
323
|
-
description: "Inspect async subagent run status and artifacts",
|
|
324
|
-
parameters: StatusParams,
|
|
325
|
-
|
|
326
|
-
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
327
|
-
if (params.action === "list") {
|
|
328
|
-
try {
|
|
329
|
-
const runs = listAsyncRuns(ASYNC_DIR, { states: ["queued", "running"] });
|
|
330
|
-
return {
|
|
331
|
-
content: [{ type: "text", text: formatAsyncRunList(runs) }],
|
|
332
|
-
details: { mode: "single", results: [] },
|
|
333
|
-
};
|
|
334
|
-
} catch (error) {
|
|
335
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
336
|
-
return {
|
|
337
|
-
content: [{ type: "text", text: message }],
|
|
338
|
-
isError: true,
|
|
339
|
-
details: { mode: "single", results: [] },
|
|
340
|
-
};
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
let asyncDir: string | null = null;
|
|
345
|
-
let resolvedId = params.id;
|
|
346
|
-
|
|
347
|
-
if (params.dir) {
|
|
348
|
-
asyncDir = path.resolve(params.dir);
|
|
349
|
-
} else if (params.id) {
|
|
350
|
-
const direct = path.join(ASYNC_DIR, params.id);
|
|
351
|
-
if (fs.existsSync(direct)) {
|
|
352
|
-
asyncDir = direct;
|
|
353
|
-
} else {
|
|
354
|
-
const match = findByPrefix(ASYNC_DIR, params.id);
|
|
355
|
-
if (match) {
|
|
356
|
-
asyncDir = match;
|
|
357
|
-
resolvedId = path.basename(match);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
const resultPath =
|
|
363
|
-
params.id && !asyncDir ? findByPrefix(RESULTS_DIR, params.id, ".json") : null;
|
|
364
|
-
|
|
365
|
-
if (!asyncDir && !resultPath) {
|
|
366
|
-
return {
|
|
367
|
-
content: [{ type: "text", text: "Async run not found. Provide id or dir." }],
|
|
368
|
-
isError: true,
|
|
369
|
-
details: { mode: "single" as const, results: [] },
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
if (asyncDir) {
|
|
374
|
-
let status;
|
|
375
|
-
try {
|
|
376
|
-
status = readStatus(asyncDir);
|
|
377
|
-
} catch (error) {
|
|
378
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
379
|
-
return {
|
|
380
|
-
content: [{ type: "text", text: message }],
|
|
381
|
-
isError: true,
|
|
382
|
-
details: { mode: "single" as const, results: [] },
|
|
383
|
-
};
|
|
384
|
-
}
|
|
385
|
-
const logPath = path.join(asyncDir, `subagent-log-${resolvedId ?? "unknown"}.md`);
|
|
386
|
-
const eventsPath = path.join(asyncDir, "events.jsonl");
|
|
387
|
-
if (status) {
|
|
388
|
-
const stepsTotal = status.steps?.length ?? 1;
|
|
389
|
-
const current = status.currentStep !== undefined ? status.currentStep + 1 : undefined;
|
|
390
|
-
const stepLine =
|
|
391
|
-
current !== undefined ? `Step: ${current}/${stepsTotal}` : `Steps: ${stepsTotal}`;
|
|
392
|
-
const started = new Date(status.startedAt).toISOString();
|
|
393
|
-
const updated = status.lastUpdate ? new Date(status.lastUpdate).toISOString() : "n/a";
|
|
394
|
-
|
|
395
|
-
const lines = [
|
|
396
|
-
`Run: ${status.runId}`,
|
|
397
|
-
`State: ${status.activityState ? `${status.state}/${status.activityState}` : status.state}`,
|
|
398
|
-
`Mode: ${status.mode}`,
|
|
399
|
-
stepLine,
|
|
400
|
-
`Started: ${started}`,
|
|
401
|
-
`Updated: ${updated}`,
|
|
402
|
-
`Dir: ${asyncDir}`,
|
|
403
|
-
];
|
|
404
|
-
for (const [index, step] of (status.steps ?? []).entries()) {
|
|
405
|
-
const stepState = step.activityState ? `${step.status}/${step.activityState}` : step.status;
|
|
406
|
-
lines.push(`Step ${index + 1}: ${step.agent} ${stepState}`);
|
|
407
|
-
}
|
|
408
|
-
if (status.sessionFile) lines.push(`Session: ${status.sessionFile}`);
|
|
409
|
-
if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
|
|
410
|
-
if (fs.existsSync(eventsPath)) lines.push(`Events: ${eventsPath}`);
|
|
411
|
-
|
|
412
|
-
return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [] } };
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
if (resultPath) {
|
|
417
|
-
try {
|
|
418
|
-
const raw = fs.readFileSync(resultPath, "utf-8");
|
|
419
|
-
const data = JSON.parse(raw) as { id?: string; success?: boolean; summary?: string };
|
|
420
|
-
const status = data.success ? "complete" : "failed";
|
|
421
|
-
const lines = [`Run: ${data.id ?? params.id}`, `State: ${status}`, `Result: ${resultPath}`];
|
|
422
|
-
if (data.summary) lines.push("", data.summary);
|
|
423
|
-
return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [] } };
|
|
424
|
-
} catch (error) {
|
|
425
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
426
|
-
return {
|
|
427
|
-
content: [{ type: "text", text: `Failed to read async result file: ${message}` }],
|
|
428
|
-
isError: true,
|
|
429
|
-
details: { mode: "single" as const, results: [] },
|
|
430
|
-
};
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
return {
|
|
435
|
-
content: [{ type: "text", text: "Status file not found." }],
|
|
436
|
-
isError: true,
|
|
437
|
-
details: { mode: "single" as const, results: [] },
|
|
438
|
-
};
|
|
439
|
-
},
|
|
440
|
-
};
|
|
441
|
-
|
|
442
379
|
pi.registerTool(tool);
|
|
443
|
-
pi.registerTool(statusTool);
|
|
444
380
|
registerSlashCommands(pi, state);
|
|
445
381
|
|
|
446
|
-
|
|
447
|
-
|
|
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;
|
|
448
425
|
|
|
449
426
|
pi.on("tool_result", (event, ctx) => {
|
|
450
427
|
if (event.toolName !== "subagent") return;
|
|
@@ -480,6 +457,16 @@ CONTROL:
|
|
|
480
457
|
resetSessionState(ctx);
|
|
481
458
|
});
|
|
482
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
|
+
}
|
|
483
470
|
stopResultWatcher();
|
|
484
471
|
if (state.poller) clearInterval(state.poller);
|
|
485
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/render.ts
CHANGED
|
@@ -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,13 +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);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function formatActivityState(state: AgentProgress["activityState"]): string | undefined {
|
|
146
|
-
if (!state) return undefined;
|
|
147
|
-
return `activity: ${state}`;
|
|
145
|
+
function buildLiveStatusLine(progress: Pick<AgentProgress, "activityState" | "lastActivityAt">): string | undefined {
|
|
146
|
+
return formatActivityLabel(progress.lastActivityAt, progress.activityState === "needs_attention");
|
|
148
147
|
}
|
|
149
148
|
|
|
150
149
|
/**
|
|
@@ -192,7 +191,9 @@ export function renderWidget(ctx: ExtensionContext, jobs: AsyncJobState[]): void
|
|
|
192
191
|
const agentLabel = job.agents ? job.agents.join(" -> ") : (job.mode ?? "single");
|
|
193
192
|
|
|
194
193
|
const tokenText = job.totalTokens ? ` | ${formatTokens(job.totalTokens.total)} tok` : "";
|
|
195
|
-
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) : "");
|
|
196
197
|
const activitySuffix = activityText ? ` | ${theme.fg("dim", activityText)}` : "";
|
|
197
198
|
|
|
198
199
|
lines.push(truncLine(`- ${id} ${status} | ${agentLabel} | ${stepText}${elapsed ? ` | ${elapsed}` : ""}${tokenText}${activitySuffix}`, w));
|
|
@@ -266,10 +267,6 @@ export function renderSubagentResult(
|
|
|
266
267
|
if (toolLine) {
|
|
267
268
|
c.addChild(new Text(fit(theme.fg("warning", `> ${toolLine}`)), 0, 0));
|
|
268
269
|
}
|
|
269
|
-
const activityStateLine = formatActivityState(r.progress.activityState);
|
|
270
|
-
if (activityStateLine) {
|
|
271
|
-
c.addChild(new Text(fit(theme.fg("accent", activityStateLine)), 0, 0));
|
|
272
|
-
}
|
|
273
270
|
const liveStatusLine = buildLiveStatusLine(r.progress);
|
|
274
271
|
if (liveStatusLine) {
|
|
275
272
|
c.addChild(new Text(fit(theme.fg("accent", liveStatusLine)), 0, 0));
|
|
@@ -478,10 +475,6 @@ export function renderSubagentResult(
|
|
|
478
475
|
if (toolLine) {
|
|
479
476
|
c.addChild(new Text(fit(theme.fg("warning", ` > ${toolLine}`)), 0, 0));
|
|
480
477
|
}
|
|
481
|
-
const activityStateLine = formatActivityState(rProg.activityState);
|
|
482
|
-
if (activityStateLine) {
|
|
483
|
-
c.addChild(new Text(fit(theme.fg("accent", ` ${activityStateLine}`)), 0, 0));
|
|
484
|
-
}
|
|
485
478
|
const liveStatusLine = buildLiveStatusLine(rProg);
|
|
486
479
|
if (liveStatusLine) {
|
|
487
480
|
c.addChild(new Text(fit(theme.fg("accent", ` ${liveStatusLine}`)), 0, 0));
|