pi-subagents 0.60.0 → 0.62.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/docs/agents.md +7 -3
  3. package/docs/configuration.md +9 -5
  4. package/docs/extension-api.md +14 -7
  5. package/docs/models.md +1 -1
  6. package/docs/observability.md +1 -1
  7. package/docs/tool-reference.md +13 -4
  8. package/docs/workflows.md +14 -13
  9. package/install.mjs +1 -1
  10. package/package.json +1 -1
  11. package/skills/pi-subagents/SKILL.md +6 -4
  12. package/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
  13. package/skills/pi-subagents/references/execution-controls.md +41 -11
  14. package/skills/pi-subagents/references/multi-lane-orchestration.md +1 -1
  15. package/skills/pi-subagents/references/prompting-and-roles.md +4 -4
  16. package/skills/pi-subagents/references/review-and-validation.md +1 -1
  17. package/src/agents/agent-management.ts +121 -63
  18. package/src/agents/agent-serializer.ts +3 -0
  19. package/src/agents/agents.ts +543 -223
  20. package/src/agents/runtime-agent-registry.ts +5 -1
  21. package/src/api/background-work.ts +7 -2
  22. package/src/api/external-runs.ts +67 -4
  23. package/src/api/preflight.ts +17 -8
  24. package/src/api/shared-types.ts +1 -0
  25. package/src/extension/index.ts +7 -4
  26. package/src/extension/public-execution.ts +48 -4
  27. package/src/extension/rpc.ts +62 -4
  28. package/src/extension/schemas.ts +17 -9
  29. package/src/extension/tool-description.ts +15 -19
  30. package/src/runs/background/active-async-capacity.ts +26 -7
  31. package/src/runs/background/async-execution.ts +98 -51
  32. package/src/runs/background/async-job-tracker.ts +62 -3
  33. package/src/runs/background/async-resume.ts +6 -3
  34. package/src/runs/background/async-status.ts +59 -9
  35. package/src/runs/background/auto-drain.ts +1 -1
  36. package/src/runs/background/fleet-view.ts +1 -1
  37. package/src/runs/background/process-terminal.ts +16 -0
  38. package/src/runs/background/result-watcher.ts +1 -1
  39. package/src/runs/background/resume-guidance.ts +1 -1
  40. package/src/runs/background/run-status.ts +2 -2
  41. package/src/runs/background/scheduled-runs.ts +63 -6
  42. package/src/runs/background/steering.ts +4 -1
  43. package/src/runs/background/subagent-runner.ts +15 -9
  44. package/src/runs/background/subagent-wait.ts +20 -21
  45. package/src/runs/background/wait-completions.ts +1 -1
  46. package/src/runs/background/wait-tool.ts +18 -18
  47. package/src/runs/foreground/execution.ts +75 -9
  48. package/src/runs/foreground/subagent-executor.ts +181 -60
  49. package/src/runs/shared/acceptance.ts +113 -27
  50. package/src/runs/shared/capability-ceiling.ts +1 -0
  51. package/src/runs/shared/dynamic-fanout.ts +1 -1
  52. package/src/runs/shared/host-step-status.ts +1 -0
  53. package/src/runs/shared/model-fallback.ts +61 -17
  54. package/src/runs/shared/parallel-utils.ts +2 -6
  55. package/src/runs/shared/permissions.ts +1 -1
  56. package/src/runs/shared/pi-args.ts +24 -12
  57. package/src/runs/shared/pi-spawn.ts +69 -35
  58. package/src/runs/shared/structured-output.ts +33 -6
  59. package/src/runs/shared/subagent-prompt-runtime.ts +20 -3
  60. package/src/runs/shared/task-intent.ts +17 -6
  61. package/src/runs/shared/tool-timeout.ts +1 -1
  62. package/src/runs/shared/workflow-graph.ts +3 -2
  63. package/src/shared/atomic-json.ts +3 -1
  64. package/src/shared/fork-context.ts +0 -12
  65. package/src/shared/fork-session-cwd.ts +27 -0
  66. package/src/shared/launch-contract.ts +3 -0
  67. package/src/shared/types.ts +43 -4
  68. package/src/shared/workflow-child-permit.ts +91 -0
  69. package/src/slash/prompt-template-bridge.ts +37 -1
  70. package/src/slash/slash-commands.ts +18 -26
  71. package/src/slash/subagents-admin.ts +2 -0
  72. package/src/tui/render.ts +95 -52
  73. package/src/workflows/scripted-workflow.ts +153 -4
  74. package/src/workflows/workflow-child-summary.ts +1 -1
  75. package/src/workflows/workflow-receipt.ts +41 -4
  76. package/src/workflows/workflow-resources.ts +150 -0
@@ -73,7 +73,22 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
73
73
  const steeringNoticeSeen = new Map<string, number>();
74
74
  const jobWatchers = new Map<string, { watchers: Map<string, fs.FSWatcher>; retryTimer?: ReturnType<typeof setTimeout> }>();
75
75
  const refreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
76
+ let widgetRerenderTimer: ReturnType<typeof setTimeout> | undefined;
76
77
  const runningJobIds = new Set<string>();
78
+ const externalJobBridgeRuns = new Set<string>();
79
+ const externalJobBridgeEligibility = (steps: AsyncJobState["steps"]): "required" | "not-required" | "unknown" => {
80
+ if (!Array.isArray(steps)) return "unknown";
81
+ for (const step of steps) {
82
+ const runner = (step as { runner?: unknown } | null)?.runner;
83
+ if (runner === undefined) continue;
84
+ if (!runner || typeof runner !== "object" || Array.isArray(runner)) return "unknown";
85
+ const runnerType = (runner as { type?: unknown }).type;
86
+ if (typeof runnerType !== "string") return "unknown";
87
+ if (runnerType === "external-job") return "required";
88
+ if (runnerType !== "pi" && runnerType !== "external-cli") return "unknown";
89
+ }
90
+ return "not-required";
91
+ };
77
92
  let rootWatcher: fs.FSWatcher | undefined;
