@jameslovespancakes/pi-plus 1.0.11 → 1.0.13

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 (133) hide show
  1. package/README.md +67 -22
  2. package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
  3. package/package.json +9 -4
  4. package/src/core/accounts/oauth-pool.ts +137 -0
  5. package/src/core/accounts/registry.ts +12 -25
  6. package/src/core/accounts/routing.ts +144 -0
  7. package/src/core/anthropic/client-identity.ts +9 -64
  8. package/src/core/anthropic/identity.ts +54 -0
  9. package/src/core/anthropic/oauth.ts +3 -0
  10. package/src/core/anthropic/quota.ts +232 -78
  11. package/src/core/anthropic/routing.ts +40 -115
  12. package/src/core/anthropic/store.ts +11 -10
  13. package/src/core/catalog/quality.ts +33 -15
  14. package/src/core/codex/quota.ts +46 -8
  15. package/src/core/codex/store.ts +29 -16
  16. package/src/core/config.ts +16 -17
  17. package/src/core/store.ts +13 -12
  18. package/src/domains/agents/format.ts +97 -0
  19. package/src/domains/agents/index.ts +58 -47
  20. package/src/domains/compact/archive.ts +140 -0
  21. package/src/domains/compact/chunking.ts +166 -0
  22. package/src/domains/compact/index.ts +452 -0
  23. package/src/domains/compact/jev.ts +242 -0
  24. package/src/domains/compact/policy.ts +255 -0
  25. package/src/domains/compact/types.ts +79 -0
  26. package/src/domains/models/provider-picker.ts +1 -1
  27. package/src/domains/setup/index.ts +23 -23
  28. package/src/domains/subscriptions/accounts-picker.ts +61 -40
  29. package/src/domains/subscriptions/accounts.ts +53 -36
  30. package/src/domains/subscriptions/footer.ts +48 -29
  31. package/src/domains/subscriptions/index.ts +11 -27
  32. package/src/domains/subscriptions/provider.ts +79 -103
  33. package/src/domains/subscriptions/providers/anthropic.ts +42 -22
  34. package/src/domains/subscriptions/providers/codex.ts +146 -113
  35. package/src/domains/subscriptions/providers/hosted.ts +18 -0
  36. package/src/domains/subscriptions/providers/oauth-pool.ts +325 -0
  37. package/src/domains/subscriptions/routing.ts +19 -11
  38. package/src/domains/workflows/LICENSE.md +21 -0
  39. package/src/domains/workflows/index.ts +836 -0
  40. package/src/domains/workflows/runtime/advisory-challenge.ts +75 -0
  41. package/src/domains/workflows/runtime/advisory-evidence.ts +90 -0
  42. package/src/domains/workflows/runtime/advisory-schema.ts +83 -0
  43. package/src/domains/workflows/runtime/agent-attempt.ts +177 -0
  44. package/src/domains/workflows/runtime/agent-failure.ts +14 -0
  45. package/src/domains/workflows/runtime/agent-limits.ts +66 -0
  46. package/src/domains/workflows/runtime/agent-replay.ts +399 -0
  47. package/src/domains/workflows/runtime/agent-retry.ts +116 -0
  48. package/src/domains/workflows/runtime/agent-runner-types.ts +96 -0
  49. package/src/domains/workflows/runtime/agent-runner.ts +225 -0
  50. package/src/domains/workflows/runtime/agent-session-identity.ts +256 -0
  51. package/src/domains/workflows/runtime/agent-session-providers.ts +50 -0
  52. package/src/domains/workflows/runtime/agent-session.ts +382 -0
  53. package/src/domains/workflows/runtime/agent-skills.ts +270 -0
  54. package/src/domains/workflows/runtime/agent-workspace.ts +79 -0
  55. package/src/domains/workflows/runtime/background-workflow-tool.ts +75 -0
  56. package/src/domains/workflows/runtime/background-workflows.ts +492 -0
  57. package/src/domains/workflows/runtime/budget.ts +53 -0
  58. package/src/domains/workflows/runtime/cancellation.ts +87 -0
  59. package/src/domains/workflows/runtime/command-completions.ts +36 -0
  60. package/src/domains/workflows/runtime/concurrency.ts +403 -0
  61. package/src/domains/workflows/runtime/debug.ts +3 -0
  62. package/src/domains/workflows/runtime/diff-capture.ts +81 -0
  63. package/src/domains/workflows/runtime/discovery.ts +137 -0
  64. package/src/domains/workflows/runtime/dynamax-shortcuts.ts +122 -0
  65. package/src/domains/workflows/runtime/dynamax.ts +330 -0
  66. package/src/domains/workflows/runtime/engine.ts +579 -0
  67. package/src/domains/workflows/runtime/filesystem-error.ts +4 -0
  68. package/src/domains/workflows/runtime/finalizers.ts +66 -0
  69. package/src/domains/workflows/runtime/identity-canonicalization.ts +138 -0
  70. package/src/domains/workflows/runtime/identity-fingerprint.ts +15 -0
  71. package/src/domains/workflows/runtime/inline-workflow.ts +403 -0
  72. package/src/domains/workflows/runtime/journal.ts +313 -0
  73. package/src/domains/workflows/runtime/model-profiles.ts +310 -0
  74. package/src/domains/workflows/runtime/options.ts +157 -0
  75. package/src/domains/workflows/runtime/perf.ts +146 -0
  76. package/src/domains/workflows/runtime/pi-compat.ts +32 -0
  77. package/src/domains/workflows/runtime/process-runner.ts +260 -0
  78. package/src/domains/workflows/runtime/progress-types.ts +59 -0
  79. package/src/domains/workflows/runtime/progress.ts +400 -0
  80. package/src/domains/workflows/runtime/provider-usage-limit.ts +191 -0
  81. package/src/domains/workflows/runtime/replay-path-identity.ts +48 -0
  82. package/src/domains/workflows/runtime/research-contract.ts +89 -0
  83. package/src/domains/workflows/runtime/research-evidence.ts +289 -0
  84. package/src/domains/workflows/runtime/resume-context.ts +751 -0
  85. package/src/domains/workflows/runtime/review/code-review-orchestration.ts +24 -0
  86. package/src/domains/workflows/runtime/review/github-pr-comments.ts +249 -0
  87. package/src/domains/workflows/runtime/review/patch-validation.ts +120 -0
  88. package/src/domains/workflows/runtime/review/review-actions.ts +180 -0
  89. package/src/domains/workflows/runtime/review/review-budget.ts +45 -0
  90. package/src/domains/workflows/runtime/review/review-fix-workflow.ts +153 -0
  91. package/src/domains/workflows/runtime/review/review-format.ts +133 -0
  92. package/src/domains/workflows/runtime/review/review-handoff.ts +38 -0
  93. package/src/domains/workflows/runtime/review/review-issues.ts +87 -0
  94. package/src/domains/workflows/runtime/review/review-report.ts +42 -0
  95. package/src/domains/workflows/runtime/review/review-results-flow.ts +52 -0
  96. package/src/domains/workflows/runtime/review/review-results-viewer.ts +312 -0
  97. package/src/domains/workflows/runtime/review/review-session-coordinator.ts +153 -0
  98. package/src/domains/workflows/runtime/review/review-snapshot.ts +294 -0
  99. package/src/domains/workflows/runtime/review-diff-target.ts +130 -0
  100. package/src/domains/workflows/runtime/session-identity.ts +6 -0
  101. package/src/domains/workflows/runtime/structured-output.ts +32 -0
  102. package/src/domains/workflows/runtime/tool-capabilities.ts +44 -0
  103. package/src/domains/workflows/runtime/tool-source-identity.ts +150 -0
  104. package/src/domains/workflows/runtime/tree-fingerprint.ts +404 -0
  105. package/src/domains/workflows/runtime/types.ts +255 -0
  106. package/src/domains/workflows/runtime/ui/display-text.ts +28 -0
  107. package/src/domains/workflows/runtime/ui/dynamax-editor-decoration.ts +220 -0
  108. package/src/domains/workflows/runtime/ui/workflow-format.ts +165 -0
  109. package/src/domains/workflows/runtime/ui/workflow-inspector.ts +435 -0
  110. package/src/domains/workflows/runtime/ui/workflow-result-renderer.ts +171 -0
  111. package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +51 -0
  112. package/src/domains/workflows/runtime/ui/workflow-widget.ts +78 -0
  113. package/src/domains/workflows/runtime/unknown-error.ts +8 -0
  114. package/src/domains/workflows/runtime/usage.ts +341 -0
  115. package/src/domains/workflows/runtime/workflow-advisory-utils.ts +245 -0
  116. package/src/domains/workflows/runtime/workflow-execution.ts +121 -0
  117. package/src/domains/workflows/runtime/workflow-module.ts +75 -0
  118. package/src/domains/workflows/runtime/workflow-run-background.ts +72 -0
  119. package/src/domains/workflows/runtime/workflow-run-controller.ts +342 -0
  120. package/src/domains/workflows/runtime/workflow-run-history.ts +164 -0
  121. package/src/domains/workflows/runtime/workflow-run-record.ts +636 -0
  122. package/src/domains/workflows/runtime/workflow-run-store.ts +176 -0
  123. package/src/domains/workflows/runtime/workflow-usage-limit-scheduler.ts +80 -0
  124. package/src/domains/workflows/runtime/workflows.ts +40 -0
  125. package/src/domains/workflows/runtime/worktree.ts +615 -0
  126. package/src/domains/workflows/workflows/code-review.ts +232 -0
  127. package/src/domains/workflows/workflows/diagnose.ts +154 -0
  128. package/src/domains/workflows/workflows/perf-review.ts +149 -0
  129. package/src/domains/workflows/workflows/refactor-scout.ts +143 -0
  130. package/src/domains/workflows/workflows/research.ts +169 -0
  131. package/src/services/usage-service.ts +6 -30
  132. package/src/ui/usage-bars.ts +1 -2
  133. package/src/core/codex/oauth.ts +0 -129
