@henryqw/pi-pr 3.1.10 → 4.0.3

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