78
93
  let nextLivenessAt = Date.now() + livenessIntervalMs;
79
94
  let nextWidgetAnimationAt = Date.now() + WIDGET_ANIMATION_INTERVAL_MS;
@@ -94,9 +109,22 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
94
109
  const rerenderLastWidget = (jobs = Array.from(state.asyncJobs.values())) => {
95
110
  withLastUiContext((ctx) => rerenderWidget(ctx, jobs));
96
111
  };
112
+ const scheduleLastWidgetRerender = () => {
113
+ if (widgetRerenderTimer) return;
114
+ widgetRerenderTimer = setTimeout(() => {
115
+ widgetRerenderTimer = undefined;
116
+ rerenderLastWidget();
117
+ }, EVENT_REFRESH_DEBOUNCE_MS);
118
+ widgetRerenderTimer.unref?.();
119
+ };
97
120
  const requestLastWidgetRender = () => {
98
121
  if (options.widgetEnabled === false) return;
99
- rerenderLastWidget();
122
+ withLastUiContext((ctx) => {
123
+ if (state.widgetsSuspended) return;
124
+ const requestRender = (ctx.ui as { requestRender?: () => void }).requestRender;
125
+ if (requestRender) requestRender.call(ctx.ui);
126
+ else renderWidget(ctx, Array.from(state.asyncJobs.values()));
127
+ });
100
128
  };
101
129
  const refreshWidget = (ctx: ExtensionContext) => rerenderWidget(ctx);
102
130
  const restoredControlEventCursor = (asyncDir: string) => {
@@ -340,6 +368,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
340
368
  if (timer) clearTimeout(timer);
341
369
  refreshTimers.delete(asyncId);
342
370
  runningJobIds.delete(asyncId);
371
+ externalJobBridgeRuns.delete(asyncId);
343
372
  };
344
373
 
345
374
  const refreshJob = (job: AsyncJobState): boolean => {
@@ -354,9 +383,15 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
354
383
  console.error(`Failed to refresh nested async descendants for '${job.asyncDir}':`, error);
355
384
  }
356
385
  };
386
+ let bridgeSweepAttempted = false;
357
387
  try {
358
388
  emitNewControlEvents(job);
359
- serviceExternalJobBridgeRequests(job.asyncDir);
389
+ const bridgeAlreadyRequired = externalJobBridgeRuns.has(job.asyncId) || externalJobBridgeEligibility(job.steps) === "required";
390
+ if (bridgeAlreadyRequired) {
391
+ externalJobBridgeRuns.add(job.asyncId);
392
+ bridgeSweepAttempted = true;
393
+ serviceExternalJobBridgeRequests(job.asyncDir);
394
+ }
360
395
  try {
361
396
  if (job.nestedRoute) reconcileNestedAsyncDescendants(job.nestedRoute, { resultsDir, kill: options.kill, now: options.now });
362
397
  } catch (error) {
@@ -382,6 +417,15 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
382
417
  },
383
418
  });
384
419
  const status = reconciliation.status ?? readStatus(job.asyncDir);
420
+ if (!bridgeAlreadyRequired) {
421
+ const bridgeEligibility = externalJobBridgeEligibility(status?.steps);
422
+ if (bridgeEligibility === "required") externalJobBridgeRuns.add(job.asyncId);
423
+ // Missing or ambiguous status retains the legacy sweep for recovery races.
424
+ if (bridgeEligibility !== "not-required") {
425
+ bridgeSweepAttempted = true;
426
+ serviceExternalJobBridgeRequests(job.asyncDir);
427
+ }
428
+ }
385
429
  if (status) {
386
430
  const previousStatus = job.status;
387
431
  job.status = status.state;
@@ -456,6 +500,14 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
456
500
  runningJobIds.add(job.asyncId);
457
501
  }
