@ferris1225/pi-subagents 0.27.0 → 0.29.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/README.md +265 -587
- package/agents/explore.md +1 -0
- package/agents/reviewer.md +1 -0
- package/agents/worker.md +1 -1
- package/package.json +1 -1
- package/src/config.ts +25 -0
- package/src/dispatch.ts +729 -0
- package/src/format.ts +142 -0
- package/src/index.ts +90 -1382
- package/src/models.ts +13 -0
- package/src/prompt.ts +11 -3
- package/src/runtime.ts +110 -0
- package/src/setup.ts +50 -0
- package/src/tools.ts +406 -0
- package/src/ui.ts +8 -3
- package/src/widget.ts +178 -0
package/src/models.ts
CHANGED
|
@@ -36,6 +36,19 @@ export function availableModelRefs(ctx: ModelContext): string[] {
|
|
|
36
36
|
return [currentRef, ...refs.filter((ref) => ref !== currentRef)];
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the model for a `vision: true` dispatch. The configured vision model
|
|
41
|
+
* wins when it is usable by the current session; otherwise the task falls back
|
|
42
|
+
* to the main window's current model (the documented behavior when the vision
|
|
43
|
+
* model is unset). Returns undefined only when neither exists — callers then
|
|
44
|
+
* keep the agent's own model as the last resort.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveVisionModelRef(ctx: ModelContext, visionModel?: string): string | undefined {
|
|
47
|
+
const configured = visionModel?.trim();
|
|
48
|
+
if (configured && availableModelRefs(ctx).includes(configured)) return configured;
|
|
49
|
+
return ctx.model ? modelRef(ctx.model) : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
/** Replace unavailable persisted overrides with a model usable by the main session. */
|
|
40
53
|
export function repairUnavailableModelOverrides(
|
|
41
54
|
ctx: ModelContext,
|
package/src/prompt.ts
CHANGED
|
@@ -41,9 +41,13 @@ It immediately ends the current main-agent turn so the user can keep working. Wh
|
|
|
41
41
|
finishes, its result is sent back as a message that automatically resumes the main agent;
|
|
42
42
|
if the main agent is busy, the result waits as a follow-up.
|
|
43
43
|
|
|
44
|
-
NEVER run sleep, wait, or polling commands (e.g. Start-Sleep, sleep, timeout)
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
NEVER run sleep, wait, or polling commands (e.g. Start-Sleep, sleep, timeout), and do NOT
|
|
45
|
+
call subagent_wait to hold the turn — dispatching already ended it, and results arrive as
|
|
46
|
+
messages that resume the main agent automatically (even mid-turn). Ending your turn is the
|
|
47
|
+
default and the only correct way to wait; subagent_wait blocks the turn so the user cannot
|
|
48
|
+
give you other work meanwhile. It is non-blocking by default: settled results return
|
|
49
|
+
immediately, active runs return a "still running — end your turn" note. Pass an explicit
|
|
50
|
+
timeoutMs only when you must stay in the turn (e.g. the user asked you to wait).
|
|
47
51
|
|
|
48
52
|
Available agents:
|
|
49
53
|
${catalog}
|
|
@@ -58,6 +62,10 @@ ${hasMultiple ? "- Run INDEPENDENT tasks in parallel: one subagent call with a `
|
|
|
58
62
|
- Treat delegated agents as leaf workers: do not ask a sub-agent to dispatch another sub-agent; child processes do not have this tool.
|
|
59
63
|
- Trust but verify: a sub-agent's summary describes intent, not outcome. Check the actual changes/results before reporting work done.
|
|
60
64
|
|
|
65
|
+
Vision tasks:
|
|
66
|
+
- Judge whether a delegated task may require viewing images (frontend screenshots, mockups, design files, visual regression comparisons). If it might, pass \`vision: true\` in the subagent call and give the sub-agent the exact image paths — it reads them with its read tool.
|
|
67
|
+
- \`vision: true\` runs the sub-agent on the vision-capable model configured in /subagents-setup; when none is configured it falls back to the main session's current model. Do not skip the flag because the agent's default model looks fast/cheap — a non-vision model cannot see the images.
|
|
68
|
+
|
|
61
69
|
Review & verification:
|
|
62
70
|
- Never report an unrun check as passed; report it as unavailable or as a pre-existing failure.
|
|
63
71
|
${hasReviewer ? "- For non-trivial diffs, run one fresh read-only `reviewer` sub-agent before reporting done. Fix only concrete blockers and re-review at most once.\n- Use multi-model cross-review only when explicitly requested or for genuinely high-risk changes (security, unsafe/FFI, persistence-migration, concurrency). Reviewers are read-only; only the main agent edits.\n" : ""}- Commit or push only when explicitly requested, applicable checks pass, and no accepted blockers remain.`;
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared per-session runtime state for pi-subagents.
|
|
3
|
+
*
|
|
4
|
+
* The extension registers several tools (subagent, subagent_wait/status/stop) and
|
|
5
|
+
* a widget that all talk to one set of live structures: the background queue, the
|
|
6
|
+
* completion batcher, abort controllers per run, and the settled-results store.
|
|
7
|
+
* `createRuntime` builds those once per extension load and hands the same object
|
|
8
|
+
* to every registration site, so state stays in one place without globals.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { BackgroundTaskQueue } from "./background.ts";
|
|
13
|
+
import {
|
|
14
|
+
completionGroupTriggersTurn,
|
|
15
|
+
createCompletionBatcher,
|
|
16
|
+
formatCompletionMessage,
|
|
17
|
+
type CompletionBatcher,
|
|
18
|
+
type CompletionMessageItem,
|
|
19
|
+
} from "./completion.ts";
|
|
20
|
+
import { loadConfigSync } from "./config.ts";
|
|
21
|
+
import { monitor } from "./monitor.ts";
|
|
22
|
+
import type { SingleResult } from "./spawn.ts";
|
|
23
|
+
|
|
24
|
+
export interface SubagentRuntime {
|
|
25
|
+
configPath: string;
|
|
26
|
+
backgroundQueue: BackgroundTaskQueue;
|
|
27
|
+
/** False after session_shutdown; guards delivery and queue work. */
|
|
28
|
+
sessionActive: boolean;
|
|
29
|
+
/** Deliver a batch of completion messages to the main window, waking it only
|
|
30
|
+
* when the batch needs a turn. */
|
|
31
|
+
sendCompletionGroup: (items: CompletionMessageItem[]) => void;
|
|
32
|
+
completionBatcher: CompletionBatcher<CompletionMessageItem>;
|
|
33
|
+
/** Abort controllers per active run, so subagent_stop can cancel a run in-turn. */
|
|
34
|
+
runControllers: Map<number, AbortController>;
|
|
35
|
+
/** Final results keyed by run id, so subagent_wait can hand the model the
|
|
36
|
+
* actual result in-turn instead of it sleeping/polling for a wake-up message. */
|
|
37
|
+
settledRuns: Map<number, SingleResult>;
|
|
38
|
+
settledListeners: Map<number, Set<(result: SingleResult) => void>>;
|
|
39
|
+
registerRunResult: (runId: number, result: SingleResult) => void;
|
|
40
|
+
/** Flip sessionActive off and release all session-scoped resources. */
|
|
41
|
+
shutdown: () => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRuntime {
|
|
45
|
+
// Init-time decisions need the config synchronously; the full (migrating)
|
|
46
|
+
// async load runs per tool call.
|
|
47
|
+
const initialConfig = loadConfigSync(configPath);
|
|
48
|
+
const backgroundQueue = new BackgroundTaskQueue(initialConfig.maxConcurrency);
|
|
49
|
+
|
|
50
|
+
const runtime: SubagentRuntime = {
|
|
51
|
+
configPath,
|
|
52
|
+
backgroundQueue,
|
|
53
|
+
sessionActive: true,
|
|
54
|
+
sendCompletionGroup: (items) => {
|
|
55
|
+
if (!runtime.sessionActive || items.length === 0) return;
|
|
56
|
+
const message = {
|
|
57
|
+
customType: "subagent-result",
|
|
58
|
+
content: formatCompletionMessage(items),
|
|
59
|
+
display: true,
|
|
60
|
+
};
|
|
61
|
+
if (completionGroupTriggersTurn(items)) {
|
|
62
|
+
// steer: the result is injected after the current tool call even mid-turn,
|
|
63
|
+
// or starts a new turn when idle. followUp would sit in the queue until the
|
|
64
|
+
// whole turn ends — a main agent waiting for the result (sleep/poll) would
|
|
65
|
+
// never see it delivered, which is exactly the "returned but never woken"
|
|
66
|
+
// failure mode.
|
|
67
|
+
pi.sendMessage(message, { deliverAs: "steer", triggerTurn: true });
|
|
68
|
+
} else {
|
|
69
|
+
// No-wake delivery: nextTurn rides along with the next user turn and can
|
|
70
|
+
// never start a continuation by itself. followUp would auto-continue
|
|
71
|
+
// whenever pi is already streaming, defeating the opt-out.
|
|
72
|
+
pi.sendMessage(message, { deliverAs: "nextTurn" });
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
completionBatcher: undefined as unknown as CompletionBatcher<CompletionMessageItem>,
|
|
76
|
+
runControllers: new Map<number, AbortController>(),
|
|
77
|
+
settledRuns: new Map<number, SingleResult>(),
|
|
78
|
+
settledListeners: new Map<number, Set<(result: SingleResult) => void>>(),
|
|
79
|
+
registerRunResult: (runId, result) => {
|
|
80
|
+
runtime.settledRuns.set(runId, result);
|
|
81
|
+
const listeners = runtime.settledListeners.get(runId);
|
|
82
|
+
if (listeners) {
|
|
83
|
+
runtime.settledListeners.delete(runId);
|
|
84
|
+
for (const listener of listeners) {
|
|
85
|
+
try {
|
|
86
|
+
listener(result);
|
|
87
|
+
} catch {
|
|
88
|
+
/* listener errors must never break settling */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
shutdown: () => {
|
|
94
|
+
runtime.sessionActive = false;
|
|
95
|
+
runtime.completionBatcher.dispose();
|
|
96
|
+
runtime.backgroundQueue.cancelAll();
|
|
97
|
+
runtime.settledRuns.clear();
|
|
98
|
+
runtime.settledListeners.clear();
|
|
99
|
+
runtime.runControllers.clear();
|
|
100
|
+
// Clear the monitor so stale runs from this session never leak into the
|
|
101
|
+
// next one (the module-level singleton survives across sessions).
|
|
102
|
+
monitor.clear();
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
runtime.completionBatcher = createCompletionBatcher<CompletionMessageItem>({
|
|
107
|
+
emit: runtime.sendCompletionGroup,
|
|
108
|
+
});
|
|
109
|
+
return runtime;
|
|
110
|
+
}
|
package/src/setup.ts
CHANGED
|
@@ -103,6 +103,31 @@ async function pickAgentModel(
|
|
|
103
103
|
);
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/** Vision model pick for image tasks (screenshots/mockups); the inherit option
|
|
107
|
+
* leaves it unset, so vision-flagged dispatches fall back to the main session's
|
|
108
|
+
* current model. */
|
|
109
|
+
async function pickVisionModel(
|
|
110
|
+
ctx: ExtensionCommandContext,
|
|
111
|
+
currentRef: string | undefined,
|
|
112
|
+
refs: readonly string[],
|
|
113
|
+
): Promise<string | typeof INHERIT | undefined> {
|
|
114
|
+
const items = [
|
|
115
|
+
{
|
|
116
|
+
value: INHERIT,
|
|
117
|
+
label: currentRef
|
|
118
|
+
? `(not set — vision tasks fall back to the main session's model; drop "${currentRef}")`
|
|
119
|
+
: "(not set — vision tasks fall back to the main session's current model)",
|
|
120
|
+
},
|
|
121
|
+
...refs.map((ref) => ({ value: ref, label: ref === currentRef ? `${ref} (current)` : ref })),
|
|
122
|
+
];
|
|
123
|
+
return promptSelectOne(
|
|
124
|
+
ctx,
|
|
125
|
+
"Vision-capable model for image tasks (screenshots, mockups, designs)?",
|
|
126
|
+
"Type to filter • ↑/↓ • PgUp/PgDn • Enter selects • Esc cancels setup",
|
|
127
|
+
items,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
106
131
|
async function pickAgentModelsAndStrength(
|
|
107
132
|
ctx: ExtensionCommandContext,
|
|
108
133
|
enabledAgents: readonly string[],
|
|
@@ -337,6 +362,18 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
|
|
|
337
362
|
const picked = await pickAgentModelsAndStrength(ctx, enabled, base.agentModels, base.agentThinkingLevels, thinkingLevel, defaults);
|
|
338
363
|
if (picked === undefined) return notifyCancelled(ctx);
|
|
339
364
|
|
|
365
|
+
let nextVisionModel: string | undefined;
|
|
366
|
+
// No models available: keep the vision model unset (vision tasks then fall
|
|
367
|
+
// back to the main session's model) instead of showing a one-option picker.
|
|
368
|
+
const refs = availableModelRefs(ctx);
|
|
369
|
+
if (refs.length === 0) {
|
|
370
|
+
ctx.ui.notify("No Pi models are currently available; vision model left unset.", "warning");
|
|
371
|
+
} else {
|
|
372
|
+
const visionModel = await pickVisionModel(ctx, base.visionModel, refs);
|
|
373
|
+
if (visionModel === undefined) return notifyCancelled(ctx);
|
|
374
|
+
if (visionModel !== INHERIT) nextVisionModel = visionModel;
|
|
375
|
+
}
|
|
376
|
+
|
|
340
377
|
const injection = await pickInjection(ctx, base.proactiveInjection);
|
|
341
378
|
if (injection === undefined) return notifyCancelled(ctx);
|
|
342
379
|
|
|
@@ -382,7 +419,9 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
|
|
|
382
419
|
maxConcurrency,
|
|
383
420
|
maxFixRounds,
|
|
384
421
|
idleTimeoutSec,
|
|
422
|
+
announcedFeatures: base.announcedFeatures,
|
|
385
423
|
};
|
|
424
|
+
if (nextVisionModel !== undefined) next.visionModel = nextVisionModel;
|
|
386
425
|
await saveConfig(next, configPath);
|
|
387
426
|
ctx.ui.notify(`pi-subagents configured. Saved to ${configPath}`, "info");
|
|
388
427
|
}
|
|
@@ -391,6 +430,7 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
|
|
|
391
430
|
const choice = await ctx.ui.select("pi-subagents is already configured. What would you like to change?", [
|
|
392
431
|
"Enable/disable agents",
|
|
393
432
|
"Configure an agent (model + thinking)",
|
|
433
|
+
"Change vision model (image tasks)",
|
|
394
434
|
"Toggle proactive injection",
|
|
395
435
|
"Change agent scope",
|
|
396
436
|
"Change max concurrent sub-agents",
|
|
@@ -440,6 +480,16 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
|
|
|
440
480
|
const injection = await pickInjection(ctx, config.proactiveInjection);
|
|
441
481
|
if (injection === undefined) return notifyCancelled(ctx);
|
|
442
482
|
next.proactiveInjection = injection;
|
|
483
|
+
} else if (choice.startsWith("Change vision")) {
|
|
484
|
+
const refs = availableModelRefs(ctx);
|
|
485
|
+
if (refs.length === 0) {
|
|
486
|
+
ctx.ui.notify("No Pi models are currently available; vision model left unchanged.", "warning");
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const visionModel = await pickVisionModel(ctx, config.visionModel, refs);
|
|
490
|
+
if (visionModel === undefined) return notifyCancelled(ctx);
|
|
491
|
+
if (visionModel === INHERIT) delete next.visionModel;
|
|
492
|
+
else next.visionModel = visionModel;
|
|
443
493
|
} else if (choice.startsWith("Change agent scope")) {
|
|
444
494
|
const scope = await pickScope(ctx, config.agentScope);
|
|
445
495
|
if (scope === undefined) return notifyCancelled(ctx);
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lookup tools around the subagent runtime: subagent_wait (in-turn result
|
|
3
|
+
* lookup, non-blocking by default), subagent_status (overview / full result by
|
|
4
|
+
* id), and subagent_stop (cancel active runs).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
import { loadConfig } from "./config.ts";
|
|
11
|
+
import { emptyUsage, formatCompletionBlock, formatUsage, matchRunIds } from "./format.ts";
|
|
12
|
+
import {
|
|
13
|
+
formatElapsed,
|
|
14
|
+
formatUsageCompact,
|
|
15
|
+
monitor,
|
|
16
|
+
statusLabel,
|
|
17
|
+
} from "./monitor.ts";
|
|
18
|
+
import type { SubagentRuntime } from "./runtime.ts";
|
|
19
|
+
import { isFailedResult, type SingleResult } from "./spawn.ts";
|
|
20
|
+
|
|
21
|
+
/** In-turn result lookup. Dispatch already ended the turn and results arrive as
|
|
22
|
+
* wake-up messages, so the default must NOT block: a settled run returns its
|
|
23
|
+
* result immediately, a still-active run returns a "still running — end your
|
|
24
|
+
* turn" note and the model finishes (the completion then wakes it). Blocking
|
|
25
|
+
* is opt-in via an explicit timeoutMs — a long default would hold the turn
|
|
26
|
+
* hostage for nothing, since the result arrives on its own either way. */
|
|
27
|
+
const SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS = 0;
|
|
28
|
+
|
|
29
|
+
function renderFirstLine(result: { content?: unknown }, label: string, theme: any): Text {
|
|
30
|
+
const parts = (result.content ?? []) as Array<{ type: string; text?: string }>;
|
|
31
|
+
const text = parts
|
|
32
|
+
.map((part) => (typeof part.text === "string" ? part.text : ""))
|
|
33
|
+
.join(" ")
|
|
34
|
+
.trim();
|
|
35
|
+
const firstLine = text.split("\n").find((line) => line.trim()) ?? "(no output)";
|
|
36
|
+
return new Text(`${theme.fg("toolTitle", theme.bold(label))}${theme.fg("dim", firstLine.slice(0, 60))}`, 0, 0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime): void {
|
|
40
|
+
const SubagentWaitParams = Type.Object({
|
|
41
|
+
id: Type.Optional(
|
|
42
|
+
Type.String({
|
|
43
|
+
description: "Run id or prefix shown in the subagent widget (#id). Omit to wait for all active runs in this session.",
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
timeoutMs: Type.Optional(
|
|
47
|
+
Type.Number({
|
|
48
|
+
description: `Block for up to this many milliseconds and report the still-running runs. Default ${SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS}: no blocking — settled runs return their result immediately, active runs return a note telling the model to end its turn.`,
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
pi.registerTool({
|
|
54
|
+
name: "subagent_wait",
|
|
55
|
+
label: "Subagent Wait",
|
|
56
|
+
description: [
|
|
57
|
+
"Look up background sub-agent run(s) and return their results.",
|
|
58
|
+
"PREFER NOT CALLING THIS: dispatching already ended your turn and results arrive as a message that wakes you automatically.",
|
|
59
|
+
"By default it does NOT block: a settled run returns its result immediately; a still-active run returns a 'still running — end your turn' note.",
|
|
60
|
+
"Pass an explicit timeoutMs ONLY when you must stay in the turn and need the result right now (sequential dependent steps).",
|
|
61
|
+
"NEVER sleep, poll, or wait with bash to get a sub-agent result: end the turn, or call this tool.",
|
|
62
|
+
"The same result is also delivered as a completion message that resumes the main agent, so you may see it twice (once here, once as a wake-up) — that is expected, not a duplicate.",
|
|
63
|
+
].join(" "),
|
|
64
|
+
promptSnippet: "Look up a background subagent result in-turn (id: run id from the widget; omit for all). Non-blocking by default; pass timeoutMs to block.",
|
|
65
|
+
promptGuidelines: [
|
|
66
|
+
"Do NOT call subagent_wait to hold the turn: results arrive as wake-up messages automatically. The default call is a non-blocking lookup — settled results return immediately, active runs return a note telling you to end your turn.",
|
|
67
|
+
"Pass an explicit timeoutMs only when you must keep the turn AND the next step depends on the result right now — e.g. the user asked you to wait for it.",
|
|
68
|
+
"Never use bash sleep/timeout/polling to wait for a sub-agent — it blocks the turn and delays result delivery.",
|
|
69
|
+
"If subagent_wait times out, end the turn and wait for the wake-up message, or call it again with a longer timeoutMs.",
|
|
70
|
+
],
|
|
71
|
+
parameters: SubagentWaitParams,
|
|
72
|
+
|
|
73
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
74
|
+
const config = await loadConfig(runtime.configPath);
|
|
75
|
+
// A non-finite or negative timeout would produce a nonsensical note
|
|
76
|
+
// ("timed out after Infinitys") or an instant "timeout" that was never
|
|
77
|
+
// asked for; fall back to the default. Zero is honored as an immediate
|
|
78
|
+
// give-up (clamped to 1ms below).
|
|
79
|
+
const timeoutMs =
|
|
80
|
+
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) && params.timeoutMs >= 0
|
|
81
|
+
? params.timeoutMs
|
|
82
|
+
: SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS;
|
|
83
|
+
const isActive = (run: { status: string; retained?: boolean }): boolean =>
|
|
84
|
+
run.status === "queued" || run.status === "running" || run.retained === true;
|
|
85
|
+
|
|
86
|
+
const requested = params.id?.trim();
|
|
87
|
+
// A run that already settled resolves immediately with its result.
|
|
88
|
+
if (requested) {
|
|
89
|
+
const settledIds = matchRunIds([...runtime.settledRuns.keys()], requested);
|
|
90
|
+
if (settledIds.length > 0) {
|
|
91
|
+
return {
|
|
92
|
+
content: [
|
|
93
|
+
{ type: "text", text: settledIds.map((id) => formatCompletionBlock(runtime.settledRuns.get(id)!, config.maxResultLines, ctx.cwd)).join("\n\n") },
|
|
94
|
+
],
|
|
95
|
+
details: {},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const activeRuns = monitor.getRuns().filter(isActive);
|
|
101
|
+
const targetIds = requested ? matchRunIds(activeRuns.map((run) => run.id), requested) : activeRuns.map((run) => run.id);
|
|
102
|
+
const targets = activeRuns.filter((run) => targetIds.includes(run.id));
|
|
103
|
+
if (targets.length === 0) {
|
|
104
|
+
const activeList = activeRuns.map((run) => `#${run.id} ${run.agent}`).join(", ");
|
|
105
|
+
return {
|
|
106
|
+
content: [
|
|
107
|
+
{
|
|
108
|
+
type: "text",
|
|
109
|
+
text: requested
|
|
110
|
+
? `No active subagent run matches "${requested}".${activeList ? ` Active runs: ${activeList}.` : ""}`
|
|
111
|
+
: `No active subagent runs${activeList ? ` (active: ${activeList})` : " right now"}.`,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
details: {},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const waitForRun = (runId: number): Promise<{ result?: SingleResult; note?: string }> => {
|
|
119
|
+
const already = runtime.settledRuns.get(runId);
|
|
120
|
+
if (already) return Promise.resolve({ result: already });
|
|
121
|
+
return new Promise((resolve) => {
|
|
122
|
+
let done = false;
|
|
123
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
124
|
+
let unsub: (() => void) | undefined;
|
|
125
|
+
const cleanup = (): void => {
|
|
126
|
+
if (timer) clearTimeout(timer);
|
|
127
|
+
if (unsub) unsub();
|
|
128
|
+
signal?.removeEventListener("abort", onAbort);
|
|
129
|
+
const listeners = runtime.settledListeners.get(runId);
|
|
130
|
+
if (listeners) {
|
|
131
|
+
listeners.delete(onSettled);
|
|
132
|
+
if (listeners.size === 0) runtime.settledListeners.delete(runId);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const finish = (outcome: { result?: SingleResult; note?: string }): void => {
|
|
136
|
+
if (done) return;
|
|
137
|
+
done = true;
|
|
138
|
+
cleanup();
|
|
139
|
+
resolve(outcome);
|
|
140
|
+
};
|
|
141
|
+
const onSettled = (result: SingleResult): void => finish({ result });
|
|
142
|
+
const onMonitor = (): void => {
|
|
143
|
+
const current = runtime.settledRuns.get(runId);
|
|
144
|
+
if (current) {
|
|
145
|
+
finish({ result: current });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (!monitor.findRun(runId)) {
|
|
149
|
+
// Removal is followed synchronously by registerRunResult in the
|
|
150
|
+
// finishing task; re-check on the next tick so the result wins.
|
|
151
|
+
setTimeout(() => {
|
|
152
|
+
const late = runtime.settledRuns.get(runId);
|
|
153
|
+
if (late) finish({ result: late });
|
|
154
|
+
else finish({ note: `run #${runId} was removed before its result was recorded (cancelled or session ended)` });
|
|
155
|
+
}, 0);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
const onAbort = (): void => finish({ note: "wait aborted" });
|
|
159
|
+
let listeners = runtime.settledListeners.get(runId);
|
|
160
|
+
if (!listeners) {
|
|
161
|
+
listeners = new Set();
|
|
162
|
+
runtime.settledListeners.set(runId, listeners);
|
|
163
|
+
}
|
|
164
|
+
listeners.add(onSettled);
|
|
165
|
+
unsub = monitor.subscribe(onMonitor);
|
|
166
|
+
timer = setTimeout(
|
|
167
|
+
() =>
|
|
168
|
+
finish({
|
|
169
|
+
note:
|
|
170
|
+
timeoutMs === 0
|
|
171
|
+
? `run #${runId} is still active — end your turn: the result will wake you (or call subagent_wait again with an explicit timeoutMs to block)`
|
|
172
|
+
: `wait timed out after ${Math.round(timeoutMs / 1000)}s — run #${runId} is still active; call subagent_wait again or end the turn (the result will wake you when ready)`,
|
|
173
|
+
}),
|
|
174
|
+
Math.max(1, timeoutMs),
|
|
175
|
+
);
|
|
176
|
+
if (signal?.aborted) onAbort();
|
|
177
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const outcomes = await Promise.all(targets.map((run) => waitForRun(run.id)));
|
|
182
|
+
const blocks = outcomes.map((outcome) =>
|
|
183
|
+
outcome.result ? formatCompletionBlock(outcome.result, config.maxResultLines, ctx.cwd) : (outcome.note ?? "(no outcome)"),
|
|
184
|
+
);
|
|
185
|
+
return { content: [{ type: "text", text: blocks.join("\n\n") }], details: {} };
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
renderCall(args, theme) {
|
|
189
|
+
const target = args.id ? `#${args.id}` : "all";
|
|
190
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("subagent_wait "))}${theme.fg("accent", target)}`, 0, 0);
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
renderResult(result, _options, theme) {
|
|
194
|
+
return renderFirstLine(result, "subagent_wait ", theme);
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Status overview: what is running right now and what finished this session,
|
|
199
|
+
// with per-run details (id, agent, model, usage, elapsed, activity) so the
|
|
200
|
+
// main agent can decide whether to wait, stop, or re-dispatch. Learned from
|
|
201
|
+
// nicobailon/pi-subagents ({action:"status"} + status files): inspect before
|
|
202
|
+
// you act, and report run ids when handing off.
|
|
203
|
+
const SubagentStatusParams = Type.Object({
|
|
204
|
+
id: Type.Optional(
|
|
205
|
+
Type.String({
|
|
206
|
+
description: "Run id or prefix to show the full result for (must already be finished; use subagent_wait to block on an active run).",
|
|
207
|
+
}),
|
|
208
|
+
),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
pi.registerTool({
|
|
212
|
+
name: "subagent_status",
|
|
213
|
+
label: "Subagent Status",
|
|
214
|
+
description: [
|
|
215
|
+
"List active background sub-agent runs (id, agent, model, usage, elapsed, current activity) and recently finished results.",
|
|
216
|
+
"Pass id to read the full result of a finished run; pass no id for the overview.",
|
|
217
|
+
"Use it to decide whether to subagent_wait, subagent_stop, or re-dispatch — never to poll: results arrive by themselves.",
|
|
218
|
+
].join(" "),
|
|
219
|
+
promptSnippet: "Inspect background subagents: active runs, finished results, full result by id.",
|
|
220
|
+
promptGuidelines: [
|
|
221
|
+
"Call subagent_status to see what is running and what already finished; the widget shows the same live state.",
|
|
222
|
+
"Never poll subagent_status in a loop to wait for a run: end the turn (you will be woken) or call subagent_wait.",
|
|
223
|
+
"A finished run's id stays available for the session; its full result is one subagent_status call away.",
|
|
224
|
+
],
|
|
225
|
+
parameters: SubagentStatusParams,
|
|
226
|
+
|
|
227
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
228
|
+
const config = await loadConfig(runtime.configPath);
|
|
229
|
+
const requested = params.id?.trim();
|
|
230
|
+
|
|
231
|
+
if (requested) {
|
|
232
|
+
const settledIds = matchRunIds([...runtime.settledRuns.keys()], requested);
|
|
233
|
+
if (settledIds.length > 0) {
|
|
234
|
+
return {
|
|
235
|
+
content: [
|
|
236
|
+
{ type: "text", text: settledIds.map((id) => formatCompletionBlock(runtime.settledRuns.get(id)!, config.maxResultLines, ctx.cwd)).join("\n\n") },
|
|
237
|
+
],
|
|
238
|
+
details: {},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const runs = monitor.getRuns();
|
|
242
|
+
const activeId = matchRunIds(runs.map((run) => run.id), requested)[0];
|
|
243
|
+
const active = activeId === undefined ? undefined : runs.find((run) => run.id === activeId);
|
|
244
|
+
if (active) {
|
|
245
|
+
return {
|
|
246
|
+
content: [
|
|
247
|
+
{
|
|
248
|
+
type: "text",
|
|
249
|
+
text: `Run #${active.id} ${active.agent} is still active (${active.activity ?? statusLabel(active.status)}). Use subagent_wait to block for its result, or subagent_stop to cancel it.`,
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
details: {},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
return { content: [{ type: "text", text: `No subagent run matches "${requested}".` }], details: {} };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const now = Date.now();
|
|
259
|
+
const activeRuns = monitor.getRuns().filter(
|
|
260
|
+
(run) => run.status === "queued" || run.status === "running" || run.retained,
|
|
261
|
+
);
|
|
262
|
+
const activeLines = activeRuns.map((run) => {
|
|
263
|
+
const parts = [
|
|
264
|
+
`#${run.id} ${run.agent}`,
|
|
265
|
+
run.model ?? "?",
|
|
266
|
+
formatUsageCompact(run.usage),
|
|
267
|
+
formatElapsed(run, now),
|
|
268
|
+
].filter(Boolean);
|
|
269
|
+
return `- ${parts.join(" · ")} · ${run.activity ?? statusLabel(run.status)}`;
|
|
270
|
+
});
|
|
271
|
+
const completed = [...runtime.settledRuns.entries()].slice(-5);
|
|
272
|
+
const completedLines = completed.map(([id, result]) => {
|
|
273
|
+
const usage = formatUsage(result.usage);
|
|
274
|
+
return `- #${id} ${result.agent} · ${isFailedResult(result) ? "failed" : "completed"}${usage ? ` · ${usage}` : ""}`;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const sections: string[] = [];
|
|
278
|
+
sections.push(`### Active subagent runs (${activeRuns.length})`);
|
|
279
|
+
sections.push(activeLines.length > 0 ? activeLines.join("\n") : "(none)");
|
|
280
|
+
sections.push(`### Finished this session (${runtime.settledRuns.size})`);
|
|
281
|
+
sections.push(completedLines.length > 0 ? completedLines.join("\n") : "(none)");
|
|
282
|
+
sections.push("Pass a run id to subagent_status for the full result, or subagent_wait to block for an active run.");
|
|
283
|
+
return { content: [{ type: "text", text: sections.join("\n\n") }], details: {} };
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
renderCall(args, theme) {
|
|
287
|
+
return new Text(
|
|
288
|
+
`${theme.fg("toolTitle", theme.bold("subagent_status "))}${theme.fg("accent", args.id ? `#${args.id}` : "overview")}`,
|
|
289
|
+
0,
|
|
290
|
+
0,
|
|
291
|
+
);
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
renderResult(result, _options, theme) {
|
|
295
|
+
return renderFirstLine(result, "subagent_status ", theme);
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Cancel one or more active runs: aborts the queue controller, which
|
|
300
|
+
// terminates the child and delivers an aborted result (with whatever partial
|
|
301
|
+
// output it produced) so the main agent always knows the run stopped.
|
|
302
|
+
const SubagentStopParams = Type.Object({
|
|
303
|
+
id: Type.Optional(
|
|
304
|
+
Type.String({
|
|
305
|
+
description: "Run id or prefix to stop (see the widget or subagent_status).",
|
|
306
|
+
}),
|
|
307
|
+
),
|
|
308
|
+
all: Type.Optional(Type.Boolean({ description: "Stop every active run (default false)." })),
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
pi.registerTool({
|
|
312
|
+
name: "subagent_stop",
|
|
313
|
+
label: "Subagent Stop",
|
|
314
|
+
description: [
|
|
315
|
+
"Cancel one or more active background sub-agent runs: the child process is terminated and an aborted result (with partial output) is delivered.",
|
|
316
|
+
"Pass id (run id or prefix) to stop one run, or all: true to stop every active run.",
|
|
317
|
+
].join(" "),
|
|
318
|
+
promptSnippet: "Stop a running background subagent (id from the widget/subagent_status; or all: true).",
|
|
319
|
+
promptGuidelines: [
|
|
320
|
+
"Stop a run when its task is obsolete, stuck, or superseded — do not leave it burning tokens.",
|
|
321
|
+
"A stopped run reports as failed with 'aborted' and its partial output, so the next step knows it did not complete.",
|
|
322
|
+
],
|
|
323
|
+
parameters: SubagentStopParams,
|
|
324
|
+
|
|
325
|
+
async execute(_toolCallId, params, _signal, _onUpdate) {
|
|
326
|
+
const targets =
|
|
327
|
+
params.all === true
|
|
328
|
+
? [...runtime.runControllers.keys()]
|
|
329
|
+
: params.id !== undefined && params.id.trim() !== ""
|
|
330
|
+
? matchRunIds([...runtime.runControllers.keys()], params.id!.trim())
|
|
331
|
+
: [];
|
|
332
|
+
|
|
333
|
+
if (targets.length === 0) {
|
|
334
|
+
const activeList = [...runtime.runControllers.keys()].map((id) => `#${id}`).join(", ");
|
|
335
|
+
return {
|
|
336
|
+
content: [
|
|
337
|
+
{
|
|
338
|
+
type: "text",
|
|
339
|
+
text:
|
|
340
|
+
params.all === true
|
|
341
|
+
? "No active subagent runs to stop."
|
|
342
|
+
: `No active subagent run matches "${params.id}".${activeList ? ` Active runs: ${activeList}.` : ""}`,
|
|
343
|
+
},
|
|
344
|
+
],
|
|
345
|
+
details: {},
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const stopped: string[] = [];
|
|
350
|
+
for (const runId of targets) {
|
|
351
|
+
const run = monitor.findRun(runId);
|
|
352
|
+
if (!run) {
|
|
353
|
+
runtime.runControllers.delete(runId);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
// Abort before registering the synthetic result: abort() only marks the
|
|
357
|
+
// queue entry (drain delivers the cancellation callback later), so the
|
|
358
|
+
// has() re-check right after it distinguishes an entry that never ran
|
|
359
|
+
// from one whose task already started under a stale "queued" status —
|
|
360
|
+
// a started task owns its own (real, partial-output) result.
|
|
361
|
+
const controller = runtime.runControllers.get(runId);
|
|
362
|
+
controller?.abort();
|
|
363
|
+
// A queued run never reaches the child-spawn code path, so its abort
|
|
364
|
+
// goes through the queue's cancelled callback with no result object;
|
|
365
|
+
// register a synthetic aborted result so subagent_wait resolves.
|
|
366
|
+
if (run.status === "queued" && runtime.runControllers.has(runId)) {
|
|
367
|
+
runtime.registerRunResult(runId, {
|
|
368
|
+
agent: run.agent,
|
|
369
|
+
agentSource: "builtin",
|
|
370
|
+
task: run.task,
|
|
371
|
+
exitCode: 1,
|
|
372
|
+
messages: [],
|
|
373
|
+
stderr: "Stopped by subagent_stop before the run started.",
|
|
374
|
+
usage: emptyUsage(),
|
|
375
|
+
model: run.model,
|
|
376
|
+
thinking: run.thinking,
|
|
377
|
+
stopReason: "aborted",
|
|
378
|
+
errorMessage: "Stopped by subagent_stop before the run started.",
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
stopped.push(`#${runId} ${run.agent}${run.status === "queued" ? " (queued)" : ""}`);
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
content: [
|
|
385
|
+
{
|
|
386
|
+
type: "text",
|
|
387
|
+
text: `Stopped ${stopped.length} run${stopped.length === 1 ? "" : "s"}: ${stopped.join(", ")}. An aborted result (with partial output) is delivered.`,
|
|
388
|
+
},
|
|
389
|
+
],
|
|
390
|
+
details: {},
|
|
391
|
+
};
|
|
392
|
+
},
|
|
393
|
+
|
|
394
|
+
renderCall(args, theme) {
|
|
395
|
+
return new Text(
|
|
396
|
+
`${theme.fg("toolTitle", theme.bold("subagent_stop "))}${theme.fg("accent", args.all === true ? "all" : args.id ? `#${args.id}` : "?")}`,
|
|
397
|
+
0,
|
|
398
|
+
0,
|
|
399
|
+
);
|
|
400
|
+
},
|
|
401
|
+
|
|
402
|
+
renderResult(result, _options, theme) {
|
|
403
|
+
return renderFirstLine(result, "subagent_stop ", theme);
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
}
|