@tranhoangnguyen0310/pi-flow-external 5.0.1-external.0 → 5.1.0-external.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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to pi-flow external are documented here.
4
4
 
5
+ ## [5.1.0-external.0] - 2026-10-19
6
+
7
+ ### Added
8
+ - Live background run status bar (`pi-flow-runs`), completion notifications, and debounced follow-up wake messages (`pi-flow-background-settled`) for UI sessions:
9
+ - Active background root runs (`external_delegate` and `external_workflow` launched with `background: true`) display a live footer indicator (`pi-flow N bg (...)`) without colliding with usage reporting (`pi-flow`) or double-counting workflow child tasks.
10
+ - When an uncollected background root run settles across a turn boundary, UI sessions emit a completion notice and queue a debounced follow-up wake message (`deliverAs: "followUp"`) carrying the redacted `PublicRun` summary and `external_runs` inspection command.
11
+ - Wake delivery is automatically suppressed for foreground runs, workflow children, caller cancellations (`external_runs cancel`), closed or switched sessions, and runs already collected in the turn via `external_runs wait` or `inspect`.
12
+
5
13
  ## [5.0.1-external.0] - 2026-10-19
6
14
 
7
15
  ### Changed
package/README.md CHANGED
@@ -149,12 +149,12 @@ Both `external_delegate` and `external_workflow` support these modes:
149
149
  | | Foreground (default) | Background (`background: true`) |
150
150
  |---|---|---|
151
151
  | Original call returns | When the work finishes or fails | After registration, with a run ID; work may already have finished |
152
- | Progress | Live execution card | A static launch receipt; inspect with `/external runs` or ask Pi for status |
152
+ | Progress | Live execution card | A static launch receipt plus a live status bar count (`pi-flow N bg`); inspect with `/external runs` |
153
153
  | Next step | Pi checks the returned outcome | Pi keeps the run ID and does independent work before collecting, or reports the ID and switches tasks/ends its turn if left running across turns |
154
154
 
155
155
  > **You:** "Have Claude audit the API in the background while we review the release checklist. Then bring me the findings."
156
156
 
157
- - **No automatic completion message:** background work does not notify or wake the parent. Pi collects once its independent work is done; if you ask to leave work running or check back later, Pi reports the run ID and proceeds or ends its turn without waiting.
157
+ - **Status bar & completion wake-up:** UI sessions show active background runs in the footer status bar, notify on completion, and automatically queue a follow-up wake message when an uncollected background run finishes across turns. If Pi already collected the run in the same turn, no duplicate message is sent.
158
158
  - **Keep Pi open:** background work belongs to its originating session, not a persistent service. Orderly shutdown requests cancellation with bounded cleanup; restart does not adopt unfinished work.
159
159
  - **Stopping is different from waiting:** interrupting the original foreground call cancels it. Interrupting `external_runs wait` only stops waiting; use `/external runs` or `external_runs cancel` to stop background work.
160
160
 
@@ -101,7 +101,7 @@ return { run, view, encoding, text, checklist };
101
101
  - **Foreground interruption:** interrupting the original blocking call cancels that work.
102
102
  - **Wait interruption:** interrupting `external_runs wait` only stops waiting; it does not cancel the selected runs. Use `external_runs cancel` to stop background work. Cancellation requests are not proof of completed cleanup; check settlement.
103
103
  - **Workflow cancellation:** cancelling the root stops its active children. Cancelling one child throws a catchable child error; a script that handles it may continue.
104
- - **No automatic delivery:** ending a turn does not stop background work; it continues in the owning session. Background completion does not notify, wake, or resume the parent.
104
+ - **Status bar & completion wake-up:** ending a turn does not stop background work; it continues in the owning session. UI sessions display active background root runs in the status bar (`pi-flow-runs`), show a completion notification, and queue a debounced follow-up wake message (`pi-flow-background-settled`) when an uncollected background root run settles across turns. Runs already collected in the turn or cancelled by the caller do not send a wake message.
105
105
  - **Not a persistent service:** keep the owning Pi session open. Orderly shutdown requests cancellation and waits for bounded cleanup. Crashes or force-kills leave unfinished evidence `interrupted_or_uncertain`; restarting Pi never adopts live orphan work.
106
106
 
107
107
  ---