458
502
  } catch (error) {
503
+ if (!bridgeSweepAttempted) {
504
+ try {
505
+ bridgeSweepAttempted = true;
506
+ serviceExternalJobBridgeRequests(job.asyncDir);
507
+ } catch (bridgeError) {
508
+ console.error(`Failed to service external job bridge for '${job.asyncDir}':`, bridgeError);
509
+ }
510
+ }
459
511
  if (job.status !== "failed") {
460
512
  console.error(`Failed to read async status for '${job.asyncDir}':`, error);
461
513
  job.status = "failed";
@@ -473,7 +525,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
473
525
  const timer = setTimeout(() => {
474
526
  refreshTimers.delete(asyncId);
475
527
  const job = state.asyncJobs.get(asyncId);
476
- if (job && refreshJob(job)) rerenderLastWidget();
528
+ if (job && refreshJob(job)) scheduleLastWidgetRerender();
477
529
  }, delayMs);
478
530
  timer.unref?.();
479
531
  refreshTimers.set(asyncId, timer);
@@ -604,6 +656,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
604
656
  : rawAgents;
605
657
  const sessionRoot = state.liveAsyncSessionRoots?.get(info.id);
606
658
  state.liveAsyncSessionRoots?.delete(info.id);
659
+ externalJobBridgeRuns.delete(info.id);
607
660
  state.asyncJobs.set(info.id, {
608
661
  asyncId: info.id,
609
662
  asyncDir,
@@ -677,11 +730,15 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
677
730
  for (const asyncId of jobWatchers.keys()) closeJobWatcher(asyncId);
678
731
  for (const timer of refreshTimers.values()) clearTimeout(timer);
679
732
  refreshTimers.clear();
733
+ if (widgetRerenderTimer) clearTimeout(widgetRerenderTimer);
734
+ widgetRerenderTimer = undefined;
680
735
  runningJobIds.clear();
736
+ externalJobBridgeRuns.clear();
681
737
  };
682
738
 
683
739
  const resetJobs = (ctx?: ExtensionContext) => {
684
740
  dispose();
741
+ state.statusProjectionSessionId = null;
685
742
  for (const timer of state.cleanupTimers.values()) clearTimeout(timer);
686
743
  state.cleanupTimers.clear();
687
744
  state.asyncJobs.clear();
@@ -698,6 +755,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
698
755
 
699
756
  const restoreActiveJobs = (ctx?: ExtensionContext) => {
700
757
  if (ctx?.hasUI) state.lastUiContext = ctx;
758
+ state.statusProjectionSessionId = null;
701
759
  if (!state.currentSessionId) return;
702
760
  let runs: AsyncRunSummary[];
703
761
  try {
@@ -713,6 +771,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
713
771
  rememberFleetJob(state, job);
714
772
  watchJob(job);
715
773
  }
774
+ state.statusProjectionSessionId = state.currentSessionId;
716
775
  if (runs.length === 0) return;
717
776
  ensurePoller();
718
777
  rerenderLastWidget();
@@ -314,8 +314,8 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
314
314
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': expected an object.`);
315
315
  const parsed = value as Record<string, unknown>;
316
316
  const allowedFields = new Set([
317
- "version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "fallbackModels", "thinking", "thinkingCeiling", "tools", "allowNestedSubagents", "extensions",
318
- "subagentOnlyExtensions", "mcpDirectTools", "mutationTools", "systemPrompt", "systemPromptMode", "inheritProjectContext", "inheritGlobalContext", "inheritSkills", "skills",
317
+ "version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "modelOrigin", "fallbackModels", "thinking", "thinkingCeiling", "tools", "allowNestedSubagents", "extensions",
318
+ "subagentOnlyExtensions", "mcpDirectTools", "excludeTools", "mutationTools", "systemPrompt", "systemPromptMode", "inheritProjectContext", "inheritGlobalContext", "inheritSkills", "skills",
319
319
  "skillPath", "agentFilePath", "completionGuard", "memory", "outputPath", "outputMode", "structuredOutputSchema", "acceptance", "sessionDir", "artifactConfig",
320
320
  "artifactsDir", "maxOutput", "controlConfig", "context", "intercomBridge", "absoluteDeadlineAt", "initialTurnBudget", "initialToolBudget", "maxSubagentDepth", "share", "capabilityCeiling",
321
321
  "launchResolvedExtensions", "runFanoutBudget", "lane",
@@ -347,6 +347,8 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
347
347
  if (parsed.outputMode !== "inline" && parsed.outputMode !== "file-only") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': outputMode is invalid.`);
348
348
  if (parsed.context !== undefined && parsed.context !== "fresh" && parsed.context !== "fork") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': context is invalid.`);
349
349
  if (parsed.modelOverrideFromParent !== undefined && typeof parsed.modelOverrideFromParent !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': modelOverrideFromParent must be a boolean.`);
350
+ if (parsed.modelOrigin !== undefined && parsed.modelOrigin !== "explicit" && parsed.modelOrigin !== "inherited" && parsed.modelOrigin !== "configured") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': modelOrigin must be 'explicit', 'inherited', or 'configured'.`);
351
+ if (parsed.modelOrigin === undefined && parsed.model !== undefined) parsed.modelOrigin = parsed.modelOverrideFromParent ? "inherited" : "configured";
350
352
  for (const field of ["inheritProjectContext", "inheritSkills", "share"] as const) {
351
353
  if (typeof parsed[field] !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${field} must be a boolean.`);
352
354
  }
@@ -354,7 +356,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
354
356
  else if (typeof parsed.inheritGlobalContext !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': inheritGlobalContext must be a boolean.`);
355
357
  if (parsed.allowNestedSubagents !== undefined && typeof parsed.allowNestedSubagents !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': allowNestedSubagents must be a boolean.`);
356
358
  if (!Number.isInteger(parsed.maxSubagentDepth) || (parsed.maxSubagentDepth as number) < 0) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': maxSubagentDepth must be a non-negative integer.`);
357
- for (const field of ["fallbackModels", "tools", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "mutationTools", "skills", "skillPath"] as const) {
359
+ for (const field of ["fallbackModels", "tools", "excludeTools", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "mutationTools", "skills", "skillPath"] as const) {
358
360
  const item = parsed[field];
359
361
  if (item !== undefined && (!Array.isArray(item) || item.some((entry) => typeof entry !== "string" || !entry.trim()))) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${field} must contain non-empty strings.`);
360
362
  }
@@ -588,6 +590,7 @@ export function applySteeringRecoveryAgentConfig(agentConfig: AgentConfig, descr
588
590
  thinking: descriptor.thinking,
589
591
  maxThinking: intersectThinkingCeilings(descriptor.thinkingCeiling, agentConfig.maxThinking),
590
592
  tools: descriptor.tools ? [...descriptor.tools] : undefined,
593
+ excludeTools: descriptor.excludeTools ? [...descriptor.excludeTools] : undefined,
591
594
  allowNestedSubagents: descriptor.allowNestedSubagents,
592
595
  extensions: descriptor.extensions ? [...descriptor.extensions] : undefined,
593
596
  subagentOnlyExtensions: descriptor.subagentOnlyExtensions ? [...descriptor.subagentOnlyExtensions] : undefined,
@@ -166,6 +166,40 @@ function getErrorMessage(error: unknown): string {
166
166
  return error instanceof Error ? error.message : String(error);
167
167
  }
168
168
 
169
+ function isAsyncStatusIsolationError(asyncDir: string, error: unknown): boolean {
170
+ const statusPath = path.join(asyncDir, "status.json");
171
+ const message = getErrorMessage(error);
172
+ return /^(?:Failed to (?:inspect|read|parse|validate) async status file|Invalid async status file) '/.test(message)
173
+ || message.startsWith(`Invalid async status '${statusPath}'`)
174
+ || message.startsWith(`Invalid host step '${statusPath}`)
175
+ || /^(workflowChildren|Invalid workflowChildren)/.test(message);
176
+ }
177
+
178
+ function isolateCorruptActiveRun(asyncDir: string, runId: string, error: unknown, now?: () => number): void {
179
+ const statusPath = path.join(asyncDir, "status.json");
180
+ const processTerminal = readProcessTerminal(asyncDir, { runId });
181
+ let markerAge: number | undefined;
182
+ try {
183
+ markerAge = activeRunMarkerAgeMs(asyncDir, now?.());
184
+ } catch (markerError) {
185
+ console.error(`Failed to inspect corrupt async active-run marker for '${runId}':`, markerError);
186
+ }
187
+ const markerCanBeReleased = processTerminal?.state === "observed"
188
+ || (markerAge !== undefined && markerAge > DEFAULT_STALE_TERMINAL_ACTIVE_MARKER_MS);
189
+ let markerAction = "active marker retained because runner liveness is unknown";
190
+ if (markerCanBeReleased) {
191
+ try {
192
+ releaseActiveRunIndex(asyncDir);
193
+ markerAction = processTerminal?.state === "observed"
194
+ ? "active marker released after observed process-terminal proof"
195
+ : "stale active marker released";
196
+ } catch (releaseError) {
197
+ markerAction = `failed to release active marker: ${getErrorMessage(releaseError)}`;
198
+ }
199
+ }
200
+ console.error(`[pi-subagents] Skipping corrupt active async run '${runId}' at '${statusPath}': ${getErrorMessage(error)}; ${markerAction}.`);
201
+ }
202
+
169
203
  function isNotFoundError(error: unknown): boolean {
170
204
  return typeof error === "object"
171
205
  && error !== null
@@ -250,14 +284,16 @@ function deriveAsyncActivityState(asyncDir: string, status: AsyncStatus): { acti
250
284
  }
251
285
 
252
286
  function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string }, nestedWarnings: string[] = [], nestedRoute?: NestedRoute): AsyncRunSummary {
253
- validateAsyncStatusLaneMetadata(status, `Invalid async status '${path.join(asyncDir, "status.json")}'`);
287
+ const statusPath = path.join(asyncDir, "status.json");
288
+ validateAsyncStatusLaneMetadata(status, `Invalid async status '${statusPath}'`);
254
289
  const workflowChildren = parseWorkflowChildSummary(status.workflowChildren);
255
- if (workflowChildren && workflowChildren.workflowRunId !== status.runId) throw new Error(`Invalid async status '${path.join(asyncDir, "status.json")}': workflowChildren.workflowRunId does not match.`);
256
- assertWorkflowGraphHostSteps(status.workflowGraph, path.join(asyncDir, "status.json"), status.runId);
290
+ if (workflowChildren && workflowChildren.workflowRunId !== status.runId) throw new Error(`Invalid async status '${statusPath}': workflowChildren.workflowRunId does not match.`);
291
+ assertWorkflowGraphHostSteps(status.workflowGraph, statusPath, status.runId);
257
292
  const hostSteps = validHostStepNodes(status.workflowGraph);
258
293
  if (status.sessionId !== undefined && typeof status.sessionId !== "string") {
259
- throw new Error(`Invalid async status '${path.join(asyncDir, "status.json")}': sessionId must be a string.`);
294
+ throw new Error(`Invalid async status '${statusPath}': sessionId must be a string.`);
260
295
  }
296
+ if (status.outputFile !== undefined && typeof status.outputFile !== "string") throw new Error(`Invalid async status '${statusPath}': outputFile must be a string.`);
261
297
  const { activityState, lastActivityAt } = deriveAsyncActivityState(asyncDir, status);
262
298
  const processTerminal = readProcessTerminal(asyncDir, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId })
263
299
  ?? sanitizeProcessTerminal(status.processTerminal, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId }, path.join(asyncDir, "status.json"));
@@ -499,10 +535,17 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
499
535
  };
500
536
  for (const entry of entries) {
501
537
  const asyncDir = path.join(asyncDirRoot, entry);
502
- const reconciliation = options.reconcile === false
503
- ? undefined
504
- : reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
505
- const status = (reconciliation?.status ?? readStatus(asyncDir)) as (AsyncStatus & { cwd?: string }) | null;
538
+ let status: (AsyncStatus & { cwd?: string }) | null;
539
+ try {
540
+ const reconciliation = options.reconcile === false
541
+ ? undefined
542
+ : reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
543
+ status = (reconciliation?.status ?? readStatus(asyncDir)) as (AsyncStatus & { cwd?: string }) | null;
544
+ } catch (error) {
545
+ if (!activeEntries.has(entry) || !isAsyncStatusIsolationError(asyncDir, error)) throw error;
546
+ isolateCorruptActiveRun(asyncDir, entry, error, options.now);
547
+ continue;
548
+ }
506
549
  if (!status) {
507
550
  if (activeEntries.has(entry)) updateActiveRunIndex(asyncDir, "failed");
508
551
  continue;
@@ -528,7 +571,14 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
528
571
  nestedWarnings.push(`Nested status unavailable: ${getErrorMessage(error)}`);
529
572
  }
530
573
  }
531
- const summary = statusToSummary(asyncDir, status, nestedWarnings, nestedRoute);
574
+ let summary: AsyncRunSummary;
575
+ try {
576
+ summary = statusToSummary(asyncDir, status, nestedWarnings, nestedRoute);
577
+ } catch (error) {
578
+ if (!activeEntries.has(entry) || !isAsyncStatusIsolationError(asyncDir, error)) throw error;
579
+ isolateCorruptActiveRun(asyncDir, entry, error, options.now);
580
+ continue;
581
+ }
532
582
  runs.push(summary);
533
583
  }
534
584
 
@@ -62,7 +62,7 @@ export async function drainOutstandingWork(deps: AutoDrainDeps): Promise<void> {
62
62
  },
63
63
  );
64
64
  if (waitResult.isError) {
65
- throw new Error(`Auto-drain failed for session '${sessionId}': ${resultText(waitResult) || "subagent_wait returned an error without details"}.`);
65
+ throw new Error(`Auto-drain failed for session '${sessionId}': ${resultText(waitResult) || "bg_wait returned an error without details"}.`);
66
66
  }
67
67
  }
68
68
  }
@@ -330,7 +330,7 @@ function formatDetachedForegroundFleetLines(runs: ForegroundRun[]): string[] {
330
330
  const childSummary = detachedChildren.map((child) => `${fleetChildDisplayName(child)} #${child.index}`).join(", ");
331
331
  lines.push(`- ${run.runId} | detached | ${run.mode}${childSummary ? ` | ${childSummary}` : ""}`);
332
332
  lines.push(` status: subagent({ action: "status", id: "${run.runId}" })`);
333
- lines.push(` recovery: reply to the supervisor request first, then wait with subagent_wait({ id: "${run.runId}" }); do not resume or launch a replacement while any child remains detached.`);
333
+ lines.push(` recovery: reply to the supervisor request first, then wait with bg_wait({ id: "${run.runId}" }); do not resume or launch a replacement while any child remains detached.`);
334
334
  }
335
335
  return lines;
336
336
  }
@@ -115,6 +115,22 @@ export function writeProcessTerminalCandidate(asyncDir: string, candidate: Proce
115
115
  writePrivateAtomicJson(processTerminalCandidatePath(asyncDir), candidate);
116
116
  }
117
117
 
118
+ /** Establish ownership before authorizing a runner to launch any child process. */
119
+ export function initializeProcessTerminal(asyncDir: string, runId: string, runnerProcessInstanceId: string): void {
120
+ writeProcessTerminalCandidate(asyncDir, {
121
+ version: 1,
122
+ runId,
123
+ runnerProcessInstanceId,
124
+ writers: {},
125
+ });
126
+ writeAtomicJson(processTerminalPath(asyncDir), {
127
+ version: 1,
128
+ state: "pending",
129
+ runId,
130
+ runnerProcessInstanceId,
131
+ });
132
+ }
133
+
118
134
  export function markProcessTerminalCandidateLeaseRelease(asyncDir: string, token: string, acknowledged: boolean): void {
119
135
  const candidate = readProcessTerminalCandidate(asyncDir);
120
136
  if (!candidate || candidate.revivalLeaseToken !== token) return;
@@ -426,7 +426,7 @@ export function createResultWatcher(
426
426
  if (observerSucceeded) removeMissionObserverIndex(resultsDir, runId);
427
427
  const epoch = deliveryEpoch;
428
428
  if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
429
- // Recorded before dedupe and before the unlink below so subagent_wait can
429
+ // Recorded before dedupe and before the unlink below so bg_wait can
430
430
  // use the in-memory record or its bounded durable replay after cleanup.
431
431
  recordWaitCompletion(state, runId, data, Date.now(), completionTtlMs, {
432
432
  resultsDir,
@@ -12,7 +12,7 @@ function isIntercomDetached(run: AsyncRunSummary): boolean {
12
12
 
13
13
  function formatIntercomDetachGuidance(run: AsyncRunSummary): string | undefined {
14
14
  if (!isIntercomDetached(run)) return undefined;
15
- return `Run "${run.id}" detached for intercom coordination. Reply to the supervisor request first, then wait with subagent_wait({ id: "${run.id}" }). Use subagent({ action: "status", id: "${run.id}" }) to recover the result; do not resume or launch a replacement while it remains detached.`;
15
+ return `Run "${run.id}" detached for intercom coordination. Reply to the supervisor request first, then wait with bg_wait({ id: "${run.id}" }). Use subagent({ action: "status", id: "${run.id}" }) to recover the result; do not resume or launch a replacement while it remains detached.`;
16
16
  }
17
17
 
18
18
  export function formatAsyncReviveCommand(run: AsyncRunSummary): string | undefined {
@@ -128,7 +128,7 @@ function formatResumeGuidance(runId: string | undefined, children: Array<{ agent
128
128
  const resumableWorkflowChildren = workflowChildren.filter(({ child }) => !(child.status === "paused" && child.activityState === "needs_attention"));
129
129
  if (workflowChildren.length > 0) {
130
130
  return [
131
- ...supervisorDetachedWorkflowChildren.map(({ child }) => `Recovery workflow child${typeof child.workflowKey === "string" && child.workflowKey.trim() ? ` '${child.workflowKey}'` : ""}: reply to the supervisor request first, then wait with subagent_wait({ id: "${child.runId}" }). Use subagent({ action: "status", id: "${child.runId}" }) to recover the result; do not resume or launch a replacement while it remains detached.`),
131
+ ...supervisorDetachedWorkflowChildren.map(({ child }) => `Recovery workflow child${typeof child.workflowKey === "string" && child.workflowKey.trim() ? ` '${child.workflowKey}'` : ""}: reply to the supervisor request first, then wait with bg_wait({ id: "${child.runId}" }). Use subagent({ action: "status", id: "${child.runId}" }) to recover the result; do not resume or launch a replacement while it remains detached.`),
132
132
  ...resumableWorkflowChildren.map(({ child }) => `Revive workflow child${typeof child.workflowKey === "string" && child.workflowKey.trim() ? ` '${child.workflowKey}'` : ""}: subagent({ action: "resume", id: "${child.runId}", message: "..." })`),
133
133
  ].join("\n");
134
134
  }
@@ -228,7 +228,7 @@ function formatRememberedForegroundStatus(run: ForegroundResumeRun): string {
228
228
  const detached = run.children.some((child) => child.status === "detached");
229
229
  const resumable = run.children.find((child) => hasExistingSessionFile(child.sessionFile));
230
230
  if (detached) {
231
- lines.push(`Recovery: reply to the supervisor request first, then wait with subagent_wait({ id: "${run.runId}" }); do not resume or launch a replacement while any child remains detached.`);
231
+ lines.push(`Recovery: reply to the supervisor request first, then wait with bg_wait({ id: "${run.runId}" }); do not resume or launch a replacement while any child remains detached.`);
232
232
  } else if (resumable) {
233
233
  lines.push(run.children.length === 1
234
234
  ? `Revive: subagent({ action: "resume", id: "${run.runId}", message: "..." })`
@@ -52,6 +52,8 @@ export interface ScheduleRecord {
52
52
  catchUp: "none" | "latest";
53
53
  timeoutMs?: number;
54
54
  paused: boolean;
55
+ sessionOnly?: boolean;
56
+ ownerSessionFile?: string;
55
57
  createdAt: string;
56
58
  updatedAt: string;
57
59
  activeRunId?: string;
@@ -72,6 +74,8 @@ export interface ScheduleRunRecord {
72
74
  error?: string;
73
75
  }
74
76
 
77
+ type PublicScheduleRecord = Omit<ScheduleRecord, "ownerSessionFile">;
78
+
75
79
  type ScheduledRunManagerDeps = {
76
80
  config: ExtensionConfig;
77
81
  launch(params: SubagentParamsLike, ctx: ExtensionContext, signal: AbortSignal): Promise<AgentToolResult<Details>>;
@@ -295,6 +299,8 @@ function parseSchedule(value: unknown, file: string): ScheduleRecord {
295
299
  } else if (record.trigger.kind === "interval") {
296
300
  if (typeof record.trigger.every !== "string" || typeof record.trigger.everyMs !== "number" || typeof record.trigger.anchorAt !== "string" || typeof record.trigger.nextRunAt !== "string") throw new Error(`Schedule record '${file}' has an invalid interval trigger.`);
297
301
  } else throw new Error(`Schedule record '${file}' has an unsupported trigger.`);
302
+ if (record.sessionOnly !== undefined && typeof record.sessionOnly !== "boolean") throw new Error(`Schedule record '${file}' has invalid sessionOnly.`);
303
+ if (record.sessionOnly === true && (typeof record.ownerSessionFile !== "string" || !record.ownerSessionFile.trim())) throw new Error(`Schedule record '${file}' is session-only but has no owner session file.`);
298
304
  return { ...record, target: parseScheduleTarget(record.target, file) } as ScheduleRecord;
299
305
  }
300
306
 
@@ -399,13 +405,19 @@ function duePlannedAt(schedule: ScheduleRecord, now: number): number | undefined
399
405
  }
400
406
 
401
407
  function textResult(text: string, schedules?: ScheduleRecord[], runs?: ScheduleRunRecord[], isError = false): AgentToolResult<Details> {
408
+ const publicSchedules = schedules?.map(publicScheduleRecord);
402
409
  return {
403
410
  content: [{ type: "text", text }],
404
411
  ...(isError ? { isError: true } : {}),
405
- details: { mode: "management", results: [], schedules: { ...(schedules ? { records: schedules } : {}), ...(runs ? { runs } : {}) } },
412
+ details: { mode: "management", results: [], schedules: { ...(publicSchedules ? { records: publicSchedules } : {}), ...(runs ? { runs } : {}) } },
406
413
  };
407
414
  }
408
415
 
416
+ function publicScheduleRecord(schedule: ScheduleRecord): PublicScheduleRecord {
417
+ const { ownerSessionFile: _ownerSessionFile, ...rest } = schedule;
418
+ return rest;
419
+ }
420
+
409
421
  function targetLabel(target: ScheduleTarget): string {
410
422
  const preview = previewSimpleWorkflowRun(target.workflowScript);
411
423
  return preview?.agent ? `workflowScript -> agent ${preview.agent}` : "workflowScript (dynamic)";
@@ -450,6 +462,32 @@ function snapshotContext(ctx: ExtensionContext, cwd: string): ExtensionContext {
450
462
  return { ...ctx, cwd, sessionManager };
451
463
  }
452
464
 
465
+ /**
466
+ * 规范化会话文件路径, 兼容 Windows 路径大小写差异.
467
+ *
468
+ * @param value 会话文件路径
469
+ * @returns 规范化后的路径, 空值时返回 undefined
470
+ */
471
+ function normalizedSessionFile(value: string | undefined): string | undefined {
472
+ if (!value || !value.trim()) return undefined;
473
+ const normalized = path.normalize(path.resolve(value));
474
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
475
+ }
476
+
477
+ /**
478
+ * 判断 Schedule 是否属于当前 Pi 会话.
479
+ *
480
+ * @param schedule Schedule 记录
481
+ * @param ctx 当前 Pi 会话上下文
482
+ * @returns 是否允许当前会话执行该 Schedule
483
+ */
484
+ function scheduleBelongsToSession(schedule: ScheduleRecord, ctx: ExtensionContext): boolean {
485
+ if (schedule.sessionOnly !== true) return true;
486
+ const ownerSessionFile = normalizedSessionFile(schedule.ownerSessionFile);
487
+ const currentSessionFile = normalizedSessionFile(ctx.sessionManager.getSessionFile());
488
+ return ownerSessionFile !== undefined && ownerSessionFile === currentSessionFile;
489
+ }
490
+
453
491
  export function listScheduledRunSummaries(cwd: string, root?: string): ScheduleRecord[] {
454
492
  return new ScheduleStore(scheduledRunStorePath(cwd, undefined, root), root === undefined ? path.resolve(cwd) : undefined).list();
455
493
  }
@@ -560,6 +598,10 @@ export class ScheduledRunManager {
560
598
  if (params.catchUp !== undefined && params.catchUp !== "none" && params.catchUp !== "latest") return textResult("catchUp must be 'none' or 'latest'.", undefined, undefined, true);
561
599
  if (params.missionId !== undefined || params.mission !== undefined || params.missionUpdate !== undefined || params.missionStatus !== undefined || params.missionScope !== undefined) return textResult("Mission attachment is deferred from this first schedule slice.", undefined, undefined, true);
562
600
  if (params.on !== undefined || params.timezone !== undefined || every === "day" || every === "week" || every === "month" || every === "year") return textResult("Calendar schedules are deferred from this first safe slice. Use a fixed interval such as every:'24h' or every:'7d'.", undefined, undefined, true);
601
+ const sessionOnly = params.sessionOnly === true;
602
+ if (sessionOnly && params.cwd !== undefined && !samePath(params.cwd, ctx.cwd)) return textResult("sessionOnly schedules cannot use an explicit cross-project cwd.", undefined, undefined, true);
603
+ const ownerSessionFile = sessionOnly ? ctx.sessionManager.getSessionFile() : undefined;
604
+ if (sessionOnly && !ownerSessionFile) return textResult("sessionOnly schedules require a persisted current session.", undefined, undefined, true);
563
605
  const sessionId = ctx.sessionManager.getSessionId() ?? "unknown";
564
606
  if (this.deps.resolveCapabilityCeiling?.(sessionId)) return textResult("Cannot persist a schedule while a capability ceiling is active.", undefined, undefined, true);
565
607
  const pendingCount = store.list().filter(hasPendingScheduleWork).length;
@@ -587,24 +629,25 @@ export class ScheduledRunManager {
587
629
  catchUp: params.catchUp ?? "latest",
588
630
  ...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }),
589
631
  paused: false,
632
+ ...(sessionOnly ? { sessionOnly: true, ownerSessionFile: path.resolve(ownerSessionFile!) } : {}),
590
633
  createdAt: timestamp(now),
591
634
  updatedAt: timestamp(now),
592
635
  };
593
636
  store.write(schedule);
594
637
  store.appendEvent(schedule, "schedule.created");
595
638
  this.arm(schedule, store);
596
- return textResult(`Created schedule ${id}.\nName: ${schedule.name}\nTrigger: ${at ? `at ${at}` : `every ${every}`}\nNext: ${schedule.trigger.nextRunAt}\nTarget: ${targetLabel(schedule.target)}`, [schedule]);
639
+ return textResult(`Created schedule ${id}.\nName: ${schedule.name}\nTrigger: ${at ? `at ${at}` : `every ${every}`}\nSession only: ${schedule.sessionOnly === true ? "yes" : "no"}\nNext: ${schedule.trigger.nextRunAt}\nTarget: ${targetLabel(schedule.target)}`, [schedule]);
597
640
  }
598
641
 
599
642
  private list(): AgentToolResult<Details> {
600
643
  const schedules = this.requireStore().list().sort((a, b) => (a.trigger.nextRunAt ?? "").localeCompare(b.trigger.nextRunAt ?? ""));
601
644
  if (!schedules.length) return textResult("No project schedules.", []);
602
- return textResult([`Project schedules: ${schedules.length}`, ...schedules.map((item) => `- ${item.id} | ${item.paused ? "paused" : item.activeRunId ? "running" : "scheduled"} | ${item.trigger.nextRunAt ?? "no next run"} | ${item.name}`)].join("\n"), schedules);
645
+ return textResult([`Project schedules: ${schedules.length}`, ...schedules.map((item) => `- ${item.id} | ${item.paused ? "paused" : item.activeRunId ? "running" : "scheduled"} | ${item.trigger.nextRunAt ?? "no next run"} | ${item.sessionOnly === true ? "session-only" : "project"} | ${item.name}`)].join("\n"), schedules);
603
646
  }
604
647
 
605
648
  private show(params: SubagentParamsLike): AgentToolResult<Details> {
606
649
  const schedule = this.resolve(params);
607
- return textResult([`Schedule: ${schedule.id}`, `Name: ${schedule.name}`, `State: ${schedule.paused ? "paused" : schedule.activeRunId ? "running" : "scheduled"}`, `Target: ${targetLabel(schedule.target)}`, `CWD: ${shortenPath(schedule.cwd)}`, `Next: ${schedule.trigger.nextRunAt ?? "none"}`, `Catch up: ${schedule.catchUp}`, schedule.activeRunId ? `Active run: ${schedule.activeRunId}` : undefined].filter(Boolean).join("\n"), [schedule]);
650
+ return textResult([`Schedule: ${schedule.id}`, `Name: ${schedule.name}`, `State: ${schedule.paused ? "paused" : schedule.activeRunId ? "running" : "scheduled"}`, `Session only: ${schedule.sessionOnly === true ? "yes" : "no"}`, `Target: ${targetLabel(schedule.target)}`, `CWD: ${shortenPath(schedule.cwd)}`, `Next: ${schedule.trigger.nextRunAt ?? "none"}`, `Catch up: ${schedule.catchUp}`, schedule.activeRunId ? `Active run: ${schedule.activeRunId}` : undefined].filter(Boolean).join("\n"), [schedule]);
608
651
  }
609
652
 
610
653
  private history(params: SubagentParamsLike): AgentToolResult<Details> {
@@ -628,13 +671,18 @@ export class ScheduledRunManager {
628
671
  private async runManual(params: SubagentParamsLike): Promise<AgentToolResult<Details>> {
629
672
  const store = this.requireStore();
630
673
  const schedule = this.resolve(params);
674
+ const context = this.requireContext(store);
675
+ if (!scheduleBelongsToSession(schedule, context)) {
676
+ return textResult(`Skipped schedule ${schedule.id}: current session is not its owner.`, [schedule]);
677
+ }
631
678
  const run = await this.launch(store, schedule, this.now(), "manual", false);
632
679
  return textResult(`Manual schedule run ${run.id}: ${run.state}${run.asyncId ? ` (async ${run.asyncId})` : ""}.`, [store.get(schedule.id)], [run], run.state === "failed_launch");
633
680
  }
634
681
 
635
682
  private async runDue(): Promise<AgentToolResult<Details>> {
636
683
  const store = this.requireStore();
637
- const due = store.list().filter((schedule) => !schedule.paused && nextRunAt(schedule) !== undefined && nextRunAt(schedule)! <= this.now());
684
+ const context = this.requireContext(store);
685
+ const due = store.list().filter((schedule) => scheduleBelongsToSession(schedule, context) && !schedule.paused && nextRunAt(schedule) !== undefined && nextRunAt(schedule)! <= this.now());
638
686
  const runs: ScheduleRunRecord[] = [];
639
687
  for (const schedule of due) {
640
688
  const planned = duePlannedAt(schedule, this.now())!;
@@ -659,6 +707,7 @@ export class ScheduledRunManager {
659
707
  }
660
708
 
661
709
  private restoreOne(store: ScheduleStore, schedule: ScheduleRecord, notBefore?: number, rearm = true): void {
710
+ if (!scheduleBelongsToSession(schedule, this.requireContext(store))) return;
662
711
  if (schedule.activeRunId) {
663
712
  const run = store.history(schedule.id).find((item) => item.id === schedule.activeRunId);
664
713
  if (run?.state === "running" && run.asyncId) this.observedAsyncIds.add(run.asyncId);
@@ -737,6 +786,7 @@ export class ScheduledRunManager {
737
786
  // is then nothing to run and nothing to re-arm.
738
787
  const schedule = store.find(id);
739
788
  if (!schedule) return;
789
+ if (!scheduleBelongsToSession(schedule, this.requireContext(store))) return;
740
790
  const planned = duePlannedAt(schedule, this.now());
741
791
  if (planned === undefined || schedule.paused) return;
742
792
  if (planned > this.now()) return this.arm(schedule, store);
@@ -857,13 +907,20 @@ export class ScheduledRunManager {
857
907
  private selectProject(cwd: string, ctx: ExtensionContext): void {
858
908
  const projectCwd = path.resolve(cwd);
859
909
  const root = scheduledRunStorePath(projectCwd, undefined, this.deps.storeRoot);
860
- if (path.resolve(ctx.cwd) === projectCwd) this.contexts.set(root, snapshotContext(ctx, projectCwd));
910
+ const isBoundContext = path.resolve(ctx.cwd) === projectCwd;
911
+ const previousContext = this.contexts.get(root);
912
+ const contextChanged = isBoundContext
913
+ && previousContext !== undefined
914
+ && normalizedSessionFile(previousContext.sessionManager.getSessionFile()) !== normalizedSessionFile(ctx.sessionManager.getSessionFile());
915
+ if (isBoundContext) this.contexts.set(root, snapshotContext(ctx, projectCwd));
861
916
  else if (!this.contexts.has(root)) throw new Error(`Cannot use project '${projectCwd}' until that project has been opened in this runtime.`);
862
917
  let store = this.stores.get(root);
863
918
  if (!store) {
864
919
  store = new ScheduleStore(root, this.deps.storeRoot === undefined ? projectCwd : undefined);
865
920
  this.stores.set(root, store);
866
921
  this.restore(store);
922
+ } else if (contextChanged) {
923
+ this.restore(store);
867
924
  }
868
925
  this.store = store;
869
926
  }
@@ -23,7 +23,10 @@ export function steeringMessagePreview(message: string): string {
23
23
  }
24
24
 
25
25
  export function steeringReceipt(message: string, receipt: string): string {
26
- return `${receipt} Message: ${JSON.stringify(steeringMessagePreview(message))}`;
26
+ const preview = steeringMessagePreview(message);
27
+ const longestFence = Math.max(2, ...[...preview.matchAll(/`{3,}/g)].map((match) => match[0]!.length));
28
+ const fence = "`".repeat(longestFence + 1);
29
+ return `${receipt}\n\nMessage sent:\n${fence}text\n${preview}\n${fence}`;
27
30
  }
28
31
 
29
32
  export function createSteeringStatus(): SteeringStatus {