@henryqw/pi-pr 3.1.9 → 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,10 +1,23 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { realpath } from "node:fs/promises";
1
3
  import {
2
4
  isBashToolResult,
3
5
  type ExtensionAPI,
4
6
  type ExtensionContext,
5
7
  } from "@earendil-works/pi-coding-agent";
8
+ import { truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
6
9
  import { createHerdrClient } from "@henryqw/pi-herdr";
7
- 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";
8
21
  import {
9
22
  hasLocalCommit,
10
23
  loadCurrentPullRequest,
@@ -13,6 +26,7 @@ import {
13
26
  samePullRequestObservation,
14
27
  type PullRequestObservation,
15
28
  } from "./pr-github.ts";
29
+ import { runChecked, spawnBounded } from "./pr-execution.ts";
16
30
  import {
17
31
  discoveryIssueKey,
18
32
  discoveryIssueMessage,
@@ -22,8 +36,12 @@ import {
22
36
  unavailablePrDisplay,
23
37
  type PrDisplay,
24
38
  } from "./pr-ui.ts";
39
+ import { PullRequestBranchUpdater, type UpdateBranchOptions } from "./pr-update-branch.ts";
25
40
 
26
41
  const POLL_INTERVAL_MS = 30_000;
42
+ const ROUTING_SPINNER_INTERVAL_MS = 80;
43
+ const ROUTING_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
44
+ const ROUTING_WIDGET_TEXT = "Checking pull request…";
27
45
  const HERDR_TIMEOUT_MS = 10_000;
28
46
  const UI_KEY = "pi-pr";
29
47
  const OBSERVATION_ENTRY = "pi-pr-observation";
@@ -32,13 +50,150 @@ const GIT_COMMIT = /(?:^|[;&|]\s*|\n\s*)git\s+commit(?=\s|$|[;&|])/;
32
50
  const GIT_PUSH = /(?:^|[;&|]\s*|\n\s*)git\s+push(?=\s|$|[;&|])/;
33
51
  const WORKFLOW_ROUTES = new Set(["create", "update-branch", "sweep", "fix-ci"]);
34
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 });
35
161
 
36
162
  type PullRequestExtensionDependencies = {
37
163
  loadCurrentPullRequest?: typeof loadCurrentPullRequest;
38
164
  hasLocalCommit?: typeof hasLocalCommit;
39
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;
40
172
  };
41
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
+
42
197
  function isRecord(value: unknown): value is Record<string, unknown> {
43
198
  return typeof value === "object" && value !== null && !Array.isArray(value);
44
199
  }
@@ -102,6 +257,12 @@ export default function pullRequestExtension(
102
257
  const discover = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest;
103
258
  const detectLocalCommit = dependencies.hasLocalCommit ?? hasLocalCommit;
104
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;
105
266
  let context: ExtensionContext | undefined;
106
267
  let observation: PullRequestObservation | undefined;
107
268
  const load: typeof loadCurrentPullRequest = async (api, loadContext, inspectedLocal) => {
@@ -130,9 +291,220 @@ export default function pullRequestExtension(
130
291
  let mergeCompleted = false;
131
292
  let displayedWidget: PrDisplay | undefined;
132
293
  let commandGeneration = 0;
133
- const activeInvocations = new Map<number, "routing" | "create-workflow" | "workflow">();
294
+ let workflowContext: WorkflowContext | undefined;
295
+ const activeInvocations = new Map<number, "routing" | "resolved" | "create-workflow" | "workflow">();
296
+ let widgetKind: "presentation" | "routing" = "presentation";
297
+ let routingSpinnerFrame = 0;
298
+ let routingSpinnerTimer: ReturnType<typeof setInterval> | undefined;
299
+ let routingSpinnerTui: TUI | undefined;
300
+ let routingSpinnerComponent: (Component & { dispose(): void }) | undefined;
301
+
302
+ const stopRoutingSpinner = (): void => {
303
+ if (routingSpinnerTimer !== undefined) clearInterval(routingSpinnerTimer);
304
+ routingSpinnerTimer = undefined;
305
+ routingSpinnerTui = undefined;
306
+ routingSpinnerComponent = undefined;
307
+ };
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
+ });
134
504
 
135
505
  const setWidget = (ctx: ExtensionContext, display: PrDisplay | undefined): void => {
506
+ stopRoutingSpinner();
507
+ widgetKind = "presentation";
136
508
  if (display?.widget === undefined) {
137
509
  ctx.ui.setWidget(UI_KEY, undefined);
138
510
  return;
@@ -147,6 +519,57 @@ export default function pullRequestExtension(
147
519
  ctx.ui.setWidget(UI_KEY, formatPrWidget(display));
148
520
  };
149
521
 
522
+ const setRoutingWidget = (ctx: ExtensionContext): void => {
523
+ if (widgetKind === "routing") return;
524
+ stopRoutingSpinner();
525
+ widgetKind = "routing";
526
+ routingSpinnerFrame = 0;
527
+ if (ctx.mode !== "tui") {
528
+ ctx.ui.setWidget(UI_KEY, [`${ROUTING_SPINNER_FRAMES[0]} ${ROUTING_WIDGET_TEXT}`]);
529
+ return;
530
+ }
531
+
532
+ ctx.ui.setWidget(UI_KEY, (tui, theme) => {
533
+ let cachedWidth: number | undefined;
534
+ let cachedLines: string[] | undefined;
535
+ const component: Component & { dispose(): void } = {
536
+ invalidate() {
537
+ cachedWidth = undefined;
538
+ cachedLines = undefined;
539
+ },
540
+ render(width) {
541
+ if (width <= 0) return [];
542
+ if (cachedLines !== undefined && cachedWidth === width) return cachedLines;
543
+ const line = `${theme.fg("accent", ROUTING_SPINNER_FRAMES[routingSpinnerFrame]!)} ${ROUTING_WIDGET_TEXT}`;
544
+ cachedWidth = width;
545
+ cachedLines = [truncateToWidth(line, Math.max(1, width))];
546
+ return cachedLines;
547
+ },
548
+ dispose() {
549
+ if (routingSpinnerComponent !== component) return;
550
+ stopRoutingSpinner();
551
+ widgetKind = "presentation";
552
+ },
553
+ };
554
+ routingSpinnerTui = tui;
555
+ routingSpinnerComponent = component;
556
+ return component;
557
+ });
558
+ routingSpinnerTimer = setInterval(() => {
559
+ routingSpinnerFrame = (routingSpinnerFrame + 1) % ROUTING_SPINNER_FRAMES.length;
560
+ routingSpinnerComponent?.invalidate();
561
+ routingSpinnerTui?.requestRender();
562
+ }, ROUTING_SPINNER_INTERVAL_MS);
563
+ };
564
+
565
+ const reconcileWidget = (ctx: ExtensionContext): void => {
566
+ if ([...activeInvocations.values()].includes("routing")) {
567
+ setRoutingWidget(ctx);
568
+ return;
569
+ }
570
+ setWidget(ctx, activeInvocations.size > 0 ? undefined : displayedWidget);
571
+ };
572
+
150
573
  const render = (
151
574
  ctx: ExtensionContext,
152
575
  discovery: Awaited<ReturnType<typeof loadCurrentPullRequest>>,
@@ -159,7 +582,7 @@ export default function pullRequestExtension(
159
582
  lastDiscovery = "inactive";
160
583
  displayedWidget = undefined;
161
584
  ctx.ui.setStatus(UI_KEY, undefined);
162
- setWidget(ctx, undefined);
585
+ reconcileWidget(ctx);
163
586
  return;
164
587
  }
165
588
  const display = projectPrDisplay(discovery, localCommit);
@@ -168,9 +591,8 @@ export default function pullRequestExtension(
168
591
  throw new Error("Pull request display is missing a footer");
169
592
  }
170
593
  displayedWidget = display.widget === undefined ? undefined : display;
171
- const widget = activeInvocations.size > 0 ? undefined : displayedWidget;
172
594
  ctx.ui.setStatus(UI_KEY, footer);
173
- setWidget(ctx, widget);
595
+ reconcileWidget(ctx);
174
596
  if (discovery.kind === "blocked") {
175
597
  const key = discoveryIssueKey(discovery.issue);
176
598
  if (lastBlockedIssueKey !== key) {
@@ -202,7 +624,10 @@ export default function pullRequestExtension(
202
624
  mergeCompleted = false;
203
625
  displayedWidget = undefined;
204
626
  commandGeneration = 0;
627
+ clearWorkflow(workflowContext);
205
628
  activeInvocations.clear();
629
+ stopRoutingSpinner();
630
+ widgetKind = "presentation";
206
631
  if (timer !== undefined) clearInterval(timer);
207
632
  timer = undefined;
208
633
  active?.abort();
@@ -255,8 +680,9 @@ export default function pullRequestExtension(
255
680
  if (!controller.signal.aborted && sessionGeneration === generation) {
256
681
  if (!displayEstablished) {
257
682
  const unavailable = unavailablePrDisplay();
683
+ displayedWidget = undefined;
258
684
  ctx.ui.setStatus(UI_KEY, formatPrFooter(unavailable, ctx.ui.theme));
259
- setWidget(ctx, undefined);
685
+ reconcileWidget(ctx);
260
686
  displayEstablished = true;
261
687
  }
262
688
  reportRefreshFailure();
@@ -301,6 +727,13 @@ export default function pullRequestExtension(
301
727
  queued = false;
302
728
  };
303
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
+
304
737
  pi.on("session_start", async (_event, ctx) => {
305
738
  stop();
306
739
  const generation = sessionGeneration;
@@ -317,19 +750,33 @@ export default function pullRequestExtension(
317
750
 
318
751
  pi.on("agent_settled", async (_event, ctx) => {
319
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;
320
756
  let workflowSettled = false;
321
757
  let createWorkflowSettled = false;
322
758
  for (const [invocation, phase] of activeInvocations) {
323
759
  if (phase !== "workflow" && phase !== "create-workflow") continue;
760
+ if (queuedHelperPending) continue;
324
761
  activeInvocations.delete(invocation);
325
762
  workflowSettled = true;
326
763
  if (phase === "create-workflow") createWorkflowSettled = true;
327
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
+ }
328
775
  const delegatedRefresh = delegatedWorkPending && lastDiscovery !== "inactive";
329
776
  delegatedWorkPending = false;
330
- if (!workflowSettled && !delegatedRefresh) return;
777
+ if (!workflowSettled && !helperSettled && !delegatedRefresh) return;
331
778
  cancelRefresh();
332
- if (createWorkflowSettled) pendingWorkspaceRename = true;
779
+ if (createWorkflowSettled || helperSettled && selected?.route === "create") pendingWorkspaceRename = true;
333
780
  await refresh().catch(reportRefreshFailure);
334
781
  });
335
782
 
@@ -344,25 +791,42 @@ export default function pullRequestExtension(
344
791
  }
345
792
  });
346
793
 
347
- const commandHandler = createCommandHandler(pi, { loadCurrentPullRequest: load });
794
+ const commandHandler = createCommandHandler(pi, {
795
+ loadCurrentPullRequest: load,
796
+ reserveWorkflow,
797
+ markWorkflowPromptQueued,
798
+ releaseWorkflow,
799
+ });
348
800
  pi.registerCommand("pr", {
349
- 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",
350
802
  handler: async (args, ctx) => {
351
803
  if (!ctx.hasUI || !context) return;
352
804
  const generation = sessionGeneration;
353
805
  const invocation = ++commandGeneration;
354
806
  activeInvocations.set(invocation, "routing");
355
- if (displayedWidget !== undefined) setWidget(ctx, undefined);
807
+ reconcileWidget(ctx);
808
+ const routeResolved = (_nextStep?: unknown): void => {
809
+ if (sessionGeneration !== generation || activeInvocations.get(invocation) !== "routing") return;
810
+ activeInvocations.set(invocation, "resolved");
811
+ reconcileWidget(ctx);
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
+ });
356
821
  let nextStep: Awaited<ReturnType<typeof commandHandler>>;
357
822
  try {
358
- nextStep = await commandHandler(args, ctx);
823
+ nextStep = await commandHandler(args, ctx, commandInvocation);
824
+ routeResolved();
359
825
  } catch (error) {
360
826
  if (sessionGeneration === generation) {
361
827
  cancelRefresh();
362
828
  activeInvocations.delete(invocation);
363
- if (!activeInvocations.size) {
364
- setWidget(ctx, displayedWidget);
365
- }
829
+ reconcileWidget(ctx);
366
830
  refreshInBackground();
367
831
  }
368
832
  throw error;
@@ -372,7 +836,7 @@ export default function pullRequestExtension(
372
836
  if (WORKFLOW_ROUTES.has(nextStep)) {
373
837
  activeInvocations.set(invocation, nextStep === "create" ? "create-workflow" : "workflow");
374
838
  if (nextStep === "create") ctx.ui.setStatus(UI_KEY, undefined);
375
- setWidget(ctx, undefined);
839
+ reconcileWidget(ctx);
376
840
  } else {
377
841
  if (nextStep === "merge") mergeCompleted = true;
378
842
  activeInvocations.delete(invocation);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "3.1.9",
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": {