package/docs/workflows.md CHANGED
@@ -54,7 +54,7 @@ Put the same roadmap in `meta.phases`, an optional ordered array of `{ title, de
54
54
 
55
55
  **A plan is intent, not proof of execution.** The briefing is an agent instruction, not a runtime-verified chat message. Runtime validation checks phase metadata, not whether the script follows its prose. Child states and activity are the source of truth for observed execution. Do not invent completion estimates or call a quiet agent healthy merely because a spinner moves.
56
56
 
57
- Choose foreground by default when you need the result next, including parallel workflows. Use background when you have independent local work to do first, or when the user asks to leave the workflow running across turns; it does not make children more parallel. While a foreground call runs, the live card carries progress; the parent cannot reliably send chat updates from inside that call. A background receipt is not a live monitor, and completion does not wake the parent. Keep the owning session open and collect results after independent work finishes, or report the run ID and switch tasks/end the turn when the user wants to check back later. See [execution and collection](supervision-and-runtime.md#foreground-background--lifecycle) for the complete lifecycle and interruption rules.
57
+ Choose foreground by default when you need the result next, including parallel workflows. Use background when you have independent local work to do first, or when the user asks to leave the workflow running across turns; it does not make children more parallel. While a foreground call runs, the live card carries progress; the parent cannot reliably send chat updates from inside that call. A background receipt is not a live monitor, though UI sessions show active background runs in the status bar and wake the session with a follow-up message when an uncollected background run settles across turns. Keep the owning session open and collect results after independent work finishes, or report the run ID and switch tasks/end the turn when the user wants to check back later. See [execution and collection](supervision-and-runtime.md#foreground-background--lifecycle) for the complete lifecycle and interruption rules.
58
58
 
59
59
  The recorded purpose and phase details remain accessible in explicit launch inspection, during execution and after completion:
60
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tranhoangnguyen0310/pi-flow-external",
3
- "version": "5.0.1-external.0",
3
+ "version": "5.1.0-external.0",
4
4
  "description": "External Claude Code, Codex CLI, Antigravity, Grok Build CLI, Muse Code, and OpenCode delegation for pi.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -20,17 +20,29 @@ export interface RegisteredRunEntry {
20
20
  sessionId: string;
21
21
  project: string;
22
22
  workflowRunId?: string;
23
+ background?: boolean;
23
24
  state: RegisteredRunState;
24
25
  observation?: unknown;
25
26
  outcome?: RegisteredRunOutcome;
26
27
  }
27
28
 
29
+ export interface BackgroundRunEvent {
30
+ type: "started" | "settled";
31
+ entry: RegisteredRunEntry;
32
+ sessionVersion: number;
33
+ suppressed: boolean;
34
+ }
35
+
28
36
  export interface RegisteredRunHandle<T> {
29
37
  result: Promise<T>;
30
38
  terminal: Promise<RegisteredRunOutcome>;
31
39
  }
32
40
 
33
41
  interface InternalEntry extends RegisteredRunEntry {
42
+ sessionVersion: number;
43
+ background: boolean;
44
+ collected: boolean;
45
+ userCancelled: boolean;
34
46
  controller: AbortController;
35
47
  terminal: Promise<RegisteredRunOutcome>;
36
48
  resolveTerminal: (outcome: RegisteredRunOutcome) => void;
@@ -45,6 +57,7 @@ export interface StartRegisteredRun<T> {
45
57
  sessionVersion?: number;
46
58
  project: string;
47
59
  workflowRunId?: string;
60
+ background?: boolean;
48
61
  signal?: AbortSignal;
49
62
  run: (signal: AbortSignal) => Promise<T> | T;
50
63
  outcome?: (result: T, signal: AbortSignal) => Omit<RegisteredRunOutcome, "runId" | "kind" | "settledAt">;
@@ -62,8 +75,10 @@ const DEFAULT_COMPLETED_LIMIT = 100;
62
75
  export class RunRegistry {
63
76
  private readonly entries = new Map<string, InternalEntry>();
64
77
  private readonly completed: string[] = [];
78
+ private readonly collectedIds = new Set<string>();
65
79
  private readonly sessionVersions = new Map<string, number>();
66
80
  private readonly closedSessions = new Set<string>();
81
+ private readonly backgroundListeners = new Set<(event: BackgroundRunEvent) => void>();
67
82
 
68
83
  constructor(private readonly completedLimit = DEFAULT_COMPLETED_LIMIT, private readonly shutdownGraceMs = 4_000) {
69
84
  if (!Number.isInteger(completedLimit) || completedLimit < 0) {
@@ -97,8 +112,12 @@ export class RunRegistry {
97
112
  runId: params.runId,
98
113
  kind: params.kind,
99
114
  sessionId: params.sessionId,
115
+ sessionVersion: params.sessionVersion ?? this.sessionVersion(params.sessionId),
100
116
  project: params.project,
101
117
  ...(params.workflowRunId ? { workflowRunId: params.workflowRunId } : {}),
118
+ background: params.background === true,
119
+ collected: false,
120
+ userCancelled: false,
102
121
  state: "running",
103
122
  controller,
104
123
  terminal,
@@ -107,6 +126,9 @@ export class RunRegistry {
107
126
  listeners: new Set(),
108
127
  };
109
128
  this.entries.set(entry.runId, entry);
129
+ if (entry.background && !entry.workflowRunId) {
130
+ this.emitBackground({ type: "started", entry: this.publicEntry(entry), sessionVersion: entry.sessionVersion, suppressed: false });
131
+ }
110
132
 
111
133
  const abortFromCaller = () => controller.abort(params.signal?.reason);
112
134
  if (params.signal?.aborted) abortFromCaller();
@@ -168,6 +190,35 @@ export class RunRegistry {
168
190
  .map((entry) => this.publicEntry(entry));
169
191
  }
170
192
 
193
+ listBackgroundRoots(sessionId: string): RegisteredRunEntry[] {
194
+ return [...this.entries.values()]
195
+ .filter((entry) => entry.sessionId === sessionId && entry.background && !entry.workflowRunId && entry.state === "running")
196
+ .map((entry) => this.publicEntry(entry));
197
+ }
198
+
199
+ onBackgroundEvent(listener: (event: BackgroundRunEvent) => void): () => void {
200
+ this.backgroundListeners.add(listener);
201
+ return () => this.backgroundListeners.delete(listener);
202
+ }
203
+
204
+ markCollected(runId: string): void {
205
+ if (this.collectedIds.size >= 512 && !this.collectedIds.has(runId)) {
206
+ const oldest = this.collectedIds.values().next().value;
207
+ if (oldest) this.collectedIds.delete(oldest);
208
+ }
209
+ this.collectedIds.add(runId);
210
+ const entry = this.entries.get(runId);
211
+ if (entry) entry.collected = true;
212
+ }
213
+
214
+ isCollected(runId: string): boolean {
215
+ return this.collectedIds.has(runId) || Boolean(this.entries.get(runId)?.collected);
216
+ }
217
+
218
+ isSessionActive(sessionId: string, sessionVersion?: number): boolean {
219
+ return !this.closedSessions.has(sessionId) && (sessionVersion === undefined || this.sessionVersion(sessionId) === sessionVersion);
220
+ }
221
+
171
222
  update(runId: string, observation: unknown): void {
172
223
  const entry = this.entries.get(runId);
173
224
  if (entry && entry.state === "running") entry.observation = observation;
@@ -176,6 +227,8 @@ export class RunRegistry {
176
227
  cancel(runId: string, reason = "run cancelled"): "requested" | "terminal" | "unknown" {
177
228
  const entry = this.entries.get(runId);
178
229
  if (!entry) return "unknown";
230
+ entry.userCancelled = true;
231
+ this.markCollected(runId);
179
232
  if (entry.state === "terminal") return "terminal";
180
233
  entry.controller.abort(new Error(reason));
181
234
  return "requested";
@@ -242,7 +295,17 @@ export class RunRegistry {
242
295
  entry.resolveTerminal(outcome);
243
296
  for (const listener of [...entry.listeners]) listener(outcome);
244
297
  entry.listeners.clear();
245
- if (this.entries.get(entry.runId) !== entry) return;
298
+ const isCurrent = this.entries.get(entry.runId) === entry;
299
+ if (entry.background && !entry.workflowRunId) {
300
+ const suppressed =
301
+ !isCurrent ||
302
+ entry.collected ||
303
+ this.collectedIds.has(entry.runId) ||
304
+ entry.userCancelled ||
305
+ !this.isSessionActive(entry.sessionId, entry.sessionVersion);
306
+ this.emitBackground({ type: "settled", entry: this.publicEntry(entry), sessionVersion: entry.sessionVersion, suppressed });
307
+ }
308
+ if (!isCurrent) return;
246
309
  this.completed.push(entry.runId);
247
310
  while (this.completed.length > this.completedLimit) {
248
311
  const evicted = this.completed.shift();
@@ -250,6 +313,16 @@ export class RunRegistry {
250
313
  }
251
314
  }
252
315
 
316
+ private emitBackground(event: BackgroundRunEvent): void {
317
+ for (const listener of [...this.backgroundListeners]) {
318
+ try {
319
+ listener(event);
320
+ } catch {
321
+ // Never allow a UI or notification listener error to corrupt registry state.
322
+ }
323
+ }
324
+ }
325
+
253
326
  private publicEntry(entry: InternalEntry): RegisteredRunEntry {
254
327
  return {
255
328
  runId: entry.runId,
@@ -257,6 +330,7 @@ export class RunRegistry {
257
330
  sessionId: entry.sessionId,
258
331
  project: entry.project,
259
332
  ...(entry.workflowRunId ? { workflowRunId: entry.workflowRunId } : {}),
333
+ ...(entry.background ? { background: true } : {}),
260
334
  state: entry.state,
261
335
  ...(entry.observation !== undefined ? { observation: entry.observation } : {}),
262
336
  ...(entry.outcome ? { outcome: entry.outcome } : {}),
@@ -99,7 +99,7 @@ Before calling external_workflow, explain the purpose, assignments, parallel/seq
99
99
 
100
100
  APIs: agent(prompt, { description, label, role, harness, subagent_type, permission, max_budget_usd, resume, context, schema, phase }); parallel(thunks); pipeline(items, ...stages); phase(title); log(message). Globals: args and cwd. Agent options other than the profile selector are optional. "description" is the common task-name option shared with the direct external_delegate tool; "label" is a compatible alias — set only one, or both to the same value. Choose role/harness or legacy exact subagent_type, never both. Use unique descriptions/labels and clear task prompts. An agent() succeeds with its value or throws ChildRunError { runId, outcome, message, outputRef, diagnosticsRef }; catch optional failures explicitly. Uncaught failures terminate the workflow and drain siblings. Helpers never convert failures to null. Context options: {mode:"none"} (default), {mode:"recent",turns:N} (positive integer, includes current user turn), or {mode:"full"} (available context after compaction). All children share invocation-time context/settings; earlier child results must still be passed explicitly. Context excludes thinking/system instructions and pending calls; images and snapshots over 1 MiB fail without truncation. Context sharing cannot be combined with resume. Use schema for results that control branching or aggregation. Imports, filesystem globals, Date APIs, and Math.random() are unavailable.
101
101
 
102
- Execution mode: foreground is blocking by default when you need the result next, including parallel workflows. Pass background:true when you have independent local work (read, edit, bash) that does not touch files the workflow may change, or on an explicit background request; do that independent work first and never launch background then immediately wait. Ending a turn does not stop a background run: if the user asks to leave the workflow running, switch tasks, or check back later, report the runId and work on the other task or end your turn without waiting; otherwise retain the ID and collect it after your independent work finishes. Both modes support parallel([() => agent(...), ...]) under the same maxConcurrentSubagents cap; sequential awaits stay serial. Background changes neither permissions nor budget enforcement. Its stable handle means accepted, not completed; the receipt is not a live monitor. No completion notification wakes this session. Keep the session open: orderly shutdown requests cancellation with bounded cleanup; crashes leave uncertain evidence and restart does not adopt work. Topic usage contains the worked collection guidance.
102
+ Execution mode: foreground is blocking by default when you need the result next, including parallel workflows. Pass background:true when you have independent local work (read, edit, bash) that does not touch files the workflow may change, or on an explicit background request; do that independent work first and never launch background then immediately wait. Ending a turn does not stop a background run: if the user asks to leave the workflow running, switch tasks, or check back later, report the runId and work on the other task or end your turn without waiting; otherwise retain the ID and collect it after your independent work finishes. Both modes support parallel([() => agent(...), ...]) under the same maxConcurrentSubagents cap; sequential awaits stay serial. Background changes neither permissions nor budget enforcement. Its stable handle means accepted, not completed; the receipt is not a live monitor, though UI sessions show active background runs in the status bar and wake the session with a follow-up message when an uncollected background run settles across turns. Keep the session open: orderly shutdown requests cancellation with bounded cleanup; crashes leave uncertain evidence and restart does not adopt work. Topic usage contains the worked collection guidance.
103
103
 
104
104
  Supervision: external_runs selects targets with runIds. list takes optional workflowRunId, cursor, workflowCursor, and limit. inspect with one id reads summary/output/diagnostics/final/launch and pages with cursor and limitBytes; several ids (up to 20) batch summary only. wait takes up to 100 runIds in mode any or all; check ok for the operation, then each completed run's outcome and pending IDs. It never cancels pending work and can return early on a failed workflow even in all mode. If settlement is already known, skip wait. Use inline successful output when available; otherwise inspect final and follow nextCursor. Failures need diagnostics/output, not final. Follow nextWorkflowCursor for workflow listings too. Foreground-call interruption cancels that call; interrupting wait only stops waiting. Use cancel with one id and an optional reason to stop background work. Cancelling a workflow stops active children; cancelling one child is a catchable workflow error. There is no live steering.
105
105
 
@@ -953,6 +953,7 @@ export function createExternalRunsTool(
953
953
  }
954
954
  }
955
955
  const served = entries.slice(startIndex, index);
956
+ for (const entry of served) if (!entry.run.live) options.registry.markCollected(entry.run.runId);
956
957
  const nextCursor = index < entries.length ? encodeBatchCursor(scope, runIds, index) : undefined;
957
958
  const payload = { entries: served.map((entry) => entry.legacy), ...(nextCursor ? { nextCursor } : {}) };
958
959
  return { ...result(JSON.stringify(payload), payload), data: { mode: "batch", entries: served.map((entry) => entry.run), ...(nextCursor ? { nextCursor } : {}) } };
@@ -962,12 +963,15 @@ export function createExternalRunsTool(
962
963
  assertRunId(params.runId);
963
964
  const runId = params.runId;
964
965
  const view = params.view ?? "summary";
965
- const single = (text: string, encoding: "json" | "text", details: Record<string, unknown>, extra: { finalAvailable?: boolean; outputStatus?: string } = {}, contentText = text) => ({
966
- ...result(contentText, details),
967
- data: { mode: "single", runId, view, page: { text, encoding, complete: typeof details.nextCursor !== "string", ...(typeof details.nextCursor === "string" ? { nextCursor: details.nextCursor } : {}) }, ...compact(extra) },
968
- });
969
966
  const entry = options.registry.get(runId);
970
967
  if (entry) assertOwned(entry, sessionId, project);
968
+ const single = (text: string, encoding: "json" | "text", details: Record<string, unknown>, extra: { finalAvailable?: boolean; outputStatus?: string } = {}, contentText = text) => {
969
+ if (!entry || entry.state === "terminal") options.registry.markCollected(runId);
970
+ return {
971
+ ...result(contentText, details),
972
+ data: { mode: "single", runId, view, page: { text, encoding, complete: typeof details.nextCursor !== "string", ...(typeof details.nextCursor === "string" ? { nextCursor: details.nextCursor } : {}) }, ...compact(extra) },
973
+ };
974
+ };
971
975
  const isWorkflowId = WORKFLOW_ID.test(runId);
972
976
  const workflowDir = getSessionWorkflowDir(ctx);
973
977
  const historical = !entry && isWorkflowId ? await loadOwnedJournal(workflowDir, runId, project) : undefined;
@@ -1162,6 +1166,7 @@ export function createExternalRunsTool(
1162
1166
  const outcomeByRunId = new Map(outcomes.map((outcome) => [outcome.runId, outcome] as const));
1163
1167
  const settled = known.flatMap((target) => {
1164
1168
  const outcome = outcomeByRunId.get(target.runId);
1169
+ if (outcome) options.registry.markCollected(target.runId);
1165
1170
  return outcome ? [{ target, outcome }] : [];
1166
1171
  });
1167
1172
  const projected = await collectOutcomes(settled.map(({ outcome }) => outcome), runsDirectory, params.limitBytes);
@@ -1,6 +1,7 @@
1
1
  import { ExpectedFlowError } from "./core/errors.ts";
2
2
  import { agentOutputSchema, agentReceipt } from "./contract/agent.ts";
3
3
  import type { PublicError } from "./contract/envelope.ts";
4
+ import { liveAgentRun, liveWorkflowRun, type PublicRun } from "./contract/run.ts";
4
5
  import { getRunRecord } from "./core/run-inspection.ts";
5
6
  import { redactSecrets } from "./core/run-record.ts";
6
7
  import {
@@ -66,6 +67,9 @@ const DEFAULT_SUBAGENT_TIMEOUT_MS = DEFAULT_EXTERNAL_SETTINGS.subagentTimeoutMs;
66
67
  const MAX_CONCURRENT_SUBAGENTS_FLAG = "max-concurrent-subagents";
67
68
  const SUBAGENT_TIMEOUT_MS_FLAG = "subagent-timeout-ms";
68
69
  const STATUS_KEY = "pi-flow";
70
+ const BG_STATUS_KEY = "pi-flow-runs";
71
+ const BACKGROUND_SETTLED_CUSTOM_TYPE = "pi-flow-background-settled";
72
+ const DEFAULT_BACKGROUND_WAKE_DEBOUNCE_MS = 150;
69
73
 
70
74
  export const agentToolParameters = Type.Object({
71
75
  context: Type.Optional(parentContextSchema),
@@ -76,7 +80,7 @@ export const agentToolParameters = Type.Object({
76
80
  description: "The task briefing to send to the subagent.",
77
81
  }),
78
82
  background: Type.Optional(Type.Boolean({
79
- description: "Defaults to false: wait for settlement. True returns a stable session-owned run handle after registration; work may already be settled. Pass true when you have independent local work to do first (do it, then call external_runs wait), or when the user asks to leave work running across turns (report the runId and do not call external_runs wait this turn). No completion notification; keep the owning session open.",
83
+ description: "Defaults to false: wait for settlement. True returns a stable session-owned run handle after registration; work may already be settled. Pass true when you have independent local work to do first (do it, then call external_runs wait), or when the user asks to leave work running across turns (report the runId and do not call external_runs wait this turn). UI sessions show active background runs in the status bar and wake the session when an uncollected run settles across turns. Keep the owning session open.",
80
84
  })),
81
85
  role: Type.Optional(Type.String({
82
86
  minLength: 1,
@@ -162,6 +166,7 @@ interface CreateAgentToolOptions {
162
166
  /** Effective default harness for render paths: cwd only, no trust signal. */
163
167
  getDefaultHarness: (cwd: string) => string;
164
168
  updateStatus: (ctx: ExtensionContext, toolCallId: string, usage: SubagentUsage) => void;
169
+ onSessionContext?: (ctx: ExtensionContext, sessionId: string) => void;
165
170
  }
166
171
 
167
172
  const PROGRESS_STATUSES: SubagentProgressNode["status"][] = ["queued", "running", "done", "error", "aborted"];
@@ -284,6 +289,32 @@ function formatUsageStatus(totals: SubagentUsage, theme: Theme): string {
284
289
  return `${theme.fg("dim", "pi-flow ")}${theme.fg("dim", formatUsage(totals))}`;
285
290
  }
286
291
 
292
+ function formatBackgroundRunsStatus(roots: RegisteredRunEntry[], theme?: Theme): string | undefined {
293
+ if (roots.length === 0) return undefined;
294
+ const ids = roots.slice(0, 2).map((r) => r.runId.slice(-8)).join(", ") + (roots.length > 2 ? ", …" : "");
295
+ const text = `pi-flow ${roots.length} bg (${ids})`;
296
+ return typeof theme?.fg === "function" ? theme.fg("accent", text) : text;
297
+ }
298
+
299
+ function buildBackgroundWakePayload(entries: RegisteredRunEntry[]): { text: string; runs: PublicRun[] } {
300
+ const runs = entries.map((entry) =>
301
+ (entry.kind === "workflow" ? liveWorkflowRun(entry, { inlineBudget: 0 }) : liveAgentRun(entry, { inlineBudget: 0 })).run,
302
+ );
303
+ const lines = runs.map((run) => {
304
+ const label = run.task.name ?? run.task.description ?? run.kind;
305
+ const outcome = run.state.outcome ?? run.state.status;
306
+ const view = outcome === "succeeded" ? "final" : "diagnostics";
307
+ const failedChildren = run.children?.failed ? ` (${run.children.failed} handled child failure(s))` : "";
308
+ return `- ${run.runId} (${run.kind} "${label}"): ${outcome}${failedChildren} — inspect with external_runs { action: "inspect", runIds: ["${run.runId}"], view: "${view}" }`;
309
+ });
310
+ const rawText = [
311
+ "Background run(s) settled:",
312
+ ...lines,
313
+ "Use external_runs inspect (view: \"final\" for succeeded runs, \"diagnostics\" or \"output\" for failed runs) to read the results and report back to the user.",
314
+ ].join("\n");
315
+ return redactSecrets({ text: rawText, runs }) as { text: string; runs: PublicRun[] };
316
+ }
317
+
287
318
  function publishUsageStatus(ctx: ExtensionContext, state: SubagentUsageStatusState): void {
288
319
  const totals = getUsageTotals(state);
289
320
  if (totals.input === 0 && totals.output === 0 && totals.cacheRead === 0 && totals.cacheWrite === 0) {
@@ -454,6 +485,7 @@ function createAgentTool(
454
485
 
455
486
  const queuedAt = Date.now();
456
487
  const sessionId = ctx.sessionManager?.getSessionId?.() ?? `unpersisted:${toolCallId}`;
488
+ options.onSessionContext?.(ctx, sessionId);
457
489
  const sessionVersion = state.registry.sessionVersion(sessionId);
458
490
  const project = resolve(ctx.cwd);
459
491
  // modelRegistry must survive into the execution context: the pi backend
@@ -595,6 +627,7 @@ function createAgentTool(
595
627
  sessionId,
596
628
  sessionVersion,
597
629
  project,
630
+ background,
598
631
  ...(background ? {} : { signal }),
599
632
  run: executeRun,
600
633
  outcome: (result, runSignal) => {
@@ -776,6 +809,118 @@ export function createSubagentExtension(options: SubagentExtensionOptions = {}):
776
809
  return rootState;
777
810
  };
778
811
  const usageStatusState = createUsageStatusState();
812
+ const wakeDebounceMs =
813
+ typeof options.backgroundWakeDebounceMs === "number" &&
814
+ Number.isInteger(options.backgroundWakeDebounceMs) &&
815
+ options.backgroundWakeDebounceMs >= 0
816
+ ? options.backgroundWakeDebounceMs
817
+ : DEFAULT_BACKGROUND_WAKE_DEBOUNCE_MS;
818
+ let activeSessionId: string | undefined;
819
+ let activeUiCtx: ExtensionContext | undefined;
820
+ let inAgentTurn = false;
821
+ let wakeTimer: ReturnType<typeof setTimeout> | undefined;
822
+ const pendingWakeBySession = new Map<string, Map<string, { entry: RegisteredRunEntry; sessionVersion: number }>>();
823
+
824
+ const rememberSessionContext = (ctx: ExtensionContext, sessionId = ctx.sessionManager?.getSessionId?.()) => {
825
+ if (sessionId && !sessionId.startsWith("unpersisted:")) {
826
+ if (activeSessionId !== sessionId) activeUiCtx = ctx;
827
+ activeSessionId = sessionId;
828
+ } else if (!activeSessionId && sessionId) {
829
+ activeSessionId = sessionId;
830
+ }
831
+ if (ctx.hasUI || !activeUiCtx) activeUiCtx = ctx;
832
+ };
833
+
834
+ const updateBackgroundStatus = (sessionId = activeSessionId) => {
835
+ if (!sessionId || sessionId !== activeSessionId || !activeUiCtx?.hasUI || typeof activeUiCtx.ui?.setStatus !== "function") return;
836
+ const roots = rootState.registry.listBackgroundRoots(sessionId);
837
+ try {
838
+ activeUiCtx.ui.setStatus(BG_STATUS_KEY, formatBackgroundRunsStatus(roots, activeUiCtx.ui.theme));
839
+ } catch {
840
+ // Ignore stale or disposed UI contexts.
841
+ }
842
+ };
843
+
844
+ const clearWakeTimer = () => {
845
+ if (wakeTimer) {
846
+ clearTimeout(wakeTimer);
847
+ wakeTimer = undefined;
848
+ }
849
+ };
850
+
851
+ const flushPendingWakes = (sessionId: string) => {
852
+ if (inAgentTurn || sessionId !== activeSessionId) return;
853
+ const pending = pendingWakeBySession.get(sessionId);
854
+ if (!pending || pending.size === 0) return;
855
+ pendingWakeBySession.delete(sessionId);
856
+ const uncollected = [...pending.values()].filter(
857
+ (item) =>
858
+ !rootState.registry.isCollected(item.entry.runId) &&
859
+ rootState.registry.isSessionActive(sessionId, item.sessionVersion),
860
+ );
861
+ if (uncollected.length === 0) return;
862
+ const payload = buildBackgroundWakePayload(uncollected.map((item) => item.entry));
863
+ try {
864
+ pi.sendMessage(
865
+ {
866
+ customType: BACKGROUND_SETTLED_CUSTOM_TYPE,
867
+ content: [{ type: "text", text: payload.text }],
868
+ display: true,
869
+ details: { runs: payload.runs },
870
+ },
871
+ { triggerTurn: true, deliverAs: "followUp" },
872
+ );
873
+ for (const item of uncollected) rootState.registry.markCollected(item.entry.runId);
874
+ } catch {
875
+ if (activeSessionId === sessionId) {
876
+ activeSessionId = undefined;
877
+ activeUiCtx = undefined;
878
+ }
879
+ }
880
+ };
881
+
882
+ const scheduleWakeFlush = (sessionId: string) => {
883
+ if (inAgentTurn || sessionId !== activeSessionId) return;
884
+ const pending = pendingWakeBySession.get(sessionId);
885
+ if (!pending || pending.size === 0) return;
886
+ clearWakeTimer();
887
+ wakeTimer = setTimeout(() => {
888
+ wakeTimer = undefined;
889
+ flushPendingWakes(sessionId);
890
+ }, wakeDebounceMs);
891
+ (wakeTimer as { unref?: () => void }).unref?.();
892
+ };
893
+
894
+ rootState.registry.onBackgroundEvent((event) => {
895
+ updateBackgroundStatus(event.entry.sessionId);
896
+ if (event.type !== "settled") return;
897
+ if (event.suppressed) {
898
+ pendingWakeBySession.get(event.entry.sessionId)?.delete(event.entry.runId);
899
+ return;
900
+ }
901
+ if (
902
+ activeUiCtx?.hasUI &&
903
+ activeSessionId === event.entry.sessionId &&
904
+ typeof activeUiCtx.ui?.notify === "function"
905
+ ) {
906
+ const outcome = event.entry.outcome?.outcome ?? "settled";
907
+ const label = event.entry.kind === "workflow" ? "Background workflow" : "Background subagent";
908
+ activeUiCtx.ui.notify(
909
+ redactSecrets(`${label} ${event.entry.runId} ${outcome}.`) as string,
910
+ outcome === "succeeded" ? "info" : "warning",
911
+ );
912
+ }
913
+ const shouldWake = options.backgroundWake ?? Boolean(activeUiCtx?.hasUI);
914
+ if (!shouldWake) return;
915
+ let sessionPending = pendingWakeBySession.get(event.entry.sessionId);
916
+ if (!sessionPending) {
917
+ sessionPending = new Map();
918
+ pendingWakeBySession.set(event.entry.sessionId, sessionPending);
919
+ }
920
+ sessionPending.set(event.entry.runId, { entry: event.entry, sessionVersion: event.sessionVersion });
921
+ scheduleWakeFlush(event.entry.sessionId);
922
+ });
923
+
779
924
  const toolOptions: CreateAgentToolOptions = {
780
925
  getLimiter: () => syncMaxConcurrentSubagents().limiter,
781
926
  getThinkingLevel: () => pi.getThinkingLevel(),
@@ -788,6 +933,7 @@ export function createSubagentExtension(options: SubagentExtensionOptions = {}):
788
933
  }
789
934
  updateUsageStatus(usageStatusState, ctx, toolCallId, usage);
790
935
  },
936
+ onSessionContext: rememberSessionContext,
791
937
  };
792
938
 
793
939
  pi.registerTool(createAgentTool(syncMaxConcurrentSubagents, toolOptions));
@@ -856,12 +1002,30 @@ export function createSubagentExtension(options: SubagentExtensionOptions = {}):
856
1002
  }
857
1003
  updateUsageStatus(usageStatusState, ctx, toolCallId, usage);
858
1004
  },
1005
+ onSessionContext: rememberSessionContext,
859
1006
  }),
860
1007
  );
861
1008
  }
862
1009
 
1010
+ pi.on("agent_start", (_event, ctx) => {
1011
+ if (!activeSessionId) rememberSessionContext(ctx);
1012
+ inAgentTurn = true;
1013
+ clearWakeTimer();
1014
+ });
1015
+
1016
+ pi.on("agent_end", (_event, ctx) => {
1017
+ if (!activeSessionId) rememberSessionContext(ctx);
1018
+ inAgentTurn = false;
1019
+ if (activeSessionId) scheduleWakeFlush(activeSessionId);
1020
+ });
1021
+
863
1022
  pi.on("session_start", (_event, ctx) => {
864
- rootState.registry.openSession(ctx.sessionManager.getSessionId());
1023
+ const sessionId = ctx.sessionManager.getSessionId();
1024
+ rememberSessionContext(ctx, sessionId);
1025
+ inAgentTurn = false;
1026
+ clearWakeTimer();
1027
+ pendingWakeBySession.clear();
1028
+ rootState.registry.openSession(sessionId);
865
1029
  syncMaxConcurrentSubagents();
866
1030
  usageStatusState.calls.clear();
867
1031
  usageStatusState.latestCacheHitRate = undefined;
@@ -869,12 +1033,24 @@ export function createSubagentExtension(options: SubagentExtensionOptions = {}):
869
1033
  void pruneRunRecords(runRecordsDirectory(), rootState.maxRunRecords).catch(() => undefined);
870
1034
  if (ctx.hasUI) {
871
1035
  ctx.ui.setStatus(STATUS_KEY, undefined);
1036
+ ctx.ui.setStatus(BG_STATUS_KEY, undefined);
872
1037
  }
873
1038
  });
874
1039
 
875
1040
  pi.on("session_shutdown", async (_event, ctx) => {
1041
+ const sessionId = ctx.sessionManager.getSessionId();
1042
+ inAgentTurn = false;
1043
+ clearWakeTimer();
1044
+ pendingWakeBySession.delete(sessionId);
1045
+ if (activeSessionId === sessionId) {
1046
+ activeSessionId = undefined;
1047
+ activeUiCtx = undefined;
1048
+ }
876
1049
  for (const controller of readinessControllers) controller.abort(new Error('Session shutting down'));
877
- const cleanup = await rootState.registry.shutdownSession(ctx.sessionManager.getSessionId());
1050
+ const cleanup = await rootState.registry.shutdownSession(sessionId);
1051
+ if (ctx.hasUI) {
1052
+ ctx.ui.setStatus(BG_STATUS_KEY, undefined);
1053
+ }
878
1054
  if (cleanup.pending.length && ctx.hasUI) {
879
1055
  const shown = cleanup.pending.slice(0, 10);
880
1056
  ctx.ui.notify(`External run cleanup could not be confirmed for ${shown.join(", ")}${cleanup.pending.length > shown.length ? ` and ${cleanup.pending.length - shown.length} more` : ""}; their evidence is interrupted or uncertain.`, "warning");
@@ -882,6 +1058,7 @@ export function createSubagentExtension(options: SubagentExtensionOptions = {}):
882
1058
  });
883
1059
 
884
1060
  pi.on("before_agent_start", (event, ctx) => {
1061
+ if (!activeSessionId) rememberSessionContext(ctx);
885
1062
  const tools = pi.getAllTools();
886
1063
  if (!tools.some((tool) => tool.name === EXTERNAL_DELEGATE_TOOL)) {
887
1064
  return;
package/src/prompts.ts CHANGED
@@ -100,7 +100,7 @@ Give every child an objective, the context it needs, the changes it may make, a
100
100
 
101
101
  Choose a fresh child or resume on purpose. Omit context for independent work. context {mode:"recent", turns:N} shares the last N user turns, including the current turn; {mode:"full"} shares the available post-compaction conversation. Snapshots drop thinking, system instructions, and pending calls, reject images and payloads over 1 MiB, and are sent to that harness. resume continues the same CLI child and cannot be combined with sharing. A named pi-* harness has no persisted session and no enforced budget. Prefer resume for the same task and a new child for an independent review.
102
102
 
103
- Default to foreground (blocking) when you need the result next, including parallel workflows. When you have independent local work (read, edit, bash) that does not touch files the delegated run may change, or the user asks for background execution, pass background:true, do that other work first, and only call external_runs wait once you actually need the result; never background then immediately wait. Ending a turn does not stop a background run: if the user asks to leave it running, switch tasks, or check back later, report the runId and end your turn or work on the other task without waiting. Otherwise retain the runId and collect it in this turn after your independent work finishes. Foreground has a live card; a background receipt is not a live monitor and completion never notifies or wakes this session. Use external_runs wait only when needed; check each run's outcome and pending IDs. Read inline successful output or inspect view final; inspect failures with diagnostics. Interrupting foreground cancels that call; interrupting wait only stops waiting. Explicit cancel stops background work. Keep the owning session open: orderly shutdown requests cancellation; restart does not adopt work. Prefer one external_workflow with parallel() for coordinated parallel tasks; all children share maxConcurrentSubagents. Sequential awaits stay serial. Report milestones. Retry a failed run, or switch its model or harness, only when the user authorized that step. agy alone may make one disclosed infrastructure retry. Nested agents may start in another workspace.
103
+ Default to foreground (blocking) when you need the result next, including parallel workflows. When you have independent local work (read, edit, bash) that does not touch files the delegated run may change, or the user asks for background execution, pass background:true, do that other work first, and only call external_runs wait once you actually need the result; never background then immediately wait. Ending a turn does not stop a background run: if the user asks to leave it running, switch tasks, or check back later, report the runId and end your turn or work on the other task without waiting. Otherwise retain the runId and collect it in this turn after your independent work finishes. Foreground has a live card; a background receipt is not a live monitor, though UI sessions show active background runs in the status bar and wake the session with a follow-up message when an uncollected background run settles across turns. Use external_runs wait only when needed; check each run's outcome and pending IDs. Read inline successful output or inspect view final; inspect failures with diagnostics. Interrupting foreground cancels that call; interrupting wait only stops waiting. Explicit cancel stops background work. Keep the owning session open: orderly shutdown requests cancellation; restart does not adopt work. Prefer one external_workflow with parallel() for coordinated parallel tasks; all children share maxConcurrentSubagents. Sequential awaits stay serial. Report milestones. Retry a failed run, or switch its model or harness, only when the user authorized that step. agy alone may make one disclosed infrastructure retry. Nested agents may start in another workspace.
104
104
 
105
105
  Catalog availability reflects enabled harnesses and built-in/authored roles, not CLI installation or authentication. Disabled harnesses reject calls without fallback; manage them through /external config. Harness and role lifecycle commands live under /external config harness and /external config role. Role selection never falls back. external_help topic usage is the playbook; roles, permissions, and workflow are the references.`;
