@tranhoangnguyen0310/pi-flow-external 5.0.0-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,22 @@
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
+
13
+ ## [5.0.1-external.0] - 2026-10-19
14
+
15
+ ### Changed
16
+ - Clarified background execution guidance across the coordinator prompt, `external_help` (`usage` and `workflow`), tool parameter descriptions, background launch receipts, and docs:
17
+ - When the parent has independent local work (`read`, `edit`, `bash`) that does not touch files the delegated run may change, pass `background: true` and complete that local work before calling `external_runs wait`.
18
+ - Ending a turn does not stop a background run: when the user asks to leave work running, switch tasks, or check back later, report the `runId` and proceed with the other task or end the turn without calling `external_runs wait`.
19
+ - Background launch receipts for `external_delegate` and `external_workflow` now instruct the model to finish independent local work or report the `runId` and proceed/end its turn before calling `external_runs`, avoiding an immediate-wait reflex.
20
+
5
21
  ## [5.0.0-external.0] - 2026-10-15
6
22
 
7
23
  ### Breaking
package/README.md CHANGED
@@ -142,19 +142,19 @@ await tools.external_delegate({
142
142
 
143
143
  ### 2. Foreground or Background?
144
144
 
145
- **Use foreground when you need the result next.** Use background when useful independent work can proceed, or you explicitly want to collect the result later. A long task alone does not need background mode.
145
+ **Use foreground when you need the result next.** Use background when independent local work can proceed first, or you want to leave the work running and check back in a later turn (ending a turn does not stop it). A long task alone does not need background mode.
146
146
 
147
147
  Both `external_delegate` and `external_workflow` support these modes:
148
148
 
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 |
153
- | Next step | Pi checks the returned outcome | Pi keeps the run ID, does independent work, then collects and checks the outcome |
152
+ | Progress | Live execution card | A static launch receipt plus a live status bar count (`pi-flow N bg`); inspect with `/external runs` |
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 owns collection unless you explicitly ask it to defer; you should not have to manage every run yourself.
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
 
@@ -35,9 +35,9 @@ Workspace /path/to/project
35
35
 
36
36
  **Foreground is the default:** omit `background` or set it to `false`. The original `external_delegate` or `external_workflow` call waits for settlement and returns the result or failure. Its live card carries progress while the parent waits.
37
37
 
38
- **Background returns a handle:** set `background: true` when the parent has useful independent work before collection, or the user explicitly requests background execution. The receipt confirms registration, not completion; a fast run may already be settled. The launch receipt is not a live monitor. Use `/external runs` to browse, or `external_runs` to inspect, wait, or cancel.
38
+ **Background returns a handle:** set `background: true` when the parent has independent local work to do first, or when the user asks to run in the background, switch tasks, or check back in a later turn. The receipt confirms registration, not completion; a fast run may already be settled. The launch receipt is not a live monitor. Use `/external runs` to browse, or `external_runs` to inspect, wait, or cancel.
39
39
 
40
- Use foreground when the result is needed next—even for a long workflow or parallel children. Do not routinely launch background then immediately wait. Both modes support `parallel()` under the same concurrency limiter; neither changes permission boundaries or budget enforcement. Prompt instructions express intent, not an enforced restriction.
40
+ Use foreground when the result is needed next—even for a long workflow or parallel children. Never launch background then immediately wait; do your independent work first, or if leaving the run active across turns, report the run ID and proceed or end your turn without waiting. Both modes support `parallel()` under the same concurrency limiter; neither changes permission boundaries or budget enforcement. Prompt instructions express intent, not an enforced restriction.
41
41
 
42
42
  ### Background Example: Launch → Independent Work → Collect
43
43
 
@@ -94,14 +94,14 @@ return { run, view, encoding, text, checklist };
94
94
  - **Check the run, not just `ok`.** `wait.ok` means supervision worked, even if a child failed. Read each `completed[i].state.outcome` and all `pending` IDs. With multiple targets, a failed workflow can return early even in `mode: "all"`; pending work continues. Keep collecting it unless the user defers, or cancel it explicitly.
95
95
  - **Avoid redundant calls.** If settlement is already known, inspect directly rather than wait again. Successful inline output needs no second fetch. A successful workflow can still have `children.failed` when the script handled child errors; check the returned reports.
96
96
  - **Read every page.** Otherwise use `final` for verified success or `diagnostics`/`output` for failure. `final` is empty without verified success. Follow `page.nextCursor`, concatenate text, and only then parse if `encoding` is `json`. Workflow final JSON wraps the value in `{ runId, result, finalAvailable }`; agent finals are text.
97
- - **Keep ownership.** The parent retains IDs and collects/checks results unless the user explicitly defers collection. A script's local variables do not survive a separate codemode call; pass the retained handle or ID explicitly. If independent work or collection fails, report any still-running IDs rather than abandoning them silently. Never retry a child automatically.
97
+ - **Keep ownership.** The parent retains IDs and collects/checks results after its independent work finishes, unless the user asks to leave work running or check back later—in which case it reports the IDs and switches tasks or ends the turn without waiting. A script's local variables do not survive a separate codemode call; pass the retained handle or ID explicitly. If independent work or collection fails, report any still-running IDs rather than abandoning them silently. Never retry a child automatically.
98
98
 
99
99
  ### Interruption, Cancellation & Session Ownership
100
100
 
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:** background completion does not notify, wake, or resume the parent. Ending a turn is not the same as closing the session; work can continue between turns in the owning session.
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, including parallel workflows. Use background only for useful independent work or an explicit user request; 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/check results unless the user explicitly defers. 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.0-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, including parallel workflows. Use background:true only for useful independent work before collection or an explicit user request; do not routinely launch background then immediately wait. 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. Retain the ID and own collection unless the user explicitly defers it. 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
 
@@ -107,7 +107,7 @@ Replay: resumeFromRunId works only with persisted scriptPath and starts an expli
107
107
 
108
108
  Background is an external_workflow tool call parameter ({ script, background: true }), never a meta field — meta.background is silently ignored and does not run the workflow in the background.
109
109
 
110
- Example (blocking by default; pass background:true on the external_workflow call only when the parent has other work):
110
+ Example (blocking by default; pass background:true on the external_workflow call when doing independent local work first or on a background request):
111
111
  export const meta = {
112
112
  apiVersion: 1, name: "review", description: "Map and review a repository",
113
113
  phases: [
@@ -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. Use for independent work or an explicit background request. Retain the ID and collect with external_runs; 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) => {
@@ -623,7 +656,7 @@ function createAgentTool(
623
656
  });
624
657
  }
625
658
  return receipt(textResult(
626
- `Subagent "${params.description}" (${subagentType}) queued as ${runRecord.runId}. Use external_runs to inspect, wait, or cancel it.`,
659
+ `Subagent "${params.description}" (${subagentType}) queued as ${runRecord.runId}. Do independent local work first, or report the runId and proceed/end your turn without waiting; use external_runs when ready to inspect, wait, or cancel.`,
627
660
  {
628
661
  description: params.description,
629
662
  subagentType,
@@ -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), including parallel workflows. Use background:true only for useful independent work before collection or an explicit user request; do not routinely background then immediately wait. Retain the run ID and own collection unless the user explicitly defers it. 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 foreground 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
  }
@@ -134,7 +134,7 @@ export const USAGE_INDEPENDENT_REVIEW: UsageAgentExample = {
134
134
  context: { mode: "none" },
135
135
  };
136
136
 
137
- /** Returns after registration. The parent reads the verified answer through external_runs. */
137
+ /** Returns after registration. The parent does independent local work first or defers collection across turns before reading through external_runs. */
138
138
  export const USAGE_BACKGROUND_AGENT: UsageAgentExample = {
139
139
  description: "Background map",
140
140
  prompt: "Map /absolute/repo read-only and return a short file list. Do not edit.",
@@ -215,9 +215,9 @@ 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, even for a long or parallel workflow: the original call waits for settlement and its live card carries progress. Use background:true for useful independent work or an explicit user request, not routinely followed by an immediate wait. The returned handle means accepted, not completed; the run may already be settled. Keep its ID and collect/check the outcome yourself unless the user explicitly defers collection. 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
- After independent work, use external_runs wait if settlement is not already known. 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.
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
 
222
222
  One external_delegate call. Permission stays omitted; the prompt carries the no-edit intent:
223
223
  ${jsonBlock(USAGE_ONE_AGENT)}
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. Use for independent work or an explicit background request. Retain the ID and collect with external_runs; 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) => ({
@@ -649,7 +652,7 @@ export function createWorkflowTool(
649
652
  }
650
653
  return {
651
654
  ...workflowResult(
652
- `Workflow "${metaName}" queued as ${identity.runId}. Use external_runs to inspect, wait, or cancel it.`,
655
+ `Workflow "${metaName}" queued as ${identity.runId}. Do independent local work first, or report the runId and proceed/end your turn without waiting; use external_runs when ready to inspect, wait, or cancel.`,
653
656
  { ...cloneSnapshot(snapshot), backgroundReceipt: true },
654
657
  ),
655
658
  ...workflowReceipt(options.registry.get(identity.runId)!, { inspectable: true }),