@jameslovespancakes/pi-plus 1.0.11 → 1.0.12

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 (120) hide show
  1. package/README.md +44 -20
  2. package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
  3. package/package.json +6 -3
  4. package/src/core/accounts/oauth-pool.ts +102 -0
  5. package/src/core/accounts/registry.ts +8 -25
  6. package/src/core/accounts/routing.ts +122 -0
  7. package/src/core/anthropic/client-identity.ts +9 -64
  8. package/src/core/anthropic/quota.ts +8 -53
  9. package/src/core/anthropic/routing.ts +40 -115
  10. package/src/core/anthropic/store.ts +7 -9
  11. package/src/core/catalog/quality.ts +33 -15
  12. package/src/core/codex/quota.ts +8 -2
  13. package/src/core/codex/store.ts +12 -9
  14. package/src/core/config.ts +4 -17
  15. package/src/core/store.ts +13 -12
  16. package/src/domains/models/provider-picker.ts +1 -1
  17. package/src/domains/setup/index.ts +23 -23
  18. package/src/domains/subscriptions/accounts-picker.ts +18 -37
  19. package/src/domains/subscriptions/accounts.ts +23 -29
  20. package/src/domains/subscriptions/footer.ts +48 -29
  21. package/src/domains/subscriptions/index.ts +11 -27
  22. package/src/domains/subscriptions/provider.ts +38 -103
  23. package/src/domains/subscriptions/providers/anthropic.ts +5 -17
  24. package/src/domains/subscriptions/providers/codex.ts +171 -4
  25. package/src/domains/subscriptions/providers/hosted.ts +18 -0
  26. package/src/domains/subscriptions/providers/oauth-pool.ts +273 -0
  27. package/src/domains/subscriptions/routing.ts +19 -11
  28. package/src/domains/workflows/LICENSE.md +21 -0
  29. package/src/domains/workflows/index.ts +836 -0
  30. package/src/domains/workflows/runtime/advisory-challenge.ts +75 -0
  31. package/src/domains/workflows/runtime/advisory-evidence.ts +90 -0
  32. package/src/domains/workflows/runtime/advisory-schema.ts +83 -0
  33. package/src/domains/workflows/runtime/agent-attempt.ts +147 -0
  34. package/src/domains/workflows/runtime/agent-limits.ts +43 -0
  35. package/src/domains/workflows/runtime/agent-replay.ts +399 -0
  36. package/src/domains/workflows/runtime/agent-retry.ts +116 -0
  37. package/src/domains/workflows/runtime/agent-runner-types.ts +96 -0
  38. package/src/domains/workflows/runtime/agent-runner.ts +203 -0
  39. package/src/domains/workflows/runtime/agent-session-identity.ts +256 -0
  40. package/src/domains/workflows/runtime/agent-session-providers.ts +50 -0
  41. package/src/domains/workflows/runtime/agent-session.ts +382 -0
  42. package/src/domains/workflows/runtime/agent-skills.ts +270 -0
  43. package/src/domains/workflows/runtime/agent-workspace.ts +103 -0
  44. package/src/domains/workflows/runtime/background-workflow-tool.ts +75 -0
  45. package/src/domains/workflows/runtime/background-workflows.ts +492 -0
  46. package/src/domains/workflows/runtime/budget.ts +53 -0
  47. package/src/domains/workflows/runtime/cancellation.ts +87 -0
  48. package/src/domains/workflows/runtime/command-completions.ts +36 -0
  49. package/src/domains/workflows/runtime/concurrency.ts +403 -0
  50. package/src/domains/workflows/runtime/debug.ts +3 -0
  51. package/src/domains/workflows/runtime/diff-capture.ts +81 -0
  52. package/src/domains/workflows/runtime/discovery.ts +137 -0
  53. package/src/domains/workflows/runtime/dynamax-shortcuts.ts +122 -0
  54. package/src/domains/workflows/runtime/dynamax.ts +330 -0
  55. package/src/domains/workflows/runtime/engine.ts +543 -0
  56. package/src/domains/workflows/runtime/filesystem-error.ts +4 -0
  57. package/src/domains/workflows/runtime/finalizers.ts +66 -0
  58. package/src/domains/workflows/runtime/identity-canonicalization.ts +138 -0
  59. package/src/domains/workflows/runtime/identity-fingerprint.ts +15 -0
  60. package/src/domains/workflows/runtime/inline-workflow.ts +403 -0
  61. package/src/domains/workflows/runtime/journal.ts +313 -0
  62. package/src/domains/workflows/runtime/model-profiles.ts +310 -0
  63. package/src/domains/workflows/runtime/options.ts +157 -0
  64. package/src/domains/workflows/runtime/perf.ts +146 -0
  65. package/src/domains/workflows/runtime/pi-compat.ts +32 -0
  66. package/src/domains/workflows/runtime/process-runner.ts +260 -0
  67. package/src/domains/workflows/runtime/progress-types.ts +59 -0
  68. package/src/domains/workflows/runtime/progress.ts +400 -0
  69. package/src/domains/workflows/runtime/provider-usage-limit.ts +191 -0
  70. package/src/domains/workflows/runtime/replay-path-identity.ts +48 -0
  71. package/src/domains/workflows/runtime/research-contract.ts +89 -0
  72. package/src/domains/workflows/runtime/research-evidence.ts +289 -0
  73. package/src/domains/workflows/runtime/resume-context.ts +751 -0
  74. package/src/domains/workflows/runtime/review/code-review-orchestration.ts +24 -0
  75. package/src/domains/workflows/runtime/review/github-pr-comments.ts +249 -0
  76. package/src/domains/workflows/runtime/review/patch-validation.ts +120 -0
  77. package/src/domains/workflows/runtime/review/review-actions.ts +180 -0
  78. package/src/domains/workflows/runtime/review/review-budget.ts +45 -0
  79. package/src/domains/workflows/runtime/review/review-fix-workflow.ts +153 -0
  80. package/src/domains/workflows/runtime/review/review-format.ts +133 -0
  81. package/src/domains/workflows/runtime/review/review-handoff.ts +38 -0
  82. package/src/domains/workflows/runtime/review/review-issues.ts +87 -0
  83. package/src/domains/workflows/runtime/review/review-report.ts +42 -0
  84. package/src/domains/workflows/runtime/review/review-results-flow.ts +52 -0
  85. package/src/domains/workflows/runtime/review/review-results-viewer.ts +312 -0
  86. package/src/domains/workflows/runtime/review/review-session-coordinator.ts +153 -0
  87. package/src/domains/workflows/runtime/review/review-snapshot.ts +294 -0
  88. package/src/domains/workflows/runtime/review-diff-target.ts +130 -0
  89. package/src/domains/workflows/runtime/session-identity.ts +6 -0
  90. package/src/domains/workflows/runtime/structured-output.ts +32 -0
  91. package/src/domains/workflows/runtime/tool-capabilities.ts +44 -0
  92. package/src/domains/workflows/runtime/tool-source-identity.ts +150 -0
  93. package/src/domains/workflows/runtime/tree-fingerprint.ts +404 -0
  94. package/src/domains/workflows/runtime/types.ts +255 -0
  95. package/src/domains/workflows/runtime/ui/display-text.ts +13 -0
  96. package/src/domains/workflows/runtime/ui/dynamax-editor-decoration.ts +220 -0
  97. package/src/domains/workflows/runtime/ui/workflow-format.ts +165 -0
  98. package/src/domains/workflows/runtime/ui/workflow-inspector.ts +398 -0
  99. package/src/domains/workflows/runtime/ui/workflow-result-renderer.ts +171 -0
  100. package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +51 -0
  101. package/src/domains/workflows/runtime/ui/workflow-widget.ts +78 -0
  102. package/src/domains/workflows/runtime/unknown-error.ts +8 -0
  103. package/src/domains/workflows/runtime/usage.ts +341 -0
  104. package/src/domains/workflows/runtime/workflow-advisory-utils.ts +245 -0
  105. package/src/domains/workflows/runtime/workflow-execution.ts +121 -0
  106. package/src/domains/workflows/runtime/workflow-module.ts +75 -0
  107. package/src/domains/workflows/runtime/workflow-run-background.ts +72 -0
  108. package/src/domains/workflows/runtime/workflow-run-controller.ts +342 -0
  109. package/src/domains/workflows/runtime/workflow-run-history.ts +162 -0
  110. package/src/domains/workflows/runtime/workflow-run-record.ts +635 -0
  111. package/src/domains/workflows/runtime/workflow-run-store.ts +176 -0
  112. package/src/domains/workflows/runtime/workflow-usage-limit-scheduler.ts +80 -0
  113. package/src/domains/workflows/runtime/workflows.ts +40 -0
  114. package/src/domains/workflows/runtime/worktree.ts +581 -0
  115. package/src/domains/workflows/workflows/code-review.ts +232 -0
  116. package/src/domains/workflows/workflows/diagnose.ts +154 -0
  117. package/src/domains/workflows/workflows/perf-review.ts +149 -0
  118. package/src/domains/workflows/workflows/refactor-scout.ts +143 -0
  119. package/src/domains/workflows/workflows/research.ts +169 -0
  120. package/src/services/usage-service.ts +6 -30