106
106
  }
@@ -215,7 +215,7 @@ Tell each child the objective, the context it needs, what it may change, the del
215
215
 
216
216
  Choose fresh context or resume deliberately. Omit context, or pass {"mode":"none"}, for independent work such as a review. {"mode":"recent","turns":N} and {"mode":"full"} share parent conversation and cannot be combined with resume. resume continues one prior CLI run that recorded a session id, on the same backend: ${EXTERNAL_HARNESSES.join(", ")}. A named pi-* harness has no persisted session, so resume does not continue it. Prefer resume for the same task and a new child when you want an independent judgment.
217
217
 
218
- Choose foreground by default when you need the result next, even for a long or parallel workflow: the original call waits for settlement and its live card carries progress. Pass background:true when you have independent local work (read, edit, bash) that does not touch files the delegated run may change, or when the user asks for background execution. Do that independent work first and never follow a background launch with an immediate external_runs wait. Ending a turn does not stop a background run: if the user asks to leave the workflow running, attend to another task, or check back later, report the runId and proceed with the other task or end your turn without waiting. Otherwise keep the ID and collect/check the outcome after your independent work finishes. The returned handle means accepted, not completed; the run may already be settled. No completion notification wakes the session. Keep Pi open: orderly shutdown requests cancellation with bounded cleanup; crashes leave uncertain evidence and restart does not adopt work.
218
+ Choose foreground by default when you need the result next, even for a long or parallel workflow: the original call waits for settlement and its live card carries progress. Pass background:true when you have independent local work (read, edit, bash) that does not touch files the delegated run may change, or when the user asks for background execution. Do that independent work first and never follow a background launch with an immediate external_runs wait. Ending a turn does not stop a background run: if the user asks to leave the workflow running, attend to another task, or check back later, report the runId and proceed with the other task or end your turn without waiting. Otherwise keep the ID and collect/check the outcome after your independent work finishes. The returned handle means accepted, not completed; the run may already be settled. UI sessions display active background runs in the status bar and send a follow-up wake message when an uncollected background run settles across turns. Keep Pi open: orderly shutdown requests cancellation with bounded cleanup; crashes leave uncertain evidence and restart does not adopt work.
219
219
 