@@ -0,0 +1,399 @@
1
+ import type { Skill } from "@earendil-works/pi-coding-agent";
2
+ import { Value } from "typebox/value";
3
+ import { captureAgentSkillIdentities, extractSkillSelectorsFromText } from "./agent-skills.ts";
4
+ import type { AgentExecutionOptions, AgentRunnerSession, AgentRunTags, RunContext } from "./agent-runner-types.ts";
5
+ import { captureEffectiveAgentSessionIdentity } from "./agent-session-identity.ts";
6
+ import { FINAL_TOOL } from "./agent-session.ts";
7
+ import type { AgentWorkspace, IsolatedAgentWorkspace } from "./agent-workspace.ts";
8
+ import { captureAgentJournalKey } from "./journal.ts";
9
+ import { unknownErrorMessage } from "./unknown-error.ts";
10
+ import {
11
+ captureRepositoryMutationGuard,
12
+ captureIsolatedRepositoryContext,
13
+ captureRepositoryResumeContext,
14
+ createAgentResumeContext,
15
+ resumeContextMismatchReason,
16
+ type AgentResumeBaseContext,
17
+ type AgentResumeContext,
18
+ type RepositoryResumeContext,
19
+ type VerifiedRepositoryResumeContext,
20
+ type VerifiedWorkflowResumeContext,
21
+ } from "./resume-context.ts";
22
+
23
+ export type AgentReplayPlan =
24
+ | { readonly kind: "off" }
25
+ | { readonly kind: "disabled"; readonly reason: string }
26
+ | { readonly kind: "shared"; readonly key: string; readonly additionalInputs: readonly string[] }
27
+ | { readonly kind: "isolated"; readonly key: string };
28
+
29
+ export type AgentReplayEvidence =
30
+ | { readonly kind: "shared" }
31
+ | { readonly kind: "isolated"; readonly mutationGuard: string };
32
+
33
+ export type AgentAttemptResult =
34
+ | { readonly kind: "cache-hit"; readonly result: unknown; readonly identity: AgentResumeContext; readonly evidence: AgentReplayEvidence }
35
+ | { readonly kind: "live-recordable"; readonly result: unknown; readonly identity: AgentResumeContext; readonly evidence: AgentReplayEvidence }
36
+ | { readonly kind: "live-unrecordable"; readonly result: unknown };
37
+
38
+ export type AgentAttemptSettlement =
39
+ | { readonly kind: "done"; readonly result: unknown }
40
+ | { readonly kind: "retry-live" };
41
+
42
+ type ReplayIdentityCapture =
43
+ | { readonly kind: "verified"; readonly identity: AgentResumeContext }
44
+ | { readonly kind: "unverifiable"; readonly reason: string };
45
+
46
+ const INACCESSIBLE_REPOSITORY_CONTEXT: VerifiedRepositoryResumeContext = {
47
+ kind: "verified",
48
+ state: "inaccessible",
49
+ workingTreeFingerprint: "no-workspace-capability",
50
+ };
51
+
52
+ export function createAgentReplayPlan(
53
+ prompt: string,
54
+ opts: AgentExecutionOptions,
55
+ ): AgentReplayPlan {
56
+ if (opts.resume !== undefined && opts.resume !== "read-only" && opts.resume !== "off") {
57
+ return { kind: "disabled", reason: "resume policy must be read-only or off" };
58
+ }
59
+ const isolated = opts.isolation === "worktree";
60
+ if (opts.resume === "off" || (!isolated && opts.resume !== "read-only")) return { kind: "off" };
61
+ const additionalInputs = inspectResumeInputs(opts);
62
+ if (typeof additionalInputs === "string") return { kind: "disabled", reason: additionalInputs };
63
+ const capture = captureAgentJournalKey(prompt, opts, opts.worktreeBaseline);
64
+ if (capture.kind === "unverifiable") return { kind: "disabled", reason: capture.reason };
65
+ return isolated
66
+ ? { kind: "isolated", key: capture.key }
67
+ : { kind: "shared", key: capture.key, additionalInputs };
68
+ }
69
+
70
+ export function isReplayEnabled(
71
+ replay: AgentReplayPlan,
72
+ ): replay is Extract<AgentReplayPlan, { readonly kind: "shared" | "isolated" }> {
73
+ return replay.kind === "shared" || replay.kind === "isolated";
74
+ }
75
+
76
+ export async function captureSharedRepositoryBeforeSetup(
77
+ rc: RunContext,
78
+ prompt: string,
79
+ opts: AgentExecutionOptions,
80
+ replay: Extract<AgentReplayPlan, { readonly kind: "shared" }>,
81
+ ): Promise<RepositoryResumeContext> {
82
+ if (declaresNoWorkspaceCapability(prompt, opts)) return INACCESSIBLE_REPOSITORY_CONTEXT;
83
+ return await captureRepositoryResumeContext(rc.cwd, replay.additionalInputs, rc.signal);
84
+ }
85
+
86
+ export async function captureIsolatedRepositoryAfterSetup(
87
+ rc: RunContext,
88
+ prompt: string,
89
+ opts: AgentExecutionOptions,
90
+ workspace: IsolatedAgentWorkspace,
91
+ ): Promise<RepositoryResumeContext> {
92
+ if (declaresNoWorkspaceCapability(prompt, opts)) return INACCESSIBLE_REPOSITORY_CONTEXT;
93
+ return await captureIsolatedRepositoryContext(workspace.cwd, workspace.baselineOid, rc.signal);
94
+ }
95
+
96
+ export async function captureReplayIdentity(input: {
97
+ readonly rc: RunContext;
98
+ readonly base: AgentResumeBaseContext;
99
+ readonly repository: RepositoryResumeContext;
100
+ readonly selectedSkills: readonly Skill[];
101
+ readonly session: AgentRunnerSession;
102
+ readonly sessionCwd: string;
103
+ }): Promise<ReplayIdentityCapture> {
104
+ if (input.repository.kind === "unverifiable") return input.repository;
105
+ if (input.base.workflow.kind === "unverifiable") return input.base.workflow;
106
+ return await captureVerifiedReplayIdentity({
107
+ ...input,
108
+ repository: input.repository,
109
+ workflow: input.base.workflow,
110
+ });
111
+ }
112
+
113
+ export async function lookupReplayResult(input: {
114
+ readonly rc: RunContext;
115
+ readonly key: string;
116
+ readonly identity: AgentResumeContext;
117
+ readonly opts: AgentExecutionOptions;
118
+ readonly workspace: AgentWorkspace;
119
+ }): Promise<{ readonly hit: true; readonly result: unknown } | { readonly hit: false; readonly reason?: string }> {
120
+ const cached = input.rc.journal.lookup(input.key, input.identity, {
121
+ allowWorkflowSourceMismatch: input.rc.resumeEditedWorkflow,
122
+ });
123
+ if (!cached.hit) return cached;
124
+ const validation = await validateCachedResult(cached.value, input.opts, input.rc, input.workspace);
125
+ return validation.ok
126
+ ? { hit: true, result: cached.value }
127
+ : { hit: false, reason: validation.reason };
128
+ }
129
+
130
+ export async function validateReplayIdentity(input: {
131
+ readonly rc: RunContext;
132
+ readonly identity: AgentResumeContext;
133
+ readonly selectedSkills: readonly Skill[];
134
+ readonly session: AgentRunnerSession;
135
+ readonly sessionCwd: string;
136
+ readonly replay: Extract<AgentReplayPlan, { readonly kind: "shared" | "isolated" }>;
137
+ readonly workspace: AgentWorkspace;
138
+ }): Promise<{ readonly ok: true } | { readonly ok: false; readonly reason: string }> {
139
+ const repository = input.identity.repository.state === "inaccessible"
140
+ ? INACCESSIBLE_REPOSITORY_CONTEXT
141
+ : await captureCurrentRepository(input.rc, input.replay, input.workspace);
142
+ if (repository.kind === "unverifiable") return { ok: false, reason: repository.reason };
143
+
144
+ const current = await captureVerifiedReplayIdentity({
145
+ rc: input.rc,
146
+ repository,
147
+ workflow: input.identity.workflow,
148
+ selectedSkills: input.selectedSkills,
149
+ session: input.session,
150
+ sessionCwd: input.sessionCwd,
151
+ });
152
+ if (current.kind === "unverifiable") return { ok: false, reason: current.reason };
153
+ const mismatch = resumeContextMismatchReason(input.identity, current.identity, {
154
+ allowWorkflowSourceMismatch: input.rc.resumeEditedWorkflow,
155
+ });
156
+ return mismatch ? { ok: false, reason: mismatch } : { ok: true };
157
+ }
158
+
159
+ export async function settleAgentAttempt(input: {
160
+ readonly rc: RunContext;
161
+ readonly label: string;
162
+ readonly tags: AgentRunTags;
163
+ readonly replay: AgentReplayPlan;
164
+ readonly outcome: AgentAttemptResult;
165
+ }): Promise<AgentAttemptSettlement> {
166
+ const { rc, label, replay, outcome } = input;
167
+ if (outcome.kind === "live-unrecordable") {
168
+ recordAgentResultSource(rc, "live");
169
+ return { kind: "done", result: outcome.result };
170
+ }
171
+ if (!isReplayEnabled(replay)) throw new Error("Replayable agent outcome produced without an enabled replay plan.");
172
+
173
+ const repository = await validateRepositoryAfterCleanup(
174
+ rc,
175
+ outcome.identity,
176
+ replay,
177
+ outcome.evidence,
178
+ );
179
+ if (!repository.ok) {
180
+ if (outcome.kind === "cache-hit") {
181
+ rc.progress.log(`${label}: cached result invalidated after cleanup (${repository.reason})`);
182
+ return { kind: "retry-live" };
183
+ }
184
+ rc.progress.log(`${label}: read-only resume contract was not recorded (${repository.reason})`);
185
+ return { kind: "done", result: outcome.result };
186
+ }
187
+
188
+ await recordJournalResult(rc, label, replay.key, outcome.result, outcome.identity);
189
+ if (outcome.kind === "cache-hit") {
190
+ rc.progress.log(`${label}: using cached result from workflow journal`);
191
+ rc.perf.counter("agent.cache_hit", 1, input.tags);
192
+ recordAgentResultSource(rc, "cached");
193
+ } else {
194
+ recordAgentResultSource(rc, "live");
195
+ }
196
+ return { kind: "done", result: outcome.result };
197
+ }
198
+
199
+ function recordAgentResultSource(rc: RunContext, source: "cached" | "live"): void {
200
+ if (!rc.resumeEditedWorkflow) return;
201
+ rc.progress.event({
202
+ type: "counter_delta",
203
+ key: `resume.${source}`,
204
+ label: source === "cached" ? "Cached agents" : "Live agents",
205
+ delta: 1,
206
+ });
207
+ }
208
+
209
+ async function captureVerifiedReplayIdentity(input: {
210
+ readonly rc: RunContext;
211
+ readonly repository: VerifiedRepositoryResumeContext;
212
+ readonly workflow: VerifiedWorkflowResumeContext;
213
+ readonly selectedSkills: readonly Skill[];
214
+ readonly session: AgentRunnerSession;
215
+ readonly sessionCwd: string;
216
+ }): Promise<ReplayIdentityCapture> {
217
+ const [skills, effectiveSession] = await Promise.all([
218
+ captureAgentSkillIdentities(input.selectedSkills, {
219
+ sessionCwd: input.sessionCwd,
220
+ workspaceRoot: input.rc.cwd,
221
+ signal: input.rc.signal,
222
+ }),
223
+ captureEffectiveAgentSessionIdentity(input.session, {
224
+ sessionCwd: input.sessionCwd,
225
+ workspaceRoot: input.rc.cwd,
226
+ signal: input.rc.signal,
227
+ }),
228
+ ]);
229
+ if (skills.kind === "unverifiable") return skills;
230
+ if (effectiveSession.kind === "unverifiable") return effectiveSession;
231
+ if (
232
+ input.repository.state === "inaccessible" &&
233
+ (skills.skills.length > 0 || effectiveSession.identity.tools.some((tool) => tool.name !== FINAL_TOOL))
234
+ ) {
235
+ return { kind: "unverifiable", reason: "session unexpectedly retained workspace-observing capabilities" };
236
+ }
237
+ return {
238
+ kind: "verified",
239
+ identity: createAgentResumeContext(
240
+ { workflow: input.workflow },
241
+ input.repository,
242
+ effectiveSession.identity,
243
+ skills.skills,
244
+ ),
245
+ };
246
+ }
247
+
248
+ async function validateCachedResult(
249
+ value: unknown,
250
+ opts: AgentExecutionOptions,
251
+ rc: RunContext,
252
+ workspace: AgentWorkspace,
253
+ ): Promise<{ readonly ok: true } | { readonly ok: false; readonly reason: string }> {
254
+ let result: unknown;
255
+ if (opts.isolation === "worktree") {
256
+ const isolated = isolatedCachedResult(value);
257
+ if (!isolated.ok) return isolated;
258
+ const patch = await rc.worktrees.validatePatch(workspace.cwd, isolated.wrapper, rc.signal);
259
+ if (!patch.ok) {
260
+ return {
261
+ ok: false,
262
+ reason: `cached isolated patch does not apply to the current baseline (${(patch.error ?? patch.stderr.trim()) || "unknown error"})`,
263
+ };
264
+ }
265
+ result = isolated.wrapper.result;
266
+ } else {
267
+ result = value;
268
+ }
269
+ if (!opts.schema) {
270
+ return typeof result === "string"
271
+ ? { ok: true }
272
+ : { ok: false, reason: "cached text result is not a string" };
273
+ }
274
+ if (result === null) return { ok: true };
275
+ try {
276
+ return Value.Check(opts.schema, result)
277
+ ? { ok: true }
278
+ : { ok: false, reason: "cached structured result does not match the current schema" };
279
+ } catch (error) {
280
+ return { ok: false, reason: `cached structured result could not be validated (${unknownErrorMessage(error)})` };
281
+ }
282
+ }
283
+
284
+ function isolatedCachedResult(value: unknown):
285
+ | {
286
+ readonly ok: true;
287
+ readonly wrapper: { readonly result: unknown; readonly patch: string; readonly changed: boolean };
288
+ }
289
+ | { readonly ok: false; readonly reason: string } {
290
+ if (typeof value !== "object" || value === null) {
291
+ return { ok: false, reason: "cached isolated result is not an object" };
292
+ }
293
+ const candidate = value as { readonly result?: unknown; readonly patch?: unknown; readonly changed?: unknown };
294
+ if (!("result" in candidate) || typeof candidate.patch !== "string" || typeof candidate.changed !== "boolean") {
295
+ return { ok: false, reason: "cached isolated result has an invalid wrapper" };
296
+ }
297
+ const wrapper = { result: candidate.result, patch: candidate.patch, changed: candidate.changed };
298
+ return { ok: true, wrapper };
299
+ }
300
+
301
+ async function validateRepositoryAfterCleanup(
302
+ rc: RunContext,
303
+ identity: AgentResumeContext,
304
+ replay: Extract<AgentReplayPlan, { readonly kind: "shared" | "isolated" }>,
305
+ evidence: AgentReplayEvidence,
306
+ ): Promise<{ readonly ok: true } | { readonly ok: false; readonly reason: string }> {
307
+ if (replay.kind === "isolated") {
308
+ return evidence.kind === "isolated"
309
+ ? await validateReplayEvidence(rc, evidence)
310
+ : { ok: false, reason: "isolated replay omitted its main-workspace mutation guard" };
311
+ }
312
+
313
+ const repository = identity.repository.state === "inaccessible"
314
+ ? INACCESSIBLE_REPOSITORY_CONTEXT
315
+ : await captureRepositoryResumeContext(rc.cwd, replay.additionalInputs, rc.signal);
316
+ if (repository.kind === "unverifiable") return { ok: false, reason: repository.reason };
317
+ const mismatch = resumeContextMismatchReason(identity, { ...identity, repository });
318
+ if (mismatch) return { ok: false, reason: mismatch };
319
+ return evidence.kind === "shared"
320
+ ? { ok: true }
321
+ : { ok: false, reason: "shared replay carried isolated mutation evidence" };
322
+ }
323
+
324
+ export async function validateReplayEvidence(
325
+ rc: RunContext,
326
+ evidence: AgentReplayEvidence,
327
+ ): Promise<{ readonly ok: true } | { readonly ok: false; readonly reason: string }> {
328
+ return evidence.kind === "shared"
329
+ ? { ok: true }
330
+ : await validateRepositoryMutationGuard(rc, evidence.mutationGuard);
331
+ }
332
+
333
+ export async function validateRepositoryMutationGuard(
334
+ rc: RunContext,
335
+ expected: string,
336
+ ): Promise<{ readonly ok: true } | { readonly ok: false; readonly reason: string }> {
337
+ const current = await captureRepositoryMutationGuard(rc.cwd, rc.signal);
338
+ if (current.kind === "unverifiable") return { ok: false, reason: current.reason };
339
+ return current.fingerprint === expected
340
+ ? { ok: true }
341
+ : { ok: false, reason: "main workspace changed outside the isolated worktree" };
342
+ }
343
+
344
+ async function captureCurrentRepository(
345
+ rc: RunContext,
346
+ replay: Extract<AgentReplayPlan, { readonly kind: "shared" | "isolated" }>,
347
+ workspace: AgentWorkspace,
348
+ ): Promise<RepositoryResumeContext> {
349
+ if (replay.kind === "shared") return await captureRepositoryResumeContext(rc.cwd, replay.additionalInputs, rc.signal);
350
+ if (workspace.kind !== "isolated") {
351
+ return { kind: "unverifiable", reason: "isolated replay lost its isolated workspace baseline" };
352
+ }
353
+ return await captureIsolatedRepositoryContext(workspace.cwd, workspace.baselineOid, rc.signal);
354
+ }
355
+
356
+ async function recordJournalResult(
357
+ rc: RunContext,
358
+ label: string,
359
+ key: string,
360
+ result: unknown,
361
+ identity: AgentResumeContext,
362
+ ): Promise<void> {
363
+ try {
364
+ const recorded = await rc.journal.record(key, result, identity);
365
+ if (!recorded.ok) {
366
+ rc.progress.log(`${label}: workflow journal write failed (${recorded.error}); future resume may be incomplete`);
367
+ }
368
+ } catch (error) {
369
+ rc.progress.log(`${label}: workflow journal write failed (${unknownErrorMessage(error)}); future resume may be incomplete`);
370
+ }
371
+ }
372
+
373
+ function declaresNoWorkspaceCapability(prompt: string, opts: AgentExecutionOptions): boolean {
374
+ return (
375
+ opts.tools !== undefined &&
376
+ opts.tools.length === 0 &&
377
+ (opts.toolHints?.length ?? 0) === 0 &&
378
+ (opts.skills?.length ?? 0) === 0 &&
379
+ extractSkillSelectorsFromText(prompt).length === 0
380
+ );
381
+ }
382
+
383
+ function inspectResumeInputs(opts: AgentExecutionOptions): readonly string[] | string {
384
+ try {
385
+ const inputs = opts.resumeInputs;
386
+ if (inputs === undefined) return [];
387
+ if (!Array.isArray(inputs)) return "read-only resume inputs must be an array";
388
+ if (inputs.length > 128) return "read-only resume accepts at most 128 repository inputs";
389
+ const inspected: string[] = [];
390
+ for (let index = 0; index < inputs.length; index++) {
391
+ const value = inputs[index];
392
+ if (typeof value !== "string") return "read-only resume inputs must be strings";
393
+ inspected.push(value);
394
+ }
395
+ return inspected;
396
+ } catch {
397
+ return "read-only resume inputs could not be inspected";
398
+ }
399
+ }
@@ -0,0 +1,116 @@
1
+ import { isRetryableAssistantError, type AssistantMessage } from "@earendil-works/pi-ai";
2
+ import { abortReason, throwIfAborted } from "./cancellation.ts";
3
+ import {
4
+ providerUsageLimitFromMessages,
5
+ type WorkflowProviderUsageLimitError,
6
+ } from "./provider-usage-limit.ts";
7
+
8
+ export const AGENT_RETRY_BASE_DELAY_MS = 1_000;
9
+ export const AGENT_RETRY_MAX_DELAY_MS = 30_000;
10
+ export const WORKFLOW_PROVIDER_ERROR_CODE = "WORKFLOW_PROVIDER_ERROR";
11
+
12
+ export interface AgentRetryScheduler {
13
+ sleep(delayMs: number, signal: AbortSignal | undefined): Promise<void>;
14
+ }
15
+
16
+ export interface ProviderErrorDetails {
17
+ readonly stopReason: "error";
18
+ readonly retryable: boolean;
19
+ readonly provider?: string;
20
+ readonly model?: string;
21
+ readonly api?: string;
22
+ }
23
+
24
+ /** Provider failure reconstructed from pi's terminal assistant-message metadata. */
25
+ export class WorkflowProviderError extends Error {
26
+ override readonly name = "WorkflowProviderError";
27
+ readonly code = WORKFLOW_PROVIDER_ERROR_CODE;
28
+
29
+ constructor(
30
+ message: string,
31
+ readonly details: ProviderErrorDetails,
32
+ ) {
33
+ super(message);
34
+ }
35
+
36
+ get retryable(): boolean {
37
+ return this.details.retryable;
38
+ }
39
+
40
+ toJSON(): {
41
+ readonly name: string;
42
+ readonly message: string;
43
+ readonly code: string;
44
+ readonly details: ProviderErrorDetails;
45
+ } {
46
+ return { name: this.name, message: this.message, code: this.code, details: this.details };
47
+ }
48
+ }
49
+
50
+ export const defaultAgentRetryScheduler: AgentRetryScheduler = {
51
+ async sleep(delayMs, signal) {
52
+ throwIfAborted(signal);
53
+ await new Promise<void>((resolve, reject) => {
54
+ const cleanup = () => signal?.removeEventListener("abort", onAbort);
55
+ const timer = setTimeout(() => {
56
+ cleanup();
57
+ resolve();
58
+ }, delayMs);
59
+ const onAbort = () => {
60
+ clearTimeout(timer);
61
+ cleanup();
62
+ reject(abortReason(signal));
63
+ };
64
+ signal?.addEventListener("abort", onAbort, { once: true });
65
+ if (signal?.aborted) onAbort();
66
+ });
67
+ },
68
+ };
69
+
70
+ export function agentRetryDelayMs(retryAttempt: number): number {
71
+ return Math.min(AGENT_RETRY_MAX_DELAY_MS, AGENT_RETRY_BASE_DELAY_MS * 2 ** Math.max(0, retryAttempt - 1));
72
+ }
73
+
74
+ export function providerErrorFromMessages(
75
+ messages: readonly unknown[],
76
+ options: { readonly pauseOnUsageLimit?: boolean } = {},
77
+ ): WorkflowProviderError | WorkflowProviderUsageLimitError | undefined {
78
+ const usageLimit = providerUsageLimitFromMessages(messages);
79
+ if (usageLimit && options.pauseOnUsageLimit) return usageLimit;
80
+ const message = messages.findLast(isAssistantMessage);
81
+ if (!message || message.stopReason !== "error") return undefined;
82
+ const errorMessage = typeof message.errorMessage === "string" && message.errorMessage.length > 0
83
+ ? message.errorMessage
84
+ : "Provider session ended with an unspecified error.";
85
+ const retryable = isRetryableAssistantError(message as AssistantMessage);
86
+ const provider = stringDetail(message.provider);
87
+ const model = stringDetail(message.model);
88
+ const api = stringDetail(message.api);
89
+ return new WorkflowProviderError(errorMessage, {
90
+ stopReason: "error",
91
+ retryable,
92
+ ...(provider ? { provider } : {}),
93
+ ...(model ? { model } : {}),
94
+ ...(api ? { api } : {}),
95
+ });
96
+ }
97
+
98
+ function isAssistantMessage(value: unknown): value is {
99
+ readonly role: "assistant";
100
+ readonly stopReason?: unknown;
101
+ readonly errorMessage?: unknown;
102
+ readonly provider?: unknown;
103
+ readonly model?: unknown;
104
+ readonly api?: unknown;
105
+ } {
106
+ return (
107
+ typeof value === "object" &&
108
+ value !== null &&
109
+ "role" in value &&
110
+ value.role === "assistant"
111
+ );
112
+ }
113
+
114
+ function stringDetail(value: unknown): string | undefined {
115
+ return typeof value === "string" && value.length > 0 ? value : undefined;
116
+ }
@@ -0,0 +1,96 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import type {
3
+ AgentSession,
4
+ CreateAgentSessionOptions,
5
+ ModelRegistry,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import type { WorkflowBudget } from "./budget.ts";
8
+ import type { Semaphore } from "./concurrency.ts";
9
+ import type { WorkflowAgentLimiter } from "./agent-limits.ts";
10
+ import type { AgentRetryScheduler } from "./agent-retry.ts";
11
+ import type { ResolvedWorkflowModelProfiles } from "./model-profiles.ts";
12
+ import type { WorkflowJournal } from "./journal.ts";
13
+ import type { PerfSink } from "./perf.ts";
14
+ import type { AgentOptions, WorkflowProgressEvent } from "./types.ts";
15
+ import type { AgentChatRole } from "./progress-types.ts";
16
+ import type { WorkflowUsageSink } from "./usage.ts";
17
+ import type { WorktreeBaseline, WorktreeRegistry } from "./worktree.ts";
18
+
19
+ export type AgentRunnerSession = Pick<
20
+ AgentSession,
21
+ | "messages"
22
+ | "systemPrompt"
23
+ | "model"
24
+ | "thinkingLevel"
25
+ | "prompt"
26
+ | "subscribe"
27
+ | "dispose"
28
+ | "abort"
29
+ | "getAllTools"
30
+ | "getActiveToolNames"
31
+ | "getToolDefinition"
32
+ | "setActiveToolsByName"
33
+ | "setAutoRetryEnabled"
34
+ | "getLastAssistantText"
35
+ | "isStreaming"
36
+ | "followUp"
37
+ >;
38
+
39
+ export type CreateAgentSession = (options: CreateAgentSessionOptions) => Promise<{ session: AgentRunnerSession }>;
40
+
41
+ export interface AgentProgress {
42
+ agentQueued(phase: string | undefined, label: string, model?: string): number;
43
+ agentStart(phase: string | undefined, label: string, id?: number, model?: string): void;
44
+ agentTool(label: string, tool: string, id?: number): void;
45
+ agentMessage(id: number, role: AgentChatRole, text: string): void;
46
+ bindAgentFollowUp(id: number, send: (message: string) => Promise<void>): () => void;
47
+ agentDone(label: string, id?: number): void;
48
+ agentFailed(label: string, error: unknown, id?: number): void;
49
+ event(event: WorkflowProgressEvent): void;
50
+ log(message: string): void;
51
+ }
52
+
53
+ interface RunContextBase {
54
+ cwd: string;
55
+ hostModel: Model<Api> | undefined;
56
+ semaphore: Semaphore;
57
+ agentLimiter: WorkflowAgentLimiter;
58
+ agentTimeoutMs: number | null;
59
+ agentRetries: number;
60
+ pauseOnProviderUsageLimit?: boolean;
61
+ resumeEditedWorkflow?: boolean;
62
+ retryScheduler: AgentRetryScheduler;
63
+ modelProfiles: ResolvedWorkflowModelProfiles;
64
+ progress: AgentProgress;
65
+ signal: AbortSignal | undefined;
66
+ perf: PerfSink;
67
+ usage: WorkflowUsageSink;
68
+ budget: WorkflowBudget;
69
+ journal: WorkflowJournal;
70
+ worktrees: WorktreeRegistry;
71
+ }
72
+
73
+ /** Shared per-run context threaded into every agent() call. */
74
+ export type RunContext = RunContextBase & (
75
+ | {
76
+ /** Production sessions inherit the host's live provider and auth state. */
77
+ modelRegistry: ModelRegistry;
78
+ createSession?: undefined;
79
+ }
80
+ | {
81
+ /** Injected test sessions bypass Pi's resource loader and therefore do not resolve skills. */
82
+ modelRegistry: Pick<ModelRegistry, "find">;
83
+ createSession: CreateAgentSession;
84
+ }
85
+ );
86
+
87
+ /** Runtime-only options. The authored WorkflowApi exposes only AgentOptions. */
88
+ export type AgentExecutionOptions = AgentOptions & {
89
+ readonly worktreeBaseline?: WorktreeBaseline;
90
+ };
91
+
92
+ export interface AgentRunTags {
93
+ readonly [key: string]: string | number;
94
+ readonly label: string;
95
+ readonly phase: string;
96
+ }