@@ -0,0 +1,103 @@
1
+ import type { AgentExecutionOptions, AgentProgress } from "./agent-runner-types.ts";
2
+ import { unknownErrorMessage } from "./unknown-error.ts";
3
+ import type { WorktreeRegistry } from "./worktree.ts";
4
+
5
+ interface AgentWorkspaceBase {
6
+ readonly cwd: string;
7
+ wrapResult(result: unknown): Promise<unknown>;
8
+ dispose(): Promise<void>;
9
+ }
10
+
11
+ export interface SharedAgentWorkspace extends AgentWorkspaceBase {
12
+ readonly kind: "shared";
13
+ }
14
+
15
+ export interface IsolatedAgentWorkspace extends AgentWorkspaceBase {
16
+ readonly kind: "isolated";
17
+ readonly baselineOid: string;
18
+ }
19
+
20
+ export type AgentWorkspace = SharedAgentWorkspace | IsolatedAgentWorkspace;
21
+
22
+ export interface AgentWorkspaceContext {
23
+ readonly cwd: string;
24
+ readonly worktrees: Pick<WorktreeRegistry, "probe" | "add" | "capturePatch" | "applyPatch" | "remove">;
25
+ readonly signal: AbortSignal | undefined;
26
+ readonly progress: Pick<AgentProgress, "log">;
27
+ }
28
+
29
+ export async function createAgentWorkspace(
30
+ rc: AgentWorkspaceContext,
31
+ opts: AgentExecutionOptions,
32
+ label: string,
33
+ ): Promise<AgentWorkspace> {
34
+ if (opts.candidatePatch && opts.isolation !== "worktree") throw new Error("Candidate evaluation requires worktree isolation");
35
+ if (opts.isolation !== "worktree") {
36
+ return {
37
+ kind: "shared",
38
+ cwd: rc.cwd,
39
+ async wrapResult(result) {
40
+ return result;
41
+ },
42
+ async dispose() {},
43
+ };
44
+ }
45
+
46
+ const probe = await rc.worktrees.probe(rc.signal);
47
+ if (!probe.ok) {
48
+ throw new Error(`Failed to check git worktree availability for isolated agent: ${probe.error ?? "unknown git error"}`);
49
+ }
50
+ if (!probe.inside) {
51
+ throw new Error("Agent requested worktree isolation, but the workflow cwd is not inside a git work tree.");
52
+ }
53
+
54
+ const added = await rc.worktrees.add(rc.signal, opts.worktreeBaseline);
55
+ if ("error" in added) throw new Error(`Failed to create isolated worktree: ${added.error}`);
56
+ const worktreePath = added.path;
57
+ if (opts.candidatePatch) {
58
+ try {
59
+ if (opts.candidatePatch.baselineOid !== added.baselineOid) throw new Error("Candidate baseline differs from evaluator baseline");
60
+ if (opts.candidatePatch.patch.trim()) {
61
+ const applied = await rc.worktrees.applyPatch(worktreePath, opts.candidatePatch.patch, rc.signal);
62
+ if (!applied.ok) throw new Error(`Candidate patch could not be applied: ${applied.error ?? applied.stderr}`);
63
+ }
64
+ } catch (error) {
65
+ const removed = await rc.worktrees.remove(worktreePath);
66
+ if (!removed.ok) rc.progress.log(`${label}: evaluator setup cleanup failed: ${removed.error ?? removed.stderr}`);
67
+ throw error;
68
+ }
69
+ }
70
+ rc.progress.log(`${label}: using isolated worktree ${worktreePath}`);
71
+
72
+ return {
73
+ kind: "isolated",
74
+ cwd: worktreePath,
75
+ baselineOid: added.baselineOid,
76
+ async wrapResult(result) {
77
+ const patch = await rc.worktrees.capturePatch(worktreePath, added.baselineOid, rc.signal);
78
+ if ("error" in patch) throw new Error(`Failed to capture isolated worktree patch: ${patch.error}`);
79
+ return { result, patch: patch.patch, changed: patch.changed, baselineOid: added.baselineOid };
80
+ },
81
+ async dispose() {
82
+ const removed = await rc.worktrees.remove(worktreePath);
83
+ if (!removed.ok) {
84
+ rc.progress.log(
85
+ `${label}: failed to remove isolated worktree (${removed.error ?? (removed.stderr.trim() || "unknown error")})`,
86
+ );
87
+ }
88
+ },
89
+ };
90
+ }
91
+
92
+ export async function disposeAgentWorkspace(
93
+ rc: Pick<AgentWorkspaceContext, "progress">,
94
+ label: string,
95
+ workspace: AgentWorkspace | undefined,
96
+ ): Promise<void> {
97
+ if (!workspace) return;
98
+ try {
99
+ await workspace.dispose();
100
+ } catch (error) {
101
+ rc.progress.log(`${label}: failed to dispose isolated workspace (${unknownErrorMessage(error)})`);
102
+ }
103
+ }
@@ -0,0 +1,75 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { BackgroundWorkflowCoordinator, backgroundOrigin } from "./background-workflows.ts";
3
+ import { createWorkflowRunId } from "./journal.ts";
4
+ import type { ResolvedWorkflowRunOptions } from "./options.ts";
5
+ import { unknownErrorMessage } from "./unknown-error.ts";
6
+
7
+ export interface BackgroundWorkflowToolResult {
8
+ readonly content: Array<{ readonly type: "text"; readonly text: string }>;
9
+ readonly details: Readonly<Record<string, unknown>>;
10
+ }
11
+
12
+ export interface BackgroundWorkflowToolStartInput {
13
+ readonly coordinator: BackgroundWorkflowCoordinator;
14
+ readonly ctx: ExtensionContext;
15
+ readonly name: string;
16
+ readonly options: ResolvedWorkflowRunOptions;
17
+ readonly execute: (ctx: ExtensionContext, options: ResolvedWorkflowRunOptions) => Promise<void>;
18
+ }
19
+
20
+ export function backgroundUnavailableResult(mode: ExtensionContext["mode"]): BackgroundWorkflowToolResult | undefined {
21
+ if (mode !== "print" && mode !== "json") return undefined;
22
+ return {
23
+ content: [{
24
+ type: "text",
25
+ text: `Background workflows are unavailable in ${mode} mode because pi exits after the prompt. Run synchronously or use TUI/RPC mode.`,
26
+ }],
27
+ details: { error: "background_unavailable", mode },
28
+ };
29
+ }
30
+
31
+ export async function startBackgroundWorkflowTool(
32
+ input: BackgroundWorkflowToolStartInput,
33
+ ): Promise<BackgroundWorkflowToolResult> {
34
+ const runId = createWorkflowRunId();
35
+ const backgroundOptions: ResolvedWorkflowRunOptions = {
36
+ ...input.options,
37
+ inspect: false,
38
+ resultViewer: "skip",
39
+ runId,
40
+ background: backgroundOrigin(input.ctx),
41
+ };
42
+ try {
43
+ await input.coordinator.start({
44
+ ctx: input.ctx,
45
+ runId,
46
+ name: input.name,
47
+ run: async (signal, onStarted) => {
48
+ await input.execute(
49
+ { ...input.ctx, signal },
50
+ {
51
+ ...backgroundOptions,
52
+ signal,
53
+ onRunMetadata(metadata) {
54
+ onStarted();
55
+ return backgroundOptions.onRunMetadata?.(metadata);
56
+ },
57
+ },
58
+ );
59
+ },
60
+ });
61
+ } catch (error) {
62
+ const message = unknownErrorMessage(error);
63
+ return {
64
+ content: [{ type: "text", text: `Background workflow did not start: ${message}` }],
65
+ details: { error: "background_start_failed", message, runId },
66
+ };
67
+ }
68
+ return {
69
+ content: [{
70
+ type: "text",
71
+ text: `Background workflow "${input.name}" started.\nRun ID: ${runId}\nDurable record: .pi/.workflow-runs/${runId}.run.json`,
72
+ }],
73
+ details: { background: true, state: "running", name: input.name, runId },
74
+ };
75
+ }
@@ -0,0 +1,492 @@
1
+ import { SessionManager, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { WorkflowAbortError, WorkflowPauseError } from "./cancellation.ts";
3
+ import type { WorkflowBackgroundOrigin } from "./types.ts";
4
+ import {
5
+ transitionWorkflowRun,
6
+ type WorkflowRunRecord,
7
+ type WorkflowRunState,
8
+ } from "./workflow-run-record.ts";
9
+ import { updateWorkflowRunDelivery } from "./workflow-run-background.ts";
10
+ import { ProjectWorkflowRunStore, type WorkflowRunStore } from "./workflow-run-store.ts";
11
+ import { unknownErrorMessage } from "./unknown-error.ts";
12
+ import { emptyWorkflowUsageTotals } from "./usage.ts";
13
+
14
+ const BACKGROUND_DELIVERY_CUSTOM_TYPE = "workflow-result";
15
+ const BACKGROUND_WIDGET_KEY = "workflow-background";
16
+ const SHUTDOWN_WAIT_MS = 5_000;
17
+ const SUMMARY_LIMIT = 500;
18
+
19
+ export interface BackgroundWorkflowStartInput {
20
+ readonly ctx: ExtensionContext;
21
+ readonly runId: string;
22
+ readonly name: string;
23
+ readonly run: (signal: AbortSignal, onStarted: () => void) => Promise<void>;
24
+ }
25
+
26
+ export interface BackgroundWorkflowResultDetails {
27
+ readonly name: string;
28
+ readonly result: { readonly summary: string };
29
+ readonly completedAt: number;
30
+ readonly usage: WorkflowRunRecord["usage"];
31
+ readonly runId: string;
32
+ readonly resumedFromRunId?: string;
33
+ readonly background: true;
34
+ readonly status: WorkflowRunState;
35
+ }
36
+
37
+ type SessionAvailability = "available" | "missing" | "unknown";
38
+
39
+ interface BackgroundWorkflowDependencies {
40
+ readonly storeForCwd?: (cwd: string) => WorkflowRunStore;
41
+ readonly sessionAvailability?: (cwd: string, sessionId: string) => Promise<SessionAvailability>;
42
+ readonly log?: (message: string) => void;
43
+ readonly shutdownWaitMs?: number;
44
+ }
45
+
46
+ interface ActiveBackgroundRun {
47
+ readonly controller: AbortController;
48
+ readonly name: string;
49
+ readonly sessionId: string;
50
+ readonly settled: Promise<void>;
51
+ readonly isSettled: () => boolean;
52
+ }
53
+
54
+ type BackgroundWorkflowSettledListener = (ctx: ExtensionContext, runId: string) => void | Promise<void>;
55
+
56
+ /** Owns background runs for one extension instance and routes their durable completion delivery. */
57
+ export class BackgroundWorkflowCoordinator {
58
+ private readonly active = new Map<string, ActiveBackgroundRun>();
59
+ private readonly settledListeners = new Set<BackgroundWorkflowSettledListener>();
60
+ private readonly pendingDelivery = new Map<string, Set<string>>();
61
+ private readonly shuttingDown = new Set<string>();
62
+ private readonly storeForCwd: (cwd: string) => WorkflowRunStore;
63
+ private readonly sessionAvailability: (cwd: string, sessionId: string) => Promise<SessionAvailability>;
64
+ private readonly log: (message: string) => void;
65
+ private readonly shutdownWaitMs: number;
66
+
67
+ constructor(
68
+ private readonly pi: Pick<ExtensionAPI, "sendMessage">,
69
+ dependencies: BackgroundWorkflowDependencies = {},
70
+ ) {
71
+ this.storeForCwd = dependencies.storeForCwd ?? ((cwd) => new ProjectWorkflowRunStore(cwd));
72
+ this.sessionAvailability = dependencies.sessionAvailability ?? defaultSessionAvailability;
73
+ this.log = dependencies.log ?? ((message) => process.stderr.write(`${message}\n`));
74
+ this.shutdownWaitMs = dependencies.shutdownWaitMs ?? SHUTDOWN_WAIT_MS;
75
+ }
76
+
77
+ async start(input: BackgroundWorkflowStartInput): Promise<void> {
78
+ if (this.active.has(input.runId)) throw new Error(`Background workflow ${input.runId} is already active.`);
79
+
80
+ const sessionId = input.ctx.sessionManager.getSessionId();
81
+ const controller = new AbortController();
82
+ let accepted = false;
83
+ let deliveryScheduled = false;
84
+ let settled = false;
85
+ let startedSignalled = false;
86
+ let resolveStarted: (() => void) | undefined;
87
+ let rejectStarted: ((error: unknown) => void) | undefined;
88
+ const started = new Promise<void>((resolve, reject) => {
89
+ resolveStarted = resolve;
90
+ rejectStarted = reject;
91
+ });
92
+
93
+ const scheduleDelivery = (): void => {
94
+ if (deliveryScheduled || !accepted || !settled || this.shuttingDown.has(sessionId)) return;
95
+ deliveryScheduled = true;
96
+ void this.queueOrDeliver(input.ctx, input.runId).catch((error: unknown) => {
97
+ this.log(`[workflow:${input.runId}] background delivery failed: ${unknownErrorMessage(error)}`);
98
+ });
99
+ };
100
+
101
+ const settledPromise = (async () => {
102
+ try {
103
+ await input.run(controller.signal, () => {
104
+ if (startedSignalled) return;
105
+ startedSignalled = true;
106
+ resolveStarted?.();
107
+ });
108
+ if (!startedSignalled) rejectStarted?.(new Error("Background workflow ended before publishing run metadata."));
109
+ } catch (error) {
110
+ if (!startedSignalled) rejectStarted?.(error);
111
+ } finally {
112
+ settled = true;
113
+ this.active.delete(input.runId);
114
+ this.updateBackgroundSurface(input.ctx);
115
+ await this.notifyRunSettled(input.ctx, input.runId);
116
+ scheduleDelivery();
117
+ }
118
+ })();
119
+ this.active.set(input.runId, {
120
+ controller,
121
+ name: input.name,
122
+ sessionId,
123
+ settled: settledPromise,
124
+ isSettled: () => settled,
125
+ });
126
+
127
+ await started;
128
+ const record = await this.storeForCwd(input.ctx.cwd).load(input.runId);
129
+ if (record?.state !== "running" || !record.background || record.background.origin.sessionId !== sessionId) {
130
+ controller.abort(new WorkflowAbortError("Background workflow could not persist its origin metadata."));
131
+ throw new Error(`Background workflow ${input.runId} did not create a durable run record.`);
132
+ }
133
+
134
+ accepted = true;
135
+ this.updateBackgroundSurface(input.ctx);
136
+ scheduleDelivery();
137
+ }
138
+
139
+ activeRunIds(ctx: Pick<ExtensionContext, "sessionManager">): ReadonlySet<string> {
140
+ const sessionId = ctx.sessionManager.getSessionId();
141
+ return new Set(
142
+ [...this.active.entries()]
143
+ .filter(([, run]) => run.sessionId === sessionId)
144
+ .map(([runId]) => runId),
145
+ );
146
+ }
147
+
148
+ onRunSettled(listener: BackgroundWorkflowSettledListener): () => void {
149
+ this.settledListeners.add(listener);
150
+ return () => this.settledListeners.delete(listener);
151
+ }
152
+
153
+ async stop(ctx: ExtensionContext, runId: string): Promise<WorkflowRunRecord> {
154
+ const active = this.active.get(runId);
155
+ if (!active || active.sessionId !== ctx.sessionManager.getSessionId()) {
156
+ throw new Error(`Workflow run ${runId} is not active in this session.`);
157
+ }
158
+ active.controller.abort(new WorkflowAbortError("Workflow stopped by user."));
159
+ await waitForRuns([active.settled], this.shutdownWaitMs);
160
+ const store = this.storeForCwd(ctx.cwd);
161
+ if (!active.isSettled()) {
162
+ await forceStoppedRecord(store, runId);
163
+ this.log(`[workflow:${runId}] background workflow did not settle after stop; retained state was forced to stopped.`);
164
+ }
165
+ const record = await store.load(runId);
166
+ if (!record) throw new Error(`Workflow run ${runId} was not found after stopping.`);
167
+ if (record.state !== "stopped") throw new Error(`Workflow run ${runId} settled as ${record.state} instead of stopped.`);
168
+ return record;
169
+ }
170
+
171
+ async agentSettled(ctx: ExtensionContext): Promise<void> {
172
+ await this.flushPending(ctx);
173
+ }
174
+
175
+ async durableRunSettled(ctx: ExtensionContext, runId: string): Promise<void> {
176
+ await this.queueOrDeliver(ctx, runId);
177
+ }
178
+
179
+ async sessionStarted(ctx: ExtensionContext): Promise<void> {
180
+ const store = this.storeForCwd(ctx.cwd);
181
+ let records: WorkflowRunRecord[];
182
+ try {
183
+ records = await store.list();
184
+ } catch (error) {
185
+ this.log(`[workflow] background recovery could not load run history: ${unknownErrorMessage(error)}`);
186
+ return;
187
+ }
188
+ const sessionId = ctx.sessionManager.getSessionId();
189
+ const availability = new Map<string, SessionAvailability>();
190
+
191
+ for (const loadedRecord of records) {
192
+ let record = loadedRecord;
193
+ try {
194
+ record = await reconcileInterruptedRun(
195
+ store,
196
+ record,
197
+ sessionId,
198
+ this.active.has(record.runId),
199
+ );
200
+ if (!isPendingBackgroundOutcome(record)) continue;
201
+ const originSessionId = record.background.origin.sessionId;
202
+ if (originSessionId === sessionId) {
203
+ await this.queueOrDeliver(ctx, record.runId);
204
+ continue;
205
+ }
206
+
207
+ let status = availability.get(originSessionId);
208
+ if (!status) {
209
+ status = await this.sessionAvailability(ctx.cwd, originSessionId);
210
+ availability.set(originSessionId, status);
211
+ }
212
+ if (status !== "missing") continue;
213
+
214
+ const message = `Originating pi session ${originSessionId} is unavailable; result remains in workflow run history.`;
215
+ await markDelivery(store, record.runId, {
216
+ state: "unavailable",
217
+ attemptedAt: Date.now(),
218
+ message,
219
+ });
220
+ this.log(`[workflow:${record.runId}] ${message}`);
221
+ } catch (error) {
222
+ this.log(`[workflow:${record.runId}] background recovery failed: ${unknownErrorMessage(error)}`);
223
+ }
224
+ }
225
+ }
226
+
227
+ async sessionShutdown(ctx: ExtensionContext): Promise<void> {
228
+ const sessionId = ctx.sessionManager.getSessionId();
229
+ this.shuttingDown.add(sessionId);
230
+ this.pendingDelivery.delete(sessionId);
231
+ const runs = [...this.active.entries()].filter(([, run]) => run.sessionId === sessionId);
232
+ for (const [, run] of runs) {
233
+ run.controller.abort(new WorkflowPauseError());
234
+ }
235
+ await waitForRuns(runs.map(([, run]) => run.settled), this.shutdownWaitMs);
236
+ const store = this.storeForCwd(ctx.cwd);
237
+ for (const [runId, run] of runs) {
238
+ if (run.isSettled()) continue;
239
+ try {
240
+ await forcePausedRecord(store, runId);
241
+ this.log(`[workflow:${runId}] background workflow did not settle during shutdown; retained state was forced to paused.`);
242
+ } catch (error) {
243
+ this.log(`[workflow:${runId}] failed to force paused state during shutdown: ${unknownErrorMessage(error)}`);
244
+ }
245
+ }
246
+ if (ctx.hasUI) ctx.ui.setWidget(BACKGROUND_WIDGET_KEY, undefined);
247
+ }
248
+
249
+ private async queueOrDeliver(ctx: ExtensionContext, runId: string): Promise<void> {
250
+ const sessionId = ctx.sessionManager.getSessionId();
251
+ if (this.shuttingDown.has(sessionId)) return;
252
+ if (ctx.isIdle()) {
253
+ try {
254
+ if (await this.deliver(ctx, runId)) return;
255
+ } catch (error) {
256
+ this.addPending(sessionId, runId);
257
+ throw error;
258
+ }
259
+ }
260
+ this.addPending(sessionId, runId);
261
+ if (ctx.isIdle()) await this.flushPending(ctx);
262
+ }
263
+
264
+ private async notifyRunSettled(ctx: ExtensionContext, runId: string): Promise<void> {
265
+ for (const listener of this.settledListeners) {
266
+ try {
267
+ await listener(ctx, runId);
268
+ } catch (error) {
269
+ this.log(`[workflow:${runId}] background settlement listener failed: ${unknownErrorMessage(error)}`);
270
+ }
271
+ }
272
+ }
273
+
274
+ private async flushPending(ctx: ExtensionContext): Promise<void> {
275
+ const sessionId = ctx.sessionManager.getSessionId();
276
+ if (this.shuttingDown.has(sessionId)) return;
277
+ const pending = this.pendingDelivery.get(sessionId);
278
+ if (!pending) return;
279
+ for (const runId of pending) {
280
+ try {
281
+ if (await this.deliver(ctx, runId)) pending.delete(runId);
282
+ } catch (error) {
283
+ this.log(`[workflow:${runId}] background delivery retry failed: ${unknownErrorMessage(error)}`);
284
+ }
285
+ }
286
+ if (pending.size === 0) this.pendingDelivery.delete(sessionId);
287
+ }
288
+
289
+ private addPending(sessionId: string, runId: string): void {
290
+ const pending = this.pendingDelivery.get(sessionId) ?? new Set<string>();
291
+ pending.add(runId);
292
+ this.pendingDelivery.set(sessionId, pending);
293
+ }
294
+
295
+ private updateBackgroundSurface(ctx: ExtensionContext): void {
296
+ if (!ctx.hasUI) return;
297
+ const sessionId = ctx.sessionManager.getSessionId();
298
+ const runs = [...this.active.entries()]
299
+ .filter(([, run]) => run.sessionId === sessionId)
300
+ .map(([runId, run]) => ({ runId, name: run.name }));
301
+ if (runs.length === 0) {
302
+ ctx.ui.setWidget(BACKGROUND_WIDGET_KEY, undefined);
303
+ return;
304
+ }
305
+ ctx.ui.setWidget(
306
+ BACKGROUND_WIDGET_KEY,
307
+ [formatBackgroundActivity(runs, ctx.ui.theme)],
308
+ { placement: "aboveEditor" },
309
+ );
310
+ }
311
+
312
+ private async deliver(ctx: ExtensionContext, runId: string): Promise<boolean> {
313
+ const store = this.storeForCwd(ctx.cwd);
314
+ const record = await store.load(runId);
315
+ if (!record?.background || record.background.delivery.state !== "pending") return true;
316
+ if (record.background.origin.sessionId !== ctx.sessionManager.getSessionId()) return false;
317
+ if (!isDeliverableState(record.state)) return false;
318
+
319
+ if (!sessionHasBackgroundDelivery(ctx, runId)) {
320
+ const details = backgroundResultDetails(record);
321
+ this.pi.sendMessage(
322
+ {
323
+ customType: BACKGROUND_DELIVERY_CUSTOM_TYPE,
324
+ content: formatBackgroundDelivery(details),
325
+ display: true,
326
+ details,
327
+ },
328
+ { triggerTurn: false },
329
+ );
330
+ }
331
+ await markDelivery(store, runId, { state: "delivered", deliveredAt: Date.now() });
332
+ return true;
333
+ }
334
+ }
335
+
336
+ function formatBackgroundActivity(
337
+ runs: readonly { readonly runId: string; readonly name: string }[],
338
+ theme: ExtensionContext["ui"]["theme"],
339
+ ): string {
340
+ const visible = runs.slice(0, 2).map((run) => `${run.name} ${run.runId.slice(0, 8)}`);
341
+ const hidden = runs.length - visible.length;
342
+ const suffix = hidden > 0 ? ` · +${hidden} more` : "";
343
+ return `${theme.fg("accent", "●")} ${theme.bold("Background workflows")} ${theme.fg("dim", `· ${visible.join(" · ")}${suffix}`)}`;
344
+ }
345
+
346
+ export function backgroundOrigin(ctx: Pick<ExtensionContext, "sessionManager">, requestedAt = Date.now()): WorkflowBackgroundOrigin {
347
+ return { sessionId: ctx.sessionManager.getSessionId(), requestedAt };
348
+ }
349
+
350
+ export function backgroundResultDetails(record: WorkflowRunRecord): BackgroundWorkflowResultDetails {
351
+ if (!isDeliverableState(record.state)) throw new Error(`Workflow run ${record.runId} has not finished or paused.`);
352
+ return {
353
+ name: record.workflow.name,
354
+ result: { summary: backgroundSummary(record) },
355
+ completedAt: record.endedAt ?? record.updatedAt,
356
+ usage: record.usage,
357
+ runId: record.runId,
358
+ resumedFromRunId: record.options.resumeFromRunId,
359
+ background: true,
360
+ status: record.state,
361
+ };
362
+ }
363
+
364
+ function backgroundSummary(record: WorkflowRunRecord): string {
365
+ if (record.state !== "completed") return boundedSummary(`Workflow ${record.state}: ${record.message}`);
366
+ if (record.result.kind === "unavailable") {
367
+ return boundedSummary(`Workflow completed; retained result is unavailable: ${record.result.reason}`);
368
+ }
369
+ const value = record.result.value;
370
+ if (typeof value === "string") return boundedSummary(value);
371
+ if (isRecord(value) && typeof value.summary === "string") return boundedSummary(value.summary);
372
+ return "Workflow completed. Open run history for the retained result.";
373
+ }
374
+
375
+ function formatBackgroundDelivery(details: BackgroundWorkflowResultDetails): string {
376
+ return [
377
+ `## Background workflow: ${details.name}`,
378
+ "",
379
+ `Run ID: ${details.runId}`,
380
+ `State: ${details.status}`,
381
+ "",
382
+ details.result.summary,
383
+ ].join("\n");
384
+ }
385
+
386
+ function sessionHasBackgroundDelivery(ctx: ExtensionContext, runId: string): boolean {
387
+ for (const entry of ctx.sessionManager.getEntries()) {
388
+ if (entry.type !== "message" || entry.message.role !== "custom") continue;
389
+ if (entry.message.customType !== BACKGROUND_DELIVERY_CUSTOM_TYPE) continue;
390
+ const details = entry.message.details;
391
+ if (isRecord(details) && details.background === true && details.runId === runId) return true;
392
+ }
393
+ return false;
394
+ }
395
+
396
+ function isPendingBackgroundOutcome(record: WorkflowRunRecord): record is WorkflowRunRecord & { readonly background: NonNullable<WorkflowRunRecord["background"]> } {
397
+ return record.background?.delivery.state === "pending" && isDeliverableState(record.state);
398
+ }
399
+
400
+ function isDeliverableState(state: WorkflowRunState): state is "completed" | "failed" | "stopped" | "paused" {
401
+ return state === "completed" || state === "failed" || state === "stopped" || state === "paused";
402
+ }
403
+
404
+ async function markDelivery(
405
+ store: WorkflowRunStore,
406
+ runId: string,
407
+ delivery: Parameters<typeof updateWorkflowRunDelivery>[1],
408
+ ): Promise<void> {
409
+ const latest = await store.load(runId);
410
+ if (!latest?.background || latest.background.delivery.state !== "pending") return;
411
+ await store.save(updateWorkflowRunDelivery(latest, delivery));
412
+ }
413
+
414
+ async function forcePausedRecord(store: WorkflowRunStore, runId: string): Promise<void> {
415
+ const record = await store.load(runId);
416
+ if (!record || (record.state !== "queued" && record.state !== "running")) return;
417
+ await store.save(transitionWorkflowRun(record, {
418
+ state: "paused",
419
+ progress: record.progress,
420
+ message: "Workflow paused because its host session shut down",
421
+ }));
422
+ }
423
+
424
+ async function forceStoppedRecord(store: WorkflowRunStore, runId: string): Promise<void> {
425
+ const record = await store.load(runId);
426
+ if (!record || (record.state !== "queued" && record.state !== "running")) return;
427
+ await store.save(transitionWorkflowRun(record, {
428
+ state: "stopped",
429
+ progress: record.progress,
430
+ usage: record.usage ?? record.progress.usage ?? {
431
+ agents: [],
432
+ totals: emptyWorkflowUsageTotals(),
433
+ assistantMessages: 0,
434
+ },
435
+ error: new WorkflowAbortError("Workflow stopped by user."),
436
+ }));
437
+ }
438
+
439
+ async function reconcileInterruptedRun(
440
+ store: WorkflowRunStore,
441
+ record: WorkflowRunRecord,
442
+ sessionId: string,
443
+ active: boolean,
444
+ ): Promise<WorkflowRunRecord> {
445
+ if (
446
+ active
447
+ || record.background?.delivery.state !== "pending"
448
+ || record.background.origin.sessionId !== sessionId
449
+ || (record.state !== "queued" && record.state !== "running")
450
+ ) {
451
+ return record;
452
+ }
453
+ const paused = transitionWorkflowRun(record, {
454
+ state: "paused",
455
+ progress: record.progress,
456
+ message: "Workflow paused because its host process ended before completion",
457
+ });
458
+ await store.save(paused);
459
+ return paused;
460
+ }
461
+
462
+ async function defaultSessionAvailability(cwd: string, sessionId: string): Promise<SessionAvailability> {
463
+ try {
464
+ const sessions = await SessionManager.list(cwd);
465
+ return sessions.some((session) => session.id === sessionId) ? "available" : "missing";
466
+ } catch {
467
+ return "unknown";
468
+ }
469
+ }
470
+
471
+ async function waitForRuns(runs: readonly Promise<void>[], timeoutMs: number): Promise<void> {
472
+ if (runs.length === 0) return;
473
+ let timer: ReturnType<typeof setTimeout> | undefined;
474
+ try {
475
+ await Promise.race([
476
+ Promise.allSettled(runs),
477
+ new Promise<void>((resolve) => {
478
+ timer = setTimeout(resolve, timeoutMs);
479
+ }),
480
+ ]);
481
+ } finally {
482
+ if (timer) clearTimeout(timer);
483
+ }
484
+ }
485
+
486
+ function boundedSummary(value: string): string {
487
+ return value.length <= SUMMARY_LIMIT ? value : `${value.slice(0, SUMMARY_LIMIT - 1)}…`;
488
+ }
489
+
490
+ function isRecord(value: unknown): value is Record<string, unknown> {
491
+ return typeof value === "object" && value !== null && !Array.isArray(value);
492
+ }