220
220
  Unless the user deferred collection, after independent work use external_runs wait if settlement is not already known. When the user later asks about a deferred run, inspect its runId first and wait only if it is still running. Check ok for the operation, then each completed run's state.outcome and pending IDs; a failed workflow can return wait early even in mode all. Use successful output when delivery is inline (null/false/0 are valid); otherwise inspect view final, following cursor pages. For failures inspect diagnostics or output; final is empty without verified success. A successful workflow may still have children.failed when its script caught child errors; read the reports. Interrupting the original foreground call cancels its work. Interrupting wait only stops waiting; cancel explicitly to stop background work. Cancelling a workflow stops active children; cancelling one child throws a catchable child error. Both modes share permissions, budget enforcement, and concurrency limits. A failed or aborted run stays failed. Retry it, or switch model or harness, only when the user authorized that step. agy may make one disclosed infrastructure retry; that exception is not yours to extend.
221
221
 
package/src/types.ts CHANGED
@@ -76,6 +76,13 @@ export interface SubagentExtensionOptions {
76
76
  * one product, two entry points. Set to false for a subagents-only surface.
77
77
  */
78
78
  workflow?: boolean;
79
+ /**
80
+ * Automatically wake the parent session with a follow-up message when an
81
+ * uncollected background root run settles across a turn. Defaults to `ctx.hasUI`.
82
+ */
83
+ backgroundWake?: boolean;
84
+ /** Debounce window in milliseconds for batching background completion wake-ups (default: 150). */
85
+ backgroundWakeDebounceMs?: number;
79
86
  }
