@henryqw/pi-pr 3.1.10 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/extensions/pr.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { realpath } from "node:fs/promises";
1
3
  import {
2
4
  isBashToolResult,
3
5
  type ExtensionAPI,
@@ -5,7 +7,17 @@ import {
5
7
  } from "@earendil-works/pi-coding-agent";
6
8
  import { truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
7
9
  import { createHerdrClient } from "@henryqw/pi-herdr";
8
- import { createPrCommandHandler } from "./pr-command.ts";
10
+ import { Type } from "typebox";
11
+ import { PullRequestCiFixer, type PullRequestCiFixOptions } from "./pr-ci.ts";
12
+ import { PullRequestCommentSweep, type PullRequestCommentSweepOptions } from "./pr-comment-sweep.ts";
13
+ import {
14
+ createPrCommandHandler,
15
+ type PrCommandDependencies,
16
+ type PrCommandInvocation,
17
+ type WorkflowPromptIdentity,
18
+ type WorkflowReservation,
19
+ } from "./pr-command.ts";
20
+ import { PullRequestCreator, type CreatePullRequestOptions } from "./pr-create.ts";
9
21
  import {
10
22
  hasLocalCommit,
11
23
  loadCurrentPullRequest,
@@ -14,6 +26,7 @@ import {
14
26
  samePullRequestObservation,
15
27
  type PullRequestObservation,
16
28
  } from "./pr-github.ts";
29
+ import { runChecked, spawnBounded } from "./pr-execution.ts";
17
30
  import {
18
31
  discoveryIssueKey,
19
32
  discoveryIssueMessage,
@@ -23,6 +36,7 @@ import {
23
36
  unavailablePrDisplay,
24
37
  type PrDisplay,
25
38
  } from "./pr-ui.ts";
39
+ import { PullRequestBranchUpdater, type UpdateBranchOptions } from "./pr-update-branch.ts";
26
40
 
27
41
  const POLL_INTERVAL_MS = 30_000;
28
42
  const ROUTING_SPINNER_INTERVAL_MS = 80;
@@ -36,13 +50,150 @@ const GIT_COMMIT = /(?:^|[;&|]\s*|\n\s*)git\s+commit(?=\s|$|[;&|])/;
36
50
  const GIT_PUSH = /(?:^|[;&|]\s*|\n\s*)git\s+push(?=\s|$|[;&|])/;
37
51
  const WORKFLOW_ROUTES = new Set(["create", "update-branch", "sweep", "fix-ci"]);
38
52
  const DELEGATED_TOOLS = new Set(["delegate_task", "delegate_flow", "delegate_flow_continue"]);
53
+ const CLOSED = { additionalProperties: false } as const;
54
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
55
+
56
+ const RouteRunId = Type.String({ minLength: 36, maxLength: 36, pattern: UUID.source });
57
+ const ResolvedPaths = Type.Array(Type.String({ minLength: 1, maxLength: 4_096 }), { minItems: 1, maxItems: 128 });
58
+ const OwnedPaths = Type.Array(Type.String({ minLength: 1, maxLength: 4_096 }), { maxItems: 128 });
59
+ const SweepGuard = Type.Object({
60
+ epoch: Type.Integer({ minimum: 1 }),
61
+ runId: Type.String({ minLength: 1, maxLength: 128 }),
62
+ generation: Type.Integer({ minimum: 1 }),
63
+ fingerprint: Type.String({ minLength: 64, maxLength: 64 }),
64
+ }, CLOSED);
65
+ const SweepLedgerEntry = Type.Object({
66
+ id: Type.String({ minLength: 1, maxLength: 1_024 }),
67
+ kind: Type.Union([
68
+ Type.Literal("conversation_comment"), Type.Literal("review"), Type.Literal("thread"), Type.Literal("thread_comment"),
69
+ ]),
70
+ disposition: Type.Union([Type.Literal("addressed"), Type.Literal("non-actionable"), Type.Literal("blocked")]),
71
+ note: Type.String({ maxLength: 2_048 }),
72
+ }, CLOSED);
73
+ const SweepLedger = Type.Array(SweepLedgerEntry, { maxItems: 1_000 });
74
+ const SweepProjection = Type.Object({
75
+ generation: Type.Integer({ minimum: 1 }),
76
+ contentFingerprint: Type.String({ minLength: 64, maxLength: 64 }),
77
+ items: Type.Array(Type.Object({
78
+ id: Type.String({ minLength: 1, maxLength: 1_024 }),
79
+ kind: Type.Union([
80
+ Type.Literal("conversation_comment"), Type.Literal("review"), Type.Literal("thread"), Type.Literal("thread_comment"),
81
+ ]),
82
+ }, CLOSED), { maxItems: 1_000 }),
83
+ threads: Type.Array(Type.Object({
84
+ id: Type.String({ minLength: 1, maxLength: 1_024 }),
85
+ isResolved: Type.Boolean(),
86
+ }, CLOSED), { maxItems: 1_000 }),
87
+ }, CLOSED);
88
+ const SweepChecks = Type.Array(Type.Object({
89
+ command: Type.String({ minLength: 1, maxLength: 1_024 }),
90
+ args: Type.Array(Type.String({ maxLength: 4_096 }), { maxItems: 256 }),
91
+ }, CLOSED), { maxItems: 32 });
92
+
93
+ const UpdateBranchParameters = Type.Union([
94
+ Type.Object({ runId: RouteRunId, action: Type.Literal("merge") }, CLOSED),
95
+ Type.Object({ runId: RouteRunId, action: Type.Literal("continue"), resolvedPaths: ResolvedPaths }, CLOSED),
96
+ Type.Object({ runId: RouteRunId, action: Type.Literal("publish") }, CLOSED),
97
+ ]);
98
+ const CreateParameters = Type.Union([
99
+ Type.Object({ runId: RouteRunId, action: Type.Literal("prepare") }, CLOSED),
100
+ Type.Object({ runId: RouteRunId, action: Type.Literal("merge") }, CLOSED),
101
+ Type.Object({ runId: RouteRunId, action: Type.Literal("continue"), resolvedPaths: ResolvedPaths }, CLOSED),
102
+ Type.Object({ runId: RouteRunId, action: Type.Literal("push") }, CLOSED),
103
+ Type.Object({
104
+ runId: RouteRunId,
105
+ action: Type.Literal("publish"),
106
+ title: Type.String({ minLength: 1, maxLength: 256 }),
107
+ body: Type.String({ maxLength: 65_536 }),
108
+ }, CLOSED),
109
+ ]);
110
+ const SweepParameters = Type.Union([
111
+ Type.Object({ runId: RouteRunId, action: Type.Literal("start") }, CLOSED),
112
+ Type.Object({ runId: RouteRunId, action: Type.Literal("resume") }, CLOSED),
113
+ Type.Object({ runId: RouteRunId, action: Type.Literal("show"), guard: SweepGuard, id: Type.String({ minLength: 1, maxLength: 1_024 }) }, CLOSED),
114
+ Type.Object({
115
+ runId: RouteRunId,
116
+ action: Type.Literal("record"),
117
+ guard: SweepGuard,
118
+ ledger: SweepLedger,
119
+ ownedPaths: Type.Optional(OwnedPaths),
120
+ }, CLOSED),
121
+ Type.Object({ runId: RouteRunId, action: Type.Literal("publish"), guard: SweepGuard }, CLOSED),
122
+ Type.Object({ runId: RouteRunId, action: Type.Literal("refresh"), guard: SweepGuard }, CLOSED),
123
+ Type.Object({
124
+ runId: RouteRunId,
125
+ action: Type.Literal("resolve"),
126
+ guard: SweepGuard,
127
+ threadIds: Type.Array(Type.String({ minLength: 1, maxLength: 1_024 }), { maxItems: 1_000 }),
128
+ }, CLOSED),
129
+ Type.Object({
130
+ runId: RouteRunId,
131
+ action: Type.Literal("finalize"),
132
+ guard: SweepGuard,
133
+ projection: SweepProjection,
134
+ checks: SweepChecks,
135
+ }, CLOSED),
136
+ ]);
137
+ const FixCiParameters = Type.Union([
138
+ Type.Object({ runId: RouteRunId, action: Type.Literal("collect") }, CLOSED),
139
+ Type.Object({ runId: RouteRunId, action: Type.Literal("publish") }, CLOSED),
140
+ ]);
141
+
142
+ type UpdateBranchWorkflow = Pick<PullRequestBranchUpdater, "state" | "merge" | "continue" | "publish">;
143
+ type CreateWorkflow = Pick<PullRequestCreator, "state" | "prepare" | "merge" | "continue" | "push" | "publish">;
144
+ type SweepWorkflow = Pick<PullRequestCommentSweep, "start" | "resume" | "show" | "record" | "publish" | "refresh" | "resolve" | "finalize">;
145
+ type FixCiWorkflow = Pick<PullRequestCiFixer, "collect" | "publish">;
146
+
147
+ type WorkflowContextBase = {
148
+ runId: string;
149
+ sessionGeneration: number;
150
+ worktree: string;
151
+ controller: AbortController;
152
+ usedSinceSettlement: boolean;
153
+ queuedPrompt?: WorkflowPromptIdentity;
154
+ conflictRetained: boolean;
155
+ };
156
+ type WorkflowContext =
157
+ | (WorkflowContextBase & { route: "update-branch"; workflow: UpdateBranchWorkflow })
158
+ | (WorkflowContextBase & { route: "create"; base?: string; workflow: CreateWorkflow })
159
+ | (WorkflowContextBase & { route: "sweep"; workflow: SweepWorkflow })
160
+ | (WorkflowContextBase & { route: "fix-ci"; workflow: FixCiWorkflow });
39
161
 
40
162
  type PullRequestExtensionDependencies = {
41
163
  loadCurrentPullRequest?: typeof loadCurrentPullRequest;
42
164
  hasLocalCommit?: typeof hasLocalCommit;
43
165
  createPrCommandHandler?: typeof createPrCommandHandler;
166
+ createBranchUpdater?: (options: UpdateBranchOptions) => UpdateBranchWorkflow;
167
+ createPullRequestCreator?: (options: CreatePullRequestOptions) => CreateWorkflow;
168
+ createCommentSweep?: (options: PullRequestCommentSweepOptions) => SweepWorkflow;
169
+ createCiFixer?: (options: PullRequestCiFixOptions) => FixCiWorkflow;
170
+ canonicalWorktree?: (cwd: string, signal?: AbortSignal) => Promise<string>;
171
+ newRunId?: () => string;
44
172
  };
45
173
 
174
+ async function canonicalWorktree(cwd: string, signal?: AbortSignal): Promise<string> {
175
+ const result = await runChecked(spawnBounded, "git", ["rev-parse", "--show-toplevel"], { cwd, signal });
176
+ const normalized = result.stdout.replace(/\r\n/g, "\n");
177
+ const lines = (normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized).split("\n");
178
+ if (lines.length !== 1 || !lines[0]) throw new Error("Git worktree root resolution returned invalid output");
179
+ return await realpath(lines[0]);
180
+ }
181
+
182
+ function toolResult(value: unknown) {
183
+ return {
184
+ content: [{ type: "text" as const, text: JSON.stringify(value) }],
185
+ details: value,
186
+ };
187
+ }
188
+
189
+ function matchesWorkflowPrompt(prompt: string, identity: WorkflowPromptIdentity): boolean {
190
+ const tokens = new Set(prompt.split(/\s+/).filter(Boolean));
191
+ const skillName = identity.skill.startsWith("skill:") ? identity.skill.slice("skill:".length) : "";
192
+ const matchesSkill = tokens.has(`/${identity.skill}`) ||
193
+ (skillName !== "" && tokens.has("<skill") && tokens.has(`name="${skillName}"`));
194
+ return matchesSkill && tokens.has(`runId=${identity.runId}`) && tokens.has(`action=${identity.action}`);
195
+ }
196
+
46
197
  function isRecord(value: unknown): value is Record<string, unknown> {
47
198
  return typeof value === "object" && value !== null && !Array.isArray(value);
48
199
  }
@@ -106,6 +257,12 @@ export default function pullRequestExtension(
106
257
  const discover = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest;
107
258
  const detectLocalCommit = dependencies.hasLocalCommit ?? hasLocalCommit;
108
259
  const createCommandHandler = dependencies.createPrCommandHandler ?? createPrCommandHandler;
260
+ const createBranchUpdater = dependencies.createBranchUpdater ?? ((options) => new PullRequestBranchUpdater(options));
261
+ const createPullRequestCreator = dependencies.createPullRequestCreator ?? ((options) => new PullRequestCreator(options));
262
+ const createCommentSweep = dependencies.createCommentSweep ?? ((options) => new PullRequestCommentSweep(options));
263
+ const createCiFixer = dependencies.createCiFixer ?? ((options) => new PullRequestCiFixer(options));
264
+ const resolveCanonicalWorktree = dependencies.canonicalWorktree ?? canonicalWorktree;
265
+ const newRunId = dependencies.newRunId ?? randomUUID;
109
266
  let context: ExtensionContext | undefined;
110
267
  let observation: PullRequestObservation | undefined;
111
268
  const load: typeof loadCurrentPullRequest = async (api, loadContext, inspectedLocal) => {
@@ -134,6 +291,7 @@ export default function pullRequestExtension(
134
291
  let mergeCompleted = false;
135
292
  let displayedWidget: PrDisplay | undefined;
136
293
  let commandGeneration = 0;
294
+ let workflowContext: WorkflowContext | undefined;
137
295
  const activeInvocations = new Map<number, "routing" | "resolved" | "create-workflow" | "workflow">();
138
296
  let widgetKind: "presentation" | "routing" = "presentation";
139
297
  let routingSpinnerFrame = 0;
@@ -148,6 +306,202 @@ export default function pullRequestExtension(
148
306
  routingSpinnerComponent = undefined;
149
307
  };
150
308
 
309
+ const clearWorkflow = (selected: WorkflowContext | undefined): void => {
310
+ if (!selected || workflowContext !== selected) return;
311
+ workflowContext = undefined;
312
+ selected.controller.abort();
313
+ };
314
+
315
+ const reserveWorkflow: NonNullable<PrCommandDependencies["reserveWorkflow"]> = async (reservation, ctx, invocation) => {
316
+ if (!invocation) throw new Error("PR workflow command generation is unavailable");
317
+ invocation.assertCurrent();
318
+ const worktree = await resolveCanonicalWorktree(ctx.cwd, ctx.signal);
319
+ invocation.assertCurrent();
320
+ if (workflowContext) throw new Error(`PR workflow ${workflowContext.runId} is still active`);
321
+ const runId = newRunId();
322
+ if (!UUID.test(runId)) throw new Error("PR workflow runId generator returned an invalid UUID");
323
+ const common: WorkflowContextBase = {
324
+ runId,
325
+ sessionGeneration: invocation.sessionGeneration,
326
+ worktree,
327
+ controller: new AbortController(),
328
+ usedSinceSettlement: false,
329
+ conflictRetained: false,
330
+ };
331
+ switch (reservation.route) {
332
+ case "update-branch":
333
+ workflowContext = {
334
+ ...common,
335
+ route: "update-branch",
336
+ workflow: createBranchUpdater({
337
+ cwd: worktree,
338
+ authority: reservation.pullRequest,
339
+ signal: common.controller.signal,
340
+ loadCurrentPullRequest: load,
341
+ }),
342
+ };
343
+ break;
344
+ case "create":
345
+ workflowContext = {
346
+ ...common,
347
+ route: "create",
348
+ ...(reservation.base === undefined ? {} : { base: reservation.base }),
349
+ workflow: createPullRequestCreator({
350
+ cwd: worktree,
351
+ target: reservation.target,
352
+ signal: common.controller.signal,
353
+ loadCurrentPullRequest: load,
354
+ }),
355
+ };
356
+ break;
357
+ case "sweep":
358
+ workflowContext = {
359
+ ...common,
360
+ route: "sweep",
361
+ workflow: createCommentSweep({
362
+ cwd: worktree,
363
+ authority: reservation.pullRequest,
364
+ signal: common.controller.signal,
365
+ loadCurrentPullRequest: load,
366
+ }),
367
+ };
368
+ break;
369
+ case "fix-ci":
370
+ workflowContext = {
371
+ ...common,
372
+ route: "fix-ci",
373
+ workflow: createCiFixer({
374
+ cwd: worktree,
375
+ authority: reservation.pullRequest,
376
+ signal: common.controller.signal,
377
+ loadCurrentPullRequest: load,
378
+ }),
379
+ };
380
+ break;
381
+ }
382
+ return common.runId;
383
+ };
384
+
385
+ const markWorkflowPromptQueued: NonNullable<PrCommandDependencies["markWorkflowPromptQueued"]> = (identity, queued) => {
386
+ if (workflowContext?.runId !== identity.runId || workflowContext.route !== identity.route) {
387
+ throw new Error("PR workflow reservation is wrong or stale");
388
+ }
389
+ workflowContext.queuedPrompt = queued ? identity : undefined;
390
+ };
391
+
392
+ const releaseWorkflow: NonNullable<PrCommandDependencies["releaseWorkflow"]> = (runId, invocation) => {
393
+ const selected = workflowContext;
394
+ if (
395
+ invocation && selected?.runId === runId &&
396
+ selected.sessionGeneration === invocation.sessionGeneration
397
+ ) clearWorkflow(selected);
398
+ };
399
+
400
+ const executeWorkflowAction = async <Route extends WorkflowContext["route"]>(
401
+ runId: string,
402
+ route: Route,
403
+ ctx: ExtensionContext,
404
+ signal: AbortSignal | undefined,
405
+ action: (selected: Extract<WorkflowContext, { route: Route }>) => Promise<unknown>,
406
+ ) => {
407
+ signal?.throwIfAborted();
408
+ const selected = workflowContext;
409
+ if (!selected) throw new Error("No PR workflow is active");
410
+ if (selected.runId !== runId) throw new Error("PR workflow runId is wrong or stale");
411
+ if (selected.sessionGeneration !== sessionGeneration) throw new Error("PR workflow session is stale");
412
+ if (selected.route !== route) throw new Error(`PR workflow route is ${selected.route}, not ${route}`);
413
+ const abortRun = () => selected.controller.abort(signal?.reason);
414
+ if (signal?.aborted) abortRun();
415
+ else signal?.addEventListener("abort", abortRun, { once: true });
416
+ try {
417
+ selected.controller.signal.throwIfAborted();
418
+ const worktree = await resolveCanonicalWorktree(ctx.cwd, selected.controller.signal);
419
+ if (workflowContext !== selected || selected.sessionGeneration !== sessionGeneration) {
420
+ throw new Error("PR workflow session changed during validation");
421
+ }
422
+ selected.controller.signal.throwIfAborted();
423
+ if (worktree !== selected.worktree) throw new Error("PR workflow worktree is wrong or stale");
424
+ selected.usedSinceSettlement = true;
425
+ return toolResult(await action(selected as Extract<WorkflowContext, { route: Route }>));
426
+ } finally {
427
+ signal?.removeEventListener("abort", abortRun);
428
+ }
429
+ };
430
+
431
+ pi.registerTool({
432
+ name: "pi_pr_update_branch",
433
+ label: "Update PR Branch",
434
+ description: "Run one guarded action for the /pr branch-update route.",
435
+ parameters: UpdateBranchParameters,
436
+ executionMode: "sequential",
437
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
438
+ return executeWorkflowAction(params.runId, "update-branch", ctx, signal, async (selected) => {
439
+ switch (params.action) {
440
+ case "merge": return await selected.workflow.merge();
441
+ case "continue": return await selected.workflow.continue(params.resolvedPaths);
442
+ case "publish": return await selected.workflow.publish();
443
+ }
444
+ });
445
+ },
446
+ });
447
+
448
+ pi.registerTool({
449
+ name: "pi_pr_create",
450
+ label: "Create Pull Request",
451
+ description: "Run one guarded action for the /pr creation route.",
452
+ parameters: CreateParameters,
453
+ executionMode: "sequential",
454
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
455
+ return executeWorkflowAction(params.runId, "create", ctx, signal, async (selected) => {
456
+ switch (params.action) {
457
+ case "prepare": return await selected.workflow.prepare(selected.base);
458
+ case "merge": return await selected.workflow.merge();
459
+ case "continue": return await selected.workflow.continue(params.resolvedPaths);
460
+ case "push": return await selected.workflow.push();
461
+ case "publish": return await selected.workflow.publish(params.title, params.body);
462
+ }
463
+ });
464
+ },
465
+ });
466
+
467
+ pi.registerTool({
468
+ name: "pi_pr_sweep",
469
+ label: "Sweep PR Feedback",
470
+ description: "Run one guarded action for the /pr feedback route.",
471
+ parameters: SweepParameters,
472
+ executionMode: "sequential",
473
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
474
+ return executeWorkflowAction(params.runId, "sweep", ctx, signal, async (selected) => {
475
+ switch (params.action) {
476
+ case "start": return await selected.workflow.start();
477
+ case "resume": return await selected.workflow.resume();
478
+ case "show": return await selected.workflow.show(params.guard, params.id);
479
+ case "record": return await selected.workflow.record(params.guard, params.ledger, params.ownedPaths);
480
+ case "publish": return await selected.workflow.publish(params.guard);
481
+ case "refresh": return await selected.workflow.refresh(params.guard);
482
+ case "resolve": return await selected.workflow.resolve(params.guard, params.threadIds);
483
+ case "finalize": return await selected.workflow.finalize(params.guard, params.projection, params.checks);
484
+ }
485
+ });
486
+ },
487
+ });
488
+
489
+ pi.registerTool({
490
+ name: "pi_pr_fix_ci",
491
+ label: "Fix PR CI",
492
+ description: "Run one guarded action for the /pr failed-CI route.",
493
+ parameters: FixCiParameters,
494
+ executionMode: "sequential",
495
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
496
+ return executeWorkflowAction(params.runId, "fix-ci", ctx, signal, async (selected) => {
497
+ switch (params.action) {
498
+ case "collect": return await selected.workflow.collect();
499
+ case "publish": return await selected.workflow.publish();
500
+ }
501
+ });
502
+ },
503
+ });
504
+
151
505
  const setWidget = (ctx: ExtensionContext, display: PrDisplay | undefined): void => {
152
506
  stopRoutingSpinner();
153
507
  widgetKind = "presentation";
@@ -270,6 +624,7 @@ export default function pullRequestExtension(
270
624
  mergeCompleted = false;
271
625
  displayedWidget = undefined;
272
626
  commandGeneration = 0;
627
+ clearWorkflow(workflowContext);
273
628
  activeInvocations.clear();
274
629
  stopRoutingSpinner();
275
630
  widgetKind = "presentation";
@@ -372,6 +727,13 @@ export default function pullRequestExtension(
372
727
  queued = false;
373
728
  };
374
729
 
730
+ pi.on("before_agent_start", (event) => {
731
+ const selected = workflowContext;
732
+ if (selected?.queuedPrompt && matchesWorkflowPrompt(event.prompt, selected.queuedPrompt)) {
733
+ selected.queuedPrompt = undefined;
734
+ }
735
+ });
736
+
375
737
  pi.on("session_start", async (_event, ctx) => {
376
738
  stop();
377
739
  const generation = sessionGeneration;
@@ -388,19 +750,33 @@ export default function pullRequestExtension(
388
750
 
389
751
  pi.on("agent_settled", async (_event, ctx) => {
390
752
  if (!ctx.hasUI || !ctx.isIdle() || !context) return;
753
+ const selected = workflowContext;
754
+ const helperSettled = selected?.usedSinceSettlement ?? false;
755
+ const queuedHelperPending = selected?.queuedPrompt !== undefined && !helperSettled;
391
756
  let workflowSettled = false;
392
757
  let createWorkflowSettled = false;
393
758
  for (const [invocation, phase] of activeInvocations) {
394
759
  if (phase !== "workflow" && phase !== "create-workflow") continue;
760
+ if (queuedHelperPending) continue;
395
761
  activeInvocations.delete(invocation);
396
762
  workflowSettled = true;
397
763
  if (phase === "create-workflow") createWorkflowSettled = true;
398
764
  }
765
+ if (selected) {
766
+ const conflictPending = (selected.route === "create" || selected.route === "update-branch") &&
767
+ selected.workflow.state.phase === "conflict-awaiting-user";
768
+ if (!queuedHelperPending) {
769
+ if (helperSettled && conflictPending && !selected.conflictRetained) {
770
+ selected.usedSinceSettlement = false;
771
+ selected.conflictRetained = true;
772
+ } else clearWorkflow(selected);
773
+ }
774
+ }
399
775
  const delegatedRefresh = delegatedWorkPending && lastDiscovery !== "inactive";
400
776
  delegatedWorkPending = false;
401
- if (!workflowSettled && !delegatedRefresh) return;
777
+ if (!workflowSettled && !helperSettled && !delegatedRefresh) return;
402
778
  cancelRefresh();
403
- if (createWorkflowSettled) pendingWorkspaceRename = true;
779
+ if (createWorkflowSettled || helperSettled && selected?.route === "create") pendingWorkspaceRename = true;
404
780
  await refresh().catch(reportRefreshFailure);
405
781
  });
406
782
 
@@ -415,23 +791,36 @@ export default function pullRequestExtension(
415
791
  }
416
792
  });
417
793
 
418
- const commandHandler = createCommandHandler(pi, { loadCurrentPullRequest: load });
794
+ const commandHandler = createCommandHandler(pi, {
795
+ loadCurrentPullRequest: load,
796
+ reserveWorkflow,
797
+ markWorkflowPromptQueued,
798
+ releaseWorkflow,
799
+ });
419
800
  pi.registerCommand("pr", {
420
- description: "[instructions] — Run the current branch pull request next step",
801
+ description: "[--base=<host>/<owner>/<repository>:<ref>] — Run the current branch pull request next step",
421
802
  handler: async (args, ctx) => {
422
803
  if (!ctx.hasUI || !context) return;
423
804
  const generation = sessionGeneration;
424
805
  const invocation = ++commandGeneration;
425
806
  activeInvocations.set(invocation, "routing");
426
807
  reconcileWidget(ctx);
427
- const routeResolved = (): void => {
808
+ const routeResolved = (_nextStep?: unknown): void => {
428
809
  if (sessionGeneration !== generation || activeInvocations.get(invocation) !== "routing") return;
429
810
  activeInvocations.set(invocation, "resolved");
430
811
  reconcileWidget(ctx);
431
812
  };
813
+ const commandInvocation: PrCommandInvocation = Object.assign(routeResolved, {
814
+ sessionGeneration: generation,
815
+ assertCurrent() {
816
+ if (sessionGeneration !== generation) {
817
+ throw new Error("PR command session changed during dispatch");
818
+ }
819
+ },
820
+ });
432
821
  let nextStep: Awaited<ReturnType<typeof commandHandler>>;
433
822
  try {
434
- nextStep = await commandHandler(args, ctx, routeResolved);
823
+ nextStep = await commandHandler(args, ctx, commandInvocation);
435
824
  routeResolved();
436
825
  } catch (error) {
437
826
  if (sessionGeneration === generation) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "3.1.10",
3
+ "version": "4.0.2",
4
4
  "description": "Run /pr to safely discover or link the current pull request, then create, update, address feedback, fix CI, or merge when ready.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -28,9 +28,11 @@
28
28
  },
29
29
  "peerDependencies": {
30
30
  "@earendil-works/pi-coding-agent": "^0.85.1",
31
- "@earendil-works/pi-tui": "^0.85.1"
31
+ "@earendil-works/pi-tui": "^0.85.1",
32
+ "typebox": "^1.3.15"
32
33
  },
33
34
  "dependencies": {
35
+ "@henryqw/pi-config-store": "^1.1.0",
34
36
  "@henryqw/pi-herdr": "^0.4.5"
35
37
  },
36
38
  "repository": {