80
87
 
81
88
  export type FlowExtensionOptions = SubagentExtensionOptions;
@@ -20,7 +20,7 @@ import type { WorkflowCachedAgentResult, WorkflowMetaPhase } from "./types.ts";
20
20
 
21
21
  export const workflowToolParameters = Type.Object({
22
22
  background: Type.Optional(Type.Boolean({
23
- description: "Defaults to false: wait for settlement. True returns a stable session-owned workflow handle after registration; work may already be settled. Pass true when you have independent local work to do first (do it, then call external_runs wait), or when the user asks to leave work running across turns (report the runId and do not call external_runs wait this turn). No completion notification; keep the owning session open.",
23
+ description: "Defaults to false: wait for settlement. True returns a stable session-owned workflow handle after registration; work may already be settled. Pass true when you have independent local work to do first (do it, then call external_runs wait), or when the user asks to leave work running across turns (report the runId and do not call external_runs wait this turn). UI sessions show active background runs in the status bar and wake the session when an uncollected run settles across turns. Keep the owning session open.",
24
24
  })),
25
25
  script: Type.Optional(
26
26
  Type.String({
@@ -47,6 +47,7 @@ export interface CreateWorkflowToolOptions {
47
47
  getThinkingLevel: () => ReturnType<ExtensionAPI["getThinkingLevel"]>;
48
48
  getSubagentTimeoutMs: () => number;
49
49
  updateStatus: (ctx: ExtensionContext, toolCallId: string, usage: SubagentUsage) => void;
50
+ onSessionContext?: (ctx: ExtensionContext, sessionId: string) => void;
50
51
  getDefaultPermission: () => PermissionTier;
51
52
  getDefaultHarness: (ctx: ExtensionContext) => string;
52
53
  getDefaultMaxBudgetUsd: () => number | undefined;
@@ -146,6 +147,7 @@ export function createWorkflowTool(
146
147
  return await withContract<WorkflowToolDetails>({ tool: EXTERNAL_WORKFLOW_TOOL, action: "run", failureData: { run: null }, redact: true }, async () => {
147
148
  const parentMessages = captureParentContext(ctx.sessionManager);
148
149
  const sessionId = ctx.sessionManager?.getSessionId?.() ?? `unpersisted:${toolCallId}`;
150
+ options.onSessionContext?.(ctx, sessionId);
149
151
  const sessionVersion = options.registry.sessionVersion(sessionId);
150
152
  const project = resolve(ctx.cwd);
151
153
  // modelRegistry must survive into the execution context: the pi backend
@@ -615,6 +617,7 @@ export function createWorkflowTool(
615
617
  sessionId,
616
618
  sessionVersion,
617
619
  project,
620
+ background,
618
621
  ...(background ? {} : { signal }),
619
622
  run: executeWorkflow,
620
623
  outcome: (result, workflowSignal) => ({